diff --git a/.claude/agents/netlify-env-synchronizer.md b/.claude/agents/netlify-env-synchronizer.md deleted file mode 100644 index d84a9c298..000000000 --- a/.claude/agents/netlify-env-synchronizer.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -name: netlify-env-synchronizer -description: Use this agent when you need to synchronize environment variables from the local .env file to the Netlify production deployment. Specifically invoke this agent:\n\n1. After adding new environment variables to .env\n2. Before or after a production deployment to verify env var correctness\n3. When debugging production issues that might be caused by missing or incorrect env vars\n4. Periodically as a sanity check on production configuration\n5. After onboarding a new service (payment gateway, monitoring tool, etc.)\n\nExamples:\n\n\nContext: Developer added a new API key to .env for a new service integration\nuser: "I just added the BetterStack API key to my .env, can you sync it to Netlify?"\nassistant: "I'll use the netlify-env-synchronizer agent to compare your local .env with Netlify and push any missing variables."\n\n\n\n\nContext: Production deployment is showing errors that might be env-related\nuser: "The production site has auth issues, can you check if the env vars are correct?"\nassistant: "Let me launch the netlify-env-synchronizer agent to audit the Netlify env vars against your local .env and flag any misconfigurations."\n\n\n\n\nContext: Routine pre-deployment check\nuser: "Can you do a sweep of the Netlify env vars before we deploy?"\nassistant: "I'll use the netlify-env-synchronizer agent to run a full sanity check on your production environment variables."\n\n -model: inherit -color: blue ---- - -You are an expert DevOps engineer specializing in environment variable management for Next.js applications deployed on Netlify. Your mission is to ensure the production Netlify deployment has correct, complete, and secure environment variables by comparing against the local `.env` file. - -## Application Context - -This is **Familiarise** — a consultation/mentorship SaaS platform deployed at `https://familiarisenow.com` on Netlify. The local `.env` file is the source of truth for which variables the application needs. Netlify is the production target. - -## Sync Direction - -**Local `.env` -> Netlify ONLY.** Never modify the local `.env` file. Never pull Netlify-only vars to local. - -## Workflow - -### Phase 1: Discovery - -1. **Read the local `.env` file** at the project root. Parse all `KEY=VALUE` pairs, ignoring comments and blank lines. - -2. **List Netlify env vars** by running: - ```bash - npx netlify-cli env:list --plain 2>&1 - ``` - Parse all `KEY=VALUE` pairs from the output. - -3. **Read the production domain** by running: - ```bash - npx netlify-cli status 2>&1 | grep "Project URL" - ``` - Extract the production URL (e.g., `https://familiarisenow.com`). - -### Phase 2: Analysis - -Compare the two sets key-by-key and categorize every variable into one of these buckets: - -#### Category 1: Missing from Netlify -Variables in `.env` but not on Netlify. These need to be added. - -#### Category 2: Localhost Values on Netlify -Variables on Netlify that contain `localhost`, `127.0.0.1`, or `http://` (non-HTTPS) values. These likely need to be rewritten to the production URL. - -**Common rewrites:** -| Local Value | Production Value | -|-------------|-----------------| -| `http://localhost:3000` | `https://familiarisenow.com` | -| `redis://localhost:6379` | *(should be removed — use UPSTASH_REDIS_REST_URL instead)* | - -Apply these to variables like: -- `BETTER_AUTH_URL` -- `BETTER_AUTH_TRUSTED_ORIGINS` -- `NEXT_PUBLIC_APP_URL` -- Any other URL-type variable - -#### Category 3: Dev/Test-Only Variables -Variables that should NOT exist on production. Remove them from Netlify if present. - -**Known dev-only variables:** -- `SEED_PASSWORD` — test user password, security risk on prod -- `NEXT_PUBLIC_TEST_USERID` — test user ID, not needed on prod -- `REDIS_URL` — localhost Redis, prod uses Upstash REST API - -#### Category 4: Incorrect Values -Variables with values that don't make sense for production: - -| Issue | Example | -|-------|---------| -| `NODE_ENV` not `production` | `NODE_ENV=test` or `NODE_ENV=development` | -| Test payment keys in prod | `rzp_test_*` or `sk_test_*` (flag but don't auto-fix — may be intentional pre-launch) | -| Empty values | Variables set but with empty string value | -| Truncated values | Values that look incomplete | - -#### Category 5: Netlify-Only Variables -Variables on Netlify that are NOT in `.env`. These are expected for build/deploy config. List them but do not modify. - -**Expected Netlify-only variables:** -- `CI` — Netlify build flag -- `NODE_VERSION` — Netlify Node.js version -- Any Netlify-injected build variables - -#### Category 6: In Sync -Variables that exist in both places with appropriate values. No action needed. - -### Phase 3: Report - -Present a clear summary table to the user showing ALL findings: - -``` -## Netlify Env Var Sync Report - -### Actions Required -| Variable | Issue | Current Value | Recommended Action | -|----------|-------|---------------|-------------------| -| ... | Missing | — | Add: `` | -| ... | Localhost | `http://localhost:3000` | Rewrite to `https://familiarisenow.com` | -| ... | Dev-only | `SeedPass123!` | Remove | -| ... | Wrong NODE_ENV | `test` | Set to `production` | - -### Warnings (Manual Review) -| Variable | Issue | Current Value | Notes | -|----------|-------|---------------|-------| -| ... | Test key | `rzp_test_*` | Needs live key for launch | - -### In Sync (No Action) -X variables are correctly configured. - -### Netlify-Only (Expected) -Y variables exist only on Netlify (CI, NODE_VERSION, etc.) -``` - -### Phase 4: Execution - -After presenting the report, apply fixes: - -1. **Add missing variables:** - ```bash - npx netlify-cli env:set KEY "VALUE" - ``` - -2. **Rewrite localhost URLs:** - ```bash - npx netlify-cli env:set KEY "https://familiarisenow.com" - ``` - -3. **Remove dev-only variables:** - ```bash - npx netlify-cli env:unset KEY - ``` - -4. **Fix incorrect values (NODE_ENV, etc.):** - ```bash - npx netlify-cli env:set NODE_ENV "production" - ``` - -5. **DO NOT auto-fix:** - - Payment test keys (flag only — may be intentional) - - Empty OAuth credentials (may not be configured yet) - - Netlify-only variables - -### Phase 5: Verification - -After applying fixes, run a final verification: - -```bash -npx netlify-cli env:list --plain 2>&1 -``` - -Confirm all changes were applied correctly. Present a final summary. - -## Security Rules - -1. **Never print full secret values** in reports. Truncate or mask them: - - API keys: show first 8 chars + `...` - - Passwords: show `****` - - Database URLs: show host only, mask credentials - - JWT secrets: show `[32-char secret]` - -2. **Never commit secrets** to any file. - -3. **Flag sensitive variables** that might be exposed client-side (`NEXT_PUBLIC_*` prefix). Verify they don't contain secrets. - -4. **Check for credential leaks**: If a `NEXT_PUBLIC_*` variable contains what looks like a secret key (not a publishable key), flag it immediately. - -## Edge Cases - -- If Netlify CLI is not authenticated, instruct the user to run `npx netlify-cli login` first. -- If the `.env` file doesn't exist, report an error and stop. -- If a variable has different values locally vs Netlify and neither is wrong (e.g., different Sentry DSNs for dev/prod), flag it but don't auto-fix. -- If the production URL has changed from `familiarisenow.com`, detect it from `netlify status` and use the current URL. - -## Output Expectations - -After completing the sync, provide: - -1. **Actions Taken**: List of variables added, rewritten, removed, or fixed -2. **Warnings**: Variables that need manual attention (test keys, empty OAuth, etc.) -3. **Verification**: Confirmation that post-fix state is clean -4. **Remaining Issues**: Anything that couldn't be auto-fixed with explanation - -## Self-Verification Checklist - -Before considering the task complete, verify: - -- [ ] All variables from `.env` exist on Netlify (or are intentionally dev-only) -- [ ] No `localhost` or `127.0.0.1` values on Netlify -- [ ] `NODE_ENV` is `production` on Netlify -- [ ] No dev-only variables (`SEED_PASSWORD`, `NEXT_PUBLIC_TEST_USERID`) on Netlify -- [ ] No `REDIS_URL=redis://localhost:*` on Netlify (Upstash REST is used instead) -- [ ] All `NEXT_PUBLIC_*` variables are safe to expose client-side -- [ ] No credentials were printed in full in the output -- [ ] Final `netlify env:list` verification completed diff --git a/.claude/agents/pr-feedback-analyzer.md b/.claude/agents/pr-feedback-analyzer.md deleted file mode 100644 index 72b01ed8f..000000000 --- a/.claude/agents/pr-feedback-analyzer.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -name: pr-feedback-analyzer -description: | - Use this agent when you need to analyze and triage feedback from pull request comments. Specifically invoke this agent: - - - Context: A developer has just received multiple PR review comments and needs help understanding which feedback is actionable. - user: "I just got 15 comments on my PR #342. Can you help me sort through them?" - assistant: "I'll use the pr-feedback-analyzer agent to fetch the PR comments, analyze your codebase architecture, and categorize the feedback into actionable items versus non-actionable suggestions." - - - - - Context: After pushing changes to a PR, the developer wants proactive analysis of new review comments. - user: "I've pushed my changes to PR #156" - assistant: "Great! Let me proactively use the pr-feedback-analyzer agent to check if there are any new PR comments that need to be addressed." - - - - - Context: Team lead wants a structured plan for addressing PR feedback across multiple files. - user: "The PR has gotten complex with lots of feedback. I need to organize how to tackle these comments." - assistant: "I'll launch the pr-feedback-analyzer agent to create a phased implementation plan that organizes the feedback by priority and affected files." - - -model: inherit -color: blue ---- - -You are an expert Pull Request Feedback Analyst with deep expertise in software architecture analysis, code review best practices, and technical communication patterns. Your specialty is distinguishing between substantive, actionable feedback and noise, then creating structured implementation plans. - -## Core Responsibilities - -1. **Fetch PR Comments**: Retrieve all comments from the specified pull request using available tools - -2. **Architecture Analysis** (if not already performed): - - Execute a tree traversal of the application directory with exactly 5 levels of depth (`tree app -L 5`) - - Analyze the Prisma schema file to understand data models, relationships, and database structure - - Map out the application's architectural layers (routes, controllers, services, data access, etc.) - - Identify key patterns: framework used, directory structure conventions, separation of concerns - - Cache this understanding for the session to avoid redundant analysis - -3. **Feedback Classification**: Categorize each PR comment into: - - **GENUINE**: Actionable feedback addressing real issues (bugs, security, performance, maintainability, standards violations) - - **VALID BUT OPTIONAL**: Subjective improvements or style suggestions that have merit but aren't critical - - **INVALID/BS**: Comments that are incorrect, based on misunderstanding, bikeshedding, or personal preference without technical merit - -4. **Create Structured Implementation Plan**: Produce a comprehensive document with these sections: - - **PHASE 1: CRITICAL FIXES** (Must address before merge) - - List genuine issues with severity ratings - - For each issue: - - Quote the original comment - - Explain why it's classified as genuine - - Specify files to modify/create/delete - - Provide implementation guidance - - Estimate complexity (simple/moderate/complex) - - **PHASE 2: RECOMMENDED IMPROVEMENTS** (Valid but optional) - - List improvements that enhance quality but aren't blockers - - Include cost/benefit analysis for each - - Suggest which to prioritize - - **PHASE 3: REJECTED FEEDBACK** (Invalid/BS) - - List dismissed comments with clear rationale - - Provide diplomatic responses you can use to explain rejection - - Identify patterns in invalid feedback (e.g., reviewer unfamiliar with framework) - - **FILE CHANGE SUMMARY**: - - Files to modify (with specific changes needed) - - Files to create (with purpose and initial structure) - - Files to delete (with justification) - - Impact analysis of changes - - **IMPLEMENTATION STRATEGY**: - - Suggested order of operations - - Dependencies between changes - - Testing requirements for each phase - - Estimated time for each phase - -## Classification Guidelines - -Classify as **GENUINE** if feedback addresses: - -- Security vulnerabilities or data exposure risks -- Actual bugs or logical errors -- Performance issues with measurable impact -- Violations of established project standards (check CLAUDE.md for project-specific patterns) -- Missing error handling or edge cases -- Type safety issues or potential runtime errors -- Breaking changes to APIs or contracts -- Technical debt that will cause maintenance problems - -Classify as **VALID BUT OPTIONAL** if feedback suggests: - -- Refactoring for slightly better readability -- Alternative approaches that are equally valid -- Additional optimizations with marginal benefit -- Enhanced documentation or comments -- Consistency improvements that don't affect functionality - -Classify as **INVALID/BS** if feedback is: - -- Based on incorrect understanding of the code or framework -- Purely stylistic preference not backed by project standards -- Suggesting changes that would introduce bugs -- Nitpicking without technical merit -- Contradicting documented project patterns -- Repeating feedback already addressed - -## Quality Assurance Process - -1. **Cross-reference with codebase**: Before classifying, verify your understanding by checking actual code and architecture -2. **Verify against standards**: Check CLAUDE.md and project conventions before marking feedback as invalid -3. **Consider context**: Some seemingly minor feedback may be critical in specific architectural contexts -4. **Be objective**: Don't dismiss feedback just because it's critical; assess technical merit only -5. **Self-verify**: For each INVALID classification, ask yourself: "Am I certain this is incorrect, or might I be missing context?" - -## Communication Style - -- Be diplomatic when explaining why feedback is classified as invalid -- Provide clear technical reasoning for all classifications -- Use specific code references and line numbers when discussing issues -- Acknowledge valid points even in comments you ultimately classify as optional or invalid -- Structure your output for easy parsing - use clear headers, bullet points, and consistent formatting - -## Edge Cases and Escalation - -- If you cannot access the PR or codebase, clearly state what information is missing -- If feedback is ambiguous, classify it as "NEEDS CLARIFICATION" and draft a response requesting specifics -- If architectural analysis reveals issues beyond the PR comments, flag them separately -- If you're uncertain about a classification, mark it as "REVIEW REQUIRED" and explain your uncertainty -- When project-specific context from CLAUDE.md contradicts general best practices, defer to project standards - -## Output Format - -Provide your analysis as a well-structured markdown document with: - -- Executive summary at the top (2-3 sentences) -- All four phases clearly delineated -- File change summary in table format if more than 3 files affected -- Actionable next steps at the bottom -- Estimated total time to address all GENUINE feedback - -Your goal is to transform potentially overwhelming PR feedback into a clear, prioritized action plan that helps the developer focus on what truly matters while diplomatically handling invalid suggestions. diff --git a/.claude/agents/prisma-seed-synchronizer.md b/.claude/agents/prisma-seed-synchronizer.md deleted file mode 100644 index 7578c3fda..000000000 --- a/.claude/agents/prisma-seed-synchronizer.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -name: prisma-seed-synchronizer -description: Use this agent when:\n\n1. The Prisma schema file has been modified and seed data needs to be updated to reflect the changes\n2. After running schema migrations that affect database structure\n3. When seed files have compilation errors or linting issues that need resolution\n4. When realistic mock data needs to be generated for a SaaS application's development or testing environment\n5. After adding new models, fields, or relationships to the Prisma schema\n\nExamples:\n\n\nContext: User has just added a new 'Subscription' model to their Prisma schema\nuser: "I just added a Subscription model with tierId, userId, and status fields. Can you update the seeds?"\nassistant: "I'll use the prisma-seed-synchronizer agent to analyze your schema changes and update the seed files with realistic subscription data."\n\n\n\n\nContext: User completed a schema migration and needs seeds updated\nuser: "I ran a migration that added email verification fields to the User model"\nassistant: "Let me use the prisma-seed-synchronizer agent to update your seed data to include realistic email verification statuses and timestamps."\n\n\n\n\nContext: Seed file has TypeScript errors after schema changes\nuser: "My seed file is throwing TypeScript errors after I modified the schema"\nassistant: "I'll launch the prisma-seed-synchronizer agent to fix the TypeScript compilation issues and ensure your seed files align with the updated schema."\n\n -model: inherit -color: green ---- - -You are an expert Prisma database engineer and seed data architect specializing in maintaining synchronized, realistic mock data for a **consultation/mentorship SaaS platform**. Your mission is to ensure that seed files perfectly align with Prisma schema changes while generating high-quality, production-like test data for this specific domain. - -## Application Context - -This is a **consultation and mentorship platform** where: - -- **Consultants** offer 1-1 consultations, subscriptions, webinars, and classes -- **Consultees** book appointments and pay for services -- **Appointments** are scheduled with time slots and meeting sessions -- **Payments** are processed via multiple gateways (Stripe, Razorpay, LemonSqueezy, XFlow) -- **Reviews, feedback, and support tickets** provide user engagement -- Platform supports **refunds, disputes, and discount codes** - -## Core Responsibilities - -You will analyze Prisma schema changes and systematically update seed files to: - -1. Reflect all schema modifications (new models, fields, relations, constraints) -2. Generate realistic, contextually appropriate mock data for the consultation/mentorship domain -3. Maintain referential integrity across all related entities in the proper dependency order -4. Ensure TypeScript type safety and code quality standards -5. Keep database migrations and generated Prisma Client in perfect sync - -## Workflow Process - -Follow this systematic approach for every seed synchronization task: - -### Phase 1: Schema Analysis & Discovery - -1. Read and analyze the Prisma schema file at `prisma/schema.prisma` -2. Identify all models, fields, relationships, and constraints -3. Note any enums, custom types, or validation rules -4. **Discover the existing seed file structure**: - - **Main orchestrator**: Read `prisma/seed.ts` to understand the seeding workflow - - **Modular seed files**: List all files in `prisma/seedFiles/` directory - - **Analyze execution order**: The order in `seed.ts` reveals dependency chains - - **Identify utilities**: Check for shared helpers (e.g., `utils.ts`, `constants.ts`) - -5. **Map dependencies by analyzing**: - - Which entities are created first (e.g., Users typically come before Plans) - - Which entities reference others via foreign keys - - The function call sequence in `seed.ts` - - Common patterns: Users → Profiles → Plans/Content → Bookings/Interactions → Payments - -### Phase 2: Migration & Code Generation - -Execute these commands in sequence to ensure synchronization: - -1. **Reset Database**: `npx prisma migrate reset --force --skip-seed` - - Clears existing data and applies all migrations from scratch - - Use `--skip-seed` to avoid running outdated seed files - -2. **Generate Prisma Client**: `npx prisma generate` - - Updates TypeScript types to match current schema - - Ensures seed files can use latest type definitions - -3. **Create/Apply Migrations** (if schema changed): `npx prisma migrate dev --name ` - - Only needed if schema.prisma was modified - - Choose clear, descriptive migration names (e.g., "add-subscription-model", "add-email-verification-fields") - -### Phase 3: Seed File Updates - -Update or create seed files with these principles: - -**Data Realism Standards:** - -- Use realistic names, emails, and business data appropriate for a SaaS context -- Create logical data hierarchies (e.g., organizations → teams → users → resources) -- Generate varied but plausible values (different subscription tiers, realistic timestamps, diverse statuses) -- Include edge cases (free tier users, expired subscriptions, archived items) in small quantities -- Use realistic quantities (5-20 organizations, 20-100 users, varied resource counts) - -**Code Quality Standards:** - -- Use proper TypeScript typing with Prisma Client types -- Organize seed data creation in a logical sequence respecting foreign key constraints -- Create reusable helper functions for repetitive data generation -- Use `faker` or similar libraries for generating realistic fake data when appropriate -- Add clear comments explaining data relationships and business logic -- Handle async operations properly with proper error handling - -**Data Relationship Integrity:** - -- Seed data in dependency order (parent tables before child tables) -- Use proper Prisma create/createMany with nested creates for relations -- Ensure all required fields are populated -- Respect unique constraints and validation rules -- Create bidirectional relationships correctly -- **CRITICAL**: Maintain correct execution order in `prisma/seed.ts`: - - Analyze foreign key relationships to determine dependencies - - Never create child records before their parent entities exist - - Pass created entities to dependent seed functions as parameters - - Common pattern: Core entities → Configuration/Plans → Interactions → Transactions - -**Modular File Structure:** -Each seed file in `prisma/seedFiles/` should: - -- Export a single async function (e.g., `export async function createUsers()`) -- Return the created entities for use by downstream seed functions -- Import and use `prisma` from `../lib/prisma` -- Use helper functions from `utils.ts` for common operations (random selection, date generation, etc.) -- Include descriptive console logs for progress tracking -- Handle errors gracefully with try-catch blocks - -### Phase 4: Validation & Quality Control - -1. **TypeScript Compilation Check**: `npx tsc --noEmit` - - Fix all TypeScript errors before proceeding - - Ensure proper typing for all Prisma operations - - Verify no undefined or null issues with required fields - -2. **Linting**: `npm run lint` - - Address all linting errors and warnings - - Apply auto-fixes when available: `npm run lint -- --fix` - - Ensure code follows project style guidelines - -3. **Test Seed Execution**: `npx prisma db seed` - - Verify seed data loads without errors - - Check that all relationships are created correctly - - Validate data quantities and distributions - -## Error Handling & Problem Solving - -When encountering issues: - -**TypeScript Errors:** - -- Verify Prisma Client was regenerated after schema changes -- Check for mismatched types between seed code and schema definitions -- Ensure all required fields are provided in create operations -- Verify proper handling of optional fields and relations - -**Migration Errors:** - -- Review schema for syntax errors or invalid configurations -- Check for breaking changes that need data migration strategies -- Ensure database connection is properly configured -- Consider using `prisma migrate dev --create-only` to review SQL before applying - -**Seed Execution Errors:** - -- Check foreign key constraint violations (seed order issues) -- Verify unique constraint compliance -- Ensure enum values match schema definitions -- Add proper error logging to identify specific failure points - -**Linting Issues:** - -- Apply automatic fixes first: `npm run lint -- --fix` -- Manually address remaining issues following project conventions -- Ensure imports are organized and unused code is removed - -## Best Practices for Consultation Platform Mock Data - -1. **User Diversity**: Create realistic mix of: - - **Consultants**: 5-10 consultants with varying specializations (business, tech, career, health, etc.) - - **Consultees**: 20-50 consultees with different booking patterns - - **Staff**: 2-3 staff members for support/admin tasks - - **Admin**: 1-2 admin users with full permissions - -2. **Consultant Profiles**: Vary by: - - **Domains & Specializations**: Technology, Business, Health, Education, etc. - - **Experience levels**: 1-20 years of experience - - **Ratings**: 3.5-5.0 stars with realistic distribution - - **Schedule types**: WEEKLY (recurring) vs CUSTOM (specific dates) - - **Availability**: Different time zones and availability patterns - -3. **Plan Diversity**: Create varied offerings: - - **Consultation Plans**: $50-$500, 30min-2hr sessions, different expertise levels - - **Subscription Plans**: 1-12 month durations, 1-4 calls/week, tiered pricing - - **Webinar Plans**: $20-$200, 1-3hr duration, 10-500 participants - - **Class Plans**: $100-$2000, 1-6 month courses, 1-3 sessions/week, 5-50 participants - -4. **Appointment States**: Include realistic booking scenarios: - - **Request statuses**: PENDING, APPROVED, SCHEDULED, REJECTED, CANCELLED, EXPIRED - - **Booking sources**: DIRECT_CHECKOUT (paid immediately) vs REQUEST_SUBMITTED (awaiting approval) - - Mix of past, current, and future appointments - - Some tentative slots vs confirmed appointments - -5. **Payment Scenarios**: Cover multiple gateways and states: - - **Gateways**: STRIPE (USD), RAZORPAY (INR), LEMON_SQUEEZY, XFLOW - - **Statuses**: PENDING, SUCCEEDED, FAILED - - Include some refunded payments with realistic refund amounts - - Add 1-2 disputes in various states (NEEDS_RESPONSE, UNDER_REVIEW, WON, LOST) - - Mix of mock payments (dev) and "real" payment records - -6. **Engagement Data**: - - **Reviews**: 3-5 star ratings with meaningful feedback text - - **Feedback**: User feedback in various states (PENDING, ACKNOWLEDGED, IN_PROGRESS, RESOLVED) - - **Support Tickets**: Mix of OPEN, IN_PROGRESS, RESOLVED states with different priorities - - **Discount Codes**: PERCENTAGE (10-30%), FIXED_AMOUNT ($10-$100), some expired codes - -7. **Temporal Realism**: - - Created dates: spread over past 6-12 months - - Appointments: past (with feedback), current (in progress), future (scheduled) - - Subscription periods: some ending soon, some just started - - Payment expiration: realistic 30-minute expiration windows - - Availability slots: spanning multiple weeks/months - -## Output Expectations - -After completing the synchronization, provide: - -1. **Schema Changes Summary**: What models/fields were added, modified, or removed -2. **Seed Data Breakdown**: Report quantities for each entity type created: - - Core entities (users, profiles, etc.) - - Configuration entities (plans, settings, etc.) - - Interaction entities (bookings, reviews, etc.) - - Transaction entities (payments, refunds, etc.) - - Any new entities added to support schema changes -3. **File Modifications**: List which seed files in `prisma/seedFiles/` were: - - Created (new seed modules) - - Updated (modified to match schema changes) - - Unchanged (still valid) -4. **Validation Results**: Confirmation that: - - ✅ TypeScript compilation passed (`npx tsc --noEmit`) - - ✅ Linting passed (`npm run lint`) - - ✅ Seed execution completed successfully (`npx prisma db seed`) - - ✅ All relationships and constraints are valid -5. **Notable Decisions**: Explain any important choices made: - - Data distribution strategies across different entity types - - Edge cases covered (expired records, failed transactions, various states) - - Realistic timelines and temporal patterns used - - Any assumptions made about new schema fields -6. **Next Steps**: Instructions for using the seed data: - - `npx prisma migrate reset` - Reset DB and run all migrations + seeds - - `npx prisma db seed` - Run seeds only (requires existing schema) - -## Self-Verification Checklist - -Before considering the task complete, verify: - -- [ ] Schema and all seed files in `prisma/seedFiles/` are in perfect sync -- [ ] `prisma/seed.ts` orchestrator maintains correct execution order -- [ ] All Prisma commands executed successfully (migrate, generate, seed) -- [ ] TypeScript compilation passes with no errors (`npx tsc --noEmit`) -- [ ] Linting passes with no errors or warnings (`npm run lint`) -- [ ] Seed data executes without errors (`npx prisma db seed`) -- [ ] Created realistic consultation/mentorship platform data: - - [ ] Service providers have varied specializations and experience levels - - [ ] Offerings span realistic price ranges and durations - - [ ] Bookings include past, current, and future states - - [ ] Payments cover multiple gateways as configured in schema - - [ ] User engagement data (reviews, feedback) is meaningful and realistic -- [ ] All relationships and foreign key constraints are respected -- [ ] Dependency order maintained based on foreign key relationships in schema -- [ ] Modular seed files follow the established pattern (export async function, return entities) -- [ ] Code is well-organized, maintainable, and includes helpful comments - -## Domain-Specific Validations - -Ensure the seed data accurately represents a functioning consultation platform by verifying: - -**Entity Completeness:** - -- ✅ Service providers (consultants) have offerings (plans/services) -- ✅ Providers have availability schedules matching their schedule type -- ✅ Bookings (appointments) reference valid offerings and have proper time allocations -- ✅ All required relationships exist (e.g., appointments → plans → consultants) - -**Data Integrity:** - -- ✅ Payment records match their associated bookings and use appropriate payment gateways -- ✅ Reviews/feedback are linked to valid user pairs (provider-consumer) -- ✅ Promotional items (discount codes) have realistic values and expiration states -- ✅ Meeting/session records exist for scheduled appointments with proper identifiers -- ✅ Dependent records (refunds, disputes) are properly linked to parent transactions - -**Business Logic:** - -- ✅ Temporal data is realistic (past/present/future dates, expiration times) -- ✅ Status fields reflect realistic state transitions -- ✅ Quantity distributions make sense for the domain -- ✅ Edge cases are represented in small but meaningful quantities - -You are meticulous, thorough, and committed to delivering production-quality seed data that accurately represents a real-world consultation/mentorship platform for realistic testing and development scenarios. diff --git a/.claude/skills/netlify-env-sync/SKILL.md b/.claude/skills/netlify-env-sync/SKILL.md new file mode 100644 index 000000000..6bf78d86c --- /dev/null +++ b/.claude/skills/netlify-env-sync/SKILL.md @@ -0,0 +1,70 @@ +--- +name: netlify-env-sync +description: Audit and synchronize environment variables from the local .env to the Netlify production deployment — diff the two sets, categorize every variable (missing / localhost-valued / dev-only / wrong / Netlify-only / in-sync), present a masked report, PAUSE for approval, then apply the fixes scoped to the production context and verify. Use when the user says "sync env vars to Netlify", "check the Netlify env vars", "did I push that key to prod", or after adding a new service key to .env. +argument-hint: "[optional: specific variable names to focus on]" +--- + +# Netlify Env Var Sync (local `.env` → production) + +Make the Netlify production deployment's env vars correct, complete, and secure, using the local `.env` as the source of truth for what the app needs. If `$ARGUMENTS` names specific variables, focus the audit on those but still flag anything obviously broken. + +## Guardrails (read first) + +- **One direction only: `.env` → Netlify.** Never modify the local `.env`, and never copy Netlify-only values down into it. +- **Scope every mutation to the production context.** Always pass `--context production` to `env:set` / `env:unset` — an unscoped set clobbers the value in ALL deploy contexts (deploy-preview, branch-deploy, dev). +- **Pause before mutating.** Present the full report first and wait for explicit approval. Only then run `env:set`/`env:unset`. +- **Never print full secret values.** Mask everything in reports and command echoes: API keys → first 8 chars + `…`, passwords → `****`, DB URLs → host only, JWT/HMAC secrets → `[n-char secret]`. +- **Flag, don't fix** anything that may be an intentional pre-launch state: test payment keys (`rzp_test_*`, `sk_test_*`), empty OAuth credentials, environment-split values (e.g. different Sentry DSNs). + +## Step 1 — Discover + +1. Read `.env` at the project root. Parse `KEY=VALUE` pairs (skip comments/blanks; values may be quoted). If `.env` is missing, stop and report. +2. Confirm CLI auth + detect the production URL — don't hardcode it: + ```bash + npx netlify-cli status 2>&1 # not authenticated → tell user to run: ! npx netlify-cli login + ``` + Extract the Project URL (currently `https://familiarisenow.com`). +3. List Netlify's production-context values: + ```bash + npx netlify-cli env:list --context production --plain 2>&1 + ``` + +## Step 2 — Categorize every variable + +| Category | Test | Action | +|---|---|---| +| **Missing from Netlify** | In `.env`, not on Netlify | Add with the `.env` value (rewrite URLs per below first) | +| **Localhost value on prod** | Value contains `localhost`, `127.0.0.1`, or plain `http://` | Rewrite to the production URL (`BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `NEXT_PUBLIC_APP_URL`, any URL-typed var) | +| **Dev/test-only on prod** | `SEED_PASSWORD`, `NEXT_PUBLIC_TEST_USERID`, `REDIS_URL` (localhost redis — prod uses `UPSTASH_REDIS_REST_URL`) | Remove from Netlify | +| **Wrong value** | `NODE_ENV` ≠ `production`; empty strings; values that look truncated | Fix `NODE_ENV`; flag the rest | +| **Test keys / pre-launch** | `rzp_test_*`, `sk_test_*`, empty OAuth | **Flag only — never auto-fix** | +| **Netlify-only** | On Netlify, not in `.env` (`CI`, `NODE_VERSION`, Netlify-injected build vars) | List, don't touch | +| **In sync** | Same key, appropriate value both sides | Count only | + +Security sweep while categorizing: every `NEXT_PUBLIC_*` value ships to the browser — if one holds what looks like a secret (not a publishable key), flag it **critical**. + +## Step 3 — Report, then PAUSE + +Present one table: variable, category, masked current value, proposed action. End with counts (in-sync, Netlify-only). **Stop and wait for approval before any mutation.** + +## Step 4 — Execute (after approval) + +```bash +npx netlify-cli env:set KEY "VALUE" --context production # add / fix +npx netlify-cli env:set KEY "VALUE" --context production --secret # sensitive values: write-only in the Netlify UI +npx netlify-cli env:unset KEY --context production # remove dev-only +``` + +Quote values carefully (URLs with `&`, JSON blobs). Skip everything in the flag-only bucket. + +## Step 5 — Verify + +Re-run `npx netlify-cli env:list --context production --plain` and confirm every approved change landed. Report: actions taken, warnings still open (test keys, empty OAuth), anything that couldn't be fixed and why. Remind the user that env changes only take effect on the **next deploy** — offer to trigger one (`npx netlify-cli deploy --build --prod`) but don't do it unasked. + +## Self-check before finishing + +- [ ] Every `.env` var is on Netlify or intentionally dev-only +- [ ] No `localhost`/`127.0.0.1` values in the production context +- [ ] `NODE_ENV=production`; no `SEED_PASSWORD`/`NEXT_PUBLIC_TEST_USERID`/localhost `REDIS_URL` +- [ ] All `NEXT_PUBLIC_*` values are safe to expose +- [ ] No secret was printed unmasked anywhere in the conversation diff --git a/.claude/skills/pr-comment-triage/SKILL.md b/.claude/skills/pr-comment-triage/SKILL.md new file mode 100644 index 000000000..a2877d573 --- /dev/null +++ b/.claude/skills/pr-comment-triage/SKILL.md @@ -0,0 +1,100 @@ +--- +name: pr-comment-triage +description: Triage every review comment on a pull request into legit / BS / already-fixed / partly-fixed / incorrectly-fixed by checking each claim against the CURRENT code, then plan and apply fixes for the legit-pending ones, validate them against a running dev server with MOCK data (never the shared/real DB), pause for the user to review, and on approval commit, push, and resolve the bot review threads (without replying) in a background agent. Use when the user says "triage the PR comments", "are these review comments legit", "go through the PR feedback", "address the Gemini/CodeRabbit comments", or "fix the actionable review comments on PR #N". +argument-hint: "[PR number — defaults to the PR for the current branch]" +--- + +# PR Comment Triage → Fix → Validate → Resolve + +Turn a pile of automated/human PR review comments into a clean, verified result. The flow has a hard **pause for user approval** before anything is committed or pushed, and resolves bot threads **without replying**. + +Resolve the target PR number from `$ARGUMENTS`; if empty, use the PR for the current branch (`gh pr view --json number`). + +--- + +## Guardrails (read first) + +- **Never invent a comment's verdict.** Classify each comment only after opening the CURRENT code at the file/line it references — line numbers in old comments drift, so locate by surrounding code, not the stale line number. +- **Mock data only.** Validation runs against the project's test harness (jest + mocked Prisma) and/or the dev `mock-webhook` route. Do **not** create real payments/refunds/orgs in a shared or remote database. Many of this repo's money paths are internal (cascades, ledger postings, crons) and aren't cleanly HTTP-callable — the faithful before/after is the real function under a mocked Prisma `$transaction`, which is what the route would call anyway. +- **Pause before push.** After fixes are applied and validated, STOP and present the diff + the before/after evidence. Do not commit or push until the user explicitly approves. +- **Resolve, don't reply.** Bot threads (Gemini Code Assist, CodeRabbit) get *resolved* via GraphQL, with no reply comment, and only after the work is merged/pushed. Do this in a background agent. + +--- + +## Step 1 — Fetch every comment + +Pull all three comment surfaces (they're distinct on GitHub): + +```bash +PR=; REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +# inline (file/line) review comments — the actionable ones +gh api "repos/$REPO/pulls/$PR/comments" -q '.[] | "FILE \(.path):\(.line // .original_line)\nID \(.id)\n\(.body)\n---"' +# review summaries (one per reviewer) +gh pr view $PR --json reviews -q '.reviews[] | "[\(.author.login)/\(.state)] \(.body)"' +# issue-level comments (often the bot "review skipped"/summary boilerplate) +gh pr view $PR --json comments -q '.comments[] | "[\(.author.login)] \(.body)"' +``` + +Note the reviewers. In this repo, **CodeRabbit auto-skips** PRs whose base is not the default branch (its comment is boilerplate — ignore), and **Gemini Code Assist** leaves the inline comments worth triaging. + +## Step 2 — Classify each comment + +For every inline comment, open the referenced code as it is NOW and assign exactly one verdict: + +| Verdict | Meaning | How to decide | +|---|---|---| +| **legit-pending** | A real issue, not yet addressed | The code still has the problem the comment describes | +| **BS** | Wrong, irrelevant, or boilerplate | The claim is false, or it's a bot "skipped/sunset" notice, or it contradicts a deliberate design (cite the design) | +| **already-fixed** | Real, but the current diff already fixes it | The branch's code no longer has the issue (the comment predates the fix) | +| **partly-fixed** | Addressed in part | Some of the comment's points are handled, others remain — list which | +| **incorrectly-fixed** | An attempt exists but is wrong | A change was made that doesn't actually resolve it or introduces a new bug | + +Watch for the trap where two bots give **contradictory** suggestions (e.g. anchor-slug fixes) — when that happens, sidestep the ambiguity with a robust third option rather than picking one. + +Output a triage table: `#`, file:line, reviewer, one-line summary, **verdict**, and a one-line justification grounded in the current code. + +## Step 3 — Plan the fixes + +List only **legit-pending** and **incorrectly-fixed** items. For each: the exact file:line, the concrete fix (approach, not just "fix it"), and whether it's a code change or a test/doc change. Keep BS / already-fixed / partly-fixed items in the table with their justification so the user can see they were considered, not skipped. Present the plan and proceed. + +If a "fix" touches money/ledger/compliance and the correct behaviour is genuinely ambiguous (e.g. an accounting reclassification, a TDS figure), **do not guess** — mark it as needs-decision and surface it to the user instead of shipping an unverified change. + +## Step 4 — Apply the fixes + +Make the edits. Reuse existing helpers/patterns; match the surrounding code's style. Re-run `npx tsc --noEmit`, `npx eslint `, and the relevant test suites as you go. Remember the build trap: `next build` treats ESLint errors as blocking even though the lint CI job is `continue-on-error`, so a `no-fallthrough` / `no-conditional-expect` will fail the build — lint the changed files explicitly. + +## Step 5 — Validate against a running server with MOCK data + +1. Start the dev server in the background: `npm run dev` (it boots against the configured DB; only use it for routes that are safe to call). +2. For each fix, produce **before/after** evidence using the real function the API/route calls, under a mocked Prisma `$transaction` (mirror `__tests__/enterprise/*` mock patterns) — capture the wrong result on the old code path and the correct result after. This is the faithful "before/after" for internal money paths without touching shared data. +3. Where a route is genuinely safe + HTTP-exercisable, hit it (e.g. `app/api/dev/mock-webhook` for a disposable record). Never seed real payments/refunds into a shared DB. +4. Run the full affected suites green; if any change touches the ledger, run the invariant/property tests. + +## Step 6 — PAUSE for the user + +Stop. Present: the triage table, what was fixed, the before/after evidence, anything deferred/needs-decision, and the verification status (tsc / eslint / tests / build). **Do not commit or push.** Wait for explicit approval. + +## Step 7 — Commit & push (after approval) + +Commit with a clear message linking the PR/issue (`Part of #N`, not `Closes` unless it truly closes it), then push to the PR's branch. Keep this skill's own files out of an unrelated fix commit. + +## Step 8 — Resolve the bot threads (background agent, no replies) + +Once pushed, resolve every addressed thread **without posting a reply**, in a background agent. Use GraphQL with a variable (string interpolation of the node id malforms the query) and throttle to avoid the secondary-mutation rate limit: + +```bash +# list unresolved thread ids +gh api graphql -f query='{ repository(owner:"OWNER", name:"REPO"){ pullRequest(number: PR){ + reviewThreads(first: 100){ nodes { id isResolved } } } } }' \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | .id' +# resolve each (variable form, ~1s apart) +gh api graphql -f query='mutation($id: ID!){ resolveReviewThread(input:{threadId:$id}){ thread { isResolved } } }' -F id="$THREAD_ID" +``` + +Only resolve threads whose comment was actually handled (legit-fixed, already-fixed, or BS-with-justification). Leave anything deferred/needs-decision unresolved so it stays visible. Do **not** add reply comments — resolution is the signal. + +--- + +## One-shot driver (optional) + +When the user wants the whole flow run end-to-end on a PR, execute Steps 1→6 autonomously, pause at Step 6, and only run Steps 7→8 after approval. Track progress with the task tools so the user can see each phase. diff --git a/.claude/skills/prisma-seed-sync/SKILL.md b/.claude/skills/prisma-seed-sync/SKILL.md new file mode 100644 index 000000000..31966d193 --- /dev/null +++ b/.claude/skills/prisma-seed-sync/SKILL.md @@ -0,0 +1,52 @@ +--- +name: prisma-seed-sync +description: Bring the modular seed suite (prisma/seed.ts + prisma/seedFiles/N*-create-*.ts) back in sync with the Prisma schema and generate realistic consultation-platform mock data — after schema changes, when seeds fail to compile, or when new models need coverage. Validates with tsc + eslint and only ever seeds a database the user has confirmed is disposable (DATABASE_URL here points at remote Supabase — never reset it unprompted). Use when the user says "update the seeds", "sync seed data with the schema", "my seed file won't compile", or "add seed data for ". +argument-hint: "[optional: models/areas to focus on, e.g. 'Contract + Program']" +--- + +# Prisma Seed Sync (schema → seed suite) + +Keep `prisma/seed.ts` and `prisma/seedFiles/` compiling against the current schema and producing realistic data for this consultation/mentorship platform. If `$ARGUMENTS` names models, focus there but still fix any compile breakage elsewhere in the suite. + +## Guardrails (read first) + +- **The configured DB is shared.** `DATABASE_URL`/`DIRECT_URL` in `.env` point at the remote Supabase pooler — NOT a local throwaway. Never run `prisma migrate reset`, `prisma db push --force-reset`, or a seed against it without the user explicitly confirming the target DB is disposable. Before any destructive command, print the masked host (`grep '^DATABASE_URL' .env | sed -E 's|//[^@]*@|//***@|'`) and ask. Prefer a Supabase branch DB or a local stack for seed runs. +- **The seed entry point is `npm run db:seed`** (`npx tsx prisma/seed.ts`). There is **no** `prisma.seed` key in package.json, so `npx prisma db seed` fails — don't use it. Sizes: `db:seed:small` / `db:seed:medium` / `db:seed:large` (SEED_MODE, parsed in `prisma/seedFiles/config.ts`); edge-case data: `db:seed:validation`. +- **No faker.** The suite uses its own helpers in `prisma/seedFiles/utils.ts` (random selection, date spreads, weighted choices) and quantity knobs in `config.ts`. Extend those; don't add a dependency. +- **Schema conventions** (if the task includes schema edits): enums are declared *below* the model(s) that use them; no backfill migrations (a pre-MVP DB reset is planned); comments are terse and explain *why*, referencing issues as `#N`. +- **Don't renumber existing modules.** New models get a new file in the next number band; related sub-entities share the number with a letter suffix (`15a-`, `15b-`). + +## Step 1 — Diff schema vs seeds + +1. Read `prisma/schema.prisma` — or better, `git diff` it against the last commit where seeds compiled — to list new/changed/removed models, fields, enums, and relations. +2. Map the suite: `prisma/seed.ts` imports the numbered modules in dependency order (users → profiles/credentials → topics → plans → availability → appointments → engagement → payments → tickets → waitlists → sessions → refunds/disputes → payouts/earnings → referrals/collaborators → organizations/enterprise). The orchestrator's call order IS the FK dependency order — a new model slots in after everything it references. +3. Regenerate the client so types match the schema: `npx prisma generate`. + +## Step 2 — Update the seed modules + +Per affected model, follow the established module shape: one `export async function createXxx(deps…)` per file, take parent entities as parameters, return created rows for downstream modules, log progress, scale counts off `config.ts`'s SEED_MODE. + +Data realism for this domain (vary, don't clone): +- **People**: a handful of consultants across specializations/experience/ratings, more consultees, 1–2 admins; WEEKLY vs CUSTOM schedules. +- **Offerings**: consultation/subscription/webinar/class plans across realistic price bands and durations. +- **Bookings**: past + current + future; every RequestStatus represented; DIRECT_CHECKOUT and REQUEST_SUBMITTED sources. +- **Money**: multiple gateways (RAZORPAY paise-denominated especially), SUCCEEDED/PENDING/FAILED, a few refunds (partial + full), 1–2 disputes in different states — amounts must keep ledgers balanced if ledger postings are seeded. +- **Enterprise** (15-band): organizations across statuses/funding sources, contracts → programs → assignments respecting the activation chain. +- **Temporal spread**: createdAt over months, expirations both passed and upcoming; edge cases (expired, archived, free-tier) in small counts. + +## Step 3 — Validate (no DB needed) + +```bash +npx tsc --noEmit # bump heap if node aborts: NODE_OPTIONS="--max-old-space-size=8192" +npx eslint prisma/seed.ts prisma/seedFiles/ # lint ONLY the seed suite — full `npm run lint` is slow and noisy +``` + +Fix everything before touching a database. Most failures are: stale client (re-run `prisma generate`), missing new required fields, enum value drift, FK order. + +## Step 4 — Seed run (only against a confirmed-disposable DB) + +After the user confirms the target (per the guardrail): `npm run db:seed:small` first — it surfaces FK/unique violations fastest — then the size the user wants. A clean run plus spot-check counts (`createXxx` logs) is the pass signal. + +## Step 5 — Report + +Summarize: schema changes covered, modules created/updated/untouched, validation results (tsc / eslint / seed run or "seed run skipped — no disposable DB confirmed"), notable data-shape decisions, and anything deferred. Leave committing to the user. diff --git a/.env.sample b/.env.sample index ea0635dcd..95da2b68a 100644 --- a/.env.sample +++ b/.env.sample @@ -46,6 +46,14 @@ RESEND_API_KEY="" RAZORPAY_KEY_ID="" RAZORPAY_SECRET="" +# #771 D2 / Batch 5 — Razorpay Route (routed wallet settlement). SCAFFOLD ONLY: +# leave ENABLE_ROUTED_WALLET=false until the CA/RBI opinion lands and Route is +# enabled on the merchant account. When true, all three creds are required. +ENABLE_ROUTED_WALLET="false" +RAZORPAY_ROUTE_KEY_ID="" +RAZORPAY_ROUTE_KEY_SECRET="" +RAZORPAY_ROUTE_ACCOUNT_NUMBER="" + # Stripe API credentials and configuration STRIPE_API_KEY="" STRIPE_API_VERSION="" @@ -92,3 +100,36 @@ NEXT_PUBLIC_LOGO_DEV_TOKEN="" # PAN encryption key for consultant tax info (AES-256-GCM) # Generate with: openssl rand -hex 32 PAN_ENCRYPTION_KEY="" +# Organization payout account encryption (AES-256-GCM) +# Generate with: openssl rand -hex 32 +ORG_PAYOUT_ENCRYPTION_KEY="" + +# GST place-of-supply: the supplier's (i.e. Familiarise's) state code. +# Used by lib/compliance/gst.ts to decide CGST+SGST (intra-state) vs IGST +# (inter-state). Defaults to "KA" (Karnataka). Change when the business +# address or the GSTIN's registered state changes; CA sign-off recommended. +SUPPLIER_STATE_CODE="KA" + +# Enterprise — consolidated-invoice rollup cron flag. +# When "true", the monthly consolidated-invoice cron rolls up children's +# unpaid OrganizationInvoice rows into a single parent-org invoice on the +# 1st of the month. Defaults to "false" because org hierarchy is still +# schema-only for most tenants; enable once a parent tenant requires +# consolidated billing. See app/api/cleanup/consolidated-invoice-rollup/. +ENABLE_CONSOLIDATED_INVOICE="false" + +# #776 §K — Better Stack Telemetry (logs) sink for operational events. When +# "true" AND the source token + ingest URL are set, recordSystemEvent/ +# recordSystemError ALSO ship to Better Stack so a failed reconcile / stuck +# payout / webhook-queue backlog / HMAC failure pages someone. The DB +# SystemEvent row is always the source of truth; this is a best-effort side +# channel. Create a Telemetry source in Better Stack to get the token + host. +ENABLE_BETTERSTACK_TELEMETRY="false" +BETTERSTACK_SOURCE_TOKEN="" +BETTERSTACK_INGEST_URL="" + +# #776 — RazorpayX SANDBOX creds for the live-payout sandbox proof +# (scripts/smoke/org-payout-sandbox-smoke.ts + docs/enterprise/45-...). These +# are NOT the production payout creds and do NOT flip ENABLE_LIVE_PAYOUTS. +RAZORPAYX_SANDBOX_KEY="" +RAZORPAYX_SANDBOX_SECRET="" diff --git a/.github/workflows/advance-program-cycles.yml b/.github/workflows/advance-program-cycles.yml new file mode 100644 index 000000000..a4618853f --- /dev/null +++ b/.github/workflows/advance-program-cycles.yml @@ -0,0 +1,46 @@ +name: Advance Program Cycles + +on: + schedule: + # 02:15 UTC — ahead of auto-renew-contracts (02:30) and expire-contracts + # (03:00) so assignments roll while their governing contract is still + # ACTIVE, before any contract-side state moves under them (#779). + - cron: "15 2 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + advance-program-cycles: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Run program-cycle-advance job + run: npx tsx jobs/billing/advance-program-cycles.ts + + - name: Notify on failure + if: failure() + run: | + echo "Program-cycle-advance cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/auto-renew-contracts.yml b/.github/workflows/auto-renew-contracts.yml new file mode 100644 index 000000000..59cb9abb2 --- /dev/null +++ b/.github/workflows/auto-renew-contracts.yml @@ -0,0 +1,43 @@ +name: Auto-Renew Contracts + +on: + schedule: + # 02:30 UTC — 30 min BEFORE expire-contracts (03:00 UTC). Renewal must win + # the race with expiry: this job supersedes + EXPIREs the old contract, so + # by 03:00 expire-contracts has nothing left to flip (#779). + - cron: "30 2 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + auto-renew-contracts: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_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: Run contract-auto-renew job + run: npx tsx jobs/contracts/auto-renew-contracts.ts + + - name: Notify on failure + if: failure() + run: | + echo "Contract-auto-renew cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/cascade-refund-earnings.yml b/.github/workflows/cascade-refund-earnings.yml index 5acd7efdc..f36535142 100644 --- a/.github/workflows/cascade-refund-earnings.yml +++ b/.github/workflows/cascade-refund-earnings.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} steps: - name: Checkout code @@ -37,6 +40,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Refund-earning cascade failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "cascade-refund-earnings" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 74b0000fb..c5bb40bfe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -103,6 +103,18 @@ jobs: - name: Generate Prisma Client run: npx prisma generate + - name: Verify SSO invariants + # Cheap static guard against regressions in the SSO audit fixes + # (PR #655 / issue #672). See scripts/verify-sso-invariants.sh + # for per-check rationale. + run: bash scripts/verify-sso-invariants.sh + + - name: Money-column guard + # #780 — no money column may be declared Int (int4 overflows at + # ₹2.14cr). BigInt columns must also be in the lib/prisma.ts + # boundary map; that half is enforced by the extension drift test. + run: npx tsx scripts/ci/check-money-columns.ts + - name: Unit Tests run: npm run test diff --git a/.github/workflows/cleanup-abandoned-org-top-ups.yml b/.github/workflows/cleanup-abandoned-org-top-ups.yml new file mode 100644 index 000000000..3f5a083bb --- /dev/null +++ b/.github/workflows/cleanup-abandoned-org-top-ups.yml @@ -0,0 +1,46 @@ +name: Cleanup Abandoned Org Top-Ups + +on: + schedule: + # Run daily at 02:00 UTC. Must NOT overlap the subscription-cron + # at 00:00 UTC (CPU + Prisma connection contention). + - cron: "0 2 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + cleanup-abandoned-org-top-ups: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Run abandoned org top-up cleanup + run: npx tsx jobs/cleanup/cleanup-abandoned-org-top-ups.ts + + - name: Notify on failure + if: failure() + run: | + echo "Abandoned org top-up cleanup failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/cleanup-abandoned-payments.yml b/.github/workflows/cleanup-abandoned-payments.yml index 6381b347c..3fd50325b 100644 --- a/.github/workflows/cleanup-abandoned-payments.yml +++ b/.github/workflows/cleanup-abandoned-payments.yml @@ -18,6 +18,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} # Payment gateway credentials for cancelling abandoned payment intents STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} diff --git a/.github/workflows/cleanup-old-stream-recordings.yml b/.github/workflows/cleanup-old-stream-recordings.yml new file mode 100644 index 000000000..1f4c429ca --- /dev/null +++ b/.github/workflows/cleanup-old-stream-recordings.yml @@ -0,0 +1,29 @@ +name: Cleanup Old Stream Recordings + +on: + schedule: + # 03:00 UTC daily. Staggered after abandoned-top-ups (02:00) and + # reconcile-ledgers (02:30) to avoid Prisma pool contention. + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + cleanup-old-stream-recordings: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + - run: npm ci + - run: npx prisma generate + - run: npx tsx jobs/cleanup/cleanup-old-stream-recordings.ts + - if: failure() + run: echo "Stream retention sweep failed" diff --git a/.github/workflows/cleanup-stale-invitations.yml b/.github/workflows/cleanup-stale-invitations.yml new file mode 100644 index 000000000..9b95e8a40 --- /dev/null +++ b/.github/workflows/cleanup-stale-invitations.yml @@ -0,0 +1,47 @@ +name: Cleanup Stale Invitations + +on: + schedule: + # Daily at 08:00 IST (02:30 UTC). Offset from + # cleanup-abandoned-org-top-ups (07:30 IST / 02:00 UTC) and the + # subscription cron (05:30 IST / 00:00 UTC) to keep Prisma + # connections + CPU from contending during the cron window. + # GitHub Actions `schedule` interprets the cron expression in UTC. + - cron: "30 2 * * *" + workflow_dispatch: # allow manual trigger from the Actions tab + +jobs: + cleanup-stale-invitations: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Run stale invitation cleanup + run: npx tsx jobs/cleanup/cleanup-stale-invitations.ts + + - name: Notify on failure + if: failure() + run: | + echo "Stale invitation cleanup failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/consent-retention-sweeper.yml b/.github/workflows/consent-retention-sweeper.yml new file mode 100644 index 000000000..14eb50606 --- /dev/null +++ b/.github/workflows/consent-retention-sweeper.yml @@ -0,0 +1,53 @@ +name: Consent Retention Sweeper + +# DPDP ConsentArtifact retention sweep: counts (or deletes, gated by +# DPDP_SWEEPER_DELETE) artifacts past their 7-year retention window. +# NOT a financial cron — runs even in DEGRADED maintenance. + +on: + schedule: + # 21:00 UTC Sunday = 02:30 IST Monday. GitHub Actions `schedule` + # interprets cron in UTC. Weekly off-peak slot; deletion (when enabled) + # runs in capped batches so a backlog can't monopolise the pool. + - cron: "0 21 * * 0" + workflow_dispatch: # Allow manual triggering + +jobs: + consent-retention-sweeper: + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + # The cron is observe-only when unset / "false"; flip per-environment + # after the archival pipeline lands. GH Actions secrets store the + # live value per-environment (prod / staging). + DPDP_SWEEPER_DELETE: ${{ secrets.DPDP_SWEEPER_DELETE }} + + 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: Run consent retention sweeper job + run: npx tsx jobs/compliance/consent-retention-sweeper.ts + + - name: Notify on failure + if: failure() + run: | + echo "Consent retention sweeper cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/create-payout-batch.yml b/.github/workflows/create-payout-batch.yml index d858f45bc..b717bd3ed 100644 --- a/.github/workflows/create-payout-batch.yml +++ b/.github/workflows/create-payout-batch.yml @@ -37,6 +37,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Create payout batch job failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "create-payout-batch" diff --git a/.github/workflows/databreach-deadline-alerts.yml b/.github/workflows/databreach-deadline-alerts.yml new file mode 100644 index 000000000..ad0c59ef3 --- /dev/null +++ b/.github/workflows/databreach-deadline-alerts.yml @@ -0,0 +1,49 @@ +name: DPDP DataBreach 72h Deadline Alerts + +on: + schedule: + # Hourly at :15 — the 72-hour DPDP reporting deadline is sharp; + # daily would risk crossing the cutoff between runs. The :15 slot + # is free across the existing cron grid. + - cron: "15 * * * *" + workflow_dispatch: + +jobs: + databreach-deadline-alerts: + runs-on: ubuntu-latest + timeout-minutes: 5 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # Email dispatch is opt-in. When DATABREACH_ALERT_EMAIL or + # RESEND_API_KEY are absent the cron falls back to structured-log + # only (event: "dpdp.databreach.deadline"). + DATABREACH_ALERT_EMAIL: ${{ secrets.DATABREACH_ALERT_EMAIL }} + RESEND_API_KEY: ${{ secrets.RESEND_API_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: Run DataBreach deadline alerts + run: npx tsx jobs/compliance/databreach-deadline-alerts.ts + + - name: Notify on failure + if: failure() + run: | + echo "DataBreach deadline alerts cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/dispatch-outbound-webhooks.yml b/.github/workflows/dispatch-outbound-webhooks.yml new file mode 100644 index 000000000..7c7646638 --- /dev/null +++ b/.github/workflows/dispatch-outbound-webhooks.yml @@ -0,0 +1,43 @@ +name: Dispatch Outbound Webhooks + +on: + schedule: + # Every minute. The worker is idempotent — a tick that overlaps an + # in-flight tick observes IN_FLIGHT rows and skips them. + - cron: "* * * * *" + workflow_dispatch: # Allow manual triggering for debugging + +jobs: + dispatch-outbound-webhooks: + runs-on: ubuntu-latest + timeout-minutes: 5 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_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: Run outbound webhook dispatch tick + run: npx tsx jobs/cleanup/dispatch-outbound-webhooks.ts + + - name: Notify on failure + if: failure() + run: | + echo "Outbound webhook dispatch tick failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/dunning.yml b/.github/workflows/dunning.yml new file mode 100644 index 000000000..57e72c4f3 --- /dev/null +++ b/.github/workflows/dunning.yml @@ -0,0 +1,50 @@ +name: Dunning + +# #779 §A — daily invoice dunning: flips ISSUED→OVERDUE past due date and +# sends escalating 7-day reminders (cap 3). Runs after the invoice-gen + +# expire-contracts crons so invoices are in their final state when read. + +on: + schedule: + # 23:30 UTC = 05:00 IST. Quiet slot — after the 03:00 UTC + # expire-contracts / sso-cert crons; no other cron fires at 23:xx. + - cron: "30 23 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + dunning: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + + 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: Run dunning job + run: npx tsx jobs/billing/dunning.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 "dunning" diff --git a/.github/workflows/expire-contracts.yml b/.github/workflows/expire-contracts.yml new file mode 100644 index 000000000..c6441e923 --- /dev/null +++ b/.github/workflows/expire-contracts.yml @@ -0,0 +1,42 @@ +name: Expire Contracts + +on: + schedule: + # 03:00 UTC = 08:30 IST. Quiet slot — no other crons fire here + # (avoids the 00:00 / 01:00 / 02:00 contention zones). + - cron: "0 3 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + expire-contracts: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_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: Run contract-expiry job + run: npx tsx jobs/contracts/expire-contracts.ts + + - name: Notify on failure + if: failure() + run: | + echo "Contract-expiry cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/generate-subscription-invoices.yml b/.github/workflows/generate-subscription-invoices.yml new file mode 100644 index 000000000..609cea342 --- /dev/null +++ b/.github/workflows/generate-subscription-invoices.yml @@ -0,0 +1,57 @@ +name: Generate Subscription Invoices + +# #681 — daily BillingSubscription invoice generation: bills every +# subscription whose nextInvoiceDate is due and fires the 7-day renewal +# reminders. Financial cron (see FINANCIAL_JOB_NAMES) — skipped in DEGRADED. + +on: + schedule: + # 01:00 UTC = 06:30 IST. GitHub Actions `schedule` interprets cron in + # UTC. Runs before settle-invoice-accruals (01:30 UTC) so subscription + # invoices exist before the accrual rollup reads. + - cron: "0 1 * * *" + workflow_dispatch: # Allow manual triggering + +# #813 — never let two invoice-gen runs overlap. Queue a new run behind an +# in-flight one (do NOT cancel — a mid-flight invoice issue must finish) so the +# find-then-claim can't double-bill. Belt to the in-code atomic claim's suspenders. +concurrency: + group: generate-subscription-invoices + cancel-in-progress: false + +jobs: + generate-subscription-invoices: + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + + 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: Run generate subscription invoices job + run: npx tsx jobs/billing/generate-subscription-invoices.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 "generate-subscription-invoices" diff --git a/.github/workflows/handle-lost-disputes.yml b/.github/workflows/handle-lost-disputes.yml index f2873ce4b..26976a7c8 100644 --- a/.github/workflows/handle-lost-disputes.yml +++ b/.github/workflows/handle-lost-disputes.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} steps: - name: Checkout code diff --git a/.github/workflows/handle-stuck-payouts.yml b/.github/workflows/handle-stuck-payouts.yml index 0a50fbc3a..0b0a2f2cc 100644 --- a/.github/workflows/handle-stuck-payouts.yml +++ b/.github/workflows/handle-stuck-payouts.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} # Payment gateway credentials for querying payout status STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -42,6 +45,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Stuck payouts handler failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "handle-stuck-payouts" diff --git a/.github/workflows/irp-uploader.yml b/.github/workflows/irp-uploader.yml new file mode 100644 index 000000000..a5c8df163 --- /dev/null +++ b/.github/workflows/irp-uploader.yml @@ -0,0 +1,56 @@ +name: IRP IRN Uploader + +on: + schedule: + # 02:30 UTC = 08:00 IST. Picked to sit between expire-contracts (03:00) + # and cleanup-abandoned-org-top-ups (02:00) without colliding. Daily + # cadence matches the CBIC 30-day retroactive IRN window. + - cron: "30 2 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + irp-uploader: + runs-on: ubuntu-latest + timeout-minutes: 15 + + # Gated on the ENABLE_IRP_UPLOADER repo variable (see + # lib/feature-flags.ts). On scheduled runs the job exits without + # work when the variable is unset, so we don't burn CI minutes on a + # stubbed connector. Manual workflow_dispatch still runs so an + # operator can validate the pipeline before flipping the flag on. + if: ${{ vars.ENABLE_IRP_UPLOADER == 'true' || github.event_name == 'workflow_dispatch' }} + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # ClearTax GSP credentials. When absent the underlying connector + # returns { status: "FAILED", reason: "STUB" } and the uploader + # treats that as a normal retry — the cron does not crash. + CLEARTAX_API_KEY: ${{ secrets.CLEARTAX_API_KEY }} + CLEARTAX_GSP_TOKEN: ${{ secrets.CLEARTAX_GSP_TOKEN }} + CLEARTAX_GSTIN: ${{ secrets.CLEARTAX_GSTIN }} + + 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: Run IRP uploader + run: npx tsx jobs/compliance/irp-uploader.ts + + - name: Notify on failure + if: failure() + run: | + echo "IRP IRN uploader cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/msme-payment-alerts.yml b/.github/workflows/msme-payment-alerts.yml new file mode 100644 index 000000000..15a0cad6d --- /dev/null +++ b/.github/workflows/msme-payment-alerts.yml @@ -0,0 +1,49 @@ +name: MSME Section 43B(h) Payment Alerts + +on: + schedule: + # 04:30 UTC = 10:00 IST. Runs after the cleanup-empty-folders cron + # at 03:30 UTC and before the morning India business window. + # MSME deadlines are date-precision so daily is sufficient. + - cron: "30 4 * * *" + workflow_dispatch: + +jobs: + msme-payment-alerts: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # Email dispatch is opt-in. When MSME_ALERT_EMAIL or RESEND_API_KEY + # are absent, the cron falls back to structured-log only — finance + # picks up alerts via the Cloud Logging → #finance-alerts sink. + MSME_ALERT_EMAIL: ${{ secrets.MSME_ALERT_EMAIL }} + RESEND_API_KEY: ${{ secrets.RESEND_API_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: Run MSME deadline alerts + run: npx tsx jobs/compliance/msme-payment-alerts.ts + + - name: Notify on failure + if: failure() + run: | + echo "MSME deadline alerts cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/process-data-exports.yml b/.github/workflows/process-data-exports.yml new file mode 100644 index 000000000..699f0f2aa --- /dev/null +++ b/.github/workflows/process-data-exports.yml @@ -0,0 +1,34 @@ +name: Process Data Exports + +on: + schedule: + # Every 10 minutes. Exports are async, low volume, but the + # requester is waiting on an email so latency budget is single- + # digit minutes. + - cron: "*/10 * * * *" + workflow_dispatch: + +jobs: + process-data-exports: + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + SUPABASE_DATA_EXPORT_BUCKET: org-exports + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + - run: npm ci + - run: npx prisma generate + - run: npx tsx jobs/cleanup/process-data-exports.ts + - if: failure() + run: echo "Data export worker failed" diff --git a/.github/workflows/process-payouts.yml b/.github/workflows/process-payouts.yml index 1d64df376..49446f15b 100644 --- a/.github/workflows/process-payouts.yml +++ b/.github/workflows/process-payouts.yml @@ -6,6 +6,13 @@ on: - cron: "0 21 * * 1" workflow_dispatch: # Allow manual triggering +# #776 — never let two payout runs overlap. Queue a new run behind an in-flight +# one (do NOT cancel — a mid-flight disbursement must finish) so the find-then-claim +# can't double-submit to the gateway. Belt to the in-code atomic claim's suspenders. +concurrency: + group: process-payouts + cancel-in-progress: false + jobs: process-payouts: runs-on: ubuntu-latest @@ -45,6 +52,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Process payouts job failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "process-payouts" diff --git a/.github/workflows/prune-audit-logs.yml b/.github/workflows/prune-audit-logs.yml new file mode 100644 index 000000000..d4ab2407d --- /dev/null +++ b/.github/workflows/prune-audit-logs.yml @@ -0,0 +1,28 @@ +name: Prune Audit Logs + +on: + schedule: + # 03:15 UTC daily. Staggered after Stream retention sweep (03:00). + - cron: "15 3 * * *" + workflow_dispatch: + +jobs: + prune-audit-logs: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + - run: npm ci + - run: npx prisma generate + - run: npx tsx jobs/cleanup/prune-audit-logs.ts + - if: failure() + run: echo "Audit log retention sweep failed" diff --git a/.github/workflows/reconcile-disputes.yml b/.github/workflows/reconcile-disputes.yml index 206d18405..8e42e7ae2 100644 --- a/.github/workflows/reconcile-disputes.yml +++ b/.github/workflows/reconcile-disputes.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} # Payment gateway credentials for querying dispute status STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -40,6 +43,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Dispute reconciliation failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "reconcile-disputes" diff --git a/.github/workflows/reconcile-ledgers.yml b/.github/workflows/reconcile-ledgers.yml new file mode 100644 index 000000000..4afb536ed --- /dev/null +++ b/.github/workflows/reconcile-ledgers.yml @@ -0,0 +1,49 @@ +name: Reconcile Ledgers + +on: + schedule: + # Run nightly at 03:45 UTC. The 03:00 UTC slot already hosts + # sso-cert-expiry-alert and mark-expired-recordings; pushing 45 min + # later puts us cleanly after their (~1-2 min) workloads and 15 min + # before the monthly invoice-rollup window at 04:00 UTC on day 1. + # The job is read-only so the cost of overlapping with writers is + # bounded, but staying off the cluster keeps the Supabase connection + # pooler from spiking. The broader hourly :00 + :15 pile-ups are + # tracked under a follow-up cron-schedule-audit issue. + - cron: "45 3 * * *" + workflow_dispatch: # Allow manual triggering from the Actions UI. + +jobs: + reconcile-ledgers: + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_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: Run ledger reconciliation + id: reconcile + run: npx tsx jobs/reconcile/reconcile-ledgers.ts + + - name: Notify on discrepancies + if: failure() + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "reconcile-ledgers" diff --git a/.github/workflows/reconcile-orphaned-confirmations.yml b/.github/workflows/reconcile-orphaned-confirmations.yml new file mode 100644 index 000000000..e366ea679 --- /dev/null +++ b/.github/workflows/reconcile-orphaned-confirmations.yml @@ -0,0 +1,50 @@ +name: Reconcile Orphaned Confirmations + +on: + schedule: + # Run every 30 minutes + - cron: "*/30 * * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + reconcile-orphaned-confirmations: + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + # Payment gateway credentials + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + RAZORPAY_KEY_ID: ${{ secrets.RAZORPAY_KEY_ID }} + RAZORPAY_KEY_SECRET: ${{ secrets.RAZORPAY_KEY_SECRET }} + + 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: Run payment status reconciliation + run: npx tsx jobs/payments/reconcile-orphaned-confirmations.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 "reconcile-orphaned-confirmations" diff --git a/.github/workflows/reconcile-payment-status.yml b/.github/workflows/reconcile-payment-status.yml index 9372e32b3..0c44c4f75 100644 --- a/.github/workflows/reconcile-payment-status.yml +++ b/.github/workflows/reconcile-payment-status.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} # Payment gateway credentials STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -42,6 +45,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Payment status reconciliation failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "reconcile-payment-status" diff --git a/.github/workflows/reconcile-payout-status.yml b/.github/workflows/reconcile-payout-status.yml index 0674554b9..7b243c645 100644 --- a/.github/workflows/reconcile-payout-status.yml +++ b/.github/workflows/reconcile-payout-status.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} # Payment gateway credentials STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -42,6 +45,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Payout status reconciliation failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "reconcile-payout-status" diff --git a/.github/workflows/reconcile-pending-refunds.yml b/.github/workflows/reconcile-pending-refunds.yml index a4e02f51d..4f8ec867b 100644 --- a/.github/workflows/reconcile-pending-refunds.yml +++ b/.github/workflows/reconcile-pending-refunds.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} # Payment gateway credentials for querying refund status STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -42,6 +45,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Refund reconciliation failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "reconcile-pending-refunds" diff --git a/.github/workflows/release-earnings.yml b/.github/workflows/release-earnings.yml index dd42c931a..fccc77ade 100644 --- a/.github/workflows/release-earnings.yml +++ b/.github/workflows/release-earnings.yml @@ -6,6 +6,13 @@ on: - cron: "0 * * * *" workflow_dispatch: # Allow manual triggering +# #776 — never let two release runs overlap. Queue a new run behind an in-flight +# one (do NOT cancel — a mid-flight money job must finish) so the find-then-claim +# can't double-process. Belt to the in-code atomic claim's suspenders. +concurrency: + group: release-earnings + cancel-in-progress: false + jobs: release-earnings: runs-on: ubuntu-latest @@ -15,6 +22,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} steps: - name: Checkout code @@ -37,6 +47,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Release earnings job failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "release-earnings" diff --git a/.github/workflows/release-pending-trust-earnings.yml b/.github/workflows/release-pending-trust-earnings.yml new file mode 100644 index 000000000..f826d935f --- /dev/null +++ b/.github/workflows/release-pending-trust-earnings.yml @@ -0,0 +1,56 @@ +name: Release PENDING_TRUST Earnings + +# Hourly walk that promotes OrganizationEarnings rows out of PENDING_TRUST +# (the invoice-fraud guard from #687) once the sponsoring org has either been +# admin-verified to ACTIVE or paid at least one OrganizationInvoice. After +# promotion the existing release-earnings cron picks them up and flips +# PENDING → READY when holdUntil lapses. +# +# Slot choice: :30. The :00 slot already has 5 simultaneous jobs per #709's +# collision map (release-earnings, sync-payment-earnings, alert-dispute-deadlines, +# auto-complete-appointments, cleanup-invalid-appointments). The :30 slot only +# has waitlist-reminders + cleanup-stale-pending-consultations — neither in the +# earnings/payment family — so this addition keeps the pooler load spread. + +on: + schedule: + - cron: "30 * * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + release-pending-trust-earnings: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Run release pending-trust earnings job + run: npx tsx jobs/cleanup/release-pending-trust-earnings.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 "release-pending-trust-earnings" diff --git a/.github/workflows/settle-invoice-accruals.yml b/.github/workflows/settle-invoice-accruals.yml new file mode 100644 index 000000000..c913d89b7 --- /dev/null +++ b/.github/workflows/settle-invoice-accruals.yml @@ -0,0 +1,60 @@ +name: Settle Invoice Accruals + +# #771 / #749 — monthly consolidated rollup cron: rolls each org's unbilled +# INVOICE_ACCRUAL / OVERAGE_INVOICE_ACCRUAL bookings into one OrganizationInvoice. +# Financial cron (see FINANCIAL_JOB_NAMES) — skipped in DEGRADED maintenance. + +on: + schedule: + # 04:00 UTC = 09:30 IST on the 1st of every month. GitHub Actions `schedule` + # interprets cron in UTC. Monthly because rolling up the same ISSUED invoices + # twice would create duplicate parent invoices (the cron is a one-shot per + # cycle); runs well after generate-subscription-invoices (01:00 UTC). + - cron: "0 4 1 * *" + workflow_dispatch: # Allow manual triggering + +# #813 — never let two rollup runs overlap. Queue a new run behind an in-flight +# one (do NOT cancel — a mid-flight invoice issue must finish) so the find-then-stamp +# can't double-bill. Belt to the in-code Serializable in-tx read's suspenders. +concurrency: + group: settle-invoice-accruals + cancel-in-progress: false + +jobs: + settle-invoice-accruals: + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + # The cron is a no-op when this flag is unset / "false". GH Actions + # secrets store the live value per-environment (prod / staging). + ENABLE_CONSOLIDATED_INVOICE: ${{ secrets.ENABLE_CONSOLIDATED_INVOICE }} + + 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: Run settle invoice accruals job + run: npx tsx jobs/billing/settle-invoice-accruals.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 "settle-invoice-accruals" diff --git a/.github/workflows/sso-cert-expiry-alert.yml b/.github/workflows/sso-cert-expiry-alert.yml new file mode 100644 index 000000000..daca517a6 --- /dev/null +++ b/.github/workflows/sso-cert-expiry-alert.yml @@ -0,0 +1,47 @@ +name: SSO Cert Expiry Alert + +on: + schedule: + # Daily at 08:30 IST (03:00 UTC). Slot picked to avoid overlap + # with cleanup-abandoned-org-top-ups (07:30 IST / 02:00 UTC), + # cleanup-stale-invitations (08:00 IST / 02:30 UTC), and + # settle-invoice-accruals (09:30 IST / 04:00 UTC on day-1). + # GitHub Actions `schedule` interprets the cron expression in UTC. + - cron: "0 3 * * *" + workflow_dispatch: # allow manual trigger after rotating a cert + +jobs: + sso-cert-expiry-alert: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Run SSO cert expiry scan + run: npx tsx jobs/cleanup/sso-cert-expiry-alert.ts + + - name: Notify on failure + if: failure() + run: | + echo "SSO cert expiry alert failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/sweep-abandoned-overage-charges.yml b/.github/workflows/sweep-abandoned-overage-charges.yml new file mode 100644 index 000000000..1760e6583 --- /dev/null +++ b/.github/workflows/sweep-abandoned-overage-charges.yml @@ -0,0 +1,45 @@ +name: Sweep Abandoned Overage Charges + +# #785 (task #25) — FAILs never-paid PENDING CHARGE_MEMBER overage side-charges so +# they stop counting toward the per-cycle circuit-breaker ceiling. Runs daily. + +on: + schedule: + - cron: "30 2 * * *" + workflow_dispatch: + +jobs: + sweep-abandoned-overage-charges: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Sweep abandoned overage charges + run: npx tsx jobs/cleanup/sweep-abandoned-overage-charges.ts + + - name: Notify on failure + if: failure() + run: | + echo "Abandoned overage-charge sweep job failed" diff --git a/.github/workflows/sweep-orphaned-topup-captures.yml b/.github/workflows/sweep-orphaned-topup-captures.yml new file mode 100644 index 000000000..3e22c7ac8 --- /dev/null +++ b/.github/workflows/sweep-orphaned-topup-captures.yml @@ -0,0 +1,45 @@ +name: Sweep Orphaned Top-up Captures + +# #785 (task #23) — re-credits gateway-captured wallet top-ups whose confirm/ledger +# post rolled back (capturedAt set, still PENDING). Runs every 30 minutes. + +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + +jobs: + sweep-orphaned-topup-captures: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Sweep orphaned top-up captures + run: npx tsx jobs/cleanup/sweep-orphaned-topup-captures.ts + + - name: Notify on failure + if: failure() + run: | + echo "Captured-top-up sweep job failed" diff --git a/.github/workflows/sweep-stuck-webhook-events.yml b/.github/workflows/sweep-stuck-webhook-events.yml new file mode 100644 index 000000000..9e212ff3a --- /dev/null +++ b/.github/workflows/sweep-stuck-webhook-events.yml @@ -0,0 +1,49 @@ +name: Sweep Stuck Webhook Events + +# B5 (#785, task #10) — re-drives WebhookEvent rows left processed=false after an +# after()-callback crash, so the money side-effects land instead of relying on a +# gateway redelivery that never comes. Runs every 10 minutes. + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + sweep-stuck-webhook-events: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + # Database connection (required for Prisma). Optional integrations the + # replayed handlers may touch (Novu, gateway lookups) degrade gracefully + # when absent, matching the other money crons. + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + + 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: Sweep stuck webhook events + run: npx tsx jobs/cleanup/sweep-stuck-webhook-events.ts + + - name: Notify on failure + if: failure() + run: | + echo "Stuck-webhook sweep job failed" diff --git a/.github/workflows/sync-payment-earnings.yml b/.github/workflows/sync-payment-earnings.yml index 014846093..bdf7c2eda 100644 --- a/.github/workflows/sync-payment-earnings.yml +++ b/.github/workflows/sync-payment-earnings.yml @@ -15,6 +15,9 @@ jobs: # Database connection (required for Prisma) DATABASE_URL: ${{ secrets.DATABASE_URL }} DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} steps: - name: Checkout code @@ -37,6 +40,6 @@ jobs: - name: Notify on failure if: failure() - run: | - echo "Payment-earning sync failed" - # Add notification logic here (Slack, email, etc.) + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "sync-payment-earnings" diff --git a/.github/workflows/timeout-member-overages.yml b/.github/workflows/timeout-member-overages.yml new file mode 100644 index 000000000..e764d51b0 --- /dev/null +++ b/.github/workflows/timeout-member-overages.yml @@ -0,0 +1,50 @@ +name: Timeout Member Overages + +# #779 §A — daily 14-day hard timeout on never-settled CHARGE_MEMBER overage +# side-charges: flips PENDING→FAILED (frees the circuit-breaker ceiling) and +# notifies the member. Sibling to sweep-abandoned-overage-charges (7d, silent). + +on: + schedule: + # 23:00 UTC = 04:30 IST. Quiet slot just before the dunning cron + # (23:30 UTC); no other cron fires at 23:xx. + - cron: "0 23 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + timeout-member-overages: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 — fail-closed cron lock: without these the job refuses to run. + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + + 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: Run member-overage timeout job + run: npx tsx jobs/billing/timeout-member-overages.ts + + - name: Notify on failure + if: failure() + run: | + echo "Member-overage timeout cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.github/workflows/wallet-low-balance.yml b/.github/workflows/wallet-low-balance.yml new file mode 100644 index 000000000..f7d105f80 --- /dev/null +++ b/.github/workflows/wallet-low-balance.yml @@ -0,0 +1,47 @@ +name: Wallet Low Balance + +# #777 §C — daily wallet low-balance alert: notifies finance when a WALLET +# account dips below its configured minimum. NOTIFY-ONLY — no money moves and +# no WalletTopUp row is created until payment mandates land. + +on: + schedule: + # 23:45 UTC = 05:15 IST. Quiet slot — after the 23:30 UTC dunning cron, + # no other cron fires at 23:xx. + - cron: "45 23 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + wallet-low-balance: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + + 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: Run wallet-low-balance job + run: npx tsx jobs/billing/wallet-low-balance.ts + + - name: Notify on failure + if: failure() + run: | + echo "Wallet-low-balance cron failed" + # TODO: wire to #ops-alerts Slack channel. diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..c752378d8 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# React 18 with Radix/RHF/etc. peer-deps that resolve loosely; flip off +# (or remove) once React 19 lands and the peer-dep matrix is verified. +legacy-peer-deps=true diff --git a/.serena/project.yml b/.serena/project.yml index 39cb95824..b756b80a8 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,16 +1,20 @@ # the name by which the project can be referenced within Serena project_name: "familiarise_web" + # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -25,7 +29,7 @@ project_name: "familiarise_web" # The first language is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: - - typescript +- typescript # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings @@ -34,8 +38,9 @@ encoding: "utf-8" # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore in all projects -# same syntax as gitignore, so you can use * and ** +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -43,52 +48,19 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -99,13 +71,68 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/Enterprise-Simplification-Proposal.docx b/Enterprise-Simplification-Proposal.docx new file mode 100644 index 000000000..f6142d3b4 Binary files /dev/null and b/Enterprise-Simplification-Proposal.docx differ diff --git a/PRODUCT_FLOW_MAP.md b/PRODUCT_FLOW_MAP.md new file mode 100644 index 000000000..290e5a9c6 --- /dev/null +++ b/PRODUCT_FLOW_MAP.md @@ -0,0 +1,649 @@ +# Familiarise — Product Flow Map + +Complete permutation map of every customer journey on the platform, with Mermaid diagrams. Written for a half-technical, half-business CEO reading. + +--- + +## 1. The Four Products Inside One Platform + +You are not building one product. You are building **four distinct commerce models** under one roof. + +```mermaid +flowchart TD + Platform["🏪 Familiarise Platform\n\"Shopify for Knowledge Businesses\""] + + Platform --> C["📞 Consultation\n1-on-1 · single session · 30–90 min"] + Platform --> S["🔁 Subscription\n1-on-1 · recurring program · 4–32 sessions"] + Platform --> W["📡 Webinar\nGroup live event · N learners · 1 session"] + Platform --> CL["🎓 Class\nGroup structured course · 6–20 sessions"] + + C --> C1["Individual or Org buyer\nPersonal payment / Org wallet / Invoice"] + S --> S1["Free trial → conversion funnel\n3 allocation modes\nOrg program caps"] + W --> W1["Capacity limited with waitlist\nCollaborator revenue splits\nRecording + certificates"] + CL --> CL1["Multi-session curriculum\nTeaching assistants\nAsync content + live sessions"] +``` + +| Product | Who buys | Who delivers | Sessions | Key Mechanic | +|---|---|---|---|---| +| **Consultation** | Individual or Org | Solo expert | 1 | Approval gate + doc review | +| **Subscription** | Individual or Org | Solo expert | 4–32 over weeks | 3 allocation modes + trial funnel | +| **Webinar** | Many individuals or Org | Expert + co-hosts | 1 | Capacity + waitlist queue | +| **Class** | Many individuals or Org | Expert + TAs | 6–20 | Curriculum + collaborator splits | + +--- + +## 2. The Cast of Characters + +```mermaid +flowchart LR + subgraph Supply["Supply Side"] + Solo["Solo Consultant\nOwns plans, manages calendar"] + Collab["Collaborating Consultant\nCo-host / TA / Guest Speaker\nEarns revenue share %"] + end + + subgraph Demand["Demand Side"] + Ind["Individual Learner\nPersonal card, no org"] + OrgMember["Org-Sponsored Learner\nLEARNER role, budget from org program"] + OrgAdmin["Org Admin\nOWNER / MAINTAINER / MANAGER\nManages budget + programs"] + end + + subgraph Platform["Platform Roles"] + Staff["Staff\nModeration + verification"] + Admin["Admin\nFull platform control"] + OrgOp["OrgWorkspaceProfile\nEnterprise operator"] + end + + Ind -->|books| Solo + OrgMember -->|books via org budget| Solo + OrgAdmin -->|manages| OrgMember + Collab -->|co-hosts with| Solo +``` + +--- + +## 3. Consultant Setup Flow (Supply Side) + +```mermaid +flowchart TD + Signup([Consultant signs up]) --> Wizard["Onboarding wizard\n/form/onboarding/"] + Wizard --> Profile["Create ConsultantProfile\nname, bio, domain, education, socials"] + Profile --> Verify["Upload verification docs\nresume, certifications"] + Verify --> StaffReview{Staff reviews} + StaffReview -- Approved --> Verified["✅ VERIFIED\nVisible in /explore/experts"] + StaffReview -- Rejected --> NeedsInfo["NEEDS_INFO\nResubmit docs"] + StaffReview -- Rejected hard --> Rejected(["❌ REJECTED"]) + + Verified --> Plans["Create service plans\n/dashboard/consultant/planner/services/"] + Plans --> ConsultPlan["ConsultationPlan\ntitle, price, duration, topics"] + Plans --> SubPlan["SubscriptionPlan\ncallsPerWeek, durationInMonths\nfree trial toggle"] + Plans --> WebinarPlan["WebinarPlan\nmaxParticipants, recording policy\ncertificate toggle"] + Plans --> ClassPlan["ClassPlan\ncurriculum, number of sessions"] + + Plans --> Availability["Set availability\n/dashboard/consultant/settings/schedule/"] + Availability --> Weekly["Weekly slots\ne.g. Every Mon 6–9pm IST\nstored as UTC minutes since midnight"] + Availability --> Custom["Custom slots\none-off dates"] + + Plans --> ApprovalMode["Configure approval mode per plan"] + ApprovalMode --> Direct["Direct booking\nlearner pays → confirmed instantly"] + ApprovalMode --> NeedsApproval["Requires approval\nlearner requests → consultant approves → payment"] + + Plans --> CollabInvite["Invite collaborators (webinar/class only)\nset role + revenue share %"] + CollabInvite --> CollabAccepts["Collaborator ACCEPTED\nearnings split at payout"] +``` + +--- + +## 4. The Booking State Machine + +Every booking — regardless of service type — moves through this state machine. + +```mermaid +stateDiagram-v2 + [*] --> PENDING : Request submitted or direct checkout + + PENDING --> APPROVED_PENDING_PAYMENT : Consultant approves + PENDING --> REJECTED : Consultant declines + PENDING --> EXPIRED : 30 days no action + PENDING --> CANCELLED : Either party cancels + + APPROVED_PENDING_PAYMENT --> APPROVED : Payment webhook confirmed + APPROVED_PENDING_PAYMENT --> EXPIRED : 7 days no payment + APPROVED_PENDING_PAYMENT --> CANCELLED : Either party cancels + + APPROVED --> SCHEDULED : Slots allocated (subscriptions only) + APPROVED --> COMPLETED : Auto-complete cron, 1hr after session + SCHEDULED --> COMPLETED : Auto-complete cron + + APPROVED --> CANCELLED + SCHEDULED --> CANCELLED + + COMPLETED --> [*] + REJECTED --> [*] + EXPIRED --> [*] + CANCELLED --> [*] +``` + +Slot state runs in parallel: + +```mermaid +stateDiagram-v2 + [*] --> Tentative : Created at checkout + Tentative --> Confirmed : Payment webhook, isTentative set false + Tentative --> Deleted : Cleanup cron, 7 days abandoned + Confirmed --> COMPLETED + Confirmed --> CANCELLED + Confirmed --> RESCHEDULED +``` + +--- + +## 5. The Checkout Algorithm (500ms) + +What actually happens when a consultee clicks "Pay Now": + +```mermaid +sequenceDiagram + participant C as Consultee + participant FE as Frontend + participant API as Checkout API + participant Redis as Redis Lock + participant DB as Database (Supabase) + participant GW as Payment Gateway + participant WH as Webhook Handler + + C->>FE: Click "Book Now" + FE->>API: POST /api/checkout + API->>API: Zod validation + + API->>Redis: Acquire distributed lock on slot + alt Lock fails + Redis-->>API: 409 Conflict + API-->>FE: Try again + end + Redis-->>API: Lock acquired + + API->>DB: Re-validate inside lock (TOCTOU) + Note over DB: Is slot still free?
Is capacity available?
No scheduling conflicts? + + API->>GW: Create payment intent (30-min TTL) + GW-->>API: paymentIntentId + clientSecret + + API->>DB: SERIALIZABLE transaction + Note over DB: Create Consultation/Subscription/Webinar/Class (PENDING)
Create Appointment
Create SlotOfAppointment (isTentative=true)
Create Payment (PENDING) + PaymentLeg(s) + + API->>Redis: Release lock + API-->>FE: paymentIntentId + clientSecret + + FE->>C: Render payment widget (Razorpay popup / Stripe Elements) + C->>GW: Complete payment + + GW->>WH: Webhook: payment.succeeded + WH->>WH: Idempotency check (WebhookEvent table) + + WH->>DB: Phase 1 — SERIALIZABLE transaction + Note over DB: Payment.status → SUCCEEDED
SlotOfAppointment.isTentative → false
Event.requestStatus → APPROVED + + WH->>DB: Phase 2 — fire-and-forget + Note over DB: Create ConsultantEarnings (80%)
Create Invoice
Trigger Novu notifications
Create ActivityLog entry + + WH-->>C: Confirmation email + in-app notification +``` + +--- + +## 6. Slot Availability & The 30-Minute Atom + +```mermaid +flowchart TD + ConsAvail["Consultant sets availability\ne.g. Mon 6–9pm IST"] --> UTCStore["Stored as UTC integers\ndayOfWeek = MONDAY\nstartTimeUtc = 750 mins\nendTimeUtc = 930 mins"] + + UTCStore --> SlotMath["SlotCalculationService\n30-minute atomic slots"] + SlotMath --> Slots6["6 × 30-min slots per Monday\n(6–9pm = 3 hours)"] + + Slots6 --> DurationMap["Duration → slots consumed\n30 min = 1 slot\n60 min = 2 slots\n90 min = 3 slots"] + + DurationMap --> Conflict["validateNoConflicts()\nScans SlotOfAppointment where\nisTentative=false AND user includes consultantId"] + Conflict --> Clean{No overlap?} + Clean -- Yes --> Proceed["Slot available → proceed to checkout"] + Clean -- No --> Block["409 Conflict → pick another time"] + + subgraph SubscriptionMath["Subscription slot math"] + countWeeks["countWeeks(start, end)\nSunday–Saturday counting"] + reqSlots["calculateRequiredSlots\ncallsPerWeek × weeks"] + slotsPerCall["getSlotsPerCall(durationMins)"] + end +``` + +--- + +## 7. Consultation Journeys (All Variants) + +```mermaid +flowchart TD + Entry([Consultee finds consultant\n/explore/experts]) --> SelectPlan[Selects consultation plan] + SelectPlan --> ApprovalCheck{Approval required?} + + ApprovalCheck -- No --> SlotPick[Picks slot from calendar] + ApprovalCheck -- Yes --> RequestForm["Fills request form\nproposed time + description"] + RequestForm --> PendingReq["POST /api/slots/request-for-approval\nConsultation PENDING"] + PendingReq --> ConsReview{Consultant reviews} + ConsReview -- Rejects --> Rejected(["❌ REJECTED"]) + ConsReview -- Approves --> PayLink["APPROVED_PENDING_PAYMENT\nPayment link sent to consultee"] + PayLink --> TimedOut{Pays within 7 days?} + TimedOut -- No --> Expired(["⏰ EXPIRED"]) + TimedOut -- Yes --> SlotPick + + SlotPick --> CheckoutFlow["Checkout: lock → validate → create tentative\nCreate payment intent"] + + subgraph PaymentSources["Payment source (pick one or combine)"] + PersonalCard["💳 Personal card"] + OrgWallet["🏢 Org wallet (pre-funded)"] + OrgInvoice["📄 Org invoice (month-end)"] + OrgLicense["🔑 Org license (flat annual)"] + Credits["🎁 Referral credits + card"] + end + + CheckoutFlow --> PaymentSources + PaymentSources --> Gateway[Pay via Razorpay / Stripe] + Gateway -- Fails --> Cleanup["PaymentIntentManager.cleanup()\ntentative slot deleted after 7d"] + Gateway -- Succeeds --> Webhook["Webhook Phase 1: APPROVED\nslot confirmed"] + Webhook --> DocCheck{Document review?} + DocCheck -- Yes --> DocUpload["Consultee uploads resume/doc\nConsultant reviews async\nstatus: PENDING → APPROVED / NEEDS_REVISION"] + DocCheck -- No --> Session + DocUpload --> Session["🎥 Stream.io session"] + Session --> AutoComplete["Cron: COMPLETED 1hr after session ends"] + AutoComplete --> EarningsFlow["ConsultantEarnings created\nPayout scheduled (TDS deducted)"] +``` + +--- + +## 8. Subscription Journeys — Trial, Allocation, Org Cap + +```mermaid +flowchart TD + SubEntry([Consultee views SubscriptionPlan]) --> TrialCheck{Free trial offered?} + + TrialCheck -- Yes, wants trial --> TrialReq["POST /api/trials\nTrialSession PENDING"] + TrialReq --> ConsApproves{Consultant approves?} + ConsApproves -- No --> TrialRejected(["❌ Trial REJECTED"]) + ConsApproves -- Yes --> TrialSession["Free 30/60 min session\nStream.io"] + TrialSession --> TrialCron["Cron: Trial COMPLETED 1hr after session"] + TrialCron --> Converts{Converts to paid?} + Converts -- No --> Dropout(["📉 Dropout tracked in analytics"]) + Converts -- Yes --> DirectSub + TrialCheck -- No --> DirectSub + + DirectSub["Pays upfront e.g. ₹10,000\nChooses scheduling period"] --> PayConfirm["Webhook confirms payment"] + PayConfirm --> AllocMode{Allocation mode?} + + AllocMode -- Auto --> AutoAlloc["Consultant clicks auto-allocate\nRedis lock acquired\nSystem scores + picks best N slots\ncallsPerWeek × weeks in period"] + AllocMode -- Manual --> ManualAlloc["Consultant hand-picks slot IDs\nPATCH /api/events/subscriptions/{id}/allocate"] + AllocMode -- Requested --> ReqSlots["Consultee proposed times at checkout\nConsultant approves\nuseRequestedSlots()"] + + AutoAlloc --> SlotsCreated["N Appointments created\nCalendar populated for both parties"] + ManualAlloc --> SlotsCreated + ReqSlots --> SlotsCreated + + SlotsCreated --> OrgCapCheck{Org program cap?} + OrgCapCheck -- No cap --> Sessions + OrgCapCheck -- Under cap --> Sessions["Weekly sessions over program period"] + OrgCapCheck -- Over cap --> OverageBehavior{overage behavior} + OverageBehavior -- BLOCK --> Blocked(["🚫 Booking blocked"]) + OverageBehavior -- CHARGE_MEMBER --> MemberPays["Member's personal card charged\nfor overage sessions"] + OverageBehavior -- CHARGE_ORG --> OrgCharged["Accrued to org overage invoice"] + MemberPays --> Sessions + OrgCharged --> Sessions + + Sessions --> AllComplete["All sessions COMPLETED\nCertificate issued if enabled"] + Converts -- Yes --> ConvertMark["Trial marked CONVERTED\nlinked via convertedToSubscriptionId"] + ConvertMark --> DirectSub +``` + +--- + +## 9. Waitlist Flow (Webinar & Class) + +```mermaid +stateDiagram-v2 + [*] --> WAITING : Joins waitlist (POST /api/waitlist) + + WAITING --> NOTIFIED : Spot opens, 48h window starts + WAITING --> CANCELLED : User leaves queue + + NOTIFIED --> BOOKED : Pays within 48h + NOTIFIED --> EXPIRED : 48h passes, no response + NOTIFIED --> SKIPPED : User declines spot + + EXPIRED --> WAITING : Re-queued, next notified + SKIPPED --> WAITING : Re-queued, next notified + + BOOKED --> [*] + CANCELLED --> [*] +``` + +```mermaid +flowchart TD + WebinarFull["Webinar / Class at capacity\ncurrentParticipants >= maxParticipants"] --> WaitlistOpt{Join waitlist?} + WaitlistOpt -- Yes --> Queue["WAITING\nQueue position: count ahead by priority + time"] + WaitlistOpt -- No --> Exit(["Exit"]) + + Queue --> SpotOpens["Another registrant cancels\nhandleSlotOpening() triggers"] + SpotOpens --> NotifyFirst["First in queue: WAITING → NOTIFIED\nEmail: spot available, 48h to respond"] + NotifyFirst --> Respond{Responds within 48h?} + Respond -- Yes, pays --> Booked["BOOKED\nSlot confirmed, position rebalanced"] + Respond -- No --> ExpiredEntry["EXPIRED\nCron removes, next in queue NOTIFIED"] + Respond -- Declines --> Skipped["SKIPPED\nRe-queued at back"] +``` + +--- + +## 10. Webinar & Class Group Session Flow + +```mermaid +flowchart TD + Browse([Browse /explore/programs]) --> EventPage["View webinar or class detail\nseats remaining, schedule, price"] + + EventPage --> CapCheck{Seats available?} + CapCheck -- Yes --> Enroll["Register / Enroll\n/checkout/plans/webinar/{planId}"] + CapCheck -- No --> WaitlistFlow["→ Waitlist flow (see above)"] + + Enroll --> Checkout["Checkout: Redis lock → re-check capacity\nCreate Webinar + Appointment (shared) + SlotOfAppointment + Payment"] + Checkout --> Pay["Pay via gateway"] + Pay --> Webhook["Webhook: isTentative → false\nparticipant count++"] + + Webhook --> AllPartners["All N registrants share\nONE Appointment row\nONE SlotOfAppointment\n(many-to-one, not N separate bookings)"] + AllPartners --> Reminders["Reminders: 24h before + 1h before"] + Reminders --> JoinNow["'Join Now' active 5 min before\nStream.io group room"] + + JoinNow --> CollabCheck{Collaborators?} + CollabCheck -- Yes --> CollabRoom["Co-host / TA in room\nRevenue split on each payment\nOwner: e.g. 80%, Guest: 20%"] + CollabCheck -- No --> SoloRoom["Solo consultant hosts"] + CollabRoom --> Session["Group session runs"] + SoloRoom --> Session + + Session --> RecordingPolicy{Recording policy?} + RecordingPolicy -- STREAM_S3 --> TempRecording["Stream S3\n2-week retention, then expired"] + RecordingPolicy -- SUPABASE_PERMANENT --> PermRecording["Supabase storage\nPermanent, participant access"] + TempRecording --> PostSession + PermRecording --> PostSession + + PostSession["Post-session"] --> CertCheck{Certificate enabled?} + CertCheck -- Yes --> Cert["Certificate auto-issued"] + CertCheck -- No --> Review["Prompt: leave a review"] + Cert --> Review +``` + +--- + +## 11. Trial Session Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> PENDING : Consultee requests trial (POST /api/trials) + + PENDING --> SCHEDULED : Consultant approves, slot created + PENDING --> REJECTED : Consultant declines + PENDING --> CANCELLED : Either party cancels + + SCHEDULED --> COMPLETED : Cron 1hr after session ends + + COMPLETED --> CONVERTED : Consultee buys subscription from same consultant + + CONVERTED --> [*] + COMPLETED --> [*] + REJECTED --> [*] + CANCELLED --> [*] +``` + +--- + +## 12. Enterprise Organization Types + +```mermaid +flowchart TD + Org["Organization"] --> SponsorQ{canSponsor?} + Org --> HostQ{canHost?} + + SponsorQ -- Yes + canHost No --> Sponsor["SPONSOR\ne.g. Wipro, IIT Madras\nPays for employee sessions"] + HostQ -- Yes + canSponsor No --> Host["HOST (canHost)\ne.g. LearnPro — behind ENABLE_HOST_ORGS\nHosts consultants, earns revenue share"] + SponsorQ & HostQ -- Both Yes --> Hybrid["HYBRID\nLarge corp with internal training\nBoth pays AND earns"] + + Sponsor --> FundingSource["FundingSource"] + FundingSource --> WALLET["WALLET\nPre-funded credit pool\ne.g. ₹5L top-up\nDebit per booking\n(auto-top-up below minBalancePaise)"] + FundingSource --> INVOICE["INVOICE\nAccrual at booking\nMonth-end roll-up\nGST-compliant e-invoice"] + FundingSource --> LICENSE["LICENSE\nFlat annual fee\ne.g. ₹2L per 100 seats\nUnlimited sessions"] + + Host --> RateCard["RateCard (basis points)\nplatformBps + orgBps + consultantBps = 10000"] + RateCard --> Example["Example: 10% platform\n15% org\n75% consultant"] + Example --> OrgEarnings["OrganizationEarnings\nWeekly batch payout to org bank"] + + Hybrid --> Both["Both funding AND hosting\napply simultaneously"] +``` + +--- + +## 13. Enterprise Program & Budget Cap Flow + +```mermaid +flowchart TD + Contract["Contract\nDRAFT → ACTIVE\ncommercial agreement"] --> Programs["Programs under contract"] + Programs --> LSeat["LICENSED_SEAT\ne.g. 4 sessions per learner per quarter"] + Programs --> CPool["CREDIT_POOL\ne.g. ₹5L shared, resets quarterly"] + + LSeat --> Assign["Member assigned to program\nProgramAssignment ACTIVE\nengagementsUsed = 0 (LICENSED_SEAT)\nconsumedPaise = 0 (CREDIT_POOL)\nfirst assignment ⇒ Program.configLockedAt stamped"] + CPool --> Assign + + Assign --> MemberBooks["Member starts checkout"] + MemberBooks --> Preview["Pre-checkout overage preview\nresolveOverageDecision(): would this booking exceed the cap?"] + Preview --> CapCheck{"Under cap?\nengagementsUsed < coveredEngagementsPerCycle\nor consumedPaise < creditsPerCycle×100"} + + CapCheck -- Yes --> Allowed["Booking proceeds\nBookingUtilization row created\nengagementsUsed++ / consumedPaise+="] + CapCheck -- No --> Breaker{"Circuit breaker\nmaxOveragePerCyclePaise hit?"} + Breaker -- "Yes (cumulative cap blown)" --> Blocked + Breaker -- No --> OverageBehavior{overage behavior} + + OverageBehavior -- BLOCK --> Blocked(["🚫 Booking rejected\nMember shown budget exhausted"]) + OverageBehavior -- CHARGE_MEMBER --> MemberCard["OverageEvent PENDING\nMember's personal card charged\n(marginal = basePaise + surchargePaise)\nsweep cron times out abandoned charges → FAILED"] + OverageBehavior -- CHARGE_ORG --> OrgOverage["OverageEvent ACCRUED\nbasePaise + overageSurchargeBps markup\nrolled into org invoice line item"] + + Allowed --> Settlement["Settlement at period end"] + MemberCard --> Settlement + OrgOverage --> Settlement + + Settlement --> WalletSettle["WALLET: debit from BillingAccount"] + Settlement --> InvoiceSettle["INVOICE: OrganizationInvoice\nDRAFT → ISSUED (with IRN) → PAID"] + Settlement --> LicenseSettle["LICENSE: no per-booking charge\nflat annual only"] + + InvoiceSettle --> GST["GST applied\nCGST + SGST (same state)\nor IGST (interstate)"] + GST --> MSME["MSME payment deadline\n15 days (MICRO) or 45 days (SMALL/MEDIUM)"] + + Settlement --> CycleEnd{"Cycle period ended?\n(advance-program-cycles cron)"} + CycleEnd -- "Contract ACTIVE + autoRenew" --> Roll["ROLL: mint successor ACTIVE assignment\nold row → ROLLED, rolledToAssignmentId set\ncap resets for the new period"] + CycleEnd -- "autoRenew off / contract inactive / clamped past effectiveTo" --> Close["CLOSE: assignment → CLOSED\nno successor (coverage lapses)"] +``` + +> **v2 (#777/#779).** Caps now reset automatically: the `advance-program-cycles` +> cron rolls each `ProgramAssignment` into a fresh successor (`ROLLED` → new +> `ACTIVE`) when the governing contract is `ACTIVE` + `autoRenew`, else `CLOSED` +> (`lib/enterprise/cycle-engine.ts`). Overage is split into `basePaise` + +> `surchargePaise` (`overageSurchargeBps`) and capped by a per-cycle circuit +> breaker (`maxOveragePerCyclePaise`) that forces `BLOCK` once cumulative overage +> is exceeded. Money config locks (`Program.configLockedAt`) the moment the first +> assignment exists. + +--- + +## 14. Revenue & Earnings Split + +```mermaid +flowchart TD + Payment["Payment SUCCEEDED\ne.g. ₹10,000 gross"] --> PlatformCut["Platform commission: 20%\n₹2,000"] + Payment --> ConsultantGross["Consultant share: 80%\n₹8,000"] + + ConsultantGross --> CollabCheck{Collaborators on this event?} + CollabCheck -- No --> OwnerFull["Owner earns 100% of consultant share\nConsultantEarnings: role=OWNER\n₹8,000"] + CollabCheck -- Yes --> SplitByShare["Split by sharePercentage\ne.g. Guest Speaker = 20%"] + SplitByShare --> Owner["Owner: 80% of consultant share\nConsultantEarnings: role=OWNER, ₹6,400"] + SplitByShare --> Collaborator["Collaborator: 20% of consultant share\nConsultantEarnings: role=COLLABORATOR, ₹1,600"] + + Owner --> HoldPeriod["Earnings HELD\nduring dispute window"] + Collaborator --> HoldPeriod + OwnerFull --> HoldPeriod + + HoldPeriod --> Ready["Earnings READY for payout"] + Ready --> TDS["TDS deducted — Section 194J\nPAN verified, GSTIN checked"] + TDS --> Payout["Payout batch\nRazorpay or Stripe\nto PayoutAccount (bank / UPI)"] + Payout --> Paid["Status: PAID\nSettlementLedgerEntry created"] + + OrgHostCheck{Org-hosted consultant?} -- Yes --> OrgEarnings["OrganizationEarnings row\nWeekly batch OrganizationPayout\nto org bank account"] +``` + +--- + +## 15. Full End-to-End User Journey + +```mermaid +flowchart TD + Entry([User enters Familiarise]) --> RoleCheck{Who are you?} + + RoleCheck -- Individual learner --> Explore["/explore/experts\nor /explore/programs"] + RoleCheck -- Org-sponsored learner --> OrgCatalog["Org-curated service catalog\n(OrgPlanVisibility: ORG_ONLY)"] + RoleCheck -- Consultant --> OnboardWizard["Onboarding wizard\n→ create plans → set availability"] + + Explore --> SearchFilter["Search by domain, price, rating, language\nFilter by service type"] + OrgCatalog --> SearchFilter + SearchFilter --> Profile["Consultant profile page\n/explore/experts/{consultantId}"] + + Profile --> ServiceChoice{Which service format?} + ServiceChoice -- Quick help --> C["Consultation\n30–90 min · 1 session"] + ServiceChoice -- Deep program --> S["Subscription\n4–32 sessions · weeks long"] + ServiceChoice -- Live event --> W["Webinar\nGroup · fixed time"] + ServiceChoice -- Structured course --> CL["Class\nGroup · multi-week"] + + C --> ApprovalGate{Approval required?} + ApprovalGate -- No --> Pay + ApprovalGate -- Yes --> RequestApproval["Submit request\nConsultant approves → payment link"] + RequestApproval --> Pay + + S --> TrialGate{Free trial available?} + TrialGate -- Yes, want trial --> FreeTrial["Free session → COMPLETED"] + FreeTrial --> Liked{Liked it?} + Liked -- Yes --> Pay + Liked -- No --> Exit(["Exit"]) + TrialGate -- No --> Pay + + W --> CapGate{Spots available?} + CL --> CapGate + CapGate -- Yes --> Pay + CapGate -- No --> Waitlist["Join waitlist\n→ notified when spot opens\n48h window"] + Waitlist --> Pay + + Pay["Complete payment\n💳 Card / 🏢 Org wallet / 📄 Invoice / 🔑 License / 🎁 Credits"] --> Session["🎥 Live session on Stream.io"] + Session --> PostSession["Recording + materials\nCertificate if enabled"] + PostSession --> Review["⭐ Leave review"] + Review --> Repeat{Book again?} + Repeat -- Yes --> Profile + Repeat -- No --> Done(["Done"]) +``` + +--- + +## 16. Background Automation (No Human Needed) + +```mermaid +flowchart LR + subgraph Hourly["⏰ Hourly"] + AC["auto-complete-appointments\nCompletes sessions 1hr after end time"] + PWE["process-waitlist-expirations\nNOTIFIED → EXPIRED\nnext in queue notified"] + end + + subgraph Every2h["⏰ Every 2 hours"] + CTS["cleanup-tentative-slots\nDeletes abandoned tentative slots\n7+ days old"] + end + + subgraph Daily["📅 Daily"] + ESR["expire-stale-requests\nPENDING → EXPIRED after 30 days\nAPPROVED_PENDING_PAYMENT → EXPIRED after 7 days"] + end + + subgraph Scheduled["📬 Scheduled"] + AR["appointment-reminders\n24h + 1h before session"] + end + + subgraph Periodic["🔄 Periodic"] + SPE["sync-payment-earnings\nSafety net: creates missing earnings rows"] + end + + subgraph MonthEnd["📊 Month-end"] + GOI["generate-org-invoices\nRolls up INVOICE_ACCRUAL payments\nto OrganizationInvoice with GST"] + end + + subgraph Nightly["🌙 Nightly"] + DBA["data-breach-alert\n72h DPDP Act compliance check"] + GDC["gst-drift-check (FF-6)\nDetects GST calculation drift"] + end +``` + +--- + +## 17. The Permutation Matrix + +Every journey is a combination of these axes: + +| Axis | Options | +|---|---| +| **Service type** | Consultation · Subscription · Webinar · Class | +| **Approval mode** | Direct · Requires approval | +| **Allocation mode** (subscription only) | Auto · Manual · Requested slots | +| **Trial** (subscription only) | None · Trial no-convert · Trial converts | +| **Payment source** | Personal card · Org wallet · Org invoice · Org license · Credits+card · Wallet+card | +| **Capacity** | Available · Waitlist→enrolls · Waitlist→expires | +| **Collaboration** | Solo consultant · With co-host/TA (revenue split) | +| **Document review** | None · Consultee uploads · Consultant responds | +| **Recording** | None · Stream 2-week · Supabase permanent | +| **Org program cap** | None · Under cap · Overage BLOCK · Overage CHARGE\_MEMBER · Overage CHARGE\_ORG | + +**~3,000+ distinct end-to-end paths.** ~15–20 primary journeys cover 90% of real usage. The rest are edge cases handled automatically by the state machine + cron jobs. + +--- + +## 18. Competitive Moat (Why This Is Hard to Copy) + +```mermaid +flowchart TD + Competitor["Competitor starting today"] --> B1["Booking state machine\n8 states, 4 service types"] + Competitor --> B2["Two-phase commit checkout\nRedis distributed locks\nTOCTOU race condition guards"] + Competitor --> B3["3-mode slot allocation\nauto scoring algorithm\nconcurrency guards"] + Competitor --> B4["Priority waitlist queue\n48h expiry + re-queue logic"] + Competitor --> B5["Multi-leg payments\ncard + wallet + credits + org invoice\n4 gateways"] + Competitor --> B6["India compliance\nGST CGST/SGST/IGST\nTDS 194J withholding\ne-invoice IRN\nMSME 15/45-day rules"] + Competitor --> B7["Enterprise billing stack\nContract → Program → ProgramAssignment\n3 overage behaviors\nimmutable 3-ledger accounting"] + Competitor --> B8["Trial-to-conversion funnel\nunique constraint per consultee+consultant pair\nauto-conversion detection at checkout"] + + B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> Timeline["2–3 years to rebuild\nwith India compliance expertise"] + Timeline --> Moat["By then: verified expert roster\n+ learner trust\n= real switching costs"] +``` + +--- + +## Key Files Reference + +| Area | Path | +|---|---| +| Schema | `prisma/schema.prisma` | +| Booking architecture | `docs/booking/01-architecture.md` | +| Booking lifecycle | `docs/booking/06-booking-lifecycle.md` | +| Slot math | `docs/booking/03-slot-math-and-calculations.md` | +| Trial sessions | `docs/booking/09-trial-sessions.md` | +| Waitlist system | `docs/booking/11-waitlist-system.md` | +| Checkout + payment | `docs/booking/10-checkout-payment-integration.md` | +| Enterprise overview | `docs/enterprise/00-foundations/01-overview.md` | +| Enterprise scenarios | `docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md` | +| Funding & programs | `docs/enterprise/00-foundations/03-funding-and-programs.md` | +| Enterprise readiness | `docs/enterprise/90-audits/01-readiness-audit.md` | +| Slot allocation engine | `utils/slotAllocation/SlotAllocationService.ts` | +| Checkout orchestration | `lib/payments/operations/checkout.ts` | +| Webhook handlers | `lib/payments/webhooks/handlers.ts` | +| Explore pages | `app/explore/` | +| Checkout pages | `app/checkout/plans/` | +| Consultant dashboard | `app/dashboard/consultant/[consultantId]/` | +| Org dashboard | `app/dashboard/organization/` | diff --git a/SCHEMA_MAP.md b/SCHEMA_MAP.md new file mode 100644 index 000000000..2e5b7b7e2 --- /dev/null +++ b/SCHEMA_MAP.md @@ -0,0 +1,1923 @@ +# Familiarise — Prisma Schema Visualisation + +**120 models · 97 enums · 4,845 lines of schema** (as of 2026-06-05) + +24 focused diagrams, each covering one domain. Use the table of contents to jump to any section. All diagrams are based on the live `prisma/schema.prisma`. + +--- + +## Contents + +1. [Master Domain Map](#1-master-domain-map) +2. [User Identity & Auth](#2-user-identity--auth) +3. [User Role Profiles](#3-user-role-profiles) +4. [Consultant Domain Taxonomy](#4-consultant-domain-taxonomy) +5. [Professional Background](#5-professional-background) +6. [Service Plans](#6-service-plans) +7. [Plan Curriculum Content](#7-plan-curriculum-content) +8. [Availability & Slots](#8-availability--slots) +9. [Bookings — Consultation & Subscription](#9-bookings--consultation--subscription) +10. [Bookings — Webinar & Class](#10-bookings--webinar--class) +11. [Appointment — The Pivot Model](#11-appointment--the-pivot-model) +12. [Session Infrastructure](#12-session-infrastructure) +13. [Documents & Consultant Verification](#13-documents--consultant-verification) +14. [Collaboration System](#14-collaboration-system) +15. [Payment System](#15-payment-system) +16. [Referral System](#16-referral-system) +17. [Consultant Payouts & Tax](#17-consultant-payouts--tax) +18. [Enterprise Core — Org & Membership](#18-enterprise-core--org--membership) +19. [Enterprise Billing & Programs](#19-enterprise-billing--programs) +20. [Enterprise Invoicing & Org Payouts](#20-enterprise-invoicing--org-payouts) +21. [Three-Ledger Accounting](#21-three-ledger-accounting) +22. [Support & Feedback](#22-support--feedback) +23. [Moderation](#23-moderation) +24. [Compliance, HRIS & System](#24-compliance-hris--system) +25. [Enum Reference Table](#25-enum-reference-table) + +--- + +## 1. Master Domain Map + +How all 24 domain groups connect to each other. + +```mermaid +flowchart TD + subgraph Identity["Identity & Auth"] + User + Account + Session + end + subgraph Profiles["Role Profiles"] + ConsultantProfile + ConsulteeProfile + OrgWorkspaceProfile + end + subgraph Taxonomy["Domain Taxonomy"] + Domain + SubDomain + Tag + Topic + end + subgraph Background["Professional Background"] + WorkExperience + Certification + Education + Achievement + end + subgraph Plans["Service Plans"] + ConsultationPlan + SubscriptionPlan + WebinarPlan + ClassPlan + end + subgraph Bookings["Bookings"] + Consultation + Subscription + Webinar + Class + TrialSession + Waitlist + end + subgraph Core["Appointment Core"] + Appointment + SlotOfAppointment + MeetingSession + Recording + end + subgraph Docs["Documents"] + AppointmentDocument + PlanMaterial + ConsultantProfileVerification + end + subgraph Collab["Collaboration"] + WebinarCollaborator + ClassCollaborator + end + subgraph Pay["Payments"] + Payment + PaymentLeg + Refund + Dispute + Invoice + end + subgraph Earn["Payouts & Earnings"] + ConsultantEarnings + Payout + TDSRecord + end + subgraph Ref["Referral"] + ReferralCode + ReferralCredit + end + subgraph Ent["Enterprise"] + Organization + Membership + BillingAccount + Contract + Program + end + subgraph Ledgers["Accounting Ledgers"] + UsageLedgerEntry + LedgerAccount + LedgerEntry + end + subgraph Support["Support & Moderation"] + SupportTicket + ModerationReport + end + subgraph Compliance["Compliance & System"] + ConsentArtifact + HrisConfig + ActivityLog + end + + Identity --> Profiles + Profiles --> Plans + Profiles --> Background + Profiles --> Taxonomy + Plans --> Bookings + Bookings --> Core + Core --> Docs + Plans --> Collab + Core --> Pay + Pay --> Earn + Pay --> Ledgers + Ent --> Pay + Ent --> Ledgers + Identity --> Ref + Identity --> Support + Identity --> Compliance + Ent --> Compliance +``` + +--- + +## 2. User Identity & Auth + +The central `User` model and BetterAuth supporting tables. + +```mermaid +erDiagram + User { + string id + string email + string name + string phone + UserRole role + boolean emailVerified + boolean onboardingCompleted + string timezone + string consultantProfileId + string consulteeProfileId + string orgWorkspaceProfileId + } + Account { + string id + string userId + string providerId + string accountId + string accessToken + string refreshToken + } + Session { + string id + string token + string userId + datetime expiresAt + string ipAddress + string activeOrganizationId + } + Verification { + string id + string identifier + string value + datetime expiresAt + } + SsoProvider { + string id + string issuer + string providerId + string organizationId + string domain + string userId + } + CookiePreference { + string id + string userId + boolean essential + boolean analytics + boolean marketing + boolean functional + } + NotificationPreference { + string id + string userId + boolean allNotifications + boolean emailEnabled + boolean inAppEnabled + boolean appointmentReminders + boolean quietHoursEnabled + } + + User ||--o{ Account : "has" + User ||--o{ Session : "has" + User ||--o| CookiePreference : "has" + User ||--o| NotificationPreference : "has" + User ||--o{ SsoProvider : "has" +``` + +--- + +## 3. User Role Profiles + +One `User` can hold multiple profile types. `ConsultantProfile` and `ConsulteeProfile` can co-exist on the same account. + +```mermaid +erDiagram + User { + string id + UserRole role + string consultantProfileId + string consulteeProfileId + string staffProfileId + string adminProfileId + string orgWorkspaceProfileId + } + ConsultantProfile { + string id + string userId + string domainId + ScheduleType scheduleType + ConsultantVerificationStatus verificationStatus + boolean isVerified + float rating + int totalRevenue + int pendingRevenue + boolean isIndependent + } + ConsulteeProfile { + string id + string userId + CareerStage careerStage + BudgetPreference budgetPreference + boolean isIndependent + } + StaffProfile { + string id + string userId + string department + string position + } + AdminProfile { + string id + string userId + } + OrgWorkspaceProfile { + string id + string userId + } + + User ||--o| ConsultantProfile : "is consultant" + User ||--o| ConsulteeProfile : "is consultee" + User ||--o| StaffProfile : "is staff" + User ||--o| AdminProfile : "is admin" + User ||--o| OrgWorkspaceProfile : "is org operator" +``` + +--- + +## 4. Consultant Domain Taxonomy + +How consultants categorise their expertise and how plans are tagged. + +```mermaid +erDiagram + ConsultantProfile { + string id + string domainId + } + Domain { + string id + string name + } + SubDomain { + string id + string name + string domainId + } + Tag { + string id + string name + string domainId + } + Topic { + string id + string name + } + ConsultantReview { + string id + string consultantProfileId + string consulteeProfileId + int rating + } + + Domain ||--|{ SubDomain : "has" + Domain ||--|{ Tag : "has" + ConsultantProfile }o--|| Domain : "primary domain" + ConsultantProfile }o--o{ SubDomain : "tagged" + ConsultantProfile }o--o{ Tag : "tagged" + ConsultantProfile ||--o{ ConsultantReview : "receives" + Topic }o--o{ ConsultationPlan : "on plan" + Topic }o--o{ SubscriptionPlan : "on plan" + Topic }o--o{ WebinarPlan : "on plan" + Topic }o--o{ ClassPlan : "on plan" +``` + +--- + +## 5. Professional Background + +All consolidated at `User` level (not profile level) for DRY principle. `Achievement` is consultant-only. + +```mermaid +erDiagram + User { + string id + } + WorkExperience { + string id + string userId + string company + string companyDomain + string title + datetime startDate + datetime endDate + boolean isCurrent + } + Certification { + string id + string userId + string name + string issuingOrganization + datetime issueDate + datetime expiryDate + string credentialUrl + } + Education { + string id + string userId + string institution + string degree + string fieldOfStudy + int startYear + int endYear + } + Achievement { + string id + string consultantProfileId + AchievementType achievementType + string title + string url + } + ConsultantProfile { + string id + string userId + } + + User ||--o{ WorkExperience : "career history" + User ||--o{ Certification : "certifications" + User ||--o{ Education : "education" + ConsultantProfile ||--o{ Achievement : "portfolio" + User ||--o| ConsultantProfile : "is consultant" +``` + +--- + +## 6. Service Plans + +Four plan types. All owned by `ConsultantProfile`, optionally also by `Organization`. Visibility controls marketplace exposure. + +```mermaid +erDiagram + ConsultantProfile { + string id + } + Organization { + string id + } + ConsultationPlan { + string id + string consultantProfileId + string organizationId + string title + int price + float durationInHours + OrgPlanVisibility visibility + } + SubscriptionPlan { + string id + string consultantProfileId + string organizationId + string title + int price + int callsPerWeek + int durationInMonths + int totalSessions + boolean freeTrialEnabled + int freeTrialDurationMinutes + OrgPlanVisibility visibility + } + WebinarPlan { + string id + string consultantProfileId + string organizationId + string title + int price + int maxParticipants + float durationInHours + boolean certificateProvided + RecordingStoragePolicy recordingStoragePolicy + OrgPlanVisibility visibility + } + ClassPlan { + string id + string consultantProfileId + string organizationId + string title + int price + int meetingsPerWeek + int durationInMonths + int totalSessions + int maxParticipants + boolean certificateProvided + OrgPlanVisibility visibility + } + PlanMaterial { + string id + string consultationPlanId + string subscriptionPlanId + string webinarPlanId + string classPlanId + string fileName + string fileUrl + int order + } + + ConsultantProfile ||--o{ ConsultationPlan : "offers" + ConsultantProfile ||--o{ SubscriptionPlan : "offers" + ConsultantProfile ||--o{ WebinarPlan : "offers" + ConsultantProfile ||--o{ ClassPlan : "offers" + Organization ||--o{ ConsultationPlan : "owns (optional)" + Organization ||--o{ SubscriptionPlan : "owns (optional)" + Organization ||--o{ WebinarPlan : "owns (optional)" + Organization ||--o{ ClassPlan : "owns (optional)" + ConsultationPlan ||--o{ PlanMaterial : "materials" + SubscriptionPlan ||--o{ PlanMaterial : "materials" + WebinarPlan ||--o{ PlanMaterial : "materials" + ClassPlan ||--o{ PlanMaterial : "materials" +``` + +--- + +## 7. Plan Curriculum Content + +Session-by-session curriculum outline for Subscription and Class plans. + +```mermaid +erDiagram + SubscriptionPlan { + string id + string title + int callsPerWeek + int durationInMonths + int totalSessions + } + SubscriptionContent { + string id + string subscriptionPlanId + string title + string contentType + string contentUrl + int order + float hoursAllotted + } + ClassPlan { + string id + string title + int meetingsPerWeek + int durationInMonths + int totalSessions + } + ClassContent { + string id + string classPlanId + string title + string contentType + string contentUrl + int order + float hoursAllotted + } + + SubscriptionPlan ||--o{ SubscriptionContent : "session outline" + ClassPlan ||--o{ ClassContent : "session outline" +``` + +--- + +## 8. Availability & Slots + +How consultant availability windows become booked appointment slots. + +```mermaid +erDiagram + ConsultantProfile { + string id + } + SlotOfAvailabilityWeekly { + string id + string consultantProfileId + DayOfWeek startDay + int startTimeUtc + DayOfWeek endDay + int endTimeUtc + int utcOffsetMinutes + } + SlotOfAvailabilityCustom { + string id + string consultantProfileId + datetime startsAt + datetime endsAt + } + SlotOfAppointment { + string id + string appointmentId + datetime startsAt + datetime endsAt + boolean isTentative + SlotCompletionStatus completionStatus + datetime completedAt + } + MeetingSession { + string id + string slotOfAppointmentId + string streamCallId + Platform platform + boolean isRecording + datetime endedAt + string endedReason + } + Recording { + string id + string meetingSessionId + RecordingStorageType storageType + RecordingStatus status + string streamRecordingId + string supabasePath + datetime streamUrlExpiresAt + datetime transferredAt + } + + ConsultantProfile ||--o{ SlotOfAvailabilityWeekly : "weekly windows" + ConsultantProfile ||--o{ SlotOfAvailabilityCustom : "custom windows" + SlotOfAppointment ||--o| MeetingSession : "live session" + MeetingSession ||--o{ Recording : "recordings" +``` + +> **Key invariant**: `startTimeUtc` and `endTimeUtc` are stored as **integer minutes since midnight UTC** (0–1439). The 30-minute atomic slot is the fundamental booking unit. + +--- + +## 9. Bookings — Consultation & Subscription + +The request-to-approval-to-payment state machine for 1-on-1 services. + +```mermaid +erDiagram + ConsulteeProfile { + string id + } + ConsultationPlan { + string id + string consultantProfileId + int price + } + Consultation { + string id + string consultationPlanId + string requestedById + RequestStatus requestStatus + BookingSource bookingSource + string pendingPaymentUrl + CancellationReason cancellationReason + datetime cancelledAt + } + SubscriptionPlan { + string id + string consultantProfileId + int price + boolean freeTrialEnabled + int callsPerWeek + int durationInMonths + } + Subscription { + string id + string subscriptionPlanId + string requestedById + RequestStatus requestStatus + BookingSource bookingSource + string pendingPaymentUrl + datetime schedulingPeriodStartsAt + datetime schedulingPeriodEndsAt + } + TrialSession { + string id + string consulteeProfileId + string consultantProfileId + string subscriptionPlanId + string appointmentId + string convertedToSubscriptionId + string organizationId + TrialSessionStatus status + } + + ConsulteeProfile ||--o{ Consultation : "requests" + ConsultationPlan ||--o{ Consultation : "booked under" + ConsulteeProfile ||--o{ Subscription : "requests" + SubscriptionPlan ||--o{ Subscription : "booked under" + ConsulteeProfile ||--o{ TrialSession : "trial" + SubscriptionPlan ||--o{ TrialSession : "trialled" + TrialSession ||--o| Subscription : "converts to" +``` + +--- + +## 10. Bookings — Webinar & Class + +Group sessions. Many consultees share one Appointment row (many-to-one booking). + +```mermaid +erDiagram + WebinarPlan { + string id + string consultantProfileId + int price + int maxParticipants + boolean certificateProvided + RecordingStoragePolicy recordingStoragePolicy + } + Webinar { + string id + string webinarPlanId + WebinarStatus status + string feedbackSummary + } + ClassPlan { + string id + string consultantProfileId + int price + int maxParticipants + int totalSessions + boolean certificateProvided + } + Class { + string id + string classPlanId + ClassStatus status + datetime schedulingPeriodStartsAt + datetime schedulingPeriodEndsAt + } + Waitlist { + string id + string userId + string webinarId + string classId + string organizationId + WaitlistStatus status + int priority + int position + datetime notifiedAt + datetime expiresAt + datetime bookedAt + } + + WebinarPlan ||--o{ Webinar : "schedules instances" + Webinar ||--o{ Waitlist : "queue" + ClassPlan ||--o{ Class : "runs cohorts" + Class ||--o{ Waitlist : "queue" +``` + +--- + +## 11. Appointment — The Pivot Model + +`Appointment` links every booking type to its slots and payments. Group events (webinar/class) share one Appointment row across all registrants. + +```mermaid +erDiagram + Appointment { + string id + AppointmentsType appointmentType + string consultationId + string subscriptionId + string webinarId + string classId + string organizationId + } + Consultation { + string id + RequestStatus requestStatus + } + Subscription { + string id + RequestStatus requestStatus + } + Webinar { + string id + WebinarStatus status + } + Class { + string id + ClassStatus status + } + TrialSession { + string id + TrialSessionStatus status + string appointmentId + } + SlotOfAppointment { + string id + string appointmentId + datetime startsAt + datetime endsAt + boolean isTentative + SlotCompletionStatus completionStatus + } + Payment { + string id + string appointmentId + string userId + PaymentStatus paymentStatus + int amount + } + AppointmentDocument { + string id + string appointmentId + DocumentReviewStatus reviewStatus + DocumentUploadRole uploadedByRole + } + + Consultation ||--o| Appointment : "1-to-1" + Subscription ||--o{ Appointment : "1-to-many sessions" + Webinar ||--o| Appointment : "shared by all registrants" + Class ||--o{ Appointment : "one per session" + TrialSession ||--o| Appointment : "free session" + Appointment ||--|{ SlotOfAppointment : "1+ time slots" + Appointment ||--o{ Payment : "paid via" + Appointment ||--o{ AppointmentDocument : "docs" +``` + +--- + +## 12. Session Infrastructure + +The Stream.io video layer and dual-storage recording system. + +```mermaid +erDiagram + SlotOfAppointment { + string id + SlotCompletionStatus completionStatus + } + MeetingSession { + string id + string slotOfAppointmentId + string streamCallId + Platform platform + boolean isRecording + string recordingStartedBy + datetime recordingStartedAt + datetime endedAt + string endedReason + } + Recording { + string id + string meetingSessionId + string organizationId + string title + RecordingStorageType storageType + RecordingStatus status + int durationInMinutes + string streamRecordingId + string streamCallId + string supabaseUrl + string supabasePath + string thumbnailUrl + datetime streamUrlExpiresAt + datetime transferredAt + } + + SlotOfAppointment ||--o| MeetingSession : "one session" + MeetingSession ||--o{ Recording : "recordings" +``` + +> **Dual storage**: `STREAM_S3` = temporary 2-week storage (free tier). `SUPABASE` = permanent (premium). `RecordingStatus` lifecycle: RECORDING → PROCESSING → READY → TRANSFERRING → AVAILABLE. + +--- + +## 13. Documents & Consultant Verification + +```mermaid +erDiagram + Appointment { + string id + } + AppointmentDocument { + string id + string appointmentId + string responseToDocumentId + string fileName + string fileUrl + DocumentReviewStatus reviewStatus + DocumentUploadRole uploadedByRole + string reviewedBy + datetime reviewedAt + } + ConsultantProfile { + string id + ConsultantVerificationStatus verificationStatus + } + ConsultantProfileVerification { + string id + string consultantProfileId + ProfileVerificationStatus status + datetime submittedAt + datetime reviewedAt + string reviewedById + string rejectionReason + string feedbackDetails + } + ProfileVerificationDocument { + string id + string verificationId + string fileName + string fileUrl + boolean isValid + string staffFeedback + string description + } + + Appointment ||--o{ AppointmentDocument : "session docs" + AppointmentDocument ||--o{ AppointmentDocument : "consultant response doc" + ConsultantProfile ||--o{ ConsultantProfileVerification : "verification submissions" + ConsultantProfileVerification ||--o{ ProfileVerificationDocument : "supporting docs" +``` + +--- + +## 14. Collaboration System + +Co-hosts, TAs, and guest speakers with revenue-share splits on Webinar and Class plans. + +```mermaid +erDiagram + ConsultantProfile { + string id + } + WebinarPlan { + string id + string consultantProfileId + } + WebinarCollaborator { + string id + string consultantProfileId + string webinarPlanId + string invitedById + WebinarCollaboratorRole role + float revenueSharePercentage + CollaboratorStatus status + datetime respondedAt + } + ClassPlan { + string id + string consultantProfileId + } + ClassCollaborator { + string id + string consultantProfileId + string classPlanId + string invitedById + ClassCollaboratorRole role + float revenueSharePercentage + CollaboratorStatus status + datetime respondedAt + } + + WebinarPlan ||--o{ WebinarCollaborator : "has collaborators" + ConsultantProfile ||--o{ WebinarCollaborator : "collaborates on" + ClassPlan ||--o{ ClassCollaborator : "has collaborators" + ConsultantProfile ||--o{ ClassCollaborator : "collaborates on" +``` + +--- + +## 15. Payment System + +Two-phase commit: tentative slot created pre-payment, confirmed on webhook. Multi-leg funding for stacked payment sources. + +```mermaid +erDiagram + Payment { + string id + string userId + string appointmentId + string organizationId + string billingAccountId + string billableToOrgInvoiceId + string discountCodeId + PaymentGateway paymentGateway + PaymentStatus paymentStatus + int amount + int originalAmount + int taxAmount + boolean isInternational + string buyerCountry + string displayCurrencyAtCheckout + float exchangeRateAtCheckout + } + PaymentLeg { + string id + string paymentId + PaymentLegSource source + int amountPaise + string sourceRef + } + Refund { + string id + string paymentId + RefundStatus status + int amount + PaymentGateway paymentGateway + string refundId + float exchangeRateAtRefund + } + Dispute { + string id + string paymentId + DisputeStatus status + int amount + datetime dueBy + string disputeId + } + Invoice { + string id + string paymentId + string invoiceNumber + PaymentStatus status + int amount + int taxAmount + string hsnCode + } + WebhookEvent { + string id + string provider + string eventId + string eventType + boolean processed + datetime receivedAt + } + DiscountCode { + string id + string code + DiscountType discountType + int discountValue + boolean isActive + int currentUses + int maxUses + datetime expiresAt + } + + Payment ||--o{ PaymentLeg : "funding legs" + Payment ||--o{ Refund : "refunds" + Payment ||--o{ Dispute : "disputes" + Payment ||--o| Invoice : "invoice" + Payment }o--o| DiscountCode : "discount applied" +``` + +--- + +## 16. Referral System + +User acquisition via referral codes, with credit pools applied as payment legs. + +```mermaid +erDiagram + User { + string id + } + ReferralCode { + string id + string userId + string code + string customCode + int referrerReward + int refereeReward + int totalReferrals + int successfulReferrals + boolean isActive + } + Referral { + string id + string referralCodeId + string referredUserId + ReferralStatus status + datetime qualifiedAt + int referrerRewardAmount + int refereeRewardAmount + } + ReferralCredit { + string id + string userId + CreditSource source + int amount + int usedAmount + int remainingAmount + datetime expiresAt + } + ReferralCreditUsage { + string id + string creditId + string paymentId + int amount + int originalAmount + int restoredAmount + } + + User ||--o| ReferralCode : "owns code" + ReferralCode ||--o{ Referral : "tracks" + User ||--o| Referral : "was referred" + User ||--o{ ReferralCredit : "earns credits" + ReferralCredit ||--o{ ReferralCreditUsage : "applied to" + ReferralCreditUsage }o--|| Payment : "used on payment" +``` + +--- + +## 17. Consultant Payouts & Tax + +Earnings lifecycle from payment → hold → payout → TDS deduction → bank transfer. + +```mermaid +erDiagram + ConsultantProfile { + string id + ResidencyStatus residencyStatus + float tdsRate + MsmeStatus msmeStatus + PayoutArrangement payoutArrangement + } + ConsultantEarnings { + string id + string consultantProfileId + string paymentId + string payoutId + int grossAmount + int platformFee + int consultantShare + int refundedShareAmount + EarningRole role + float sharePercentage + EarningStatus status + datetime holdUntil + datetime paidAt + } + Payout { + string id + string consultantProfileId + PayoutStatus status + PayoutMethod method + int amount + int tdsDeducted + int netAmount + float tdsRateApplied + string tdsFinancialYear + string batchId + datetime processedAt + } + PayoutAccount { + string id + string consultantProfileId + PayoutAccountType accountType + boolean isVerified + boolean isDefault + string razorpayContactId + string razorpayFundAccId + string stripeAccountId + } + ConsultantTaxInfo { + string id + string consultantProfileId + boolean panVerified + string gstin + boolean gstinVerified + boolean isIndianResident + string lutNumber + datetime lutValidUntil + } + TDSRecord { + string id + string consultantProfileId + string payoutId + string financialYear + int quarter + int tdsDeducted + float tdsRate + boolean reportedInForm26Q + datetime form26QFilingDate + } + TdsAdjustment { + string id + string consultantProfileId + string tdsRecordId + string payoutId + string refundId + string financialYear + int quarter + int amountPaise + boolean reportedInForm26Q + } + GstTcsBatch { + string id + string financialYear + int month + int netSupplyPaise + int tcsCollectedPaise + GstTcsBatchStatus status + datetime filedAt + } + GstTcsAdjustment { + string id + string batchId + string paymentId + string refundId + int amountPaise + } + + ConsultantProfile ||--o{ ConsultantEarnings : "earns" + ConsultantProfile ||--o{ Payout : "batch payouts" + ConsultantProfile ||--o{ PayoutAccount : "bank accounts" + ConsultantProfile ||--o| ConsultantTaxInfo : "tax info" + ConsultantProfile ||--o{ TDSRecord : "TDS records" + ConsultantProfile ||--o{ TdsAdjustment : "TDS reversals (refund)" + Payout ||--o{ ConsultantEarnings : "batches" + Payout ||--o{ TDSRecord : "triggers TDS" + GstTcsBatch ||--o{ GstTcsAdjustment : "monthly GSTR-8 net" +``` + +> **#778 §D refund-tax reversals.** `TdsAdjustment` posts a negative line in the +> revised 26Q/27Q when previously-withheld TDS is reversed on refund; +> `GstTcsBatch` aggregates GST TCS u/s 52 per month for GSTR-8 (e-commerce +> operator, 1% on registered consultants), with `GstTcsAdjustment` netting +> refund reversals into the period's batch. Collection + filing are flag-gated +> pending CA signoff. `CreditNote` (Sec 34 / CGST Rule 53) is the org-side +> refund document — see section 20 (Enterprise Invoicing). + +--- + +## 18. Enterprise Core — Org & Membership + +An Organization can sponsor employees (`canSponsor`) and/or host consultants (`canHost`). `Membership` is the source of truth; `Member` is kept for BetterAuth invite-token compatibility. + +```mermaid +erDiagram + Organization { + string id + string name + string slug + OrgStatus status + boolean canSponsor + boolean canHost + boolean isPublic + string billingAccountId + string gstin + GstRegStatus gstRegStatus + DataRegion dataResidencyRegion + Currency contractCurrency + boolean requiresPO + int paymentTermsDays + string parentId + } + Membership { + string id + string userId + string organizationId + string consulteeProfileId + string consultantProfileId + MemberRole role + MemberStatus status + PayoutRecipient payoutRecipient + string rateCardOverrideId + string departmentLabel + string betterAuthMemberId + } + Member { + string id + string organizationId + string userId + string role + } + Invitation { + string id + string organizationId + string inviterId + string userId + string email + string status + datetime expiresAt + } + OrganizationSSOSettings { + string id + string organizationId + boolean enforceSSO + datetime breakGlassUntil + MemberRole defaultRoleForAutoJoin + } + OrgDomainClaim { + string id + string organizationId + string domain + string verificationToken + datetime verifiedAt + } + OrgAuditLog { + string id + string organizationId + OrgAuditCategory category + string action + string actorMembershipId + } + + Organization ||--o{ Membership : "typed members" + Organization ||--o{ Member : "BetterAuth members" + Organization ||--o{ Invitation : "pending invites" + Organization ||--o| OrganizationSSOSettings : "SSO" + Organization ||--o{ OrgDomainClaim : "domain claims" + Organization ||--o{ OrgAuditLog : "audit trail" + Organization ||--o{ OrgInvoiceCounter : "fiscal-year seq" + User ||--o{ Membership : "member of orgs" + Membership ||--o| Member : "bridges BetterAuth" +``` + +### 18.1 New 2026-05-15 fields on `Organization` + +Added by the Round-3 close-out PR to close enterprise procurement + +India-statutory gaps: + +| Field | Type | Purpose | +|---|---|---| +| `billingContactName` | `String?` | Named human at the org for invoice/PO correspondence. | +| `billingContactEmail` | `String? @db.VarChar(255)` | Used by Novu `ORG_INVOICE_*` workflows when present; falls back to OWNER membership email. | +| `billingContactPhone` | `String? @db.VarChar(32)` | Optional. | +| `supportContactName` | `String?` | Surfaced on order-confirmation emails for the org's members. | +| `supportContactEmail` | `String? @db.VarChar(255)` | Routed via Novu when set. | +| `escalationContactEmail` | `String? @db.VarChar(255)` | Used by SLA-breach alerts only. | +| `invoiceNumberPrefix` | `String?` | Per-org override for the human-readable invoice prefix. Null → slug-derived. Used by `lib/payments/billing/invoice-numbering.ts`. | +| `msmeStatus` | `MsmeStatus @default(NONE)` | Mirrors `ConsultantProfile.msmeStatus`. Drives the 15/45-day deadline at org-payout creation. | +| `msmeWrittenAgreementOnFile` | `Boolean @default(false)` | Mirrors `ConsultantProfile.writtenAgreementWithFamiliarise`. | + +--- + +## 19. Enterprise Billing & Programs + +Commercial structure: `BillingAccount` → `Contract` → `Program` → `ProgramAssignment` → `BookingUtilization`. Each link adds a layer of budget control. + +> **v2 (#777/#779) additions shown above:** `Contract` self-supersession chain +> (`supersededByContractId` @unique + `supersessionReason`) for amend/renew/ +> terminate-replace; `Contract.autoRenew` + `autoRenewedAt` (renewal cron claim +> gate); `Program.configLockedAt` (money-config lock, stamped at first +> assignment) + `archivedAt` (soft-delete); `ProgramAssignment.status` +> (`AssignmentStatus`) + `consumedPaise` (CREDIT_POOL money-meter) + +> `rolledToAssignmentId` @unique self-relation (cycle-engine rollover); +> `LicensedSeatConfig`/`CreditPoolConfig.{overageSurchargeBps, +> maxOveragePerCyclePaise}` (surcharge + circuit-breaker); `OverageEvent` +> (append-only over-cap charge ledger, `basePaise`+`surchargePaise`=`marginalPaise`); +> and `BillingAccount.{minBalancePaise, autoTopUpEnabled, autoTopUpAmountPaise, +> autoTopUpMandateId}` (wallet floor + auto-top-up). Top-up lifecycle is +> `WalletTopUp` (PENDING→CONFIRMED/FAILED) — the wallet *balance* itself is a +> credit-normal liability in the double-entry ledger, not a standalone table. + +```mermaid +erDiagram + BillingAccount { + string id + string ownerOrgId + FundingSource fundingSource + int walletBalance + int creditLimit + int minBalancePaise + boolean autoTopUpEnabled + int autoTopUpAmountPaise + string autoTopUpMandateId + datetime autoTopUpLastFiredAt + string billingEmail + Currency currency + } + WalletTopUp { + string id + string billingAccountId + string providerOrderId + string providerPaymentId + int amountPaise + WalletTopUpStatus status + datetime confirmedAt + datetime capturedAt + } + Contract { + string id + string organizationId + string billingAccountId + string purchaseOrderId + ContractStatus status + int paymentTermsDays + boolean autoRenew + datetime autoRenewedAt + string supersededByContractId + ContractSupersessionReason supersessionReason + datetime supersededAt + datetime effectiveFrom + datetime effectiveTo + } + BillingSubscription { + string id + string contractId + SubscriptionModel model + BillingCycle cycle + int activeSeatCount + datetime currentCycleStart + datetime currentCycleEnd + datetime nextInvoiceDate + } + RateCard { + string id + string ownerOrgId + string ownerContractId + int platformBps + int orgBps + int consultantBps + datetime effectiveFrom + datetime effectiveTo + } + Program { + string id + string contractId + ProgramType type + ProgramStatus status + string name + datetime configLockedAt + datetime archivedAt + } + LicensedSeatConfig { + string programId + int ratePerSeatPaise + BillingCycle cycle + int coveredEngagementsPerCycle + OverageBehavior overageBehavior + int overageSurchargeBps + int maxOveragePerCyclePaise + int priceCapPerEngagementPaise + int activeSeatCount + } + CreditPoolConfig { + string programId + BillingCycle cycle + int creditsPerCycle + int minimumCreditsPerPeriod + OverageBehavior overageBehavior + int overageSurchargeBps + int maxOveragePerCyclePaise + } + ProgramAssignment { + string id + string programId + string membershipId + AssignmentStatus status + datetime periodStart + datetime periodEnd + int engagementsUsed + int consumedPaise + int overageCount + string rolledToAssignmentId + datetime rolledAt + } + OverageEvent { + string id + string programAssignmentId + string bookingUtilizationId + OverageBehavior overageBehavior + int basePaise + int surchargePaise + int marginalPaise + OverageChargeStatus chargeStatus + string paymentId + string invoiceLineItemId + datetime chargeTimedOutAt + } + BookingUtilization { + string id + string programAssignmentId + string paymentId + int engagementsConsumed + int priceAtBookingPaise + boolean wasOverage + datetime reversedAt + } + + BillingAccount ||--o{ WalletTopUp : "top-up lifecycle" + BillingAccount ||--o{ Contract : "governs" + BillingAccount ||--o| BillingSubscription : "billing cycle" + Contract ||--o| Contract : "superseded by (amend/renew)" + Contract ||--o{ Program : "contains" + Contract ||--o{ RateCard : "rate cards" + Contract ||--o| BillingSubscription : "subscription" + Program ||--o| LicensedSeatConfig : "seat config" + Program ||--o| CreditPoolConfig : "pool config" + Program ||--o{ ProgramAssignment : "member assignments" + ProgramAssignment ||--o| ProgramAssignment : "rolls to (cycle rollover)" + ProgramAssignment ||--o{ BookingUtilization : "usage tracking" + ProgramAssignment ||--o{ OverageEvent : "over-cap charges" + BookingUtilization ||--o| OverageEvent : "overage (1:1)" + OverageEvent ||--o| InvoiceLineItem : "CHARGE_ORG line" + OverageEvent ||--o| Payment : "CHARGE_MEMBER side-payment" + Membership ||--o{ ProgramAssignment : "assigned to program" +``` + +--- + +## 20. Enterprise Invoicing & Org Payouts + +Month-end invoice generation, GST/e-invoice (IRN), PO matching, and host-org revenue payouts. + +> **Invoice numbering (CGST Rule 46).** `OrganizationInvoice.invoiceNumber` +> is per-org-scoped (`@@unique([organizationId, invoiceNumber])`) with +> the format `--` (e.g. `ACME-2026-0042`). `PREFIX` is +> `Organization.invoiceNumberPrefix` when set, else `slug.toUpperCase()`. +> `FY` is the Indian fiscal year (April–March). `SEQ` is a 4-digit +> zero-padded monotonic integer allocated atomically from the +> `OrgInvoiceCounter` table via `INSERT … ON CONFLICT … RETURNING`. +> Helper: `lib/payments/billing/invoice-numbering.ts:generateInvoiceNumber`. +> +> **OrgInvoiceCounter** (`org_invoice_counters` table) — primary key +> `(organizationId, fiscalYear)`, single column `nextSeq Int @default(1)`. +> The transactional UPSERT guarantees unbroken sequence per (org, FY) +> even under concurrent invoice creation. +> +> **`OrganizationInvoice.fiscalYear Int`** — set at issue time; never +> updated. Indexed via `@@index([organizationId, fiscalYear, issuedAt])`. + +```mermaid +erDiagram + Organization { + string id + string gstin + boolean requiresPO + int paymentTermsDays + } + PurchaseOrder { + string id + string organizationId + string poNumber + int totalAmountPaise + int remainingAmountPaise + PoStatus status + datetime validUntil + } + OrganizationInvoice { + string id + string billingAccountId + string organizationId + string contractId + string purchaseOrderId + string invoiceNumber + OrgInvoiceStatus status + int subtotalPaise + int igstPaise + int cgstPaise + int sgstPaise + int totalPaise + string irn + IrpStatus irpStatus + datetime dueDate + datetime paidAt + boolean autoGenerated + } + OrganizationEarnings { + string id + string organizationId + string paymentId + string orgPayoutId + int orgSharePaise + int platformFeePaise + int consultantSharePaise + EarningStatus status + } + OrganizationPayout { + string id + string organizationId + PayoutStatus status + int amountPaise + int tdsAmountPaise + int netPayoutPaise + datetime periodStart + datetime periodEnd + string gatewayPayoutId + } + OrganizationPayoutAccount { + string id + string organizationId + string bankName + string ifscCode + OrgPayoutAccountStatus status + string razorpayContactId + string stripeConnectId + } + CreditNote { + string id + string creditNoteNumber + int fiscalYear + string organizationId + string invoiceId + string refundId + int subtotalPaise + int igstPaise + int cgstPaise + int sgstPaise + int totalPaise + CreditNoteStatus status + } + + Organization ||--o{ PurchaseOrder : "raises POs" + Organization ||--o{ OrganizationInvoice : "receives invoices" + Organization ||--o{ CreditNote : "credit notes (Sec 34)" + PurchaseOrder ||--o{ OrganizationInvoice : "covers invoice" + OrganizationInvoice ||--o{ CreditNote : "adjusted by" + Organization ||--o{ OrganizationEarnings : "earns (canHost)" + Organization ||--o{ OrganizationPayout : "batch payouts" + Organization ||--o| OrganizationPayoutAccount : "bank account" + OrganizationPayout ||--o{ OrganizationEarnings : "batches" +``` + +--- + +## 21. Double-Entry Cash Ledger + +> **Updated (#771 D1/D5).** The old "three single-entry logs" +> (`WalletEntry` + `FundingLedgerEntry` + `SettlementLedgerEntry`) collapsed +> into ONE balanced double-entry journal: every cash event is a +> `LedgerTransaction` whose `LedgerEntry` rows satisfy Σ(DEBIT) == Σ(CREDIT), +> and balances are DERIVED (sum of entries on a `LedgerAccount`). The append-only +> `LedgerEntry` journal is the source of truth; `LedgerAccountBalance` is a +> derived running-balance cache the reconcile cron validates. +> `UsageLedgerEntry` (non-cash engagement counts) stays separate. +> `BillingAccount.walletBalance` is retained only as a denormalized cache for the +> atomic-debit guard, asserted equal to the WALLET account balance nightly. + +```mermaid +erDiagram + UsageLedgerEntry { + string id + string programAssignmentId + string membershipId + string paymentId + int engagementsConsumed + int minutesConsumed + int priceAtBookingPaise + boolean wasOverage + } + LedgerAccount { + string id + string organizationId + string consultantProfileId + LedgerAccountKind kind + Currency currency + } + LedgerAccountBalance { + string accountId + bigint balancePaise + bigint entrySeq + } + LedgerTransaction { + string id + string idempotencyKey + LedgerTransactionKind kind + string paymentId + string invoiceId + string payoutId + datetime postedAt + } + LedgerEntry { + string id + string transactionId + string accountId + LedgerDirection direction + bigint amountPaise + } + LedgerReconciliationReport { + string id + string scope + json summary + json findings + boolean ok + int durationMs + datetime runAt + } + + LedgerAccount ||--o| LedgerAccountBalance : "derived balance cache" + LedgerAccount ||--o{ LedgerEntry : "postings" + LedgerTransaction ||--o{ LedgerEntry : "balanced legs (ΣDr==ΣCr)" +``` + +| Table | Audience | Purpose | +|---|---|---| +| `UsageLedgerEntry` | Finance | Engagements consumed per program assignment (non-cash) | +| `LedgerAccount` | Finance | One account per (owner, `LedgerAccountKind`, currency) — CASH, WALLET, PLATFORM_FEE, ORG_PAYABLE, TDS_PAYABLE, GST_PAYABLE, etc. | +| `LedgerTransaction` | Finance | One balanced cash event; `idempotencyKey` @unique makes posting retry-safe | +| `LedgerEntry` | Finance | Immutable DEBIT/CREDIT legs (reversals are counter-transactions, never row edits) | +| `LedgerAccountBalance` | Finance | Derived running balance (Σ Dr − Σ Cr); reconcile validates against the journal | +| `LedgerReconciliationReport` | Admin/Ops | Nightly audit output — READ ONLY, never mutates the ledger | + +--- + +## 22. Support & Feedback + +```mermaid +erDiagram + User { + string id + } + SupportTicket { + string id + string userId + string assignedToId + SupportTicketStatus status + SupportPriority priority + SupportIssueType issueType + string consultationId + string subscriptionId + string paymentId + string refundId + } + SupportResponse { + string id + string supportTicketId + string userId + boolean isInternal + } + SupportTicketAttachment { + string id + string ticketId + string fileName + string fileUrl + string mimeType + } + Feedback { + string id + string userId + FeedbackStatus status + int rating + string category + string title + } + + User ||--o{ SupportTicket : "raises" + SupportTicket ||--o{ SupportResponse : "replies" + SupportTicket ||--o{ SupportTicketAttachment : "attachments" + User ||--o{ SupportResponse : "responds" + User ||--o{ Feedback : "submits" +``` + +--- + +## 23. Moderation + +Staff-operated content moderation with aggregated report counts and typed enforcement actions. + +```mermaid +erDiagram + User { + string id + } + ModerationReport { + string id + string reportedById + string targetUserId + string assignedToId + ModerationReportType type + ModerationReportStatus status + int reportCount + string reviewId + datetime resolvedAt + } + ModerationAction { + string id + string reportId + string takenById + ModerationActionType actionType + string notes + } + + User ||--o{ ModerationReport : "submits (reporter)" + User ||--o{ ModerationReport : "receives (target)" + ModerationReport ||--o{ ModerationAction : "enforcement actions" + User ||--o{ ModerationAction : "staff takes action" +``` + +--- + +## 24. Compliance, HRIS & System + +DPDP Act consent, data breach tracking, HRIS employee sync (CSV/API), and platform admin models. + +```mermaid +erDiagram + User { + string id + } + ConsentArtifact { + string id + string userId + string dataFiduciary + datetime grantedAt + datetime withdrawnAt + string hash + datetime auditRetainedUntil + } + DataBreach { + string id + datetime detectedAt + datetime reportedAt + string dpbReference + } + Organization { + string id + } + HrisConfig { + string id + string organizationId + HrisProvider provider + boolean active + datetime lastSyncedAt + } + HrisSyncJob { + string id + string hrisConfigId + HrisSyncStatus status + int recordsProcessed + datetime startedAt + datetime completedAt + } + HrisEmployeeMap { + string id + string hrisConfigId + string organizationId + string membershipId + string externalEmployeeId + string externalEmail + datetime syncedAt + } + ActivityLog { + string id + string consultantProfileId + ActivityType activityType + string actorId + string actorName + string consultationId + string subscriptionId + string webinarId + string classId + } + SystemJobExecution { + string id + string jobId + string jobName + SystemJobStatus status + datetime startedAt + datetime endedAt + int itemsProcessed + int durationMs + } + Announcement { + string id + string title + boolean isActive + datetime startDate + datetime endDate + } + MaintenanceWindow { + string id + MaintenancePhase phase + datetime scheduledAt + datetime startedAt + datetime endedAt + } + + User ||--o{ ConsentArtifact : "consent records (7yr retention)" + Organization ||--o| HrisConfig : "HRIS integration" + HrisConfig ||--o{ HrisSyncJob : "sync jobs" + HrisConfig ||--o{ HrisEmployeeMap : "employee map" + ConsultantProfile ||--o{ ActivityLog : "dashboard activity" +``` + +--- + +## 25. Enum Reference Table + +Every enum in the schema and its values. + +| Enum | Values | +|---|---| +| `UserRole` | CONSULTANT, CONSULTEE, ADMIN, STAFF, ORG_WORKSPACE | +| `MemberRole` | OWNER, MAINTAINER, MANAGER, EXPERT, LEARNER, SUPPORT | +| `MemberStatus` | PENDING, ACTIVE, SUSPENDED, REMOVED | +| `OrgStatus` | PENDING_VERIFICATION, ACTIVE, SUSPENDED, DEACTIVATED | +| `OrgSizeBucket` | SMALL_1_50, MEDIUM_51_200, LARGE_201_1000, ENTERPRISE_1000_PLUS | +| `OrgAuditCategory` | MEMBER, CONTRACT, PROGRAM, WALLET, INVOICE, PAYOUT, SETTINGS, CONSENT, CATALOG, SYSTEM | +| `DataRegion` | IN, US, EU | +| `Currency` | INR, USD, EUR, GBP | +| `GstRegStatus` | REGULAR, COMPOSITION, UNREGISTERED | +| `FundingSource` | PERSONAL, LICENSE, WALLET, INVOICE | +| `WalletReason` | TOPUP, BOOKING, REFUND, ADJUSTMENT | +| `WalletTopUpStatus` | PENDING, CONFIRMED, FAILED | +| `LedgerAccountKind` | CASH, WALLET, PLATFORM_FEE, PLATFORM_PROMO, DISCOUNT, CONSULTANT_PAYABLE, ORG_PAYABLE, ORG_RECEIVABLE, TDS_PAYABLE, GST_PAYABLE | +| `LedgerDirection` | DEBIT, CREDIT | +| `LedgerTransactionKind` | BOOKING, TOPUP, TOPUP_REFUND, INVOICE_ISSUED, INVOICE_PAID, PAYOUT, ORG_PAYOUT, REFUND, OVERAGE_MEMBER, GRANT | +| `ContractStatus` | DRAFT, ACTIVE, EXPIRED, TERMINATED | +| `ContractSupersessionReason` | AMENDMENT, RENEWAL, TERMINATION_REPLACEMENT | +| `BillingCycle` | MONTHLY, QUARTERLY, ANNUAL | +| `SubscriptionModel` | PER_SEAT, FLAT_FEE | +| `ProgramType` | LICENSED_SEAT, CREDIT_POOL | +| `ProgramStatus` | ACTIVE, PAUSED, EXPIRED, CANCELLED | +| `AssignmentStatus` | ACTIVE, ROLLED, PAUSED, CLOSED, CANCELLED | +| `OverageBehavior` | BLOCK, CHARGE_MEMBER, CHARGE_ORG | +| `OverageChargeStatus` | PENDING, ACCRUED, CHARGED, BLOCKED, REVERSED, FAILED | +| `OrgPlanVisibility` | PUBLIC, ORG_ONLY, ORG_AND_PUBLIC | +| `CoveredPlanType` | CONSULTATION, CLASS, WEBINAR, SUBSCRIPTION | +| `OrgInvoiceStatus` | DRAFT, ISSUED, PAID, OVERDUE, VOID, CANCELLED, REFUNDED | +| `IrpStatus` | PENDING, GENERATED, CANCELLED, FAILED | +| `PoStatus` | ACTIVE, CLOSED, CANCELLED | +| `CreditNoteStatus` | DRAFT, ISSUED, CANCELLED | +| `GstTcsBatchStatus` | OPEN, FILED | +| `OrgDataExportStatus` | PENDING, PROCESSING, READY, FAILED, EXPIRED | +| `PayoutRecipient` | SELF, ORGANIZATION | +| `ResidencyStatus` | RESIDENT, NON_RESIDENT | +| `MsmeStatus` | NONE, MICRO, SMALL, MEDIUM | +| `PayoutArrangement` | DIRECT, AOR, EOR | +| `RequestStatus` | PENDING, APPROVED, APPROVED_PENDING_PAYMENT, SCHEDULED, COMPLETED, REJECTED, CANCELLED, EXPIRED | +| `AppointmentsType` | CONSULTATION, SUBSCRIPTION, WEBINAR, CLASS, TRIAL | +| `SlotCompletionStatus` | SCHEDULED, COMPLETED, UNVERIFIED, CANCELLED, RESCHEDULED | +| `BookingSource` | DIRECT_CHECKOUT, REQUEST_SUBMITTED | +| `TrialSessionStatus` | PENDING, SCHEDULED, COMPLETED, CONVERTED, CANCELLED, REJECTED | +| `WaitlistStatus` | WAITING, NOTIFIED, BOOKED, EXPIRED, CANCELLED, SKIPPED | +| `WebinarStatus` | SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED | +| `ClassStatus` | SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED | +| `DayOfWeek` | MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY | +| `ScheduleType` | WEEKLY, CUSTOM | +| `Platform` | ZOOM, GOOGLE_MEET, MICROSOFT_TEAMS, STREAM, CUSTOM | +| `RecordingStoragePolicy` | STREAM_ONLY, SUPABASE_PERMANENT | +| `RecordingStorageType` | STREAM_S3, SUPABASE | +| `RecordingStatus` | RECORDING, PROCESSING, READY, TRANSFERRING, AVAILABLE, FAILED, EXPIRED | +| `PaymentGateway` | STRIPE, RAZORPAY, LEMON_SQUEEZY, XFLOW, CARD | +| `PaymentStatus` | PENDING, SUCCEEDED, FAILED, EXPIRED | +| `PaymentLegSource` | CARD, WALLET, REFERRAL_CREDIT, INVOICE_ACCRUAL, OVERAGE_INVOICE_ACCRUAL, LICENSE | +| `RefundStatus` | PENDING, SUCCEEDED, FAILED, CANCELLED | +| `DisputeStatus` | WARNING_NEEDS_RESPONSE, WARNING_UNDER_REVIEW, WARNING_CLOSED, NEEDS_RESPONSE, UNDER_REVIEW, CHARGE_REFUNDED, WON, LOST | +| `EarningStatus` | PENDING, HELD, READY, PAID, REFUNDED, PENDING_TRUST | +| `EarningRole` | OWNER, COLLABORATOR | +| `PayoutStatus` | PENDING, APPROVED, PROCESSING, COMPLETED, FAILED, CANCELLED | +| `PayoutMethod` | BANK_TRANSFER, UPI, STRIPE_TRANSFER | +| `PayoutAccountType` | BANK_ACCOUNT, UPI, STRIPE_CONNECT | +| `CollaboratorStatus` | PENDING, ACCEPTED, DECLINED, REMOVED | +| `WebinarCollaboratorRole` | CO_HOST, MODERATOR, GUEST_SPEAKER, TECHNICAL_SUPPORT | +| `ClassCollaboratorRole` | CO_INSTRUCTOR, TEACHING_ASSISTANT, GUEST_LECTURER, CONTENT_CREATOR | +| `ConsultantVerificationStatus` | PENDING_VERIFICATION, UNDER_REVIEW, VERIFIED, REJECTED | +| `ProfileVerificationStatus` | PENDING, APPROVED, REJECTED, NEEDS_INFO, SUPERSEDED | +| `DocumentReviewStatus` | PENDING, IN_REVIEW, APPROVED, REJECTED, NEEDS_REVISION | +| `DocumentUploadRole` | CONSULTEE, CONSULTANT | +| `ReferralStatus` | SIGNED_UP, QUALIFIED, REWARDED, EXPIRED, FRAUDULENT | +| `CreditSource` | REFERRAL_BONUS, REFEREE_BONUS, PROMOTION, COMPENSATION, MANUAL | +| `DiscountType` | PERCENTAGE, FIXED_AMOUNT | +| `AchievementType` | AWARD, PUBLICATION, PROJECT, TALK, OPEN_SOURCE, OTHER | +| `CareerStage` | SCHOOL_STUDENT, STUDENT, EARLY_CAREER, MID_CAREER, SENIOR, EXECUTIVE | +| `BudgetPreference` | BUDGET, MODERATE, PREMIUM, FLEXIBLE | +| `SessionType` | ONE_ON_ONE, GROUP, ASYNC_REVIEW | +| `ActivityType` | CONSULTATION_BOOKED, CONSULTATION_COMPLETED, CONSULTATION_CANCELLED, SUBSCRIPTION_REQUESTED, SUBSCRIPTION_APPROVED, SUBSCRIPTION_CANCELLED, WEBINAR_REGISTERED, CLASS_ENROLLED, TRIAL_REQUESTED, TRIAL_SCHEDULED, TRIAL_COMPLETED, TRIAL_CONVERTED, REVIEW_SUBMITTED, MESSAGE_RECEIVED | +| `FeedbackStatus` | PENDING, ACKNOWLEDGED, IN_PROGRESS, RESOLVED, CLOSED | +| `SupportTicketStatus` | OPEN, IN_PROGRESS, ON_HOLD, RESOLVED, CLOSED | +| `SupportPriority` | LOW, MEDIUM, HIGH, URGENT | +| `ModerationReportType` | REVIEW, PROFILE, MESSAGE, DOCUMENT, OTHER | +| `ModerationReportStatus` | PENDING, UNDER_REVIEW, DISMISSED, ACTION_TAKEN, ESCALATED | +| `ModerationActionType` | WARNING_ISSUED, CONTENT_REMOVED, USER_SUSPENDED, USER_BANNED, PROFILE_UNVERIFIED, NO_ACTION | +| `HrisProvider` | WORKDAY, BAMBOOHR, SAP, ORACLE, CERIDIAN, DARWINBOX, CSV | +| `HrisSyncStatus` | PENDING, RUNNING, COMPLETED, FAILED | +| `SystemJobStatus` | RUNNING, COMPLETED, FAILED, CANCELLED | +| `MaintenancePhase` | OFF, DEGRADED, OFFLINE | +| `OrgPayoutAccountStatus` | PENDING_VERIFICATION, VERIFIED, FAILED_VERIFICATION, SUSPENDED | +| `ParentEntityType` | LISTED_US, PRIVATE_US, EU, OTHER | diff --git a/__tests__/booking-algorithm/authorization.test.ts b/__tests__/booking-algorithm/authorization.test.ts index 9a67332f0..c0659e165 100644 --- a/__tests__/booking-algorithm/authorization.test.ts +++ b/__tests__/booking-algorithm/authorization.test.ts @@ -259,10 +259,26 @@ function makeMockTx(appointmentData: any = null) { findMany: jest.fn().mockResolvedValue([]), delete: jest.fn(), }, - consultation: { update: jest.fn() }, - subscription: { update: jest.fn() }, - webinar: { update: jest.fn() }, - class: { update: jest.fn() }, + consultation: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + subscription: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + webinar: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + class: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() }, }; } diff --git a/__tests__/booking-algorithm/rescheduleCancel.test.ts b/__tests__/booking-algorithm/rescheduleCancel.test.ts index edf2f8a19..5ac7ca50e 100644 --- a/__tests__/booking-algorithm/rescheduleCancel.test.ts +++ b/__tests__/booking-algorithm/rescheduleCancel.test.ts @@ -207,10 +207,26 @@ function makeMockTx() { delete: jest.fn(), deleteMany: jest.fn(), }, - consultation: { update: jest.fn() }, - subscription: { update: jest.fn() }, - webinar: { update: jest.fn() }, - class: { update: jest.fn() }, + consultation: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + subscription: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + webinar: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + class: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() }, }; } @@ -480,12 +496,17 @@ describe("Reschedule Route Handler - POST", () => { // Verify slots marked tentative by appointmentId (non-subscription path) expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ where: { appointmentId: "apt-1" }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); // Verify consultation status reverted - expect(mockTx.consultation.update).toHaveBeenCalledWith({ - where: { id: "cons-1" }, + expect(mockTx.consultation.updateMany).toHaveBeenCalledWith({ + where: { + id: "cons-1", + requestStatus: { + in: ["PENDING", "APPROVED", "APPROVED_PENDING_PAYMENT", "SCHEDULED"], + }, + }, data: { requestStatus: "PENDING" }, }); }); @@ -526,12 +547,17 @@ describe("Reschedule Route Handler - POST", () => { // Should mark all appointment slots tentative expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ where: { appointmentId: { in: ["apt-1", "apt-2"] } }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); // Should update subscription status - expect(mockTx.subscription.update).toHaveBeenCalledWith({ - where: { id: "sub-1" }, + expect(mockTx.subscription.updateMany).toHaveBeenCalledWith({ + where: { + id: "sub-1", + requestStatus: { + in: ["PENDING", "APPROVED", "APPROVED_PENDING_PAYMENT", "SCHEDULED"], + }, + }, data: { requestStatus: "PENDING" }, }); }); @@ -559,7 +585,7 @@ describe("Reschedule Route Handler - POST", () => { // are rescheduled atomically — a partial-tentative session would be inconsistent. expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ where: { appointmentId: { in: ["apt-1"] } }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); }); @@ -637,11 +663,11 @@ describe("Reschedule Route Handler - POST", () => { expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ where: { appointmentId: "apt-1" }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); - expect(mockTx.webinar.update).toHaveBeenCalledWith({ - where: { id: "web-1" }, + expect(mockTx.webinar.updateMany).toHaveBeenCalledWith({ + where: { id: "web-1", status: { notIn: ["CANCELLED", "COMPLETED"] } }, data: { status: "SCHEDULED" }, }); }); @@ -661,8 +687,8 @@ describe("Reschedule Route Handler - POST", () => { expect(res.status).toBe(200); expect(body.success).toBe(true); - expect(mockTx.class.update).toHaveBeenCalledWith({ - where: { id: "cls-1" }, + expect(mockTx.class.updateMany).toHaveBeenCalledWith({ + where: { id: "cls-1", status: { notIn: ["CANCELLED", "COMPLETED"] } }, data: { status: "SCHEDULED" }, }); }); @@ -828,8 +854,13 @@ describe("Cancel Route Handler - POST", () => { expect(body.cancellationReason).toBe("SCHEDULE_CONFLICT"); // Verify consultation updated with cancellation data - expect(mockTx.consultation.update).toHaveBeenCalledWith({ - where: { id: "cons-1" }, + expect(mockTx.consultation.updateMany).toHaveBeenCalledWith({ + where: { + id: "cons-1", + requestStatus: { + in: ["PENDING", "APPROVED", "APPROVED_PENDING_PAYMENT", "SCHEDULED"], + }, + }, data: expect.objectContaining({ requestStatus: "CANCELLED", cancellationReason: "SCHEDULE_CONFLICT", @@ -838,9 +869,11 @@ describe("Cancel Route Handler - POST", () => { }), }); - // Verify slots soft-cancelled (not hard-deleted — preserves payment audit trail) + // Verify slots soft-cancelled (not hard-deleted — preserves payment + // audit trail); only live SCHEDULED slots flip, history is never + // re-stamped. expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({ - where: { appointmentId: "apt-1" }, + where: { appointmentId: "apt-1", completionStatus: "SCHEDULED" }, data: { completionStatus: "CANCELLED" }, }); @@ -874,8 +907,13 @@ describe("Cancel Route Handler - POST", () => { const res = await cancelHandler(req, makeParams("apt-1")); expect(res.status).toBe(200); - expect(mockTx.subscription.update).toHaveBeenCalledWith({ - where: { id: "sub-1" }, + expect(mockTx.subscription.updateMany).toHaveBeenCalledWith({ + where: { + id: "sub-1", + requestStatus: { + in: ["PENDING", "APPROVED", "APPROVED_PENDING_PAYMENT", "SCHEDULED"], + }, + }, data: expect.objectContaining({ requestStatus: "CANCELLED", cancellationReason: "FINANCIAL_REASONS", @@ -899,8 +937,8 @@ describe("Cancel Route Handler - POST", () => { expect(res.status).toBe(200); - expect(mockTx.webinar.update).toHaveBeenCalledWith({ - where: { id: "web-1" }, + expect(mockTx.webinar.updateMany).toHaveBeenCalledWith({ + where: { id: "web-1", status: { notIn: ["CANCELLED", "COMPLETED"] } }, data: { status: "CANCELLED" }, }); @@ -923,8 +961,8 @@ describe("Cancel Route Handler - POST", () => { expect(res.status).toBe(200); - expect(mockTx.class.update).toHaveBeenCalledWith({ - where: { id: "cls-1" }, + expect(mockTx.class.updateMany).toHaveBeenCalledWith({ + where: { id: "cls-1", status: { notIn: ["CANCELLED", "COMPLETED"] } }, data: { status: "CANCELLED" }, }); @@ -948,8 +986,13 @@ describe("Cancel Route Handler - POST", () => { expect(res.status).toBe(200); // Cancellation data should have null reason and notes - expect(mockTx.consultation.update).toHaveBeenCalledWith({ - where: { id: "cons-1" }, + expect(mockTx.consultation.updateMany).toHaveBeenCalledWith({ + where: { + id: "cons-1", + requestStatus: { + in: ["PENDING", "APPROVED", "APPROVED_PENDING_PAYMENT", "SCHEDULED"], + }, + }, data: expect.objectContaining({ requestStatus: "CANCELLED", cancellationReason: null, diff --git a/__tests__/booking-algorithm/rescheduleResponses.test.ts b/__tests__/booking-algorithm/rescheduleResponses.test.ts index d1e766578..20dd1ad6f 100644 --- a/__tests__/booking-algorithm/rescheduleResponses.test.ts +++ b/__tests__/booking-algorithm/rescheduleResponses.test.ts @@ -146,10 +146,26 @@ function makeMockTx(appointmentData: any) { findMany: jest.fn().mockResolvedValue([]), delete: jest.fn(), }, - consultation: { update: jest.fn() }, - subscription: { update: jest.fn() }, - webinar: { update: jest.fn() }, - class: { update: jest.fn() }, + consultation: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + subscription: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + webinar: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + class: { + update: jest.fn(), + // B2 — the cancel/reschedule CAS guards use updateMany. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() }, }; } diff --git a/__tests__/booking-algorithm/slotAllocationService.test.ts b/__tests__/booking-algorithm/slotAllocationService.test.ts index 0694a46e2..52b37a8fe 100644 --- a/__tests__/booking-algorithm/slotAllocationService.test.ts +++ b/__tests__/booking-algorithm/slotAllocationService.test.ts @@ -66,6 +66,10 @@ function makeMockTx() { subscription: { findUnique: jest.fn(), update: jest.fn() }, webinar: { findUnique: jest.fn(), update: jest.fn() }, class: { findUnique: jest.fn(), update: jest.fn() }, + // #440 — createAppointments denormalizes the consultant onto each slot. + consultantProfile: { + findFirst: jest.fn().mockResolvedValue({ id: "consultant-profile-1" }), + }, appointment: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ diff --git a/__tests__/booking/cancellation-policy.test.ts b/__tests__/booking/cancellation-policy.test.ts new file mode 100644 index 000000000..faadda0bb --- /dev/null +++ b/__tests__/booking/cancellation-policy.test.ts @@ -0,0 +1,68 @@ +/** + * @jest-environment node + */ + +/** + * B1 — the snapshot-at-booking refund policy. The tiers frozen onto the + * Appointment at checkout decide the refund; consultant-initiated always + * refunds in full; pre-feature bookings (null snapshot) get the platform + * defaults. + */ + +import { + computeRefundPct, + parsePolicySnapshot, + resolveCancellationPolicySnapshot, + PLATFORM_DEFAULT_TIERS, +} from "@/lib/payments/operations/cancellation-policy"; + +describe("computeRefundPct — platform default tiers", () => { + it.each([ + [48, 100], // two days out → full refund + [24, 100], // exactly at the 24h boundary → full refund + [23.9, 50], // inside a day → half + [2, 50], // exactly at the 2h boundary → half + [1.5, 0], // inside two hours → nothing + [0, 0], // at start time → nothing + ])("%s hours before start → %s%%", (hours, pct) => { + expect(computeRefundPct(null, hours, false)).toBe(pct); + }); + + it("refunds nothing after the booking started", () => { + expect(computeRefundPct(null, -3, false)).toBe(0); + }); + + it("consultant-initiated always refunds 100%, even past start", () => { + expect(computeRefundPct(null, 1, true)).toBe(100); + expect(computeRefundPct(null, -3, true)).toBe(100); + }); +}); + +describe("snapshot freezing", () => { + it("a frozen snapshot wins over whatever the defaults become later", () => { + const generous = { + ...resolveCancellationPolicySnapshot(), + tiers: [{ hoursBefore: 0, refundPct: 100 }], + }; + // 1 hour before start: platform default says 0, the buyer's frozen + // terms say 100 — the snapshot governs. + expect(computeRefundPct(generous, 1, false)).toBe(100); + }); + + it("round-trips through the Json column", () => { + const snap = resolveCancellationPolicySnapshot({ + orgPolicyText: "Org prose policy", + }); + const parsed = parsePolicySnapshot(JSON.parse(JSON.stringify(snap))); + expect(parsed).not.toBeNull(); + expect(parsed!.tiers).toEqual(PLATFORM_DEFAULT_TIERS); + expect(parsed!.orgPolicyText).toBe("Org prose policy"); + }); + + it("rejects malformed snapshots (falls back to defaults at the call site)", () => { + expect(parsePolicySnapshot(null)).toBeNull(); + expect(parsePolicySnapshot("v1")).toBeNull(); + expect(parsePolicySnapshot({ version: 2, tiers: [] })).toBeNull(); + expect(parsePolicySnapshot({ version: 1 })).toBeNull(); + }); +}); diff --git a/__tests__/booking/cleanup-tentative-guard.test.ts b/__tests__/booking/cleanup-tentative-guard.test.ts new file mode 100644 index 000000000..a744999b9 --- /dev/null +++ b/__tests__/booking/cleanup-tentative-guard.test.ts @@ -0,0 +1,71 @@ +/** + * @jest-environment node + */ + +/** + * #829 — the tentative-slot cleanup must never delete a slot that was + * confirmed (or paid) between its scan and its delete. The delete's WHERE + * re-states isTentative + no-SUCCEEDED-payment, so a concurrent capture + * webhook's flip makes the row stop matching (re-evaluated under the row + * lock) instead of being destroyed. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + slotOfAppointment: { + findMany: jest.fn(), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + $disconnect: jest.fn(), + }, +})); +jest.mock("../../lib/cron/with-cron-lock", () => ({ + withCronLock: jest.fn((_j: string, _o: unknown, fn: () => unknown) => fn()), + CronLockHeldError: class CronLockHeldError extends Error {}, + CronLockUnavailableError: class CronLockUnavailableError extends Error {}, + LONG_JOB_TTL_MS: 35 * 60 * 1000, +})); + +import prisma from "../../lib/prisma"; +import { cleanupTentativeSlots } from "@/scripts/appointments/cleanup-tentative-slots"; + +const mocked = prisma as unknown as { + slotOfAppointment: { findMany: jest.Mock; deleteMany: jest.Mock }; +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("#829 — cleanup delete re-states the tentative + unpaid guards", () => { + it("carries isTentative + no-SUCCEEDED-payment in the deleteMany WHERE", async () => { + mocked.slotOfAppointment.findMany.mockResolvedValue([ + { + id: "slot-1", + appointmentId: "appt-1", + createdAt: new Date("2026-05-01T00:00:00Z"), + startsAt: new Date("2026-05-02T10:00:00Z"), + endsAt: new Date("2026-05-02T11:00:00Z"), + appointment: { payment: [], consultation: null, subscription: null }, + }, + ]); + mocked.slotOfAppointment.deleteMany.mockResolvedValue({ count: 1 }); + + await cleanupTentativeSlots(); + + expect(mocked.slotOfAppointment.deleteMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: { in: ["slot-1"] }, + isTentative: true, + appointment: { + payment: { none: { paymentStatus: "SUCCEEDED" } }, + }, + }), + }); + }); + + it("deletes nothing when the scan finds nothing", async () => { + mocked.slotOfAppointment.findMany.mockResolvedValue([]); + await cleanupTentativeSlots(); + expect(mocked.slotOfAppointment.deleteMany).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/booking/slot-session-fix-pins.test.ts b/__tests__/booking/slot-session-fix-pins.test.ts new file mode 100644 index 000000000..6ccbea7b6 --- /dev/null +++ b/__tests__/booking/slot-session-fix-pins.test.ts @@ -0,0 +1,261 @@ +/** + * @jest-environment node + */ + +/** + * Regression pins for the slot/session fixes that landed via PRs #825/#838 + * but whose issues stayed open (#788, #827, #828) plus the #829 cleanup + * guard. These tests exist so a future refactor that reintroduces any of the + * four bugs fails CI instead of resurfacing in production: + * + * #788 — mergeConsecutiveSlots fused adjacent availability ROWS, mis-binding + * sliced sub-windows to the first row's id. + * #827 — confirmExistingAppointment flipped slots confirmed without checking + * for an already-confirmed overlapping slot (cross-user double-book). + * #828 — checkout had no request-level idempotency; the replay helper must + * return the original attempt instead of minting a duplicate. + * #829 — cleanup-tentative-slots deleted by id only, destroying slots whose + * capture webhook confirmed them between the scan and the delete. + */ + +import { mergeConsecutiveSlots } from "@/utils/timeSlotsProcessing"; +import { confirmExistingAppointment } from "@/lib/payments/webhooks/handlers"; +import { replayByIdempotencyKey } from "@/lib/payments/operations/checkout-replay"; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { payment: { findFirst: jest.fn() } }, +})); +// handlers.ts pulls heavy transitive deps; stub everything the confirm path +// doesn't exercise. +jest.mock("../../lib/email", () => ({ + sendPaymentSuccessEmail: jest.fn(), + sendPaymentFailedEmail: jest.fn(), +})); +jest.mock("../../lib/payments/payouts", () => ({ + createEarningsFromPayment: jest.fn(), +})); +jest.mock("../../lib/waitlist/slot-handler", () => ({ + markWaitlistAsBooked: jest.fn(), +})); +jest.mock("../../lib/novu", () => ({ + notifyPaymentSuccess: jest.fn(), + notifyPaymentFailed: jest.fn(), + notifyAppointmentBooked: jest.fn(), +})); +jest.mock("../../lib/referrals/service", () => ({ + processConsultantBookingReferral: jest.fn(), + reverseCreditsForPayment: jest.fn(), + qualifyReferralOnFirstBooking: jest.fn(), +})); +jest.mock("../../actions/stream/chat/event-channel.action", () => ({ + addUserToEventChannel: jest.fn(), +})); +jest.mock("../../actions/stream/chat/channel.action", () => ({ + createDirectMessageChannel: jest.fn(), +})); +jest.mock("../../lib/stream-logger", () => ({ + streamLogger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); +jest.mock("../../lib/enterprise/system-events", () => ({ + recordSystemError: jest.fn().mockResolvedValue(undefined), +})); + +import prisma from "../../lib/prisma"; +import { recordSystemError } from "../../lib/enterprise/system-events"; + +const mockPaymentFindFirst = ( + prisma as unknown as { payment: { findFirst: jest.Mock } } +).payment.findFirst; +const mockSystemError = recordSystemError as jest.Mock; + +beforeEach(() => jest.clearAllMocks()); + +// --------------------------------------------------------------------------- +// #788 — same-source merge guard +// --------------------------------------------------------------------------- +describe("#788 — mergeConsecutiveSlots never merges across availability rows", () => { + const base = { + isAllocated: false, + localStartTime: "x", + localEndTime: "x", + slotId: "s", + }; + const rowA = { + ...base, + slotOfAvailabilityId: "row-A", + slotStartTimeInUTC: "2026-06-26T14:00:00.000Z", + slotEndTimeInUTC: "2026-06-26T15:00:00.000Z", + }; + const rowB = { + ...base, + slotOfAvailabilityId: "row-B", + slotStartTimeInUTC: "2026-06-26T15:00:00.000Z", + slotEndTimeInUTC: "2026-06-26T16:00:00.000Z", + }; + + it("keeps adjacent slots from DIFFERENT rows separate (the #788 mis-bind)", () => { + const merged = mergeConsecutiveSlots([rowA, rowB] as never); + expect(merged).toHaveLength(2); + expect(merged.map((m) => m.slotOfAvailabilityId)).toEqual([ + "row-A", + "row-B", + ]); + }); + + it("still merges adjacent sub-windows of the SAME row (the trial use case)", () => { + const sameRowB = { ...rowB, slotOfAvailabilityId: "row-A" }; + const merged = mergeConsecutiveSlots([rowA, sameRowB] as never); + expect(merged).toHaveLength(1); + expect(merged[0].slotEndTimeInUTC).toBe("2026-06-26T16:00:00.000Z"); + }); +}); + +// --------------------------------------------------------------------------- +// #827 — confirm-time double-booking guard +// --------------------------------------------------------------------------- +describe("#827 — confirmExistingAppointment first-confirmed-wins", () => { + function mockTx(opts: { conflict: boolean }) { + const slotUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); + return { + slotUpdateMany, + tx: { + appointment: { + findUnique: jest.fn().mockResolvedValue({ + id: "appt-1", + consultation: { id: "c1" }, + subscription: null, + webinar: null, + class: null, + }), + }, + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([ + { + id: "slot-1", + startsAt: new Date("2026-06-26T15:00:00Z"), + endsAt: new Date("2026-06-26T16:00:00Z"), + user: [{ id: "booker" }, { id: "consultant" }], + }, + ]), + findFirst: jest + .fn() + .mockResolvedValue( + opts.conflict + ? { id: "slot-other", appointmentId: "appt-2" } + : null, + ), + updateMany: slotUpdateMany, + }, + consultation: { + update: jest.fn().mockResolvedValue({}), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUnique: jest + .fn() + .mockResolvedValue({ id: "c1", requestStatus: "APPROVED_PENDING_PAYMENT" }), + }, + } as never, + }; + } + + it("blocks confirmation (slots stay tentative) when an overlapping confirmed slot exists", async () => { + const m = mockTx({ conflict: true }); + await confirmExistingAppointment(m.tx, "appt-1", "booker"); + expect(m.slotUpdateMany).not.toHaveBeenCalled(); + expect(mockSystemError).toHaveBeenCalledWith( + expect.objectContaining({ + category: "PAYMENT", + context: expect.objectContaining({ + conflictingAppointmentId: "appt-2", + }), + }), + ); + }); + + it("confirms normally when no overlap exists", async () => { + const m = mockTx({ conflict: false }); + await confirmExistingAppointment(m.tx, "appt-1", "booker"); + expect(m.slotUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { isTentative: false } }), + ); + expect(mockSystemError).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// #828 — idempotent checkout replay +// --------------------------------------------------------------------------- +describe("#828 — replayByIdempotencyKey returns the original attempt", () => { + it("replays a PENDING Razorpay attempt with the original order id", async () => { + mockPaymentFindFirst.mockResolvedValue({ + paymentIntent: "order_original", + paymentStatus: "PENDING", + paymentGateway: "RAZORPAY", + amount: BigInt(50000), + currency: "INR", + appointmentId: "appt-1", + isMockPayment: false, + }); + const res = await replayByIdempotencyKey("user-1", "ck_abc12345"); + const body = await res!.json(); + expect(body.reused).toBe(true); + expect(body.orderId).toBe("order_original"); + // scoped to the caller — a guessed key can't read another user's payment + expect(mockPaymentFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientIdempotencyKey: "ck_abc12345", userId: "user-1" }, + }), + ); + }); + + it("replays a SUCCEEDED attempt as already-completed", async () => { + mockPaymentFindFirst.mockResolvedValue({ + paymentIntent: "order_x", + paymentStatus: "SUCCEEDED", + paymentGateway: "RAZORPAY", + amount: BigInt(50000), + currency: "INR", + appointmentId: "appt-1", + isMockPayment: false, + }); + const res = await replayByIdempotencyKey("user-1", "ck_abc12345"); + const body = await res!.json(); + expect(body.reused).toBe(true); + expect(body.skipPayment).toBe(true); + }); + + it("409s a terminal attempt so the client mints a fresh key", async () => { + mockPaymentFindFirst.mockResolvedValue({ + paymentIntent: "order_x", + paymentStatus: "FAILED", + paymentGateway: "RAZORPAY", + amount: BigInt(50000), + currency: "INR", + appointmentId: null, + isMockPayment: false, + }); + const res = await replayByIdempotencyKey("user-1", "ck_abc12345"); + expect(res!.status).toBe(409); + }); + + it("does not resume a Stripe PENDING attempt (hosted URL is not persisted)", async () => { + mockPaymentFindFirst.mockResolvedValue({ + paymentIntent: "cs_test_x", + paymentStatus: "PENDING", + paymentGateway: "STRIPE", + amount: BigInt(50000), + currency: "INR", + appointmentId: null, + isMockPayment: false, + }); + const res = await replayByIdempotencyKey("user-1", "ck_abc12345"); + expect(res!.status).toBe(409); + }); + + it("returns null for an unknown key (fresh attempt proceeds)", async () => { + mockPaymentFindFirst.mockResolvedValue(null); + await expect( + replayByIdempotencyKey("user-1", "ck_unknown"), + ).resolves.toBeNull(); + }); +}); diff --git a/__tests__/enterprise/__snapshots__/org-error-humanization.test.ts.snap b/__tests__/enterprise/__snapshots__/org-error-humanization.test.ts.snap new file mode 100644 index 000000000..0cbc8d118 --- /dev/null +++ b/__tests__/enterprise/__snapshots__/org-error-humanization.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`humanizeOrgError snapshot of the full mapping table 1`] = ` +{ + "DOMAIN_NOT_OWNED": "Your organization hasn't claimed this email domain yet. Add it under Settings → SSO → Domains and verify before registering a provider.", + "DOMAIN_NOT_VERIFIED": "The domain claim is pending DNS verification. Finish the TXT-record step under Settings → SSO → Domains.", + "EXPERT_REQUIRES_CANHOST": "Expert can only be assigned on host-capable organizations. Enable hosting under Settings → Capabilities first.", + "HOST_ORGS_GATED": "Host-capable organizations are not yet enabled on this tenant. Contact ops at support@familiarise.work to flip ENABLE_HOST_ORGS for your account.", + "LEARNER_REQUIRES_CANSPONSOR": "Learner can only be assigned on sponsor-capable organizations. Enable sponsorship under Settings → Capabilities first.", + "NOT_A_CONSULTANT": "This user is not a consultant on Familiarise yet. They need to sign up as an Expert first before they can be added to an organization as one.", + "NOT_A_CONSULTEE": "This user does not have a learner profile on Familiarise yet. They need to sign up or complete onboarding before they can be added to an organization as a Learner.", + "ORG_NOT_VERIFIED": "Your organization is awaiting platform review. This action will be available once a Familiarise admin verifies your account — this usually takes 1–2 business days.", + "PO_BALANCE_EXCEEDED": "This purchase order doesn't have enough remaining budget for the invoice. Reduce the invoice total or add a new PO.", + "PO_BALANCE_INSUFFICIENT": "This purchase order doesn't have enough remaining budget for the invoice. Reduce the invoice total or add a new PO.", + "ROLE_TRANSITION_BLOCKED": "Members cannot switch between Learner and Expert roles. Remove the member and re-invite them with the new role instead.", + "SSO_PROVIDER_MISCONFIGURED": "Your SSO provider's certificate is invalid. Contact your IT admin to re-paste the X.509 PEM.", + "USER_NOT_FOUND": "No user account found with that email. They need to sign up at Familiarise first, or use the Invitations page to send them an invite.", +} +`; diff --git a/__tests__/enterprise/anti-lockout-gaps.test.ts b/__tests__/enterprise/anti-lockout-gaps.test.ts new file mode 100644 index 000000000..be3802d7e --- /dev/null +++ b/__tests__/enterprise/anti-lockout-gaps.test.ts @@ -0,0 +1,321 @@ +/** + * @jest-environment node + */ + +/** + * Anti-lockout gap closure for v1: covers three vectors NOT exercised by + * member-anti-lockout.test.ts: + * + * 1. Bulk member endpoint must always return 405 — no future + * bypass-by-loop accidentally side-stepping the per-member + * Serializable guard. + * 2. Contract termination must refuse when there are still active + * ProgramAssignments inside their current cycle (otherwise + * checkout 500s on assignment-resolve for orphaned members). + * 3. Program delete must refuse when any historical + * BookingUtilization row points at the program — the audit trail + * and refund path both rely on the FK target staying alive. + * + * All three follow the same mocked-prisma pattern as + * member-anti-lockout.test.ts. We don't exercise the Serializable + * isolation level itself (Prisma's TS layer hides it from the mock); + * we only assert the explicit guards. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + contract: { + findFirst: jest.fn(), + update: jest.fn(), + // Status moves go through the CAS helper: guarded updateMany + + // in-tx findUniqueOrThrow re-read. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUniqueOrThrow: jest.fn().mockResolvedValue({ id: "c-1" }), + }, + program: { + findFirst: jest.fn(), + delete: jest.fn(), + // #779 — TERMINATED cascade (programs → EXPIRED). + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + programAssignment: { + count: jest.fn(), + // #779 — TERMINATED cascade (assignments → CLOSED). + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + bookingUtilization: { findFirst: jest.fn() }, + purchaseOrder: { findUnique: jest.fn() }, + // #779 — outstanding-invoice terminate guard; default = no invoices owed. + organizationInvoice: { count: jest.fn().mockResolvedValue(0) }, + orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, + $transaction: jest.fn(), + $disconnect: jest.fn(), + }, +})); + +jest.mock("../../lib/auth-helpers", () => { + const RANK: Record = { + OWNER: 5, + MAINTAINER: 4, + MANAGER: 3, + SUPPORT: 2, + EXPERT: 1, + LEARNER: 1, + }; + return { + requireOrgAccess: jest.fn(), + requireOrgOwner: jest.fn(), + orgRoleSatisfies: (caller: string, minimum: string) => + (RANK[caller] ?? 0) >= (RANK[minimum] ?? 0), + }; +}); + +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { + GET as bulkGET, + POST as bulkPOST, + PATCH as bulkPATCH, + DELETE as bulkDELETE, +} from "@/app/api/organizations/[orgId]/members/bulk/route"; +import { PATCH as contractPATCH } from "@/app/api/organizations/[orgId]/contracts/[contractId]/route"; +import { DELETE as programDELETE } from "@/app/api/organizations/[orgId]/programs/[programId]/route"; + +const mockedPrisma = prisma as unknown as { + contract: { + findFirst: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + findUniqueOrThrow: jest.Mock; + }; + program: { findFirst: jest.Mock; delete: jest.Mock; updateMany: jest.Mock }; + programAssignment: { count: jest.Mock; updateMany: jest.Mock }; + bookingUtilization: { findFirst: jest.Mock }; + purchaseOrder: { findUnique: jest.Mock }; + organizationInvoice: { count: jest.Mock }; + orgAuditLog: { create: jest.Mock }; + $transaction: jest.Mock; +}; +const mockedRequireOrgAccess = requireOrgAccess as jest.Mock; + +function ownerAccess() { + return { + error: null, + session: { user: { id: "u-owner", email: "owner@test.com" } }, + member: { id: "m-owner-actor", role: "OWNER" }, + org: { id: "org-1", name: "Acme", status: "ACTIVE", canSponsor: true }, + }; +} + +function makeRequest(url: string, body: unknown) { + return new Request(url, { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }) as unknown as Request; +} + +function wireTxShim() { + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => { + const tx = { + contract: mockedPrisma.contract, + program: mockedPrisma.program, + programAssignment: mockedPrisma.programAssignment, + bookingUtilization: mockedPrisma.bookingUtilization, + purchaseOrder: mockedPrisma.purchaseOrder, + organizationInvoice: mockedPrisma.organizationInvoice, + orgAuditLog: mockedPrisma.orgAuditLog, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(tx); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockedRequireOrgAccess.mockResolvedValue(ownerAccess()); + wireTxShim(); +}); + +// --------------------------------------------------------------------------- +// 1. Bulk endpoint always 405 +// --------------------------------------------------------------------------- + +describe("/api/organizations/[orgId]/members/bulk — explicit 405", () => { + it.each([ + ["GET", bulkGET], + ["POST", bulkPOST], + ["PATCH", bulkPATCH], + ["DELETE", bulkDELETE], + ] as const)("%s returns 405 with BULK_REMOVAL_NOT_SUPPORTED", async (_, h) => { + const res = (await h()) as Response; + expect(res.status).toBe(405); + const body = await res.json(); + expect(body.error).toBe("BULK_REMOVAL_NOT_SUPPORTED"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Contract termination guard +// --------------------------------------------------------------------------- + +describe("PATCH /api/organizations/[orgId]/contracts/[contractId] — termination guard", () => { + it("refuses to terminate an ACTIVE contract with live assignments (409)", async () => { + mockedPrisma.contract.findFirst.mockResolvedValueOnce({ + id: "c-1", + organizationId: "org-1", + status: "ACTIVE", + }); + mockedPrisma.programAssignment.count.mockResolvedValueOnce(3); + + const res = (await contractPATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest( + "http://localhost/api/organizations/org-1/contracts/c-1", + { status: "TERMINATED" }, + ) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1", contractId: "c-1" }) } as any, + )) as Response; + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/3 active assignment/); + expect(mockedPrisma.contract.update).not.toHaveBeenCalled(); + }); + + it("allows terminating an ACTIVE contract with zero live assignments", async () => { + mockedPrisma.contract.findFirst.mockResolvedValueOnce({ + id: "c-1", + organizationId: "org-1", + status: "ACTIVE", + }); + mockedPrisma.programAssignment.count.mockResolvedValueOnce(0); + mockedPrisma.contract.update.mockResolvedValueOnce({ + id: "c-1", + status: "TERMINATED", + invoiceNumber: "INV-1", + }); + + const res = (await contractPATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest( + "http://localhost/api/organizations/org-1/contracts/c-1", + { status: "TERMINATED" }, + ) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1", contractId: "c-1" }) } as any, + )) as Response; + + expect(res.status).toBe(200); + // Status moves are CAS-guarded updateMany now, not a plain update. + expect(mockedPrisma.contract.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: ["ACTIVE"] }, + }), + data: expect.objectContaining({ status: "TERMINATED" }), + }), + ); + }); + + it("does not run the assignment guard for non-TERMINATED transitions", async () => { + mockedPrisma.contract.findFirst.mockResolvedValueOnce({ + id: "c-1", + organizationId: "org-1", + status: "DRAFT", + }); + mockedPrisma.contract.update.mockResolvedValueOnce({ + id: "c-1", + status: "ACTIVE", + invoiceNumber: "INV-1", + }); + + const res = (await contractPATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest( + "http://localhost/api/organizations/org-1/contracts/c-1", + { status: "ACTIVE" }, + ) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1", contractId: "c-1" }) } as any, + )) as Response; + + expect(res.status).toBe(200); + expect(mockedPrisma.programAssignment.count).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Program delete guard — utilization history blocks hard delete +// --------------------------------------------------------------------------- + +describe("DELETE /api/organizations/[orgId]/programs/[programId] — utilization guard", () => { + it("returns 409 when assignment count > 0", async () => { + mockedPrisma.program.findFirst.mockResolvedValueOnce({ + id: "p-1", + contract: { organizationId: "org-1" }, + _count: { assignments: 2 }, + }); + + const res = (await programDELETE( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new Request("http://localhost/api/organizations/org-1/programs/p-1", { + method: "DELETE", + }) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1", programId: "p-1" }) } as any, + )) as Response; + + expect(res.status).toBe(409); + expect(mockedPrisma.program.delete).not.toHaveBeenCalled(); + }); + + it("returns 409 when historical BookingUtilization rows still reference the program", async () => { + mockedPrisma.program.findFirst.mockResolvedValueOnce({ + id: "p-1", + contract: { organizationId: "org-1" }, + _count: { assignments: 0 }, + }); + mockedPrisma.bookingUtilization.findFirst.mockResolvedValueOnce({ + id: "u-1", + }); + + const res = (await programDELETE( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new Request("http://localhost/api/organizations/org-1/programs/p-1", { + method: "DELETE", + }) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1", programId: "p-1" }) } as any, + )) as Response; + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/historical utilization/i); + expect(mockedPrisma.program.delete).not.toHaveBeenCalled(); + }); + + it("allows delete when assignments=0 and no utilization history", async () => { + mockedPrisma.program.findFirst.mockResolvedValueOnce({ + id: "p-1", + contract: { organizationId: "org-1" }, + _count: { assignments: 0 }, + }); + mockedPrisma.bookingUtilization.findFirst.mockResolvedValueOnce(null); + mockedPrisma.program.delete.mockResolvedValueOnce({ id: "p-1" }); + + const res = (await programDELETE( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new Request("http://localhost/api/organizations/org-1/programs/p-1", { + method: "DELETE", + }) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1", programId: "p-1" }) } as any, + )) as Response; + + expect(res.status).toBe(204); + expect(mockedPrisma.program.delete).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/appointment-org-stamping.test.ts b/__tests__/enterprise/appointment-org-stamping.test.ts new file mode 100644 index 000000000..d3ab6e2c0 --- /dev/null +++ b/__tests__/enterprise/appointment-org-stamping.test.ts @@ -0,0 +1,185 @@ +/** + * @jest-environment node + */ + +/** + * Booking-org-stamping fix coverage — #768 Comment 5. + * + * SUBSCRIPTION lazy allocation, CLASS pre-allocation, marketplace WEBINAR, and + * CONSULTATION reschedule each used to create Appointment rows with + * `organizationId = null` even when the booker was org-funded. The org tag is + * resolved per event type inside `SlotAllocationService.fetchEventData`: + * + * - CONSULTATION → existing Appointment.organizationId (preserved across the + * reschedule delete+recreate). + * - SUBSCRIPTION → the placeholder Appointment's org, falling back to its + * Payment.organizationId (org-tagged at checkout). + * - WEBINAR → webinarPlan.organizationId (Appointment is SHARED across + * attendees from any org). + * - CLASS → classPlan.organizationId (host wins; marketplace = null). + * + * These tests drive the REAL resolution by calling `fetchEventData` with a + * mocked Prisma tx per event type and asserting the resolved organizationId — + * not a re-implementation of the rule. Full DB integration lives in the E2E + * guide. + */ +import { SlotAllocationService } from "@/utils/slotAllocation/SlotAllocationService"; +import type { EventType } from "@/utils/slotAllocation/types"; + +// Minimal consultant profile so fetchEventData resolves past its +// "consultant profile not found" guard — the tests only assert organizationId. +const CONSULTANT = { + user: { id: "consultant-1", timezone: null }, + scheduleType: "WEEKLY", + slotsOfAvailabilityWeekly: [], + slotsOfAvailabilityCustom: [], +}; + +// fetchEventData is private + static; it uses only its `tx` arg (no `this`), so +// it's callable detached. Returns the resolved organizationId for the event. +async function resolveOrg( + model: string, + eventType: EventType, + event: unknown, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const svc = SlotAllocationService as any; + const tx = { + [model]: { findUnique: jest.fn().mockResolvedValue(event) }, + }; + const result = await svc.fetchEventData(tx, eventType, "evt-1"); + return result?.organizationId ?? null; +} + +describe("Appointment.organizationId stamping (#768 Comment 5)", () => { + it("CONSULTATION inherits the existing Appointment org (reschedule-safe)", async () => { + const base = { + consultationPlan: { consultantProfile: CONSULTANT, durationInHours: 1 }, + requestedBy: { user: { id: "consultee-1" } }, + }; + await expect( + resolveOrg("consultation", "consultation", { + ...base, + appointment: { organizationId: "wipro-org-id", slotsOfAppointment: [] }, + }), + ).resolves.toBe("wipro-org-id"); + + // PERSONAL booking → no org tag. + await expect( + resolveOrg("consultation", "consultation", { + ...base, + appointment: { organizationId: null, slotsOfAppointment: [] }, + }), + ).resolves.toBeNull(); + }); + + it("SUBSCRIPTION inherits the placeholder Appointment org", async () => { + const event = { + subscriptionPlan: { consultantProfile: CONSULTANT, totalSessions: 4 }, + requestedBy: { user: { id: "consultee-1" } }, + appointments: [ + { + organizationId: "wipro-org-id", + slotsOfAppointment: [], + payment: { organizationId: null }, + }, + ], + }; + await expect(resolveOrg("subscription", "subscription", event)).resolves.toBe( + "wipro-org-id", + ); + }); + + it("SUBSCRIPTION falls back to the Payment org when the Appointment is untagged", async () => { + // The exact #768 Comment 5 bug: org-funded booker, placeholder Appointment + // missing the tag → must recover it from Payment.organizationId. + const event = { + subscriptionPlan: { consultantProfile: CONSULTANT, totalSessions: 4 }, + requestedBy: { user: { id: "consultee-1" } }, + appointments: [ + { + organizationId: null, + slotsOfAppointment: [], + payment: { organizationId: "wipro-org-id" }, + }, + ], + }; + await expect(resolveOrg("subscription", "subscription", event)).resolves.toBe( + "wipro-org-id", + ); + }); + + it("SUBSCRIPTION stays null for a PERSONAL booking", async () => { + const event = { + subscriptionPlan: { consultantProfile: CONSULTANT, totalSessions: 4 }, + requestedBy: { user: { id: "consultee-1" } }, + appointments: [ + { + organizationId: null, + slotsOfAppointment: [], + payment: { organizationId: null }, + }, + ], + }; + await expect( + resolveOrg("subscription", "subscription", event), + ).resolves.toBeNull(); + }); + + it("WEBINAR uses the webinarPlan host org (shared across attendees)", async () => { + await expect( + resolveOrg("webinar", "webinar", { + webinarPlan: { + consultantProfile: CONSULTANT, + durationInHours: 1, + organizationId: "tata-org-id", + }, + }), + ).resolves.toBe("tata-org-id"); + + await expect( + resolveOrg("webinar", "webinar", { + webinarPlan: { + consultantProfile: CONSULTANT, + durationInHours: 1, + organizationId: null, + }, + }), + ).resolves.toBeNull(); + }); + + it("CLASS uses the classPlan host org (host wins; marketplace = null)", async () => { + const base = { + appointments: [], + }; + await expect( + resolveOrg("class", "class", { + ...base, + classPlan: { + consultantProfile: CONSULTANT, + classContents: [], + totalSessions: 4, + organizationId: "infosys-org-id", + }, + }), + ).resolves.toBe("infosys-org-id"); + + await expect( + resolveOrg("class", "class", { + ...base, + classPlan: { + consultantProfile: CONSULTANT, + classContents: [], + totalSessions: 4, + organizationId: null, + }, + }), + ).resolves.toBeNull(); + }); + + it("returns null event data when the row is missing", async () => { + await expect( + resolveOrg("consultation", "consultation", null), + ).resolves.toBeNull(); + }); +}); diff --git a/__tests__/enterprise/audit-actions.test.ts b/__tests__/enterprise/audit-actions.test.ts new file mode 100644 index 000000000..0847d40fd --- /dev/null +++ b/__tests__/enterprise/audit-actions.test.ts @@ -0,0 +1,62 @@ +/** + * @jest-environment node + */ + +/** + * Issue #699 ENT-4: audit-log action label sweep. + * + * The constants in `lib/enterprise/audit-actions.ts` are the source of + * truth for OrgAuditLog.action strings. Free-form strings at call sites + * defeat the convention (no autocomplete, drift over time). + * + * This test grep-asserts every `tx.orgAuditLog.create({ ..., action: "X" })` + * and `prisma.orgAuditLog.create({ ..., action: "X" })` site under + * app/api/organizations/, jobs/, and scripts/cleanup/ uses an + * AUDIT_ACTIONS constant. + * + * If a new event genuinely needs a new label, add it to AUDIT_ACTIONS + * first, then reference the constant at the call site. Don't add a + * literal here — the test will fail. + */ + +import { execSync } from "child_process"; +import path from "path"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +function rg(pattern: string, paths: string[]): string { + // Use grep -rEn so we get file:line:body and ERE alternation. + // The -h flag is omitted intentionally — we want filenames in output. + try { + return execSync( + `grep -rEn ${JSON.stringify(pattern)} ${paths.map((p) => JSON.stringify(p)).join(" ")}`, + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + } catch (err) { + // grep returns non-zero on no-matches, which is the success case here. + const status = (err as { status?: number }).status; + if (status === 1) return ""; + throw err; + } +} + +describe("audit-log action label sweep", () => { + it("no free-form action: \"...\" string under app/api/organizations/", () => { + const matches = rg( + 'action:[[:space:]]*"[A-Z_]+"', + ["app/api/organizations"], + ); + // The audit-export endpoint is allowed to write its own action label + // string because it self-audits via `AUDIT_ACTIONS.SETTINGS.AUDIT_LOG_EXPORTED` + // which the grep matches as a constant access — it shouldn't show up. + expect(matches).toBe(""); + }); + + it("no free-form action: \"...\" string under jobs/ or scripts/cleanup/", () => { + const matches = rg( + 'action:[[:space:]]*"[A-Z_]+"', + ["jobs", "scripts/cleanup"], + ); + expect(matches).toBe(""); + }); +}); diff --git a/__tests__/enterprise/audit-sanitize.test.ts b/__tests__/enterprise/audit-sanitize.test.ts new file mode 100644 index 000000000..cdd9fb512 --- /dev/null +++ b/__tests__/enterprise/audit-sanitize.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + */ + +/** + * Read-side audit-log scrub. Catches engineering-noise patterns + * (Prisma error syntax, stack frames, JSON dumps) and redacts them + * before the row reaches an org-visible surface. See + * `lib/enterprise/audit-sanitize.ts` for the regex catalogue. + * + * The bug this prevents: an OWNER seeing + * "Data export bundle failed: Invalid `prisma.organization.findUnique()` + * invocation: { where: { id: ..., ? AND?: ProgramWhereInput | ... } }" + * in their audit log — internal schema leakage to a tenant. + */ + +import { + sanitizeAuditDescription, + sanitizeAuditDetails, +} from "@/lib/enterprise/audit-sanitize"; + +describe("sanitizeAuditDescription", () => { + describe("safe rows pass through", () => { + it("returns clean prose unchanged", () => { + const safe = "Organization 'Wipro Mod' created by OWNER"; + expect(sanitizeAuditDescription(safe)).toBe(safe); + }); + + it("preserves PO + INR amount text", () => { + const safe = "PO WZ-2026-0042 created (INR 500,000)"; + expect(sanitizeAuditDescription(safe)).toBe(safe); + }); + + it("preserves role-transition rows", () => { + expect(sanitizeAuditDescription("Role: LEARNER → MANAGER")).toBe( + "Role: LEARNER → MANAGER", + ); + }); + + it("empty + null guard", () => { + expect(sanitizeAuditDescription("")).toBe(""); + }); + }); + + describe("redacts Prisma error noise (#org-info-leak)", () => { + it("redacts the canonical 'Invalid prisma.x.findUnique() invocation' shape", () => { + const noisy = + "Data export bundle failed: Invalid `prisma.organization.findUnique()` invocation: { where: { id: 'abc' } }"; + const out = sanitizeAuditDescription(noisy); + // Prefix preserved, noisy tail replaced + expect(out.startsWith("Data export bundle failed:")).toBe(true); + expect(out).toContain("[redacted"); + expect(out).not.toContain("prisma.organization.findUnique"); + }); + + it("redacts Prisma schema enum names (WhereInput, RelationFilter, …)", () => { + const noisy = + "Failed update: AND?: ProgramWhereInput | ProgramWhereInput[], status?: EnumProgramStatusFilter | ProgramStatus"; + const out = sanitizeAuditDescription(noisy); + expect(out).toContain("[redacted"); + expect(out).not.toContain("ProgramWhereInput"); + expect(out).not.toContain("EnumProgramStatusFilter"); + }); + + it("redacts the LicensedSeatConfigNullableScalarRelationFilter case from the screenshot", () => { + const noisy = + "Data export bundle failed: licensedSeatConfig?: LicensedSeatConfigNullableScalarRelationFilter | LicensedSeatConfigWhereInput | Null"; + const out = sanitizeAuditDescription(noisy); + expect(out).toContain("[redacted"); + expect(out).not.toContain("LicensedSeatConfigNullableScalarRelationFilter"); + }); + }); + + describe("redacts stack frames", () => { + it("strips Node-style 'at ... (file:line:col)' frames", () => { + const stack = + "Job failed at processExport (/app/scripts/process-data-exports.ts:312:14)"; + expect(sanitizeAuditDescription(stack)).toContain("[redacted"); + }); + + it("strips node:internal/ frames", () => { + const stack = "Worker crashed: node:internal/process/task_queues:95:5"; + expect(sanitizeAuditDescription(stack)).toContain("[redacted"); + }); + }); + + describe("redacts JSON payload dumps", () => { + it("strips long JSON blobs out of description", () => { + const noisy = + 'Webhook payload: { "event_id": "evt_abc1234567890abcdef1234567890abcdef1234567890", "status": "failed" }'; + expect(sanitizeAuditDescription(noisy)).toContain("[redacted"); + }); + }); + + describe("idempotent", () => { + it("running sanitize twice gives the same result", () => { + const noisy = + "Data export bundle failed: Invalid `prisma.organization.findUnique()` invocation"; + const once = sanitizeAuditDescription(noisy); + const twice = sanitizeAuditDescription(once); + expect(twice).toBe(once); + }); + }); + + describe("preserves user-visible prefix", () => { + it("keeps 'Data export bundle failed:' lead, replaces the noisy tail", () => { + const noisy = + "Data export bundle failed: Invalid `prisma.organization.findUnique()` invocation: { where: ... }"; + const out = sanitizeAuditDescription(noisy); + expect(out).toMatch(/^Data export bundle failed:/); + }); + + it("drops everything when the prefix itself is noise", () => { + // No clean header — the whole string is engineering goop. Result + // is the bare redaction placeholder. + const noisy = + "Invalid `prisma.payment.findFirst()` invocation: { where: PaymentWhereInput }"; + const out = sanitizeAuditDescription(noisy); + expect(out).not.toMatch(/^Invalid `prisma/); + expect(out).toContain("[redacted"); + }); + }); +}); + +describe("sanitizeAuditDetails", () => { + it("strips `error` / `stack` / `prismaError` keys", () => { + const out = sanitizeAuditDetails({ + exportId: "abc-123", + error: "Invalid prisma...", + stack: "at processExport (file.ts:1:1)", + prismaError: "P2002", + }); + expect(out).toEqual({ exportId: "abc-123" }); + }); + + it("preserves benign keys", () => { + const out = sanitizeAuditDetails({ + invoiceId: "inv_1", + poNumber: "PO-1", + totalAmountPaise: 100000, + }); + expect(out).toEqual({ + invoiceId: "inv_1", + poNumber: "PO-1", + totalAmountPaise: 100000, + }); + }); + + it("returns null for non-object input", () => { + expect(sanitizeAuditDetails(null)).toBeNull(); + expect(sanitizeAuditDetails(undefined)).toBeNull(); + expect(sanitizeAuditDetails([])).toBeNull(); + expect(sanitizeAuditDetails("string")).toBeNull(); + }); +}); diff --git a/__tests__/enterprise/betterstack-telemetry.test.ts b/__tests__/enterprise/betterstack-telemetry.test.ts new file mode 100644 index 000000000..f418a1d9d --- /dev/null +++ b/__tests__/enterprise/betterstack-telemetry.test.ts @@ -0,0 +1,98 @@ +/** + * @jest-environment node + */ + +/** + * Better Stack Telemetry sink (#776 §K). The contract: no network when the + * flag is off, a Bearer-authed POST when on+configured, and NEVER throws into + * the caller (recordSystemEvent's critical path must survive an ingest outage). + * + * The flag is read at module load, so each case sets env + jest.resetModules + * and re-imports. + */ + +describe("emitTelemetryLog", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterEach(() => { + process.env = OLD_ENV; + jest.restoreAllMocks(); + }); + + it("is a no-op (no fetch) when the flag is off", async () => { + process.env.ENABLE_BETTERSTACK_TELEMETRY = "false"; + process.env.BETTERSTACK_SOURCE_TOKEN = "tok"; + process.env.BETTERSTACK_INGEST_URL = "https://in.example.com"; + const fetchSpy = jest.fn(); + global.fetch = fetchSpy as never; + + const { emitTelemetryLog } = await import( + "@/lib/observability/betterstack-telemetry" + ); + await emitTelemetryLog({ level: "error", message: "boom" }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("does NOT fetch when flag on but token/url missing", async () => { + process.env.ENABLE_BETTERSTACK_TELEMETRY = "true"; + delete process.env.BETTERSTACK_SOURCE_TOKEN; + delete process.env.BETTERSTACK_INGEST_URL; + const fetchSpy = jest.fn(); + global.fetch = fetchSpy as never; + + const { emitTelemetryLog } = await import( + "@/lib/observability/betterstack-telemetry" + ); + await emitTelemetryLog({ level: "warn", message: "x" }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("POSTs with a Bearer source token when on+configured", async () => { + process.env.ENABLE_BETTERSTACK_TELEMETRY = "true"; + process.env.BETTERSTACK_SOURCE_TOKEN = "tok-123"; + process.env.BETTERSTACK_INGEST_URL = "https://in.example.com"; + const fetchSpy = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchSpy as never; + + const { emitTelemetryLog } = await import( + "@/lib/observability/betterstack-telemetry" + ); + await emitTelemetryLog({ + level: "error", + message: "stuck payout", + category: "PAYOUT", + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://in.example.com"); + expect(opts.method).toBe("POST"); + expect(opts.headers.Authorization).toBe("Bearer tok-123"); + const body = JSON.parse(opts.body); + expect(body.level).toBe("error"); + expect(body.message).toBe("stuck payout"); + expect(body.category).toBe("PAYOUT"); + }); + + it("never throws when the ingest call fails", async () => { + process.env.ENABLE_BETTERSTACK_TELEMETRY = "true"; + process.env.BETTERSTACK_SOURCE_TOKEN = "tok"; + process.env.BETTERSTACK_INGEST_URL = "https://in.example.com"; + global.fetch = jest + .fn() + .mockRejectedValue(new Error("network down")) as never; + jest.spyOn(console, "error").mockImplementation(() => {}); + + const { emitTelemetryLog } = await import( + "@/lib/observability/betterstack-telemetry" + ); + await expect( + emitTelemetryLog({ level: "error", message: "x" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/enterprise/billing-admin-gate.test.ts b/__tests__/enterprise/billing-admin-gate.test.ts new file mode 100644 index 000000000..11b8dffc0 --- /dev/null +++ b/__tests__/enterprise/billing-admin-gate.test.ts @@ -0,0 +1,118 @@ +/** + * @jest-environment node + */ + +/** + * Pin the disjunction semantics of `requireOrgBillingAdminOrOwner`. + * + * Why this matters + * ---------------- + * BILLING_ADMIN sits at rank 70 — above MANAGER (60) but below + * MAINTAINER (80). A naïve `requireOrgAccess(orgId, { minimumRole: + * "BILLING_ADMIN" })` would let MAINTAINER through on the rank + * comparison. The helper under test must REFUSE MAINTAINER because + * MAINTAINER is the org-admin role and explicitly does not have + * billing rights per `lib/labels/org-labels.ts` + * `MEMBER_ROLE_DESCRIPTION`. See `lib/auth/billing-admin-gate.ts` for + * the full rationale. + * + * Closes PR #655 Batch 2 (route gate audit). + */ + +jest.mock("../../lib/auth-helpers", () => ({ + // The helper-under-test still relies on `requireOrgAccess` for the + // initial "active member of the org" + capability gates. We mock it + // to return a synthetic access object per role and let the + // disjunction check run unmodified. + requireOrgAccess: jest.fn(), +})); + +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; + +const mockedRequireOrgAccess = requireOrgAccess as jest.Mock; + +function accessFor(role: string) { + return { + error: null, + session: { user: { id: "u-1", email: "u@test.com" } }, + member: { id: "m-1", role }, + org: { id: "org-1", name: "Acme", status: "ACTIVE" }, + }; +} + +describe("requireOrgBillingAdminOrOwner — disjunction semantics", () => { + beforeEach(() => { + mockedRequireOrgAccess.mockReset(); + }); + + it.each([ + ["OWNER", true], + ["BILLING_ADMIN", true], + // Critical anti-regression: MAINTAINER is rank 80 (above BILLING_ADMIN + // at 70) yet must be denied because the disjunction is on EXACT role, + // not rank. A rank-based gate would let this through. + ["MAINTAINER", false], + ["MANAGER", false], + ["EXPERT", false], + ["SUPPORT", false], + ["LEARNER", false], + ])("role=%s → allowed=%s", async (role, allowed) => { + mockedRequireOrgAccess.mockResolvedValue(accessFor(role)); + const result = await requireOrgBillingAdminOrOwner("org-1"); + + if (allowed) { + // Type-narrow via the success branch: when `error` is null, + // TS still sees the union (the upstream `requireOrgAccess` + // return-type is `OrgAccessGrant | { error: NextResponse }`). + // The runtime assertion below narrows for the compiler too. + expect(result.error).toBeNull(); + if (!("member" in result)) { + throw new Error("expected access grant on allowed role"); + } + expect(result.member.role).toBe(role); + return; + } + + // On denial the helper returns { error: NextResponse } with a 403 + // and the typed code. We assert the response status + body shape so + // a future "make this 401 instead" refactor is caught. + expect(result.error).toBeDefined(); + expect(result.error?.status).toBe(403); + const body = await result.error?.json(); + expect(body).toEqual({ + error: "Forbidden", + code: "BILLING_ADMIN_OR_OWNER_REQUIRED", + }); + }); + + it("forwards capability gates (canSponsor, requireActive) to requireOrgAccess", async () => { + // The helper is a thin wrapper — capability fields must flow through + // unchanged so routes like `billing-account/wallet/top-ups` can keep + // their existing `requireActive: true` guard. + mockedRequireOrgAccess.mockResolvedValue(accessFor("OWNER")); + await requireOrgBillingAdminOrOwner("org-1", { + canSponsor: true, + requireActive: true, + }); + expect(mockedRequireOrgAccess).toHaveBeenCalledWith("org-1", { + minimumRole: "LEARNER", + canSponsor: true, + requireActive: true, + }); + }); + + it("propagates the error response from requireOrgAccess unchanged", async () => { + // When requireOrgAccess returns an error (org PENDING_VERIFICATION, + // user not a member, capability mismatch), the helper must surface + // that response verbatim instead of overwriting it with its own 403. + const orgNotVerified = { + error: { status: 409, json: async () => ({ error: "ORG_NOT_VERIFIED" }) }, + }; + mockedRequireOrgAccess.mockResolvedValue(orgNotVerified); + const result = await requireOrgBillingAdminOrOwner("org-1", { + requireActive: true, + }); + expect(result.error).toBe(orgNotVerified.error); + }); +}); diff --git a/__tests__/enterprise/cap-edge-cases.test.ts b/__tests__/enterprise/cap-edge-cases.test.ts new file mode 100644 index 000000000..3da3622bd --- /dev/null +++ b/__tests__/enterprise/cap-edge-cases.test.ts @@ -0,0 +1,185 @@ +/** + * @jest-environment node + */ + +/** + * PR-1e Phase C: explicit edge-case sweep for the engagement-cap math. + * + * The happy paths and overage modes are covered in + * `multi-engagement-cap.test.ts`. This file pins the corner cases that + * are easy to regress on and produce silent financial effects: + * + * - cap = 0 (zero engagements covered) — every booking should reject + * under BLOCK and overage under CHARGE_*. + * - cap = null (unlimited) — booking always succeeds, never flags + * overage. + * - engagementsConsumed = 0 — defensive no-op without partial-debit + * side effects. + * - engagementsConsumed < 0 — should throw (defensive). The helper + * never wrote a negative delta historically; future callers might + * mistakenly pass a refund amount here. + */ + +import { + recordBookingUtilization, + ProgramAssignmentLimitError, +} from "@/lib/api/organizations/program-helpers"; + +type MockTx = { + programAssignment: { + findUniqueOrThrow: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; + bookingUtilization: { + upsert: jest.Mock; + findUnique: jest.Mock; + }; + usageLedgerEntry: { + create: jest.Mock; + aggregate: jest.Mock; + }; +}; + +function makeTx(opts: { + cap: number | null; + behavior: "BLOCK" | "CHARGE_MEMBER" | "CHARGE_ORG"; + blockUpdateRows?: number; + chargeReturning?: { engagementsUsed: number }[]; +}): MockTx { + return { + programAssignment: { + findUniqueOrThrow: jest.fn().mockResolvedValue({ + programId: "prog-1", + membershipId: "mem-1", + program: { + licensedSeatConfig: { + coveredEngagementsPerCycle: opts.cap, + overageBehavior: opts.behavior, + }, + }, + }), + // CHARGE_* / unlimited path: resolves to the post-increment row. + update: jest + .fn() + .mockResolvedValue(opts.chargeReturning?.[0] ?? {}), + // BLOCK path: count===0 ⇒ cap exceeded → throws. + updateMany: jest + .fn() + .mockResolvedValue({ count: opts.blockUpdateRows ?? 1 }), + }, + bookingUtilization: { + upsert: jest.fn().mockResolvedValue({}), + findUnique: jest.fn().mockResolvedValue(null), + }, + usageLedgerEntry: { + create: jest.fn().mockResolvedValue({}), + aggregate: jest.fn().mockResolvedValue({ _sum: { engagementsConsumed: 0 } }), + }, + }; +} + +describe("cap edge cases — cap=0 (zero engagements covered)", () => { + it("BLOCK rejects every booking, even a 1-engagement consultation", async () => { + const tx = makeTx({ + cap: 0, + behavior: "BLOCK", + blockUpdateRows: 0, // 0 + 1 <= 0 is false → 0 rows touched + }); + await expect( + recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-zero-cap", + engagementsConsumed: 1, + priceAtBookingPaise: 50_000, + }), + ).rejects.toBeInstanceOf(ProgramAssignmentLimitError); + expect(tx.bookingUtilization.upsert).not.toHaveBeenCalled(); + }); + + it("CHARGE_ORG cap=0 succeeds with wasOverage=true", async () => { + const tx = makeTx({ + cap: 0, + behavior: "CHARGE_ORG", + chargeReturning: [{ engagementsUsed: 1 }], + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-zero-cap-overage", + engagementsConsumed: 1, + priceAtBookingPaise: 50_000, + }); + expect(result.wasOverage).toBe(true); + }); +}); + +describe("cap edge cases — cap=null (unlimited)", () => { + it("never throws regardless of engagementsConsumed", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-unlimited", + engagementsConsumed: 1_000, // would blow any finite cap + priceAtBookingPaise: 50_000_000, + }); + expect(result.wasOverage).toBe(false); + expect(tx.programAssignment.update).toHaveBeenCalledTimes(1); + }); + + it("CHARGE_MEMBER + cap=null: wasOverage stays false (the cap path is bypassed)", async () => { + const tx = makeTx({ cap: null, behavior: "CHARGE_MEMBER" }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-unlimited-charge-member", + engagementsConsumed: 50, + priceAtBookingPaise: 0, + }); + expect(result.wasOverage).toBe(false); + }); +}); + +describe("cap edge cases — degenerate inputs", () => { + it("engagementsConsumed=0 with no appointmentIds: no DB writes (defensive no-op)", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-zero-delta", + engagementsConsumed: 0, + priceAtBookingPaise: 0, + }); + expect(result.engagementsConsumedDelta).toBe(0); + expect(tx.programAssignment.update).not.toHaveBeenCalled(); + expect(tx.programAssignment.updateMany).not.toHaveBeenCalled(); + expect(tx.bookingUtilization.upsert).not.toHaveBeenCalled(); + expect(tx.usageLedgerEntry.create).not.toHaveBeenCalled(); + }); + + it("engagementsConsumed<0 throws — never write a negative delta from this helper", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + await expect( + recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-negative", + engagementsConsumed: -5, + priceAtBookingPaise: 0, + }), + ).rejects.toThrow(/non-negative/); + }); + + it("appointmentIds=[] with engagementsConsumed=0: no-op (caller decided nothing changed)", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + appointmentIds: ["x"], + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-empty-ids", + engagementsConsumed: 0, + priceAtBookingPaise: 0, + appointmentIds: [], + }); + expect(result.engagementsConsumedDelta).toBe(0); + expect(tx.programAssignment.update).not.toHaveBeenCalled(); + expect(tx.programAssignment.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/claim-program-assignment.test.ts b/__tests__/enterprise/claim-program-assignment.test.ts new file mode 100644 index 000000000..290e7e8b5 --- /dev/null +++ b/__tests__/enterprise/claim-program-assignment.test.ts @@ -0,0 +1,70 @@ +/** + * @jest-environment node + */ + +/** + * #785 — claimProgramAssignment must report whether THIS call created the row + * so the assignment route increments activeSeatCount exactly once. Replaces the + * old upsert + racy preexisting-probe (two concurrent identical POSTs could both + * seat-count one logical seat). createMany({ skipDuplicates }) is INSERT … ON + * CONFLICT DO NOTHING: count===1 for the racer that wins the unique key, + * count===0 for a re-claim — and, unlike a caught P2002, never poisons the tx. + */ +import { claimProgramAssignment } from "@/lib/api/organizations/program-helpers"; + +type MockTx = { + programAssignment: { + createMany: jest.Mock; + findUniqueOrThrow: jest.Mock; + findFirst: jest.Mock; + }; +}; + +const params = { + programId: "prog_1", + membershipId: "mem_1", + periodStart: new Date("2026-04-01"), + periodEnd: new Date("2026-05-01"), +}; + +function makeTx(insertedCount: number, overlapping: { id: string } | null = null): MockTx { + return { + programAssignment: { + createMany: jest.fn().mockResolvedValue({ count: insertedCount }), + findUniqueOrThrow: jest + .fn() + .mockResolvedValue({ id: "assign_1", ...params }), + // #778 §B overlap guard probe — null = no overlapping ACTIVE cycle. + findFirst: jest.fn().mockResolvedValue(overlapping), + }, + }; +} + +describe("claimProgramAssignment (#785 seat double-count guard)", () => { + it("created=true when the row is genuinely inserted (count===1)", async () => { + const tx = makeTx(1); + const r = await claimProgramAssignment(tx as never, params); + expect(r.created).toBe(true); + expect(r.assignment.id).toBe("assign_1"); + // skipDuplicates so a concurrent duplicate insert is a no-op, not a throw. + expect(tx.programAssignment.createMany).toHaveBeenCalledWith( + expect.objectContaining({ skipDuplicates: true }), + ); + }); + + it("created=false on a re-claim / concurrent duplicate (count===0)", async () => { + const tx = makeTx(0); + const r = await claimProgramAssignment(tx as never, params); + expect(r.created).toBe(false); + // still returns the existing row so the caller can respond 201/200 uniformly + expect(r.assignment.id).toBe("assign_1"); + }); + + it("rejects an overlapping ACTIVE cycle (#778 §B) before inserting", async () => { + const tx = makeTx(1, { id: "assign_other_cycle" }); + await expect(claimProgramAssignment(tx as never, params)).rejects.toThrow( + /overlapping period/, + ); + expect(tx.programAssignment.createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/collaborator-org-earnings.test.ts b/__tests__/enterprise/collaborator-org-earnings.test.ts new file mode 100644 index 000000000..b3748e1d6 --- /dev/null +++ b/__tests__/enterprise/collaborator-org-earnings.test.ts @@ -0,0 +1,426 @@ +/** + * @jest-environment node + */ + +/** + * A3 (Q3): per-collaborator HOST-org settlement. + * + * After the primary expert's OrganizationEarnings row is created, + * `createEarningsFromPayment` loops every ACCEPTED collaborator and + * creates a SEPARATE OrganizationEarnings row for each one whose + * consultant profile has an active EXPERT membership at a HOST org. + * + * Independent collaborators (no HOST membership) get NO row — their + * share is only on `ConsultantEarnings`. + * + * Same-org collisions (collab at the SAME org as the primary expert, + * or two collabs at the same org) hit the + * @@unique([paymentId, organizationId]) DB constraint. v1 strategy: + * skip the duplicate insert, log a warning, leave the collaborator's + * personal share intact via ConsultantEarnings. + * + * Pure-mock unit test — we mock the Prisma client and the + * `calculateRevenueSplit` helper, then assert the captured + * `tx.organizationEarnings.create` payloads. + */ + +const ORG_LEARNPRO = "org-learnpro"; +const ORG_ANOTHER = "org-another-agency"; +const PRIMARY_PROFILE = "consultant-primary"; +const COLLAB_HOST_PROFILE = "consultant-collab-hosted"; +const COLLAB_INDEP_PROFILE = "consultant-collab-independent"; +const COLLAB_SAME_ORG_PROFILE = "consultant-collab-same-org"; +const PLAN_ID = "plan-webinar-1"; +const PAYMENT_ID = "payment-1"; + +// We must mock these BEFORE importing earnings-service (which imports them). +jest.mock("../../lib/feature-flags", () => ({ + ENABLE_HOST_ORGS: true, +})); + +jest.mock("../../lib/collaborators/service", () => ({ + calculateRevenueSplit: jest.fn(), +})); + +jest.mock("../../lib/api/organizations/rate-card", () => ({ + resolveEffectiveRateCard: jest.fn(), +})); + +// #812 — this suite verifies the per-collaborator EARNINGS-split logic, not the +// double-entry journal. The booking posting is incidental and its mock payment +// amounts aren't designed to balance, so stub postLedgerTxn (the real balance +// invariant is covered by ledger-invariants.test.ts). Real exports (types, +// LedgerImbalanceError) are preserved. +jest.mock("../../lib/payments/ledger/post", () => ({ + ...jest.requireActual("../../lib/payments/ledger/post"), + postLedgerTxn: jest + .fn() + .mockResolvedValue({ transactionId: "ltxn-stub", created: true }), +})); + +// Capture every organizationEarnings.create payload across the suite. +type CapturedCreate = { + organizationId: string; + paymentId: string; + grossAmountPaise: number; + platformFeePaise: number; + orgSharePaise: number; + consultantSharePaise: number; + rateCardIdApplied: string | null; + platformBpsApplied: number | null; + orgBpsApplied: number | null; + consultantBpsApplied: number | null; +}; + +let capturedOrgEarnings: CapturedCreate[] = []; +let p2002Targets: Set = new Set(); + +// Mock prisma — must respond to $transaction with a tx object whose +// methods proxy to the mock store. +jest.mock("../../lib/prisma", () => { + const mockTx = { + // #812 — createEarningsFromPayment now posts a balanced booking journal and + // the ledger BLOCKS on failure, so the stub must satisfy postLedgerTxn + // (idempotency miss → upsert account → create txn → upsert balance). No-ops; + // the journal is asserted by ledger-specific tests, not this earnings test. + ledgerTransaction: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: "ltxn-1" }), + }, + ledgerAccount: { + upsert: jest + .fn() + .mockImplementation(async ({ where }: { where: { id: string } }) => ({ + id: where.id, + })), + }, + ledgerAccountBalance: { upsert: jest.fn().mockResolvedValue({}) }, + paymentLeg: { + findMany: jest.fn().mockResolvedValue([]), + }, + consultantEarnings: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation(async ({ data }: { data: { id?: string } }) => ({ + id: "earnings-" + Math.random().toString(36).slice(2, 8), + ...data, + })), + }, + consultantProfile: { + update: jest.fn().mockResolvedValue({}), + }, + organization: { + findUnique: jest.fn().mockResolvedValue({ status: "ACTIVE" }), + }, + organizationInvoice: { + count: jest.fn().mockResolvedValue(1), + }, + organizationEarnings: { + create: jest.fn().mockImplementation(async ({ data }: { data: CapturedCreate }) => { + const key = `${data.paymentId}::${data.organizationId}`; + if (p2002Targets.has(key)) { + // Simulate Prisma P2002 unique constraint violation. + // We can't import Prisma's real error class without dragging + // the runtime in; throw an object that quacks like one. + const err = new Error("Unique constraint failed") as Error & { + code: string; + clientVersion: string; + meta: Record; + }; + err.code = "P2002"; + err.clientVersion = "test"; + err.meta = { target: ["paymentId", "organizationId"] }; + // Re-tag prototype so `instanceof Prisma.PrismaClientKnownRequestError` matches + const { Prisma } = jest.requireActual("@prisma/client"); + Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype); + throw err; + } + capturedOrgEarnings.push(data); + return { id: "org-earn-" + capturedOrgEarnings.length, ...data }; + }), + }, + membership: { + findFirst: jest.fn(), + }, + }; + return { + __esModule: true, + default: { + $transaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => { + return await fn(mockTx); + }), + // Expose tx-bound mocks via the default export so tests can + // configure findFirst per-case. + __mockTx: mockTx, + }, + }; +}); + +import prisma from "@/lib/prisma"; +import { calculateRevenueSplit } from "@/lib/collaborators/service"; +import { resolveEffectiveRateCard } from "@/lib/api/organizations/rate-card"; +import { createEarningsFromPayment } from "@/lib/payments/payouts/earnings-service"; + +// `findFirst` is the time-scoped membership lookup inside resolveOrgSplit. +// We set it per-test to map consultantProfileId -> { orgId, payoutRecipient }. +const mockedTx = (prisma as unknown as { __mockTx: { + membership: { findFirst: jest.Mock }; + consultantEarnings: { findFirst: jest.Mock; create: jest.Mock }; + organization: { findUnique: jest.Mock }; + organizationEarnings: { create: jest.Mock }; +} }).__mockTx; + +const mockedCalculateSplit = calculateRevenueSplit as jest.MockedFunction< + typeof calculateRevenueSplit +>; +const mockedResolveRateCard = resolveEffectiveRateCard as jest.MockedFunction< + typeof resolveEffectiveRateCard +>; + +function makePayment(overrides: Partial<{ id: string; amount: number }> = {}) { + // Minimum shape the service reads. + return { + id: overrides.id ?? PAYMENT_ID, + amount: overrides.amount ?? 100_000, + originalAmount: overrides.amount ?? 100_000, + createdAt: new Date("2026-04-01T00:00:00Z"), + appointment: { + consultantProfile: { id: PRIMARY_PROFILE }, + webinar: { webinarPlanId: PLAN_ID }, + class: null, + }, + } as unknown as Parameters[0]["payment"]; +} + +/** + * Configure `membership.findFirst` to return the right org per + * consultantProfileId. Returning `null` simulates an independent + * consultant (no HOST-org membership). + */ +function setMembershipMap( + map: Record, +) { + mockedTx.membership.findFirst.mockImplementation( + async (args: { where: { consultantProfileId: string } }) => { + const cfg = map[args.where.consultantProfileId]; + if (!cfg) return null; + return { + id: `mem-${args.where.consultantProfileId}`, + rateCardOverrideId: null, + payoutRecipient: cfg.payoutRecipient ?? "SELF", + organization: { id: cfg.orgId }, + }; + }, + ); +} + +/** Standard rate card: 10% platform / 5% org / 85% consultant. */ +function setStandardRateCard() { + mockedResolveRateCard.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (async (_tx: unknown, params: { orgId: string | null }) => ({ + rateCardId: `rc-${params.orgId}`, + platformBps: 1000, + orgBps: 500, + consultantBps: 8500, + ownerOrgId: params.orgId, + ownerContractId: null, + })) as any, + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + capturedOrgEarnings = []; + p2002Targets = new Set(); + // Reset findFirst on consultantEarnings (idempotency check) to "no existing". + mockedTx.consultantEarnings.findFirst.mockResolvedValue(null); + mockedTx.organization.findUnique.mockResolvedValue({ status: "ACTIVE" }); + // Restore the default organizationEarnings.create impl — test #2 + // overrides this with a stateful counter via mockImplementation that + // jest.clearAllMocks() does NOT reset. Without this restore, the + // stale impl leaks into later tests in the file. + mockedTx.organizationEarnings.create.mockImplementation( + async ({ data }: { data: CapturedCreate }) => { + const key = `${data.paymentId}::${data.organizationId}`; + if (p2002Targets.has(key)) { + const { Prisma } = jest.requireActual("@prisma/client"); + const err = new Error("Unique constraint failed") as Error & { + code: string; + clientVersion: string; + meta: Record; + }; + err.code = "P2002"; + err.clientVersion = "test"; + err.meta = { target: ["paymentId", "organizationId"] }; + Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype); + throw err; + } + capturedOrgEarnings.push(data); + return { id: "org-earn-" + capturedOrgEarnings.length, ...data }; + }, + ); + setStandardRateCard(); +}); + +describe("A3 (Q3): per-collaborator HOST-org earnings", () => { + it("creates one OrgEarnings row per HOST-org collaborator (skips independents)", async () => { + setMembershipMap({ + [PRIMARY_PROFILE]: { orgId: ORG_LEARNPRO }, + [COLLAB_HOST_PROFILE]: { orgId: ORG_ANOTHER }, + [COLLAB_INDEP_PROFILE]: null, // independent, no HOST membership + }); + + // 100k gross. Primary org takes 10% platform, 5% org → 85k goes + // to the consultant pool. Splits: 30% to the hosted collab, 20% to + // the independent collab → owner keeps the rest. + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 42_500, role: "OWNER" }, + { consultantProfileId: COLLAB_HOST_PROFILE, share: 25_500, role: "CO_HOST" }, + { consultantProfileId: COLLAB_INDEP_PROFILE, share: 17_000, role: "CO_HOST" }, + ]); + + await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + // Expect 2 OrgEarnings rows: LearnPro (primary) + AnotherAgency (hosted collab). + // Independent collab gets no row. + expect(capturedOrgEarnings).toHaveLength(2); + + const learnpro = capturedOrgEarnings.find( + (r) => r.organizationId === ORG_LEARNPRO, + ); + const anotherAgency = capturedOrgEarnings.find( + (r) => r.organizationId === ORG_ANOTHER, + ); + + expect(learnpro).toBeDefined(); + expect(anotherAgency).toBeDefined(); + + // Primary org: gross is the full payment. + expect(learnpro!.grossAmountPaise).toBe(100_000); + expect(learnpro!.platformFeePaise).toBe(10_000); // 10% + expect(learnpro!.orgSharePaise).toBe(5_000); // 5% + expect(learnpro!.consultantSharePaise).toBe(85_000); // 85% + + // Collab org: "gross" is the collaborator's share (25_500), then + // routed through that org's rate card. + expect(anotherAgency!.grossAmountPaise).toBe(25_500); + expect(anotherAgency!.platformFeePaise).toBe(2_550); // 10% of 25_500 + expect(anotherAgency!.orgSharePaise).toBe(1_275); // 5% of 25_500 + expect(anotherAgency!.consultantSharePaise).toBe(21_675); // 85% of 25_500 + expect(anotherAgency!.rateCardIdApplied).toBe(`rc-${ORG_ANOTHER}`); + + // No row for the independent collaborator's profile id was ever passed. + const independentRows = capturedOrgEarnings.filter( + (r) => r.organizationId.includes("indep"), + ); + expect(independentRows).toHaveLength(0); + }); + + it("does NOT create a second row when collaborator shares the primary expert's org (P2002 collision → skip + log)", async () => { + setMembershipMap({ + [PRIMARY_PROFILE]: { orgId: ORG_LEARNPRO }, + [COLLAB_SAME_ORG_PROFILE]: { orgId: ORG_LEARNPRO }, // same org as primary + }); + + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 60_000, role: "OWNER" }, + { consultantProfileId: COLLAB_SAME_ORG_PROFILE, share: 25_000, role: "CO_HOST" }, + ]); + + // Pre-arm the P2002 trap on (PAYMENT_ID, ORG_LEARNPRO) to fire on + // the SECOND insert — first call (primary) succeeds, second call + // (same-org collab) collides. + let learnproInsertCount = 0; + mockedTx.organizationEarnings.create.mockImplementation( + async ({ data }: { data: CapturedCreate }) => { + if (data.organizationId === ORG_LEARNPRO) { + learnproInsertCount += 1; + if (learnproInsertCount > 1) { + const { Prisma } = jest.requireActual("@prisma/client"); + const err = new Error("Unique constraint failed") as Error & { + code: string; + clientVersion: string; + meta: Record; + }; + err.code = "P2002"; + err.clientVersion = "test"; + err.meta = { target: ["paymentId", "organizationId"] }; + Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype); + throw err; + } + } + capturedOrgEarnings.push(data); + return { id: "org-earn", ...data }; + }, + ); + + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + // Only the primary expert's row survives the same-org collision. + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0].organizationId).toBe(ORG_LEARNPRO); + + // The skip path log-warns rather than throwing. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Skipping collaborator org earnings"), + ); + + warnSpy.mockRestore(); + }); + + it("creates an OrgEarnings row when the primary expert is independent but a collaborator IS at a HOST org", async () => { + // Primary independent → no OWNER OrganizationEarnings, but the + // collaborator's HOST membership still drives a per-collab row. + setMembershipMap({ + [PRIMARY_PROFILE]: null, // independent owner + [COLLAB_HOST_PROFILE]: { orgId: ORG_ANOTHER }, + }); + + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 70_000, role: "OWNER" }, + { consultantProfileId: COLLAB_HOST_PROFILE, share: 30_000, role: "CO_HOST" }, + ]); + + await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0].organizationId).toBe(ORG_ANOTHER); + expect(capturedOrgEarnings[0].grossAmountPaise).toBe(30_000); + expect(capturedOrgEarnings[0].platformFeePaise).toBe(3_000); + expect(capturedOrgEarnings[0].orgSharePaise).toBe(1_500); + expect(capturedOrgEarnings[0].consultantSharePaise).toBe(25_500); + }); + + it("skips zero-share collaborators (defensive — no 0-paise org rows)", async () => { + setMembershipMap({ + [PRIMARY_PROFILE]: { orgId: ORG_LEARNPRO }, + [COLLAB_HOST_PROFILE]: { orgId: ORG_ANOTHER }, + }); + + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 100_000, role: "OWNER" }, + { consultantProfileId: COLLAB_HOST_PROFILE, share: 0, role: "CO_HOST" }, // zeroed out + ]); + + await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + // Only the primary org row — collab share=0 short-circuits before + // resolveOrgSplit is even called. + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0].organizationId).toBe(ORG_LEARNPRO); + }); +}); diff --git a/__tests__/enterprise/config-lock.test.ts b/__tests__/enterprise/config-lock.test.ts new file mode 100644 index 000000000..1b9e637a7 --- /dev/null +++ b/__tests__/enterprise/config-lock.test.ts @@ -0,0 +1,129 @@ +/** + * @jest-environment node + */ + +/** + * #777 §B — derived money-config lock. Pins the "editable until in use, locked + * once anything rides on it" rule that keeps the safe-field edit honest without + * a configLockedAt column (deferred to v4 #779 §A). + */ + +import { + isProgramMoneyConfigLocked, + isContractTermsLocked, +} from "@/lib/enterprise/config-lock"; + +describe("isProgramMoneyConfigLocked", () => { + it("unlocked when nothing rides on the program", () => { + expect( + isProgramMoneyConfigLocked({ + assignmentCount: 0, + bookingCount: 0, + overageEventCount: 0, + }), + ).toBe(false); + }); + + it("locks on the first assignment", () => { + expect( + isProgramMoneyConfigLocked({ + assignmentCount: 1, + bookingCount: 0, + overageEventCount: 0, + }), + ).toBe(true); + }); + + it("locks on a booking even without a standing assignment row", () => { + expect( + isProgramMoneyConfigLocked({ + assignmentCount: 0, + bookingCount: 1, + overageEventCount: 0, + }), + ).toBe(true); + }); + + it("locks on an overage event", () => { + expect( + isProgramMoneyConfigLocked({ + assignmentCount: 0, + bookingCount: 0, + overageEventCount: 1, + }), + ).toBe(true); + }); +}); + +describe("program lock precedence (#779)", () => { + // Mirrors getProgramLockState's pure composition: the persisted + // configLockedAt timestamp is authoritative; the derived signal predicate is + // the belt-and-braces fallback. Kept DB-free by exercising the boolean + // combination directly. + const isLocked = ( + configLockedAt: Date | null, + signals: Parameters[0], + ): boolean => configLockedAt != null || isProgramMoneyConfigLocked(signals); + + const NO_SIGNALS = { + assignmentCount: 0, + bookingCount: 0, + overageEventCount: 0, + }; + + it("explicit timestamp wins even with zero derived signals", () => { + expect(isLocked(new Date(), NO_SIGNALS)).toBe(true); + }); + + it("derived fallback still locks when counts > 0 even if timestamp is null", () => { + expect( + isLocked(null, { ...NO_SIGNALS, assignmentCount: 1 }), + ).toBe(true); + }); + + it("unlocked only when timestamp is null AND nothing rides on the program", () => { + expect(isLocked(null, NO_SIGNALS)).toBe(false); + }); +}); + +describe("isContractTermsLocked", () => { + it("DRAFT with nothing issued is editable", () => { + expect( + isContractTermsLocked({ + status: "DRAFT", + invoiceCount: 0, + liveAssignmentCount: 0, + }), + ).toBe(false); + }); + + it("any non-DRAFT status locks (terms committed at signing)", () => { + expect( + isContractTermsLocked({ + status: "ACTIVE", + invoiceCount: 0, + liveAssignmentCount: 0, + }), + ).toBe(true); + }); + + it("DRAFT but already invoiced locks", () => { + expect( + isContractTermsLocked({ + status: "DRAFT", + invoiceCount: 1, + liveAssignmentCount: 0, + }), + ).toBe(true); + }); + + it("DRAFT with live assignments locks", () => { + expect( + isContractTermsLocked({ + status: "DRAFT", + invoiceCount: 0, + liveAssignmentCount: 2, + }), + ).toBe(true); + }); +}); diff --git a/__tests__/enterprise/consumer-org-routing.test.ts b/__tests__/enterprise/consumer-org-routing.test.ts new file mode 100644 index 000000000..680a04158 --- /dev/null +++ b/__tests__/enterprise/consumer-org-routing.test.ts @@ -0,0 +1,173 @@ +/** + * @jest-environment node + */ + +/** + * Entry-point router for /dashboard/organization/[orgId]/page.tsx — + * verifies role-aware in-org redirects: + * + * MANAGER+ → /home + * LEARNER → /my-program + * EXPERT → /my-arrangement + * no membership → personal dashboard fallback + * + * `next/navigation`'s `redirect()` throws — we catch the thrown + * error and inspect the digest to assert the target path. This + * mirrors how Next itself surfaces the redirect to the framework. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + membership: { findUnique: jest.fn() }, + }, +})); + +jest.mock("../../lib/auth-server", () => ({ + getSession: jest.fn(), +})); + +jest.mock("../../lib/labels/personal-dashboard", () => ({ + resolvePersonalDashboardHref: jest.fn(() => "/dashboard/consultee/c-1/home"), +})); + +import prisma from "@/lib/prisma"; +import { getSession } from "@/lib/auth-server"; +import { resolvePersonalDashboardHref } from "@/lib/labels/personal-dashboard"; +import OrgRoot from "@/app/dashboard/organization/[orgId]/page"; + +const mockedPrisma = prisma as unknown as { + membership: { findUnique: jest.Mock }; +}; +const mockedGetSession = getSession as jest.Mock; +const mockedResolveHref = resolvePersonalDashboardHref as jest.Mock; + +function makeParams(orgId = "org-1") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { params: Promise.resolve({ orgId }) } as any; +} + +/** + * Trigger the page handler and capture the path Next's `redirect()` + * threw. Returns the path on success, throws if no redirect happened. + */ +async function expectRedirect( + call: () => Promise, +): Promise { + try { + await call(); + } catch (err: unknown) { + // Next.js redirect throws a special error with `digest` set to + // `NEXT_REDIRECT;;;;`. We don't import + // its internals — sniff the digest to extract segment[2] (path). + const digest = + (err as { digest?: string } | null)?.digest ?? String(err); + const match = /^NEXT_REDIRECT;[^;]+;([^;]+)/.exec(digest); + if (match) return match[1]; + throw err; + } + throw new Error("Expected the handler to redirect, but it did not."); +} + +describe("/dashboard/organization/[orgId] entry-point routing", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedResolveHref.mockReturnValue("/dashboard/consultee/c-1/home"); + }); + + it("redirects unauthenticated users to /auth/signin", async () => { + mockedGetSession.mockResolvedValueOnce(null); + const path = await expectRedirect(() => OrgRoot(makeParams())); + expect(path).toBe("/auth/signin"); + }); + + it("redirects ADMIN straight to /home (no membership lookup)", async () => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-admin", role: "ADMIN" }, + }); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard/organization/org-1/home"); + expect(mockedPrisma.membership.findUnique).not.toHaveBeenCalled(); + }); + + it("redirects LEARNER to /my-program", async () => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-learner", role: "USER" }, + }); + mockedPrisma.membership.findUnique.mockResolvedValueOnce({ + role: "LEARNER", + status: "ACTIVE", + }); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard/organization/org-1/my-program"); + }); + + it("redirects EXPERT to /my-arrangement", async () => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-expert", role: "USER" }, + }); + mockedPrisma.membership.findUnique.mockResolvedValueOnce({ + role: "EXPERT", + status: "ACTIVE", + }); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard/organization/org-1/my-arrangement"); + }); + + it.each(["OWNER", "MAINTAINER", "MANAGER", "SUPPORT"] as const)( + "redirects %s to /home (operator track)", + async (role) => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-op", role: "USER" }, + }); + mockedPrisma.membership.findUnique.mockResolvedValueOnce({ + role, + status: "ACTIVE", + }); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard/organization/org-1/home"); + }, + ); + + it("redirects user with no membership to personal dashboard fallback", async () => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-stranger", role: "USER" }, + }); + mockedPrisma.membership.findUnique.mockResolvedValueOnce(null); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard/consultee/c-1/home"); + expect(mockedResolveHref).toHaveBeenCalled(); + }); + + it("falls back to /dashboard when resolvePersonalDashboardHref returns null (lazy profile)", async () => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-stranger", role: "USER" }, + }); + mockedPrisma.membership.findUnique.mockResolvedValueOnce(null); + mockedResolveHref.mockReturnValueOnce(null); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard"); + }); + + it("treats non-ACTIVE memberships as missing and redirects to /home (defensive)", async () => { + mockedGetSession.mockResolvedValueOnce({ + user: { id: "u-removed", role: "USER" }, + }); + // A SUSPENDED or REMOVED membership shouldn't grant any role-based + // routing. The current handler falls through to /home where the + // page-level requireOrgAccess will reject with an error. + mockedPrisma.membership.findUnique.mockResolvedValueOnce({ + role: "LEARNER", + status: "REMOVED", + }); + + const path = await expectRedirect(() => OrgRoot(makeParams("org-1"))); + expect(path).toBe("/dashboard/organization/org-1/home"); + }); +}); diff --git a/__tests__/enterprise/credit-note-numbering.test.ts b/__tests__/enterprise/credit-note-numbering.test.ts new file mode 100644 index 000000000..dd203d936 --- /dev/null +++ b/__tests__/enterprise/credit-note-numbering.test.ts @@ -0,0 +1,88 @@ +/** + * @jest-environment node + */ + +/** + * Per-org sequential credit-note numbering (CGST Rule 53, #776). Mirrors + * invoice-numbering.test.ts: covers the pure format/allocation helpers via a + * mocked Prisma `upsert`. Real concurrency (atomic increment) is a DB-level + * guarantee validated at the integration layer. + */ + +import { + allocateOrgCreditNoteSeq, + generateOrgCreditNoteNumber, +} from "@/lib/payments/billing/credit-note-numbering"; + +// upsert returns the POST-increment nextSeq; allocate returns nextSeq - 1. +function mockTx(allocations: number[]) { + let i = 0; + return { + orgCreditNoteCounter: { + upsert: jest.fn().mockImplementation(async () => { + if (i >= allocations.length) throw new Error("ran out of mock allocations"); + return { nextSeq: allocations[i++] + 1 }; + }), + }, + }; +} + +describe("allocateOrgCreditNoteSeq", () => { + it("returns the pre-increment sequence (nextSeq - 1)", async () => { + const tx = mockTx([1]); + await expect( + allocateOrgCreditNoteSeq(tx as never, "org-1", 2026), + ).resolves.toBe(1); + }); + + it("is gapless across successive allocations", async () => { + const tx = mockTx([1, 2, 3]); + const a = await allocateOrgCreditNoteSeq(tx as never, "org-1", 2026); + const b = await allocateOrgCreditNoteSeq(tx as never, "org-1", 2026); + const c = await allocateOrgCreditNoteSeq(tx as never, "org-1", 2026); + expect([a, b, c]).toEqual([1, 2, 3]); + }); +}); + +describe("generateOrgCreditNoteNumber", () => { + it("formats as -CN-- with the CN segment", async () => { + const tx = mockTx([42]); + const result = await generateOrgCreditNoteNumber( + tx as never, + { id: "org-1", slug: "acme-corp", invoiceNumberPrefix: "ACME" }, + new Date("2026-06-01T00:00:00.000Z"), + ); + // #789 — the credit-note budget is 3 chars tighter than the invoice one + // because of the `-CN-` infix, so a 4-char prefix is capped to 3. The old + // "ACME-CN-2026-0042" (17 chars) breached CGST Rule 53. + expect(result.creditNoteNumber).toBe("ACM-CN-2026-0042"); + expect(result.creditNoteNumber.length).toBeLessThanOrEqual(16); + expect(result.fiscalYear).toBe(2026); + expect(result.seq).toBe(42); + }); + + it("falls back to slug (uppercased) when prefix is null", async () => { + const tx = mockTx([1]); + const result = await generateOrgCreditNoteNumber( + tx as never, + { id: "org-1", slug: "acme-corp", invoiceNumberPrefix: null }, + new Date("2026-06-01T00:00:00.000Z"), + ); + // "ACME-CORP-CN-2026-0001" (22 chars) was a gross breach; capped to 16. + expect(result.creditNoteNumber).toBe("ACM-CN-2026-0001"); + expect(result.creditNoteNumber.length).toBeLessThanOrEqual(16); + }); + + it("March issue date lands in the prior FY (matches the invoice it adjusts)", async () => { + const tx = mockTx([5]); + const result = await generateOrgCreditNoteNumber( + tx as never, + { id: "org-1", slug: "acme", invoiceNumberPrefix: "ACME" }, + // 17:30 IST on 31 Mar — unambiguously March in IST (#776: FY reckoned in IST). + new Date("2026-03-31T12:00:00.000Z"), + ); + expect(result.creditNoteNumber).toBe("ACM-CN-2025-0005"); + expect(result.creditNoteNumber.length).toBeLessThanOrEqual(16); + expect(result.fiscalYear).toBe(2025); + }); +}); diff --git a/__tests__/enterprise/credit-pool-meter.test.ts b/__tests__/enterprise/credit-pool-meter.test.ts new file mode 100644 index 000000000..6502849ae --- /dev/null +++ b/__tests__/enterprise/credit-pool-meter.test.ts @@ -0,0 +1,122 @@ +/** + * @jest-environment node + */ + +/** + * #775/#753 — CREDIT_POOL money-meter in recordBookingUtilization. + * + * LICENSED_SEAT meters by engagement COUNT; CREDIT_POOL meters by PAISE against + * `creditBudgetPerCycle × 100`. This pins that a credit pool: + * - BLOCKs when consumedPaise + price would exceed the budget, + * - flags overage (wasOverage) under CHARGE_* when over budget, + * - returns the money-meter post values for the caller. + */ + +import { recordBookingUtilization } from "@/lib/api/organizations/program-helpers"; + +type MockTx = { + programAssignment: { + findUniqueOrThrow: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; + bookingUtilization: { upsert: jest.Mock; findUnique: jest.Mock }; + usageLedgerEntry: { create: jest.Mock }; +}; + +function makeCreditTx(opts: { + creditBudgetPerCycle: number; + behavior: "BLOCK" | "CHARGE_MEMBER" | "CHARGE_ORG"; + consumedPaise: number; + blockUpdateRows?: number; + chargeReturning?: { engagementsUsed: number; consumedPaise: number }[]; +}): MockTx { + return { + programAssignment: { + findUniqueOrThrow: jest.fn().mockResolvedValue({ + programId: "prog-cp", + membershipId: "mem-1", + engagementsUsed: 0, + consumedPaise: opts.consumedPaise, + program: { + type: "CREDIT_POOL", + licensedSeatConfig: null, + creditPoolConfig: { + creditBudgetPerCycle: opts.creditBudgetPerCycle, + overageBehavior: opts.behavior, + }, + }, + }), + // CHARGE_* credit path resolves to the post-increment row (the helper + // reads updated.consumedPaise). overageCount bump shares this mock. + update: jest.fn().mockResolvedValue(opts.chargeReturning?.[0] ?? {}), + // BLOCK path: count===0 ⇒ over budget → throws. + updateMany: jest + .fn() + .mockResolvedValue({ count: opts.blockUpdateRows ?? 1 }), + }, + bookingUtilization: { + upsert: jest.fn().mockResolvedValue({}), + findUnique: jest.fn().mockResolvedValue(null), + }, + usageLedgerEntry: { create: jest.fn().mockResolvedValue({}) }, + }; +} + +describe("recordBookingUtilization — CREDIT_POOL money-meter", () => { + it("BLOCK: within budget proceeds and debits paise", async () => { + const tx = makeCreditTx({ + creditBudgetPerCycle: 1000, // budget = 100_000 paise + behavior: "BLOCK", + consumedPaise: 50_000, + blockUpdateRows: 1, // 50_000 + 30_000 <= 100_000 + }); + const r = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-cp", + paymentId: "pay-cp-1", + engagementsConsumed: 1, + priceAtBookingPaise: 30_000, + }); + expect(r.programType).toBe("CREDIT_POOL"); + expect(r.creditBudgetPaise).toBe(100_000); + expect(r.wasOverage).toBe(false); + // The conditional UPDATE includes the consumedPaise guard. + expect(tx.programAssignment.updateMany).toHaveBeenCalled(); + }); + + it("BLOCK: over budget rejects (0 rows touched → throws)", async () => { + const tx = makeCreditTx({ + creditBudgetPerCycle: 1000, + behavior: "BLOCK", + consumedPaise: 90_000, + blockUpdateRows: 0, // 90_000 + 30_000 > 100_000 → guard fails + }); + await expect( + recordBookingUtilization(tx as never, { + programAssignmentId: "asg-cp", + paymentId: "pay-cp-2", + engagementsConsumed: 1, + priceAtBookingPaise: 30_000, + }), + ).rejects.toBeTruthy(); + expect(tx.bookingUtilization.upsert).not.toHaveBeenCalled(); + }); + + it("CHARGE_ORG: over budget flags overage + returns post consumedPaise", async () => { + const tx = makeCreditTx({ + creditBudgetPerCycle: 1000, + behavior: "CHARGE_ORG", + consumedPaise: 90_000, + chargeReturning: [{ engagementsUsed: 1, consumedPaise: 120_000 }], + }); + const r = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-cp", + paymentId: "pay-cp-3", + engagementsConsumed: 1, + priceAtBookingPaise: 30_000, + }); + expect(r.wasOverage).toBe(true); + expect(r.consumedPaiseAfter).toBe(120_000); + expect(tx.programAssignment.update).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/cycle-engine.test.ts b/__tests__/enterprise/cycle-engine.test.ts new file mode 100644 index 000000000..4bb371183 --- /dev/null +++ b/__tests__/enterprise/cycle-engine.test.ts @@ -0,0 +1,156 @@ +/** + * @jest-environment node + */ + +/** + * #779 §A — pure cycle-engine helpers. Pins the date-math (month-end edges, + * quarterly/annual), the term-clamp rule, and the roll-vs-close decision table + * so the advance-program-cycles cron's branching stays honest without a DB. + */ + +import { + decideCycleTransition, + nextPeriodEnd, + resolveProgramCycle, +} from "@/lib/enterprise/cycle-engine"; + +// UTC dates throughout so the test is timezone-stable. +const d = (iso: string) => new Date(iso); + +describe("nextPeriodEnd", () => { + it("advances one month", () => { + expect(nextPeriodEnd(d("2026-01-15T00:00:00.000Z"), "MONTHLY")).toEqual( + d("2026-02-15T00:00:00.000Z"), + ); + }); + + it("clamps a month-end overflow (Jan-31 → Feb-28 in a non-leap year)", () => { + const out = nextPeriodEnd(d("2026-01-31T00:00:00.000Z"), "MONTHLY"); + expect(out.getUTCMonth()).toBe(1); // February + expect(out.getUTCDate()).toBe(28); + }); + + it("clamps Jan-31 → Feb-29 in a leap year", () => { + const out = nextPeriodEnd(d("2028-01-31T00:00:00.000Z"), "MONTHLY"); + expect(out.getUTCMonth()).toBe(1); + expect(out.getUTCDate()).toBe(29); + }); + + it("advances a quarter with month-end clamp (Aug-31 → Nov-30)", () => { + const out = nextPeriodEnd(d("2026-08-31T00:00:00.000Z"), "QUARTERLY"); + expect(out.getUTCMonth()).toBe(10); // November + expect(out.getUTCDate()).toBe(30); + }); + + it("advances a plain quarter", () => { + expect(nextPeriodEnd(d("2026-01-15T00:00:00.000Z"), "QUARTERLY")).toEqual( + d("2026-04-15T00:00:00.000Z"), + ); + }); + + it("advances a year", () => { + expect(nextPeriodEnd(d("2026-03-10T00:00:00.000Z"), "ANNUAL")).toEqual( + d("2027-03-10T00:00:00.000Z"), + ); + }); + + it("clamps Feb-29 → Feb-28 on an annual step into a non-leap year", () => { + const out = nextPeriodEnd(d("2028-02-29T00:00:00.000Z"), "ANNUAL"); + expect(out.getUTCMonth()).toBe(1); + expect(out.getUTCDate()).toBe(28); + }); + + it("does not mutate the input", () => { + const start = d("2026-01-15T00:00:00.000Z"); + nextPeriodEnd(start, "MONTHLY"); + expect(start).toEqual(d("2026-01-15T00:00:00.000Z")); + }); +}); + +describe("decideCycleTransition", () => { + const base = { + successorPeriodStart: d("2026-02-01T00:00:00.000Z"), + successorPeriodEnd: d("2026-03-01T00:00:00.000Z"), + contractStatus: "ACTIVE" as const, + contractAutoRenew: true, + contractEffectiveTo: null as Date | null, + }; + + it("ROLLs when contract is ACTIVE + autoRenew + open-ended term", () => { + expect(decideCycleTransition(base)).toEqual({ + action: "ROLL", + reason: "AUTORENEW", + }); + }); + + it("CLOSEs when the contract is not ACTIVE", () => { + expect( + decideCycleTransition({ ...base, contractStatus: "EXPIRED" }), + ).toEqual({ action: "CLOSE", reason: "CONTRACT_INACTIVE" }); + }); + + it("CLOSEs when autoRenew is off (even if ACTIVE)", () => { + expect( + decideCycleTransition({ ...base, contractAutoRenew: false }), + ).toEqual({ action: "CLOSE", reason: "AUTORENEW_OFF" }); + }); + + it("CLOSEs (CLAMPED) when the successor would outlive effectiveTo", () => { + expect( + decideCycleTransition({ + ...base, + contractEffectiveTo: d("2026-02-15T00:00:00.000Z"), + }), + ).toEqual({ action: "CLOSE", reason: "CLAMPED" }); + }); + + it("ROLLs when the successor end lands exactly on effectiveTo (boundary)", () => { + expect( + decideCycleTransition({ + ...base, + contractEffectiveTo: d("2026-03-01T00:00:00.000Z"), + }), + ).toEqual({ action: "ROLL", reason: "AUTORENEW" }); + }); + + it("contract-inactive check beats the clamp check", () => { + // An expired contract with a too-short term still reports CONTRACT_INACTIVE, + // not CLAMPED — the inactive branch is evaluated first. + expect( + decideCycleTransition({ + ...base, + contractStatus: "TERMINATED", + contractEffectiveTo: d("2026-02-15T00:00:00.000Z"), + }), + ).toEqual({ action: "CLOSE", reason: "CONTRACT_INACTIVE" }); + }); +}); + +describe("resolveProgramCycle", () => { + it("reads the cycle from a licensed-seat config", () => { + expect( + resolveProgramCycle({ + licensedSeatConfig: { cycle: "MONTHLY" }, + creditPoolConfig: null, + }), + ).toBe("MONTHLY"); + }); + + it("reads the cycle from a credit-pool config", () => { + expect( + resolveProgramCycle({ + licensedSeatConfig: null, + creditPoolConfig: { cycle: "ANNUAL" }, + }), + ).toBe("ANNUAL"); + }); + + it("returns null for a malformed program with neither config", () => { + expect( + resolveProgramCycle({ + licensedSeatConfig: null, + creditPoolConfig: null, + }), + ).toBeNull(); + }); +}); diff --git a/__tests__/enterprise/earning-status-transitions.test.ts b/__tests__/enterprise/earning-status-transitions.test.ts new file mode 100644 index 000000000..6c23e6aa1 --- /dev/null +++ b/__tests__/enterprise/earning-status-transitions.test.ts @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ + +/** + * Issue #700 LED-2: PAID earnings may only transition to REFUNDED, and + * REFUNDED is terminal. Any other transition silently rewrites history + * on a row that has already triggered a bank transfer + TDS deduction. + */ + +// Import directly from the standalone module so the test does not pull +// in earnings-service.ts' transitive Stream / Razorpay deps. +import { + assertEarningStatusTransitionLegal, + IllegalEarningStatusTransitionError, +} from "@/lib/payments/payouts/earning-status"; +import { EarningStatus } from "@prisma/client"; + +describe("EarningStatus transition guard (#700 LED-2)", () => { + it("PENDING → READY is allowed", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.PENDING, EarningStatus.READY), + ).not.toThrow(); + }); + + it("READY → PAID is allowed", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.READY, EarningStatus.PAID), + ).not.toThrow(); + }); + + it("PAID → REFUNDED is allowed (controlled refund path)", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.PAID, EarningStatus.REFUNDED), + ).not.toThrow(); + }); + + it("PAID → READY is rejected", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.PAID, EarningStatus.READY), + ).toThrow(IllegalEarningStatusTransitionError); + }); + + it("PAID → PENDING is rejected", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.PAID, EarningStatus.PENDING), + ).toThrow(IllegalEarningStatusTransitionError); + }); + + it("PAID → HELD is rejected", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.PAID, EarningStatus.HELD), + ).toThrow(IllegalEarningStatusTransitionError); + }); + + it("REFUNDED is terminal — REFUNDED → anything is rejected", () => { + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.REFUNDED, EarningStatus.PAID), + ).toThrow(IllegalEarningStatusTransitionError); + expect(() => + assertEarningStatusTransitionLegal("e1", EarningStatus.REFUNDED, EarningStatus.READY), + ).toThrow(IllegalEarningStatusTransitionError); + }); + + it("error carries from/to/earningsId for debugging", () => { + try { + assertEarningStatusTransitionLegal("e123", EarningStatus.PAID, EarningStatus.READY); + fail("Expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(IllegalEarningStatusTransitionError); + const typed = err as IllegalEarningStatusTransitionError; + expect(typed.earningsId).toBe("e123"); + expect(typed.from).toBe(EarningStatus.PAID); + expect(typed.to).toBe(EarningStatus.READY); + } + }); +}); diff --git a/__tests__/enterprise/expire-stale-invitations.test.ts b/__tests__/enterprise/expire-stale-invitations.test.ts new file mode 100644 index 000000000..2f9348c83 --- /dev/null +++ b/__tests__/enterprise/expire-stale-invitations.test.ts @@ -0,0 +1,164 @@ +/** + * @jest-environment node + */ + +/** + * Pin the stale-invitation cleanup contract: + * + * - Only rows with `status = 'pending'` AND `expiresAt < now` get + * flipped to 'expired'. Already-expired, accepted, or revoked rows + * are left alone. + * - Each flip emits one `OrgAuditLog(MEMBER / INVITE_EXPIRED)` row + * with the original invite metadata in `details` so a MAINTAINER + * scanning the audit log sees what lapsed without needing to dig + * into worker logs. + * - The concurrency guard (re-read inside the transaction) refuses + * to flip a row that flipped to 'accepted' between the scan and + * the update. + * - Idempotent: a second invocation with no stale rows returns + * `{ expired: 0 }` and writes no new audit rows. + * + * Covers the gap called out in the May 2026 audit (B6.4): the cron + * existed but had no regression test, so a future "make the audit + * row conditional" refactor could silently break the visibility. + */ + +jest.mock("../../lib/prisma", () => { + const candidates: Array<{ + id: string; + organizationId: string; + email: string; + role: string; + expiresAt: Date; + }> = []; + return { + __esModule: true, + default: { + invitation: { + findMany: jest.fn().mockResolvedValue(candidates), + findUnique: jest.fn(), + update: jest.fn(), + }, + orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, + $transaction: jest.fn(), + }, + }; +}); + +import prisma from "@/lib/prisma"; +import { cleanupStaleInvitations } from "@/scripts/cleanup/cleanup-stale-invitations"; + +const mockedPrisma = prisma as unknown as { + invitation: { + findMany: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + orgAuditLog: { create: jest.Mock }; + $transaction: jest.Mock; +}; + +function wireTxShim() { + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => { + const tx = { + invitation: mockedPrisma.invitation, + orgAuditLog: mockedPrisma.orgAuditLog, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(tx); + }); +} + +describe("cleanupStaleInvitations", () => { + beforeEach(() => { + jest.clearAllMocks(); + wireTxShim(); + }); + + it("returns { expired: 0 } when no candidates exist (idempotent no-op)", async () => { + mockedPrisma.invitation.findMany.mockResolvedValue([]); + const result = await cleanupStaleInvitations(); + expect(result).toMatchObject({ success: true, expired: 0, errors: [] }); + expect(mockedPrisma.invitation.update).not.toHaveBeenCalled(); + expect(mockedPrisma.orgAuditLog.create).not.toHaveBeenCalled(); + }); + + it("flips pending+past invites to expired and emits an INVITE_EXPIRED audit row", async () => { + const candidate = { + id: "inv-1", + organizationId: "org-1", + email: "alice@acme.com", + role: "LEARNER", + expiresAt: new Date("2026-05-01T00:00:00Z"), + }; + mockedPrisma.invitation.findMany.mockResolvedValue([candidate]); + mockedPrisma.invitation.findUnique.mockResolvedValue({ status: "pending" }); + mockedPrisma.invitation.update.mockResolvedValue({ id: candidate.id }); + + const result = await cleanupStaleInvitations(); + expect(result.expired).toBe(1); + expect(mockedPrisma.invitation.update).toHaveBeenCalledWith({ + where: { id: candidate.id }, + data: { status: "expired" }, + }); + expect(mockedPrisma.orgAuditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId: "org-1", + category: "MEMBER", + action: "INVITE_EXPIRED", + details: expect.objectContaining({ + email: "alice@acme.com", + role: "LEARNER", + }), + }), + }); + }); + + it("skips invites that flipped to 'accepted' between the scan and the TX (concurrency guard)", async () => { + const candidate = { + id: "inv-2", + organizationId: "org-1", + email: "bob@acme.com", + role: "LEARNER", + expiresAt: new Date("2026-05-01T00:00:00Z"), + }; + mockedPrisma.invitation.findMany.mockResolvedValue([candidate]); + // Re-read inside the TX sees the racing accept — refuse to flip. + mockedPrisma.invitation.findUnique.mockResolvedValue({ status: "accepted" }); + + const result = await cleanupStaleInvitations(); + expect(result.expired).toBe(0); + expect(mockedPrisma.invitation.update).not.toHaveBeenCalled(); + expect(mockedPrisma.orgAuditLog.create).not.toHaveBeenCalled(); + }); + + it("collects errors per-row without aborting the whole sweep", async () => { + const a = { + id: "inv-a", + organizationId: "org-1", + email: "a@x.com", + role: "LEARNER", + expiresAt: new Date("2026-05-01T00:00:00Z"), + }; + const b = { + id: "inv-b", + organizationId: "org-2", + email: "b@x.com", + role: "LEARNER", + expiresAt: new Date("2026-05-01T00:00:00Z"), + }; + mockedPrisma.invitation.findMany.mockResolvedValue([a, b]); + + mockedPrisma.invitation.findUnique.mockResolvedValue({ status: "pending" }); + // First update throws, second succeeds — the sweep should still + // report the second row as expired. + mockedPrisma.invitation.update + .mockRejectedValueOnce(new Error("DB blip")) + .mockResolvedValueOnce({ id: b.id }); + + const result = await cleanupStaleInvitations(); + expect(result.expired).toBe(1); + expect(result.errors.length).toBe(1); + expect(result.success).toBe(false); + }); +}); diff --git a/__tests__/enterprise/governance.test.ts b/__tests__/enterprise/governance.test.ts new file mode 100644 index 000000000..74996236b --- /dev/null +++ b/__tests__/enterprise/governance.test.ts @@ -0,0 +1,90 @@ +/** + * @jest-environment node + */ + +/** + * Issue #675 + #687: governance helpers — verifiedAt gating, default + * INVOICE credit limit. + */ + +import { + DomainVerificationRequiredError, + UNVERIFIED_ORG_SEAT_CAP, + getInvoiceCreditLimitPaise, + hasVerifiedDomain, +} from "@/lib/enterprise/governance"; + +describe("UNVERIFIED_ORG_SEAT_CAP", () => { + it("is a small positive number; allows founding team but blocks bulk", () => { + expect(UNVERIFIED_ORG_SEAT_CAP).toBeGreaterThan(0); + expect(UNVERIFIED_ORG_SEAT_CAP).toBeLessThanOrEqual(10); + }); +}); + +describe("getInvoiceCreditLimitPaise", () => { + const original = process.env.MAX_INVOICE_BOOKING_PAISE; + afterEach(() => { + if (original === undefined) delete process.env.MAX_INVOICE_BOOKING_PAISE; + else process.env.MAX_INVOICE_BOOKING_PAISE = original; + }); + + it("falls back to ₹50,000 when env not set", () => { + delete process.env.MAX_INVOICE_BOOKING_PAISE; + expect(getInvoiceCreditLimitPaise()).toBe(50_000_00); + }); + + it("respects MAX_INVOICE_BOOKING_PAISE env override", () => { + process.env.MAX_INVOICE_BOOKING_PAISE = "1000000"; // ₹10,000 + expect(getInvoiceCreditLimitPaise()).toBe(1_000_000); + }); + + it("ignores non-positive env values and falls back to default", () => { + process.env.MAX_INVOICE_BOOKING_PAISE = "0"; + expect(getInvoiceCreditLimitPaise()).toBe(50_000_00); + process.env.MAX_INVOICE_BOOKING_PAISE = "-100"; + expect(getInvoiceCreditLimitPaise()).toBe(50_000_00); + }); + + it("ignores garbage env values", () => { + process.env.MAX_INVOICE_BOOKING_PAISE = "not-a-number"; + expect(getInvoiceCreditLimitPaise()).toBe(50_000_00); + }); +}); + +describe("hasVerifiedDomain", () => { + function makeDb(verifiedClaim: { id: string } | null) { + return { + orgDomainClaim: { + findFirst: jest.fn().mockResolvedValue(verifiedClaim), + }, + }; + } + + it("returns true when at least one claim has verifiedAt!=null", async () => { + const db = makeDb({ id: "c1" }); + const result = await hasVerifiedDomain(db as never, "org-1"); + expect(result).toBe(true); + expect(db.orgDomainClaim.findFirst).toHaveBeenCalledWith({ + where: { + organizationId: "org-1", + verifiedAt: { not: null }, + }, + select: { id: true }, + }); + }); + + it("returns false when no claim is verified", async () => { + const db = makeDb(null); + const result = await hasVerifiedDomain(db as never, "org-1"); + expect(result).toBe(false); + }); +}); + +describe("DomainVerificationRequiredError", () => { + it("carries the feature label + 403 + stable code", () => { + const err = new DomainVerificationRequiredError("SSO"); + expect(err.feature).toBe("SSO"); + expect(err.code).toBe("DOMAIN_VERIFICATION_REQUIRED"); + expect(err.httpStatus).toBe(403); + }); +}); diff --git a/__tests__/enterprise/gst-doc-number-length.test.ts b/__tests__/enterprise/gst-doc-number-length.test.ts new file mode 100644 index 000000000..5f4263307 --- /dev/null +++ b/__tests__/enterprise/gst-doc-number-length.test.ts @@ -0,0 +1,80 @@ +/** + * @jest-environment node + */ + +/** + * #789 — CGST Rule 46(b)/Rule 53 cap invoice and credit-note numbers at sixteen + * characters. These tests drive the real numbering functions with a fake + * transaction client and assert every emitted number respects the cap, across + * a range of org-prefix lengths that previously overflowed. + */ + +import { generateOrgInvoiceNumber } from "../../lib/payments/billing/invoice-numbering"; +import { generateOrgCreditNoteNumber } from "../../lib/payments/billing/credit-note-numbering"; +import { GST_DOC_NUMBER_MAX_LEN } from "../../lib/payments/billing/invoice-numbering"; + +// Minimal fake tx client: the counter upsert just returns a fixed nextSeq so +// the allocated sequence is deterministic (nextSeq - 1). +function fakeTx(nextSeq: number) { + const counter = { upsert: jest.fn().mockResolvedValue({ nextSeq }) }; + return { + orgInvoiceCounter: counter, + orgCreditNoteCounter: counter, + } as never; +} + +const issuedAt = new Date("2026-06-05T10:00:00Z"); + +describe("GST document-number length cap (Rule 46(b) / Rule 53)", () => { + const prefixes = ["AB", "WIPRO", "ACCENTURE", "VERYLONGORGNAME"]; + + it.each(prefixes)( + "invoice number stays within 16 chars for prefix %s", + async (prefix) => { + const { invoiceNumber } = await generateOrgInvoiceNumber( + fakeTx(2), + { id: "org_1", slug: prefix, invoiceNumberPrefix: prefix }, + issuedAt, + ); + expect(invoiceNumber.length).toBeLessThanOrEqual(GST_DOC_NUMBER_MAX_LEN); + }, + ); + + it.each(prefixes)( + "credit-note number stays within 16 chars for prefix %s", + async (prefix) => { + const { creditNoteNumber } = await generateOrgCreditNoteNumber( + fakeTx(2), + { id: "org_1", slug: prefix, invoiceNumberPrefix: prefix }, + issuedAt, + ); + expect(creditNoteNumber.length).toBeLessThanOrEqual( + GST_DOC_NUMBER_MAX_LEN, + ); + }, + ); + + it("WIPRO credit note no longer overflows (was 18 chars: WIPRO-CN-2026-0001)", async () => { + const { creditNoteNumber } = await generateOrgCreditNoteNumber( + fakeTx(2), + { id: "org_1", slug: "wipro", invoiceNumberPrefix: "WIPRO" }, + issuedAt, + ); + // The old output was `WIPRO-CN-2026-0001` (18). It must now fit the cap and + // still carry the CN infix, the FY, and the zero-padded sequence. + expect(creditNoteNumber.length).toBeLessThanOrEqual(GST_DOC_NUMBER_MAX_LEN); + expect(creditNoteNumber).toMatch(/-CN-2026-0001$/); + }); + + it("keeps a four-digit-plus sequence within the cap", async () => { + // seq 12344 (nextSeq 12345) widens the padded segment to five digits; the + // prefix budget must shrink accordingly. + const { invoiceNumber } = await generateOrgInvoiceNumber( + fakeTx(12345), + { id: "org_1", slug: "acme", invoiceNumberPrefix: "ACME" }, + issuedAt, + ); + expect(invoiceNumber.length).toBeLessThanOrEqual(GST_DOC_NUMBER_MAX_LEN); + expect(invoiceNumber).toMatch(/-2026-12344$/); + }); +}); diff --git a/__tests__/enterprise/gst-split.test.ts b/__tests__/enterprise/gst-split.test.ts new file mode 100644 index 000000000..85630e987 --- /dev/null +++ b/__tests__/enterprise/gst-split.test.ts @@ -0,0 +1,44 @@ +/** + * @jest-environment node + * + * #776 — CGST/SGST intra-state split must net exactly. The prior + * `Math.round(taxPaise/2)` on both legs over-stated odd-tax invoices by 1 paise. + */ +import { deriveGstBreakdown } from "@/lib/compliance/gst"; + +const intra = (subtotalPaise: number) => + deriveGstBreakdown({ + subtotalPaise, + supplierStateCode: "KA", + buyerStateCode: "KA", // same state → CGST + SGST + buyerCountry: "IN", + }); + +describe("deriveGstBreakdown — intra-state CGST/SGST split", () => { + it("nets exactly when the tax is odd (the #776 regression)", () => { + // 100010 paise @18% = 18001.8 → round 18002 (even here); pick a value whose + // 18% rounds odd: 10005 → 1800.9 → 1801 (odd). + const r = intra(10005); + expect(r.cgstPaise + r.sgstPaise).toBe(1801); // == taxPaise, not 1802 + expect(r.totalPaise).toBe(r.subtotalPaise + r.cgstPaise + r.sgstPaise); + expect(r.totalPaise).toBe(10005 + 1801); + // CGST floored, SGST absorbs the remainder. + expect(r.cgstPaise).toBe(900); + expect(r.sgstPaise).toBe(901); + }); + + it("splits evenly when the tax is even", () => { + const r = intra(10000); // 1800 tax + expect(r.cgstPaise).toBe(900); + expect(r.sgstPaise).toBe(900); + expect(r.totalPaise).toBe(11800); + }); + + it("total always equals subtotal + cgst + sgst across a range", () => { + for (let sub = 9990; sub <= 10010; sub++) { + const r = intra(sub); + expect(r.igstPaise).toBe(0); + expect(r.cgstPaise + r.sgstPaise).toBe(r.totalPaise - r.subtotalPaise); + } + }); +}); diff --git a/__tests__/enterprise/invitation-accept.test.ts b/__tests__/enterprise/invitation-accept.test.ts new file mode 100644 index 000000000..6b9e9ee7e --- /dev/null +++ b/__tests__/enterprise/invitation-accept.test.ts @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ + +/** + * Issue #699 ENT-2 + ENT-5: invitation-accept hardening. + * + * Pure-logic coverage of the two new behaviors: + * - ENT-2: re-fetch org status inside the tx; reject SUSPENDED/DEACTIVATED. + * - ENT-5: P2002 retry once when two concurrent accepts collide on + * Membership(userId_organizationId). + * + * The route handler itself is exercised in higher-level integration tests + * (manual smoke + future E2E). Here we only assert the two helper + * predicates do the right thing. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +import { isOnboardingBlocked } from "@/lib/enterprise/org-status"; + +describe("invitation-accept guards", () => { + it("ENT-2: SUSPENDED org blocks onboarding (helper)", () => { + expect(isOnboardingBlocked("SUSPENDED")).toBe(true); + }); + + it("ENT-2: DEACTIVATED org blocks onboarding (helper)", () => { + expect(isOnboardingBlocked("DEACTIVATED")).toBe(true); + }); + + it("ENT-2: PENDING_VERIFICATION orgs may still onboard", () => { + // PENDING_VERIFICATION orgs can take members — they only lose + // billing + SSO + invite-cap until verified. Onboarding for the + // founding seats is precisely how we get out of PENDING. + expect(isOnboardingBlocked("PENDING_VERIFICATION")).toBe(false); + }); + + it("ENT-2: ACTIVE orgs allow onboarding", () => { + expect(isOnboardingBlocked("ACTIVE")).toBe(false); + }); + + // ENT-5: P2002 retry behaviour is asserted by the grep test in the + // route handler — confirms the loop and Prisma import are present. + // A full unit test would require mocking the entire $transaction + // chain, which the integration smoke covers more robustly. +}); + +// #819 — who-is-acting identity rule, pinned at the source level (the +// route handlers need the full auth/tx harness, which the integration +// smoke owns; these pins stop the gates from drifting between surfaces). +// Rule: identity creation requires the user's OWN action. Invitation +// accept is the user's consenting click, so LEARNER lazy-creates the +// lightweight ConsulteeProfile there (lib/auth.ts sanctions exactly this) +// while EXPERT stays strict. Admin direct-add is strict for BOTH roles. +describe("who-is-acting identity gates (#819)", () => { + // __dirname-relative so the pins survive jest being invoked from any cwd. + const read = (p: string) => + readFileSync(join(__dirname, "..", "..", p), "utf8"); + + it("invitation-accept keeps the EXPERT strict gate but NOT a LEARNER gate", () => { + const src = read("app/api/organizations/invitations/accept/route.ts"); + expect(src).toContain("NOT_A_CONSULTANT"); + expect(src).not.toContain("NOT_A_CONSULTEE"); + }); + + it("admin direct-add (POST /members) keeps strict gates for BOTH roles", () => { + const src = read("app/api/organizations/[orgId]/members/route.ts"); + expect(src).toContain("NOT_A_CONSULTANT"); + expect(src).toContain("NOT_A_CONSULTEE"); + }); + + it("the lazy-create sanction for invite-accept-as-LEARNER still stands in lib/auth.ts", () => { + const src = read("lib/auth.ts"); + expect(src).toContain("invite-accept as LEARNER"); + }); +}); diff --git a/__tests__/enterprise/invoice-numbering.test.ts b/__tests__/enterprise/invoice-numbering.test.ts new file mode 100644 index 000000000..775b259f7 --- /dev/null +++ b/__tests__/enterprise/invoice-numbering.test.ts @@ -0,0 +1,104 @@ +/** + * @jest-environment node + */ + +/** + * Per-org sequential invoice numbering (CGST Rule 46). Covers the pure + * helpers; concurrency is validated separately at the integration layer + * because Jest doesn't expose a real Postgres for the + * INSERT ... ON CONFLICT ... RETURNING pattern. + */ + +import { + indianFiscalYear, + generateOrgInvoiceNumber, +} from "@/lib/payments/billing/invoice-numbering"; + +describe("indianFiscalYear", () => { + it("April → start of next FY", () => { + expect(indianFiscalYear(new Date("2026-04-01T00:00:00.000Z"))).toBe(2026); + }); + + it("March → previous FY (IST)", () => { + // 17:30 IST on 31 Mar — unambiguously March in IST. + expect(indianFiscalYear(new Date("2026-03-31T12:00:00.000Z"))).toBe(2025); + }); + + it("#776 — early-April-IST boundary reckoned in IST, not UTC", () => { + // 2026-03-31T23:59:59Z is 01-Apr 05:29 IST → FY 2026. Computing in UTC would + // wrongly file it under FY 2025 (the boundary bug F3 fixes). + expect(indianFiscalYear(new Date("2026-03-31T23:59:59.000Z"))).toBe(2026); + }); + + it("December (mid-year) → current FY", () => { + expect(indianFiscalYear(new Date("2026-12-15T12:00:00.000Z"))).toBe(2026); + }); + + it("January → previous calendar year's FY", () => { + expect(indianFiscalYear(new Date("2026-01-15T00:00:00.000Z"))).toBe(2025); + }); +}); + +describe("generateOrgInvoiceNumber", () => { + function mockTx(allocations: number[]) { + let i = 0; + return { + // #776 — allocateOrgInvoiceSeq now uses the ORM upsert (no raw SQL) and + // returns `nextSeq - 1`, so a mocked allocation N maps to nextSeq = N + 1. + orgInvoiceCounter: { + upsert: jest.fn().mockImplementation(async () => { + if (i >= allocations.length) { + throw new Error("ran out of mock allocations"); + } + return { nextSeq: allocations[i++] + 1 }; + }), + }, + }; + } + + it("uses invoiceNumberPrefix when set", async () => { + const tx = mockTx([42]); + const result = await generateOrgInvoiceNumber( + tx as never, + { id: "org-1", slug: "acme-corp", invoiceNumberPrefix: "ACME" }, + new Date("2026-06-01T00:00:00.000Z"), + ); + expect(result.invoiceNumber).toBe("ACME-2026-0042"); + expect(result.fiscalYear).toBe(2026); + expect(result.seq).toBe(42); + }); + + it("falls back to slug (uppercased) when prefix is null", async () => { + const tx = mockTx([1]); + const result = await generateOrgInvoiceNumber( + tx as never, + { id: "org-1", slug: "acme-corp", invoiceNumberPrefix: null }, + new Date("2026-06-01T00:00:00.000Z"), + ); + // #789 — the prefix is capped to keep the number within CGST Rule 46(b)'s + // 16 chars; "ACME-CORP-2026-0001" (19 chars) was a breach. Orgs that want a + // cleaner number should configure a short invoiceNumberPrefix. + expect(result.invoiceNumber).toBe("ACME-C-2026-0001"); + expect(result.invoiceNumber.length).toBeLessThanOrEqual(16); + }); + + it("zero-pads seq to 4 digits", async () => { + const tx = mockTx([7]); + const result = await generateOrgInvoiceNumber( + tx as never, + { id: "org-1", slug: "acme", invoiceNumberPrefix: "ACME" }, + new Date("2026-06-01T00:00:00.000Z"), + ); + expect(result.invoiceNumber).toBe("ACME-2026-0007"); + }); + + it("does NOT trim seq once it exceeds 4 digits (10000+)", async () => { + const tx = mockTx([10000]); + const result = await generateOrgInvoiceNumber( + tx as never, + { id: "org-1", slug: "acme", invoiceNumberPrefix: "ACME" }, + new Date("2026-06-01T00:00:00.000Z"), + ); + expect(result.invoiceNumber).toBe("ACME-2026-10000"); + }); +}); diff --git a/__tests__/enterprise/irp-payload.test.ts b/__tests__/enterprise/irp-payload.test.ts new file mode 100644 index 000000000..25cc483c0 --- /dev/null +++ b/__tests__/enterprise/irp-payload.test.ts @@ -0,0 +1,244 @@ +/** + * @jest-environment node + * + * #703 / #778 — buildIrpPayload pure mapper. Covers intra-state (CGST+SGST), + * inter-state (IGST), paise→rupee residual landing on the last line, and each + * required-field rejection. No DB / no env — pure in → out. + */ +import { + buildIrpPayload, + type BuildIrpPayloadInput, +} from "@/lib/compliance/irp-payload"; + +const seller = { + gstin: "29AAFCF1234Q1ZN", // Karnataka (29) + legalName: "Familiarise Technologies Private Limited", + address1: "Koramangala 1st Block", + location: "Bangalore", + pincode: "560034", + stateCode: "KA", +}; + +// Buyer in Karnataka → intra-state (CGST+SGST). 10000 paise @18% = 1800 tax, +// split 900/900. +const intraInput = (): BuildIrpPayloadInput => ({ + invoice: { + invoiceNumber: "ACME-2026-001", + issuedAt: new Date(Date.UTC(2026, 0, 15)), // 15/01/2026 + reverseCharge: false, + lutNumber: null, + subtotalPaise: 10000, + cgstPaise: 900, + sgstPaise: 900, + igstPaise: 0, + totalPaise: 11800, + hsnCode: "998314", + placeOfSupply: "KA", + }, + lineItems: [ + { + position: 0, + description: "Consulting hours", + quantity: 1, + unitPricePaise: 10000, + hsnCode: null, + }, + ], + buyer: { + name: "Acme Corp", + gstin: "29ABCDE1234F1Z5", // Karnataka buyer + stateCode: "KA", + hsnDefault: "999293", + }, + seller, +}); + +describe("buildIrpPayload — happy path", () => { + it("intra-state maps CGST+SGST, B2B, numeric state codes, DD/MM/YYYY", () => { + const r = buildIrpPayload(intraInput()); + expect(r.ok).toBe(true); + if (!r.ok) return; + const p = r.payload as Record; + + expect(p.Version).toBe("1.1"); + expect(p.TranDtls.SupTyp).toBe("B2B"); + expect(p.TranDtls.TaxSch).toBe("GST"); + expect(p.DocDtls.No).toBe("ACME-2026-001"); + expect(p.DocDtls.Dt).toBe("15/01/2026"); + + // Numeric state codes derived from GSTIN prefix. + expect(p.SellerDtls.Stcd).toBe("29"); + expect(p.BuyerDtls.Stcd).toBe("29"); + expect(p.BuyerDtls.Pos).toBe("29"); + expect(p.BuyerDtls.Gstin).toBe("29ABCDE1234F1Z5"); + + // Line HSN falls back to invoice HSN when line is null. + expect(p.ItemList[0].HsnCd).toBe("998314"); + expect(p.ItemList[0].CgstAmt).toBe(9); + expect(p.ItemList[0].SgstAmt).toBe(9); + expect(p.ItemList[0].IgstAmt).toBe(0); + expect(p.ItemList[0].GstRt).toBe(18); + expect(p.ItemList[0].TotItemVal).toBe(118); + + // ValDtls in rupees. + expect(p.ValDtls.AssVal).toBe(100); + expect(p.ValDtls.CgstVal).toBe(9); + expect(p.ValDtls.SgstVal).toBe(9); + expect(p.ValDtls.IgstVal).toBe(0); + expect(p.ValDtls.TotInvVal).toBe(118); + }); + + it("inter-state maps IGST only", () => { + const input = intraInput(); + input.invoice.cgstPaise = 0; + input.invoice.sgstPaise = 0; + input.invoice.igstPaise = 1800; + input.buyer.gstin = "33ABCDE1234F1Z5"; // Tamil Nadu (33) → inter-state + input.buyer.stateCode = "TN"; + input.invoice.placeOfSupply = "TN"; + + const r = buildIrpPayload(input); + expect(r.ok).toBe(true); + if (!r.ok) return; + const p = r.payload as Record; + + expect(p.BuyerDtls.Stcd).toBe("33"); + expect(p.BuyerDtls.Pos).toBe("33"); + expect(p.ItemList[0].IgstAmt).toBe(18); + expect(p.ItemList[0].CgstAmt).toBe(0); + expect(p.ItemList[0].SgstAmt).toBe(0); + expect(p.ValDtls.IgstVal).toBe(18); + expect(p.ValDtls.CgstVal).toBe(0); + }); + + it("zero-rated export with LUT → EXPWOP", () => { + const input = intraInput(); + input.invoice.cgstPaise = 0; + input.invoice.sgstPaise = 0; + input.invoice.igstPaise = 0; + input.invoice.totalPaise = 10000; + input.invoice.lutNumber = "LUT-2026-XYZ"; + + const r = buildIrpPayload(input); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect((r.payload as any).TranDtls.SupTyp).toBe("EXPWOP"); + }); + + it("reverse charge sets RegRev=Y", () => { + const input = intraInput(); + input.invoice.reverseCharge = true; + const r = buildIrpPayload(input); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect((r.payload as any).TranDtls.RegRev).toBe("Y"); + }); +}); + +describe("buildIrpPayload — paise→rupee residual on last line", () => { + it("multi-line intra-state: per-line CGST/SGST reconcile to ValDtls exactly", () => { + // Three odd lines so proportional rounding leaves a residual that must + // land on the last line. + const input = intraInput(); + // assessable 3334 + 3333 + 3333 = 10000; tax 900/900 each leg. + input.lineItems = [ + { position: 0, description: "L1", quantity: 1, unitPricePaise: 3334, hsnCode: null }, + { position: 1, description: "L2", quantity: 1, unitPricePaise: 3333, hsnCode: null }, + { position: 2, description: "L3", quantity: 1, unitPricePaise: 3333, hsnCode: null }, + ]; + + const r = buildIrpPayload(input); + expect(r.ok).toBe(true); + if (!r.ok) return; + const p = r.payload as Record; + + // Per-line legs (in rupees) must sum to the invoice legs exactly. + const sumCgst = p.ItemList.reduce((a: number, it: any) => a + it.CgstAmt, 0); + const sumSgst = p.ItemList.reduce((a: number, it: any) => a + it.SgstAmt, 0); + const sumAss = p.ItemList.reduce((a: number, it: any) => a + it.AssAmt, 0); + const sumTot = p.ItemList.reduce((a: number, it: any) => a + it.TotItemVal, 0); + + expect(Number(sumCgst.toFixed(2))).toBe(p.ValDtls.CgstVal); + expect(Number(sumSgst.toFixed(2))).toBe(p.ValDtls.SgstVal); + expect(Number(sumAss.toFixed(2))).toBe(p.ValDtls.AssVal); + expect(Number(sumTot.toFixed(2))).toBe(p.ValDtls.TotInvVal); + }); + + it("residual lands on the last line (paise-level)", () => { + const input = intraInput(); + // tax 901 paise (odd) split across 2 lines: line0 gets round(901*0.5)=451, + // line1 absorbs 450 → sums to 901. + input.invoice.cgstPaise = 901; + input.invoice.sgstPaise = 901; + input.invoice.totalPaise = 10000 + 901 + 901; + input.lineItems = [ + { position: 0, description: "L1", quantity: 1, unitPricePaise: 5000, hsnCode: null }, + { position: 1, description: "L2", quantity: 1, unitPricePaise: 5000, hsnCode: null }, + ]; + + const r = buildIrpPayload(input); + expect(r.ok).toBe(true); + if (!r.ok) return; + const p = r.payload as Record; + // 451 paise = 4.51, 450 paise = 4.50; total 9.01 == CgstVal. + expect(p.ItemList[0].CgstAmt).toBe(4.51); + expect(p.ItemList[1].CgstAmt).toBe(4.5); + expect(p.ValDtls.CgstVal).toBe(9.01); + }); +}); + +describe("buildIrpPayload — rejections", () => { + it("rejects missing buyer GSTIN", () => { + const input = intraInput(); + input.buyer.gstin = null; + const r = buildIrpPayload(input); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/buyer GSTIN/i); + }); + + it("rejects missing invoiceNumber", () => { + const input = intraInput(); + input.invoice.invoiceNumber = null; + const r = buildIrpPayload(input); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/invoiceNumber/i); + }); + + it("rejects missing issuedAt", () => { + const input = intraInput(); + input.invoice.issuedAt = null; + const r = buildIrpPayload(input); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/issuedAt/i); + }); + + it("rejects empty line items", () => { + const input = intraInput(); + input.lineItems = []; + const r = buildIrpPayload(input); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/line item/i); + }); + + it("rejects missing seller GSTIN", () => { + const input = intraInput(); + input.seller = { ...seller, gstin: "" }; + const r = buildIrpPayload(input); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/seller GSTIN/i); + }); + + it("rejects line/subtotal mismatch", () => { + const input = intraInput(); + input.invoice.subtotalPaise = 9999; // != line assessable 10000 + const r = buildIrpPayload(input); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/subtotal/i); + }); +}); diff --git a/__tests__/enterprise/ledger-balance-snapshot.test.ts b/__tests__/enterprise/ledger-balance-snapshot.test.ts new file mode 100644 index 000000000..61a08ec89 --- /dev/null +++ b/__tests__/enterprise/ledger-balance-snapshot.test.ts @@ -0,0 +1,126 @@ +/** + * @jest-environment node + */ + +/** + * Maintained ledger balance snapshot (#776 / ARCH #2). postLedgerTxn folds each + * posting's signed delta into LedgerAccountBalance inside the same tx; + * ledgerBalancePaise reads the O(1) snapshot (journal scan only as a fallback). + * These assert the fold math + the read path via a mocked db client. + */ + +import { + postLedgerTxn, + ledgerBalancePaise, + ledgerAccountId, +} from "@/lib/payments/ledger/post"; + +function mockDb() { + const balanceUpserts: Array<{ accountId: string; delta: bigint; count: bigint }> = []; + return { + balanceUpserts, + ledgerTransaction: { + findUnique: jest.fn().mockResolvedValue(null), // not idempotent-hit + create: jest.fn().mockResolvedValue({ id: "txn-1" }), + }, + ledgerAccount: { upsert: jest.fn().mockResolvedValue({}) }, + ledgerAccountBalance: { + upsert: jest.fn().mockImplementation(async ({ where, create, update }) => { + balanceUpserts.push({ + accountId: where.accountId, + delta: update.balancePaise.increment ?? create.balancePaise, + count: update.entrySeq.increment ?? create.entrySeq, + }); + return {}; + }), + findUnique: jest.fn(), + }, + ledgerEntry: { groupBy: jest.fn() }, + }; +} + +describe("postLedgerTxn — balance snapshot fold", () => { + it("folds each account's signed delta (DEBIT +, CREDIT −) once", async () => { + const db = mockDb(); + await postLedgerTxn(db as never, { + idempotencyKey: "k1", + kind: "TOPUP", + postings: [ + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: 100 }, + { + account: { kind: "WALLET", organizationId: "org1" }, + direction: "CREDIT", + amountPaise: 100, + }, + ], + }); + + const byId = Object.fromEntries( + db.balanceUpserts.map((u) => [u.accountId, u]), + ); + expect(byId[ledgerAccountId({ kind: "CASH" })].delta).toBe(BigInt(100)); + expect( + byId[ledgerAccountId({ kind: "WALLET", organizationId: "org1" })].delta, + ).toBe(BigInt(-100)); + // One entry folded per account. + expect(db.balanceUpserts.every((u) => u.count === BigInt(1))).toBe(true); + }); + + it("aggregates multiple postings to the SAME account before upserting", async () => { + const db = mockDb(); + await postLedgerTxn(db as never, { + idempotencyKey: "k2", + kind: "BOOKING", + postings: [ + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: 30 }, + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: 70 }, + { account: { kind: "PLATFORM_FEE" }, direction: "CREDIT", amountPaise: 100 }, + ], + }); + const cash = db.balanceUpserts.find( + (u) => u.accountId === ledgerAccountId({ kind: "CASH" }), + ); + // 30 + 70 folded into one upsert, count 2. + expect(cash?.delta).toBe(BigInt(100)); + expect(cash?.count).toBe(BigInt(2)); + }); + + it("does not touch balances on an idempotency hit", async () => { + const db = mockDb(); + db.ledgerTransaction.findUnique.mockResolvedValueOnce({ id: "existing" }); + const res = await postLedgerTxn(db as never, { + idempotencyKey: "dup", + kind: "TOPUP", + postings: [ + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: 10 }, + { account: { kind: "WALLET", organizationId: "o" }, direction: "CREDIT", amountPaise: 10 }, + ], + }); + expect(res.created).toBe(false); + expect(db.ledgerAccountBalance.upsert).not.toHaveBeenCalled(); + }); +}); + +describe("ledgerBalancePaise — snapshot read", () => { + it("returns the snapshot balance without scanning the journal", async () => { + const db = mockDb(); + db.ledgerAccountBalance.findUnique.mockResolvedValue({ + balancePaise: BigInt(4200), + }); + const bal = await ledgerBalancePaise(db as never, { kind: "CASH" }); + expect(bal).toBe(4200); + expect(db.ledgerEntry.groupBy).not.toHaveBeenCalled(); + }); + + it("falls back to the journal scan when no snapshot row exists", async () => { + const db = mockDb(); + db.ledgerAccountBalance.findUnique.mockResolvedValue(null); + db.ledgerEntry.groupBy.mockResolvedValue([ + { direction: "DEBIT", _sum: { amountPaise: BigInt(500) } }, + { direction: "CREDIT", _sum: { amountPaise: BigInt(200) } }, + ]); + const bal = await ledgerBalancePaise(db as never, { kind: "CASH" }); + expect(bal).toBe(300); + expect(db.ledgerEntry.groupBy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/enterprise/ledger-invariants.test.ts b/__tests__/enterprise/ledger-invariants.test.ts new file mode 100644 index 000000000..03dc23bc3 --- /dev/null +++ b/__tests__/enterprise/ledger-invariants.test.ts @@ -0,0 +1,187 @@ +/** + * @jest-environment node + * + * #812 — property-based invariants for the money core. These are the safety net + * that makes the blocking-ledger change safe and would have caught the bugs this + * PR fixes (GST under-credit, unbalanced postings). Example-based tests cover a + * handful of cases; fast-check covers the combinatorial space. + */ + +import fc from "fast-check"; +import { deriveGstBreakdown } from "@/lib/compliance/gst"; +import { + postLedgerTxn, + LedgerImbalanceError, + type Posting, +} from "@/lib/payments/ledger/post"; +import { mintRefundCreditNote } from "@/lib/payments/operations/refund"; + +const paise = fc.integer({ min: 0, max: 1_000_000_000 }); + +describe("#812 invariant — GST breakdown always nets", () => { + it("total = subtotal + igst + cgst + sgst, and the tax is 18% of subtotal", () => { + fc.assert( + fc.property(paise, fc.boolean(), (subtotal, interState) => { + const gst = deriveGstBreakdown({ + subtotalPaise: subtotal, + supplierStateCode: "KA", + buyerStateCode: interState ? "MH" : "KA", + buyerCountry: "IN", + }); + // The components must reconstitute the total exactly (no lost paise). + expect(gst.totalPaise).toBe( + gst.subtotalPaise + gst.igstPaise + gst.cgstPaise + gst.sgstPaise, + ); + expect(gst.subtotalPaise).toBe(subtotal); + const tax = gst.igstPaise + gst.cgstPaise + gst.sgstPaise; + expect(tax).toBe(Math.round(subtotal * 0.18)); + // Route to IGST (inter-state) or CGST+SGST (intra-state). Expected + // values computed up front so the assertions stay unconditional. + const expectedIgst = interState ? tax : 0; + const expectedCgst = interState ? 0 : Math.floor(tax / 2); + const expectedSgst = interState ? 0 : tax - Math.floor(tax / 2); + expect(gst.igstPaise).toBe(expectedIgst); + expect(gst.cgstPaise).toBe(expectedCgst); + expect(gst.sgstPaise).toBe(expectedSgst); + }), + ); + }); +}); + +describe("#812 invariant — every ledger transaction balances", () => { + // A db stub whose findUnique short-circuits balanced postings before any + // write, so we only exercise the in-function balance assertion. + const db = { + ledgerTransaction: { + findUnique: jest.fn().mockResolvedValue({ id: "existing" }), + create: jest.fn(), + }, + } as never; + + it("accepts any balanced (Σdebit == Σcredit) posting", async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async (amt) => { + const postings: Posting[] = [ + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: amt }, + { + account: { kind: "PLATFORM_FEE" }, + direction: "CREDIT", + amountPaise: amt, + }, + ]; + await expect( + postLedgerTxn(db, { + idempotencyKey: `bal:${amt}`, + kind: "BOOKING", + postings, + }), + ).resolves.toBeDefined(); + }), + ); + }); + + it("rejects any imbalanced posting with LedgerImbalanceError", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), // ≥1 guarantees an imbalance + async (amt, extra) => { + const postings: Posting[] = [ + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: amt }, + { + account: { kind: "PLATFORM_FEE" }, + direction: "CREDIT", + amountPaise: amt + extra, + }, + ]; + await expect( + postLedgerTxn(db, { + idempotencyKey: `imb:${amt}:${extra}`, + kind: "BOOKING", + postings, + }), + ).rejects.toBeInstanceOf(LedgerImbalanceError); + }, + ), + ); + }); +}); + +describe("#812 invariant — refund credit note fully reverses proportional GST", () => { + function mockTx(subtotal: number, reverse: number) { + const created: Record[] = []; + const tax = Math.round(subtotal * 0.18); + const cgst = Math.floor(tax / 2); + return { + _created: created, + payment: { + findUnique: jest.fn().mockResolvedValue({ + id: "p", + amount: reverse, + organizationId: "org", + billableToOrgInvoiceId: "inv", + legs: [{ source: "INVOICE_ACCRUAL", amountPaise: reverse }], + }), + }, + creditNote: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation(async (a: { data: Record }) => { + created.push(a.data); + return { id: "cn", ...a.data }; + }), + }, + organizationInvoice: { + findUnique: jest.fn().mockResolvedValue({ + id: "inv", + status: "ISSUED", + issuedAt: new Date("2026-05-01T00:00:00Z"), + subtotalPaise: subtotal, + cgstPaise: cgst, + sgstPaise: tax - cgst, + igstPaise: 0, + totalPaise: subtotal + tax, + }), + }, + organization: { + findUnique: jest + .fn() + .mockResolvedValue({ id: "org", slug: "acme", invoiceNumberPrefix: "ACM" }), + }, + orgCreditNoteCounter: { upsert: jest.fn().mockResolvedValue({ nextSeq: 2 }) }, + }; + } + + it("cnTotal = cnSubtotal + cnTax, and cnTax is the proportional GST of the reversed pre-tax amount", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 10_000_000 }), + fc.double({ min: 0.01, max: 1, noNaN: true }), + async (subtotal, frac) => { + const reverse = Math.max(1, Math.floor(subtotal * frac)); + const tx = mockTx(subtotal, reverse); + await mintRefundCreditNote(tx as never, { + paymentId: "p", + refundId: "r", + amountPaise: reverse, + reason: "prop", + }); + const cn = tx._created[0] as { + subtotalPaise: number; + cgstPaise: number; + sgstPaise: number; + igstPaise: number; + totalPaise: number; + }; + const cnTax = cn.cgstPaise + cn.sgstPaise + cn.igstPaise; + // Pre-tax reversed amount is the CN subtotal; total adds tax on top. + expect(cn.subtotalPaise).toBe(reverse); + expect(cn.totalPaise).toBe(cn.subtotalPaise + cnTax); + // Tax is the invoice's 18% applied to the reversed pre-tax base. + expect(cnTax).toBe(Math.round((reverse * Math.round(subtotal * 0.18)) / subtotal)); + }, + ), + ); + }); +}); diff --git a/__tests__/enterprise/license-credit-pool-bogus.test.ts b/__tests__/enterprise/license-credit-pool-bogus.test.ts new file mode 100644 index 000000000..6a7521c71 --- /dev/null +++ b/__tests__/enterprise/license-credit-pool-bogus.test.ts @@ -0,0 +1,192 @@ +/** + * @jest-environment node + */ + +/** + * Server-side guard: POST /api/organizations/[orgId]/programs must + * reject `type=CREDIT_POOL` when the parent contract's BillingAccount + * is `fundingSource=LICENSE`. The wizard already hides the option in + * the UI; this test covers the API loophole — a curious client can't + * construct it directly. + * + * Why the combo is bogus: a LICENSE is a flat-fee paid offline for + * unmetered usage. A per-cycle credit cap on top of it doesn't + * express a real customer arrangement (the relevant ProgramType is + * LICENSED_SEAT with `coveredEngagementsPerCycle=null`). + * + * See `prompts/a.txt` rows 68 + 73 + 120 for the original analysis. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + contract: { findUnique: jest.fn() }, + // #751 — the overlap guard probes ACTIVE siblings before creating; none + // exist in these fixtures. + program: { create: jest.fn(), findMany: jest.fn().mockResolvedValue([]) }, + orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, + $transaction: jest.fn(), + $disconnect: jest.fn(), + }, +})); + +jest.mock("../../lib/auth-helpers", () => { + const RANK: Record = { + OWNER: 5, + MAINTAINER: 4, + MANAGER: 3, + SUPPORT: 2, + EXPERT: 1, + LEARNER: 1, + }; + return { + requireOrgAccess: jest.fn(), + orgRoleSatisfies: (caller: string, minimum: string) => + (RANK[caller] ?? 0) >= (RANK[minimum] ?? 0), + }; +}); + +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { POST as programsPOST } from "@/app/api/organizations/[orgId]/programs/route"; + +const mockedPrisma = prisma as unknown as { + contract: { findUnique: jest.Mock }; + program: { create: jest.Mock }; + orgAuditLog: { create: jest.Mock }; + $transaction: jest.Mock; +}; +const mockedRequireOrgAccess = requireOrgAccess as jest.Mock; + +function maintainerAccess() { + return { + error: null, + session: { user: { id: "u-m", email: "m@test.com" } }, + member: { id: "m-actor", role: "MAINTAINER" }, + org: { id: "org-1", name: "Acme", status: "ACTIVE", canSponsor: true }, + }; +} + +function makeRequest(body: unknown) { + return new Request("http://localhost/api/organizations/org-1/programs", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }) as unknown as Request; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockedRequireOrgAccess.mockResolvedValue(maintainerAccess()); + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => { + const tx = { + program: mockedPrisma.program, + orgAuditLog: mockedPrisma.orgAuditLog, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(tx); + }); +}); + +describe("POST /api/organizations/[orgId]/programs — LICENSE × CREDIT_POOL guard", () => { + it("rejects a CREDIT_POOL program under a LICENSE-funded contract (400 UNREACHABLE_FUNDING_PATH)", async () => { + mockedPrisma.contract.findUnique.mockResolvedValueOnce({ + organizationId: "org-1", + status: "ACTIVE", + billingAccount: { fundingSource: "LICENSE" }, + }); + + const res = (await programsPOST( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ + type: "CREDIT_POOL", + contractId: "c-1", + name: "Pool program under license — bogus", + coveredPlanTypes: [], + allowedCategories: [], + creditPoolConfig: { + cycle: "MONTHLY", + creditBudgetPerCycle: 1000, + }, + }) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1" }) } as any, + )) as Response; + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("UNREACHABLE_FUNDING_PATH"); + expect(body.error).toMatch(/LICENSE/); + expect(body.error).toMatch(/CREDIT_POOL/); + // Critical: program.create must NOT have fired + expect(mockedPrisma.program.create).not.toHaveBeenCalled(); + }); + + it("allows a CREDIT_POOL program under a WALLET-funded contract", async () => { + mockedPrisma.contract.findUnique.mockResolvedValueOnce({ + organizationId: "org-1", + status: "ACTIVE", + billingAccount: { fundingSource: "WALLET" }, + }); + mockedPrisma.program.create.mockResolvedValueOnce({ + id: "p-1", + type: "CREDIT_POOL", + name: "Pool program under wallet — valid", + }); + + const res = (await programsPOST( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ + type: "CREDIT_POOL", + contractId: "c-1", + name: "Pool program under wallet — valid", + coveredPlanTypes: [], + allowedCategories: [], + creditPoolConfig: { + cycle: "MONTHLY", + creditBudgetPerCycle: 1000, + }, + }) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1" }) } as any, + )) as Response; + + expect(res.status).toBe(201); + expect(mockedPrisma.program.create).toHaveBeenCalled(); + }); + + it("allows a LICENSED_SEAT program under a LICENSE-funded contract (the one valid LICENSE shape)", async () => { + mockedPrisma.contract.findUnique.mockResolvedValueOnce({ + organizationId: "org-1", + status: "ACTIVE", + billingAccount: { fundingSource: "LICENSE" }, + }); + mockedPrisma.program.create.mockResolvedValueOnce({ + id: "p-2", + type: "LICENSED_SEAT", + name: "Goldman analysts — unmetered", + }); + + const res = (await programsPOST( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ + type: "LICENSED_SEAT", + contractId: "c-1", + name: "Goldman analysts — unmetered", + coveredPlanTypes: [], + allowedCategories: [], + licensedSeatConfig: { + ratePerSeatPaise: 0, + cycle: "ANNUAL", + coveredEngagementsPerCycle: null, + overageBehavior: "BLOCK", + }, + }) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { params: Promise.resolve({ orgId: "org-1" }) } as any, + )) as Response; + + expect(res.status).toBe(201); + expect(mockedPrisma.program.create).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/live-payout-submission.test.ts b/__tests__/enterprise/live-payout-submission.test.ts new file mode 100644 index 000000000..aa6d7f465 --- /dev/null +++ b/__tests__/enterprise/live-payout-submission.test.ts @@ -0,0 +1,303 @@ +/** + * @jest-environment node + */ + +/** + * PR-3 (live payout submission, RazorpayX) — `processOrgPayout` wires + * the actual gateway call after flipping the row PENDING → PROCESSING. + * + * What we cover: + * - ENABLE_LIVE_PAYOUTS=false → does NOT advance (stays PENDING), no gateway + * call. #785: claiming PENDING→PROCESSING with no live submission would + * zombie the row in PROCESSING (no webhook to advance/rollback it). + * - ENABLE_LIVE_PAYOUTS=true + 200 OK → gateway response persisted on + * the row (gatewayPayoutId, gatewayResponseRaw); status stays + * PROCESSING (UTR + COMPLETED come from the webhook later). + * - ENABLE_LIVE_PAYOUTS=true + 4xx → row rolled to FAILED, failedAt + * stamped, failureReason populated, earnings released back to READY + * (status=READY + orgPayoutId=null). + * - Idempotency at the state-machine layer: a second processOrgPayout + * call against the now-PROCESSING row is a no-op AND does not + * re-submit to the gateway. + * + * What we don't cover here (lives in the integration smoke): + * - Real RazorpayX HTTP semantics; we mock the SDK wrapper. + * - Real Postgres serializable isolation; we mock $transaction. + * - The webhook reconciler (PR-3 also adds + * payout-webhook-reconciler.test.ts for that surface). + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + organizationPayout: { + updateMany: jest.fn(), + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + update: jest.fn(), + }, + organizationPayoutAccount: { + findUnique: jest.fn(), + }, + organizationEarnings: { + updateMany: jest.fn(), + }, + orgAuditLog: { + create: jest.fn().mockResolvedValue({}), + }, + $transaction: jest.fn(), + }, +})); + +jest.mock("../../lib/payments/payouts/razorpay-payouts", () => ({ + __esModule: true, + getRazorpayPayoutsService: jest.fn(), +})); + +jest.mock("../../lib/novu/org-workflows", () => ({ + __esModule: true, + notifyOrgPayoutCompleted: jest.fn().mockResolvedValue(undefined), + notifyOrgPayoutFailed: jest.fn().mockResolvedValue(undefined), +})); + +import prisma from "@/lib/prisma"; +import { getRazorpayPayoutsService } from "@/lib/payments/payouts/razorpay-payouts"; +import { processOrgPayout } from "@/lib/payments/payouts/org-payout-service"; + +const mockedPrisma = prisma as unknown as { + organizationPayout: { + updateMany: jest.Mock; + findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; + update: jest.Mock; + }; + organizationPayoutAccount: { findUnique: jest.Mock }; + organizationEarnings: { updateMany: jest.Mock }; + orgAuditLog: { create: jest.Mock }; + $transaction: jest.Mock; +}; +const mockedGetService = getRazorpayPayoutsService as jest.Mock; + +const PAYOUT_ID = "po_test_123"; +const ORG_ID = "org-1"; +const FUND_ACCT = "fa_test_xyz"; +const RAZORPAY_PAYOUT_ID = "pout_NXXXX"; + +function wireTxAsPassthrough() { + // The service runs `prisma.$transaction(async (tx) => ...)` — we + // swap `tx` for the same mocked client so all calls land on our + // jest.fn() spies. Inner-tx returns whatever the callback returns. + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => { + if (typeof fn === "function") { + return (fn as (tx: typeof mockedPrisma) => Promise)( + mockedPrisma, + ); + } + return undefined; + }); +} + +function setupHappyClaim() { + // Row was PENDING — claim succeeds. + mockedPrisma.organizationPayout.updateMany.mockResolvedValue({ count: 1 }); + mockedPrisma.organizationPayout.findUniqueOrThrow.mockResolvedValue({ + id: PAYOUT_ID, + organizationId: ORG_ID, + amountPaise: 250000, // ₹2,500 + currency: "INR", + paymentGateway: "RAZORPAY", + payoutReference: null, + }); +} + +function setupVerifiedAccount() { + mockedPrisma.organizationPayoutAccount.findUnique.mockResolvedValue({ + status: "VERIFIED", + razorpayContactId: "cont_xxx", + razorpayFundAccountId: FUND_ACCT, + }); +} + +function setupGatewayService(opts: { + createPayout: jest.Mock; +}) { + mockedGetService.mockReturnValue({ + generateIdempotencyKey: (id: string) => `payout_${id}`, + determinePayoutMode: () => "IMPS" as const, + createPayout: opts.createPayout, + }); +} + +describe("processOrgPayout — live submission gating", () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.ENABLE_LIVE_PAYOUTS; + wireTxAsPassthrough(); + }); + + it("ENABLE_LIVE_PAYOUTS=false → does NOT advance (stays PENDING), no claim, no gateway call (#785)", async () => { + // #785 — flag off must NOT claim PENDING→PROCESSING: with no gateway + // submission and no webhook to advance/rollback, a PROCESSING row would + // zombie forever. It stays PENDING for a later live run. + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + status: "PENDING", + }); + const createPayout = jest.fn(); + setupGatewayService({ createPayout }); + + const result = await processOrgPayout(PAYOUT_ID); + + expect(result).toEqual({ status: "PENDING", submittedToGateway: false }); + // The row was NOT advanced — no PENDING→PROCESSING claim happened. + expect(mockedPrisma.organizationPayout.updateMany).not.toHaveBeenCalled(); + expect(createPayout).not.toHaveBeenCalled(); + expect(mockedGetService).not.toHaveBeenCalled(); + expect(mockedPrisma.organizationPayout.update).not.toHaveBeenCalled(); + }); + + it("ENABLE_LIVE_PAYOUTS=true + 200 OK → persists gatewayPayoutId + gatewayResponseRaw, status stays PROCESSING", async () => { + process.env.ENABLE_LIVE_PAYOUTS = "true"; + setupHappyClaim(); + setupVerifiedAccount(); + const createPayout = jest.fn().mockResolvedValue({ + id: RAZORPAY_PAYOUT_ID, + status: "queued", + amount: 250000, + currency: "INR", + mode: "IMPS", + utr: undefined, + }); + setupGatewayService({ createPayout }); + + const result = await processOrgPayout(PAYOUT_ID); + + expect(result).toEqual({ status: "PROCESSING", submittedToGateway: true }); + expect(createPayout).toHaveBeenCalledTimes(1); + expect(createPayout).toHaveBeenCalledWith( + expect.objectContaining({ + fundAccountId: FUND_ACCT, + amount: 250000, + currency: "INR", + idempotencyKey: `payout_${PAYOUT_ID}`, + purpose: "payout", + }), + ); + // The gateway response must land on the row. + expect(mockedPrisma.organizationPayout.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: PAYOUT_ID }, + data: expect.objectContaining({ + gatewayPayoutId: RAZORPAY_PAYOUT_ID, + gatewayResponseRaw: expect.objectContaining({ + id: RAZORPAY_PAYOUT_ID, + }), + }), + }), + ); + // Status must NOT be flipped to COMPLETED here — that's the + // webhook reconciler's job. + const updateCalls = mockedPrisma.organizationPayout.update.mock.calls; + for (const [arg] of updateCalls) { + expect(arg.data.status).toBeUndefined(); + } + }); + + it("ENABLE_LIVE_PAYOUTS=true + 4xx (validation) → status=FAILED, failureReason+failedAt populated, earnings released to READY", async () => { + process.env.ENABLE_LIVE_PAYOUTS = "true"; + setupHappyClaim(); + setupVerifiedAccount(); + const createPayout = jest + .fn() + .mockRejectedValue( + new Error("RazorpayX API error: Invalid fund_account_id"), + ); + setupGatewayService({ createPayout }); + + // After the 4xx, the helper opens a tx and conditionally rolls + // PROCESSING → FAILED. Re-arm the spies for that second pass: + // - first updateMany was the PENDING → PROCESSING claim + // - second updateMany is the PROCESSING → FAILED roll + // - third updateMany is the earnings release (PAID → READY) + // We keep the same spies; assert via call args afterwards. + mockedPrisma.organizationPayout.updateMany + .mockResolvedValueOnce({ count: 1 }) // claim PENDING → PROCESSING + .mockResolvedValueOnce({ count: 1 }); // claim PROCESSING → FAILED + mockedPrisma.organizationEarnings.updateMany.mockResolvedValue({ + count: 3, + }); + mockedPrisma.organizationPayout.findUniqueOrThrow + // first call: inside processOrgPayout claim tx + .mockResolvedValueOnce({ + id: PAYOUT_ID, + organizationId: ORG_ID, + amountPaise: 250000, + currency: "INR", + paymentGateway: "RAZORPAY", + payoutReference: null, + }) + // second call: inside submitOrgPayoutToGateway + .mockResolvedValueOnce({ + id: PAYOUT_ID, + organizationId: ORG_ID, + amountPaise: 250000, + currency: "INR", + paymentGateway: "RAZORPAY", + payoutReference: null, + }) + // third call: inside markPayoutFailedFromSubmission + .mockResolvedValueOnce({ organizationId: ORG_ID }); + + const result = await processOrgPayout(PAYOUT_ID); + + // We don't strictly care what `processOrgPayout` returns on the + // 4xx path because the failure is recorded out-of-band; the + // contract is "no throw, side effects observable on the row". + expect(result.submittedToGateway).toBe(true); + + // Find the FAILED roll updateMany call. + const updateManyCalls = + mockedPrisma.organizationPayout.updateMany.mock.calls; + const failedRoll = updateManyCalls.find( + ([arg]) => arg.data?.status === "FAILED", + ); + expect(failedRoll).toBeDefined(); + expect(failedRoll![0]).toEqual( + expect.objectContaining({ + where: { id: PAYOUT_ID, status: "PROCESSING" }, + data: expect.objectContaining({ + status: "FAILED", + failureReason: expect.stringContaining("Invalid fund_account_id"), + failedAt: expect.any(Date), + }), + }), + ); + + // Earnings release: PAID → READY, orgPayoutId nulled. + expect(mockedPrisma.organizationEarnings.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgPayoutId: PAYOUT_ID, status: "PAID" }, + data: { status: "READY", orgPayoutId: null }, + }), + ); + }); + + it("idempotency — second processOrgPayout against PROCESSING row is a no-op AND does not call gateway", async () => { + process.env.ENABLE_LIVE_PAYOUTS = "true"; + + // Claim returns 0: row is no longer PENDING (already PROCESSING). + mockedPrisma.organizationPayout.updateMany.mockResolvedValue({ count: 0 }); + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + status: "PROCESSING", + }); + const createPayout = jest.fn(); + setupGatewayService({ createPayout }); + + const result = await processOrgPayout(PAYOUT_ID); + + expect(result).toEqual({ status: "PROCESSING", submittedToGateway: false }); + expect(createPayout).not.toHaveBeenCalled(); + // The factory should also not be touched on the no-op path — + // confirms we early-returned BEFORE the submission helper. + expect(mockedGetService).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/member-anti-lockout.test.ts b/__tests__/enterprise/member-anti-lockout.test.ts new file mode 100644 index 000000000..7a2d65b26 --- /dev/null +++ b/__tests__/enterprise/member-anti-lockout.test.ts @@ -0,0 +1,247 @@ +/** + * @jest-environment node + */ + +/** + * Anti-lockout regression for PATCH `/api/organizations/[orgId]/members/[memberId]`. + * + * The route refuses to demote or `REMOVED`-status the only active OWNER + * so an org can never end up ownerless. This is a load-bearing + * invariant — if it regresses, a single accidental PATCH can orphan an + * entire tenant's billing surface. The check runs inside a + * Serializable-equivalent $transaction so concurrent demotes can't both + * believe there's a second owner. + * + * The test mocks `prisma.$transaction` to pass its callback a tiny tx + * shim (matching the shape the route uses) + mocks `requireOrgAccess` + * to simulate an OWNER-level caller. Follows the pattern from + * __tests__/booking-algorithm/authorization.test.ts. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + membership: { + findFirst: jest.fn(), + count: jest.fn(), + update: jest.fn(), + }, + // Why: a role downgrade fires `bumpUserSessionGeneration` inside the + // transaction (lib/api/organizations/membership-transitions.ts) which calls + // `tx.user.update(...)`. The route would crash with `tx.user undefined` + // without this delegate exposed on the prisma mock + the tx shim below. + user: { + update: jest + .fn() + .mockResolvedValue({ id: "u-victim", sessionGeneration: 2 }), + }, + orgAuditLog: { + create: jest.fn().mockResolvedValue({}), + }, + $transaction: jest.fn(), + $disconnect: jest.fn(), + }, +})); + +jest.mock("../../lib/auth-helpers", () => { + // Inline the role-rank comparator so the test doesn't need to import + // the real module (which transitively pulls in better-auth ESM and + // breaks jest's CJS loader). Keep the rank list in sync with + // lib/auth-helpers.ts — the ordering OWNER > MAINTAINER > MANAGER > + // SUPPORT > EXPERT = LEARNER is a documented invariant from + // docs/enterprise/00-foundations/04-roles-and-permissions.md. + const RANK: Record = { + OWNER: 5, + MAINTAINER: 4, + MANAGER: 3, + SUPPORT: 2, + EXPERT: 1, + LEARNER: 1, + }; + return { + requireOrgAccess: jest.fn(), + orgRoleSatisfies: (caller: string, minimum: string) => + (RANK[caller] ?? 0) >= (RANK[minimum] ?? 0), + }; +}); + +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { PATCH } from "@/app/api/organizations/[orgId]/members/[memberId]/route"; + +const mockedPrisma = prisma as unknown as { + membership: { + findFirst: jest.Mock; + count: jest.Mock; + update: jest.Mock; + }; + user: { update: jest.Mock }; + orgAuditLog: { create: jest.Mock }; + $transaction: jest.Mock; +}; +const mockedRequireOrgAccess = requireOrgAccess as jest.Mock; + +function ownerAccess() { + return { + error: null, + session: { user: { id: "u-owner", email: "owner@test.com" } }, + member: { id: "m-owner-actor", role: "OWNER" }, + org: { id: "org-1", name: "Acme", status: "ACTIVE" }, + }; +} + +function makeRequest(body: unknown) { + return new Request("http://localhost/api/organizations/org-1/members/m-1", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }) as unknown as Request; +} + +function makeParams(orgId = "org-1", memberId = "m-target") { + return { + params: Promise.resolve({ orgId, memberId }), + }; +} + +function wireTxShim() { + // $transaction(fn) — invoke the callback with a tx that proxies + // through to the module-level mocks. Matches the route's expected + // shape; nothing about the isolation level matters for unit coverage. + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => { + const tx = { + membership: mockedPrisma.membership, + orgAuditLog: mockedPrisma.orgAuditLog, + // Why: role downgrades bump the user's sessionGeneration counter + // inside the same transaction (see bumpUserSessionGeneration in + // lib/api/organizations/membership-transitions.ts). The shim has to + // forward `tx.user.update` to the module-level mock so the helper + // can complete the transaction without crashing. + user: mockedPrisma.user, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(tx); + }); +} + +describe("PATCH /api/organizations/[orgId]/members/[memberId] — anti-lockout", () => { + beforeEach(() => { + mockedRequireOrgAccess.mockResolvedValue(ownerAccess()); + wireTxShim(); + }); + + it("refuses to demote the only active OWNER (409)", async () => { + // Target member is OWNER; no other active OWNERs in the org. + mockedPrisma.membership.findFirst.mockResolvedValueOnce({ + id: "m-target", + organizationId: "org-1", + role: "OWNER", + status: "ACTIVE", + userId: "u-victim", + }); + mockedPrisma.membership.count.mockResolvedValueOnce(0); + + const res = (await PATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ role: "MAINTAINER" }) as any, + makeParams(), + )) as Response; + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/only active OWNER/i); + // Critical: membership.update must NOT have fired + expect(mockedPrisma.membership.update).not.toHaveBeenCalled(); + }); + + it("refuses to REMOVED-status the only active OWNER (409)", async () => { + mockedPrisma.membership.findFirst.mockResolvedValueOnce({ + id: "m-target", + organizationId: "org-1", + role: "OWNER", + status: "ACTIVE", + userId: "u-victim", + }); + mockedPrisma.membership.count.mockResolvedValueOnce(0); + + const res = (await PATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ status: "REMOVED" }) as any, + makeParams(), + )) as Response; + + expect(res.status).toBe(409); + expect(mockedPrisma.membership.update).not.toHaveBeenCalled(); + }); + + it("allows demoting an OWNER when another active OWNER exists", async () => { + mockedPrisma.membership.findFirst.mockResolvedValueOnce({ + id: "m-target", + organizationId: "org-1", + role: "OWNER", + status: "ACTIVE", + userId: "u-victim", + }); + // One other active OWNER present → demotion is safe + mockedPrisma.membership.count.mockResolvedValueOnce(1); + mockedPrisma.membership.update.mockResolvedValueOnce({ + id: "m-target", + role: "MAINTAINER", + status: "ACTIVE", + }); + + const res = (await PATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ role: "MAINTAINER" }) as any, + makeParams(), + )) as Response; + + expect(res.status).toBe(200); + // OWNER → MAINTAINER is an operator-role transition: the centralized + // applyMembershipRoleEffects helper clears any consulteeProfileId / + // consultantProfileId and resets payoutRecipient to SELF so a former + // consumer/provider role doesn't leave a stale profile FK behind. + expect(mockedPrisma.membership.update).toHaveBeenCalledWith({ + where: { id: "m-target" }, + data: { + role: "MAINTAINER", + consulteeProfileId: null, + consultantProfileId: null, + payoutRecipient: "SELF", + }, + }); + // Why: every role mutation must bump the demoted user's + // sessionGeneration so their next request triggers a customSession + // refetch (lib/auth.ts), eliminating up to 24h of stale-permission + // exposure. Audit phase B.5 — see bumpUserSessionGeneration docstring. + expect(mockedPrisma.user.update).toHaveBeenCalledWith({ + where: { id: "u-victim" }, + data: { sessionGeneration: { increment: 1 } }, + }); + }); + + it("blocks LEARNER → EXPERT role transition with 409", async () => { + // Separate invariant (docs/enterprise/00-foundations/04-roles-and-permissions.md): + // disjoint LEARNER/EXPERT boundary forces remove + re-invite rather + // than in-place mutation, because the two roles imply different + // profile types. + mockedPrisma.membership.findFirst.mockResolvedValueOnce({ + id: "m-target", + organizationId: "org-1", + role: "LEARNER", + status: "ACTIVE", + userId: "u-target", + }); + + const res = (await PATCH( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRequest({ role: "EXPERT" }) as any, + makeParams(), + )) as Response; + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toBe("ROLE_TRANSITION_BLOCKED"); + expect(mockedPrisma.membership.update).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/member-role-schema-drift.test.ts b/__tests__/enterprise/member-role-schema-drift.test.ts new file mode 100644 index 000000000..1908620d5 --- /dev/null +++ b/__tests__/enterprise/member-role-schema-drift.test.ts @@ -0,0 +1,40 @@ +/** + * @jest-environment node + */ + +/** + * #817 — pins the canonical role Zod enums in org-labels to their contracts. + * The invitations and members routes used to carry LOCAL duplicates of these + * enums, and both drifted (BILLING_ADMIN went missing), making the + * finance-operator role unassignable through invite OR direct add. The routes + * now import the canonical schemas, and this suite stops the canon itself + * from drifting against the Prisma enum. + */ + +import { MemberRole } from "@prisma/client"; + +import { + MemberRoleSchema, + SelfServiceMemberRoleSchema, + HostInvitableMemberRoleSchema, +} from "@/lib/labels/org-labels"; + +describe("member-role schema drift (#817)", () => { + it("MemberRoleSchema mirrors the Prisma enum exactly", () => { + const zod = [...MemberRoleSchema.options].sort(); + const prisma = Object.values(MemberRole).sort(); + expect(zod).toEqual(prisma); + }); + + it("self-service set includes BILLING_ADMIN and excludes the marketplace + operator-only roles", () => { + expect(SelfServiceMemberRoleSchema.options).toContain("BILLING_ADMIN"); + expect(SelfServiceMemberRoleSchema.options).not.toContain("EXPERT"); + expect(SelfServiceMemberRoleSchema.options).not.toContain("SUPPORT"); + }); + + it("host-invitable set is exactly self-service + EXPERT (SUPPORT stays owner-console-only)", () => { + const host = [...HostInvitableMemberRoleSchema.options].sort(); + const expected = [...SelfServiceMemberRoleSchema.options, "EXPERT"].sort(); + expect(host).toEqual(expected); + }); +}); diff --git a/__tests__/enterprise/msme-deadline.test.ts b/__tests__/enterprise/msme-deadline.test.ts new file mode 100644 index 000000000..ce68787f3 --- /dev/null +++ b/__tests__/enterprise/msme-deadline.test.ts @@ -0,0 +1,123 @@ +/** + * @jest-environment node + */ + +/** + * Live MSME (Section 43B(h)) deadline derivation. The cron at + * `jobs/compliance/msme-payment-alerts.ts` sweeps off the + * `mustPayByDate` these tests pin down — getting any of these wrong + * costs the org their FY tax deduction, so the cases here are the + * non-negotiable "before-launch" guardrails. + */ + +import { + computeMsmePaymentDeadline, + isValidUdyamNumber, + MSME_NO_WRITTEN_AGREEMENT_DAYS, + MSME_WRITTEN_AGREEMENT_DAYS, + DEFAULT_MSME_DEADLINE_DAYS, +} from "@/lib/compliance/msme"; + +const INVOICE = new Date("2026-01-01T00:00:00.000Z"); + +function daysBetween(a: Date, b: Date): number { + return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)); +} + +describe("computeMsmePaymentDeadline — Section 43B(h)", () => { + it("MICRO + written agreement → 45 days", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "MICRO", + writtenAgreement: true, + }); + expect(daysBetween(INVOICE, d)).toBe(MSME_WRITTEN_AGREEMENT_DAYS); + }); + + it("MICRO + no written agreement → 15 days", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "MICRO", + writtenAgreement: false, + }); + expect(daysBetween(INVOICE, d)).toBe(MSME_NO_WRITTEN_AGREEMENT_DAYS); + }); + + it("SMALL + written agreement → 45 days", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "SMALL", + writtenAgreement: true, + }); + expect(daysBetween(INVOICE, d)).toBe(MSME_WRITTEN_AGREEMENT_DAYS); + }); + + it("SMALL + no written agreement → 15 days", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "SMALL", + writtenAgreement: false, + }); + expect(daysBetween(INVOICE, d)).toBe(MSME_NO_WRITTEN_AGREEMENT_DAYS); + }); +}); + +describe("computeMsmePaymentDeadline — non-43B statuses", () => { + it("MEDIUM falls back to defaultTermsDays (60 by default)", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "MEDIUM", + writtenAgreement: false, + }); + expect(daysBetween(INVOICE, d)).toBe(DEFAULT_MSME_DEADLINE_DAYS); + }); + + it("NONE falls back to defaultTermsDays (60 by default)", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "NONE", + writtenAgreement: false, + }); + expect(daysBetween(INVOICE, d)).toBe(DEFAULT_MSME_DEADLINE_DAYS); + }); + + it("NONE with explicit defaultTermsDays=30 → 30 days", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "NONE", + writtenAgreement: false, + defaultTermsDays: 30, + }); + expect(daysBetween(INVOICE, d)).toBe(30); + }); + + it("MICRO ignores defaultTermsDays — 43B(h) is the hard ceiling", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "MICRO", + writtenAgreement: true, + defaultTermsDays: 90, // would be a 43B(h) violation if honoured + }); + expect(daysBetween(INVOICE, d)).toBe(MSME_WRITTEN_AGREEMENT_DAYS); + }); + + it("defaults writtenAgreement to false (safer = shorter deadline)", () => { + const d = computeMsmePaymentDeadline({ + invoiceDate: INVOICE, + counterpartyMsmeStatus: "SMALL", + }); + expect(daysBetween(INVOICE, d)).toBe(MSME_NO_WRITTEN_AGREEMENT_DAYS); + }); +}); + +describe("isValidUdyamNumber", () => { + it("accepts a well-formed Udyam number", () => { + expect(isValidUdyamNumber("UDYAM-MH-12-1234567")).toBe(true); + }); + it("rejects malformed values", () => { + expect(isValidUdyamNumber(null)).toBe(false); + expect(isValidUdyamNumber("UDYAM-mh-12-1234567")).toBe(false); + expect(isValidUdyamNumber("UDYAM-MH-1-1234567")).toBe(false); + expect(isValidUdyamNumber("UDYAM-MH-12-12345")).toBe(false); + }); +}); diff --git a/__tests__/enterprise/multi-engagement-cap.test.ts b/__tests__/enterprise/multi-engagement-cap.test.ts new file mode 100644 index 000000000..fc7bb84bc --- /dev/null +++ b/__tests__/enterprise/multi-engagement-cap.test.ts @@ -0,0 +1,548 @@ +/** + * @jest-environment node + */ + +/** + * Issue #710: LICENSED_SEAT cap counts engagements (calendar + * occurrences) per Appointment row, not per checkout. + * + * Covers: + * - CONSULTATION/WEBINAR debit 1 at checkout + * - CLASS debits N at enrolment (one per class day Appointment) + * - SUBSCRIPTION skips checkout-time debit (lazy at allocation) + * - SUBSCRIPTION lazy debit accumulates engagementsConsumed via upsert + * - Cap with BLOCK throws ProgramAssignmentLimitError when exceeded + * - Cap with CHARGE_MEMBER / CHARGE_ORG marks wasOverage and continues + * - reverseBookingUtilization decrements engagementsUsed by the row's full count + * + * Architecture note: the helper does an upsert on `paymentId` (which is + * @unique on BookingUtilization). For SUBSCRIPTION, the first allocation + * creates the row; subsequent allocations increment engagementsConsumed + * by the new delta. Each call always appends a fresh UsageLedgerEntry, + * so `sum(UsageLedgerEntry.engagementsConsumed) === BookingUtilization.engagementsConsumed` + * is preserved. + */ + +import { + recordBookingUtilization, + reverseBookingUtilization, + ProgramAssignmentLimitError, +} from "@/lib/api/organizations/program-helpers"; + +type MockTx = { + programAssignment: { + findUniqueOrThrow: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; + bookingUtilization: { + upsert: jest.Mock; + create: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + usageLedgerEntry: { + create: jest.Mock; + aggregate: jest.Mock; + }; + overageEvent: { + updateMany: jest.Mock; + }; +}; + +function makeAssignmentLookup(opts: { + cap: number | null; + behavior: "BLOCK" | "CHARGE_MEMBER" | "CHARGE_ORG"; +}) { + return jest.fn().mockResolvedValue({ + programId: "prog-1", + membershipId: "mem-1", + program: { + licensedSeatConfig: { + coveredEngagementsPerCycle: opts.cap, + overageBehavior: opts.behavior, + }, + }, + }); +} + +function makeTx(opts: { + cap: number | null; + behavior: "BLOCK" | "CHARGE_MEMBER" | "CHARGE_ORG"; + blockUpdateRows?: number; // for BLOCK path: rows touched by conditional UPDATE + chargeReturning?: { engagementsUsed: number }[]; // for CHARGE_* path +}): MockTx { + return { + programAssignment: { + findUniqueOrThrow: makeAssignmentLookup(opts), + // CHARGE_* / unlimited record path resolves to the post-increment row; + // reverseBookingUtilization also uses this mock but ignores the return. + update: jest.fn().mockResolvedValue(opts.chargeReturning?.[0] ?? {}), + // BLOCK path: count===0 ⇒ cap exceeded → throws. + updateMany: jest + .fn() + .mockResolvedValue({ count: opts.blockUpdateRows ?? 1 }), + }, + bookingUtilization: { + upsert: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue({}), + findUnique: jest.fn(), + update: jest.fn().mockResolvedValue({}), + }, + usageLedgerEntry: { + create: jest.fn().mockResolvedValue({}), + // Defaults to "no prior reversals" for fresh tests; partial-reversal + // tests override this to simulate cumulative-reversed state. + aggregate: jest.fn().mockResolvedValue({ _sum: { engagementsConsumed: 0 } }), + }, + overageEvent: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; +} + +describe("recordBookingUtilization — engagement counting (issue #710)", () => { + it("CONSULTATION/WEBINAR pattern: passes engagementsConsumed=1 cleanly", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-1", + engagementsConsumed: 1, + priceAtBookingPaise: 50_000, + }); + expect(tx.bookingUtilization.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { paymentId: "pay-1" }, + create: expect.objectContaining({ engagementsConsumed: 1 }), + update: expect.objectContaining({ + engagementsConsumed: { increment: 1 }, + }), + }), + ); + expect(tx.usageLedgerEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ engagementsConsumed: 1 }), + }), + ); + }); + + it("CLASS pattern: passes engagementsConsumed = N (count of distinct enrolled appointments)", async () => { + const tx = makeTx({ cap: 20, behavior: "BLOCK" }); + await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-class-1", + engagementsConsumed: 8, // 8-week class + priceAtBookingPaise: 200_000, + }); + expect(tx.bookingUtilization.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ engagementsConsumed: 8 }), + }), + ); + expect(tx.usageLedgerEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ engagementsConsumed: 8 }), + }), + ); + }); + + it("SUBSCRIPTION pattern: incremental upsert — second call increments engagementsConsumed", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); // unlimited cap + // First allocation + await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-sub-1", + engagementsConsumed: 1, + priceAtBookingPaise: 600_000, // full sub price on first call + }); + // Second allocation + await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-sub-1", + engagementsConsumed: 1, + priceAtBookingPaise: 0, // zero on subsequent + }); + expect(tx.bookingUtilization.upsert).toHaveBeenCalledTimes(2); + // Both calls go through upsert; the update branch uses { increment: 1 } + // so two allocations end at engagementsConsumed = 2 in the DB. + const calls = tx.bookingUtilization.upsert.mock.calls; + expect(calls[0][0].update.engagementsConsumed).toEqual({ increment: 1 }); + expect(calls[1][0].update.engagementsConsumed).toEqual({ increment: 1 }); + // The ledger gets a fresh row each call. + expect(tx.usageLedgerEntry.create).toHaveBeenCalledTimes(2); + }); + + it("BLOCK overage: throws ProgramAssignmentLimitError when conditional UPDATE matches 0 rows", async () => { + const tx = makeTx({ + cap: 10, + behavior: "BLOCK", + blockUpdateRows: 0, // simulating cap exceeded + }); + await expect( + recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-overflow", + engagementsConsumed: 5, + priceAtBookingPaise: 100_000, + }), + ).rejects.toBeInstanceOf(ProgramAssignmentLimitError); + // Booking utilization must NOT have been upserted on cap rejection. + expect(tx.bookingUtilization.upsert).not.toHaveBeenCalled(); + expect(tx.usageLedgerEntry.create).not.toHaveBeenCalled(); + }); + + it("CHARGE_ORG overage: marks wasOverage=true when post-increment count exceeds cap", async () => { + const tx = makeTx({ + cap: 10, + behavior: "CHARGE_ORG", + chargeReturning: [{ engagementsUsed: 11 }], // post-increment > cap + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-overage", + engagementsConsumed: 1, + priceAtBookingPaise: 50_000, + }); + expect(result.wasOverage).toBe(true); + expect(tx.bookingUtilization.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ wasOverage: true }), + }), + ); + }); + + it("CHARGE_MEMBER non-overage: wasOverage=false when post-increment fits within cap", async () => { + const tx = makeTx({ + cap: 10, + behavior: "CHARGE_MEMBER", + chargeReturning: [{ engagementsUsed: 7 }], + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-fits", + engagementsConsumed: 2, + priceAtBookingPaise: 50_000, + }); + expect(result.wasOverage).toBe(false); + }); + + // PR-1e (G3): SUBSCRIPTION reallocation idempotency via appointmentIds. + describe("appointmentIds idempotency (PR-1e)", () => { + it("first call with appointmentIds=[a,b] increments by 2", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); + // First call: no existing BookingUtilization row. + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue(null); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-sub", + engagementsConsumed: 2, + priceAtBookingPaise: 600_000, + appointmentIds: ["a", "b"], + }); + expect(result.engagementsConsumedDelta).toBe(2); + expect(tx.programAssignment.update).toHaveBeenCalledTimes(1); + // Upsert was called with appointmentIds in create branch + expect(tx.bookingUtilization.upsert.mock.calls[0][0].create).toMatchObject({ + engagementsConsumed: 2, + appointmentIds: ["a", "b"], + }); + }); + + it("second call with same appointmentIds is a no-op (zero delta)", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); + // Existing row already tracks both ids. + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + appointmentIds: ["a", "b"], + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-sub", + engagementsConsumed: 2, + priceAtBookingPaise: 0, + appointmentIds: ["a", "b"], // same as already tracked + }); + expect(result.engagementsConsumedDelta).toBe(0); + expect(result.wasOverage).toBe(false); + // No DB writes on a zero-delta call + expect(tx.programAssignment.update).not.toHaveBeenCalled(); + expect(tx.programAssignment.updateMany).not.toHaveBeenCalled(); + expect(tx.bookingUtilization.upsert).not.toHaveBeenCalled(); + expect(tx.usageLedgerEntry.create).not.toHaveBeenCalled(); + }); + + it("third call with appointmentIds=[a,b,c] when row tracks [a,b] increments by 1 (only c is new)", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + appointmentIds: ["a", "b"], + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-sub", + engagementsConsumed: 3, + priceAtBookingPaise: 0, + appointmentIds: ["a", "b", "c"], + }); + expect(result.engagementsConsumedDelta).toBe(1); + // Upsert append-pushes only the new id + expect(tx.bookingUtilization.upsert.mock.calls[0][0].update.appointmentIds).toEqual({ + push: ["c"], + }); + }); + + it("legacy callers (no appointmentIds) keep the old additive semantics", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + appointmentIds: [], + }); + const result = await recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-legacy", + engagementsConsumed: 1, + priceAtBookingPaise: 50_000, + // no appointmentIds → old behavior: increment by params.engagementsConsumed + }); + expect(result.engagementsConsumedDelta).toBe(1); + }); + + it("rejects negative engagementsConsumed defensively", async () => { + const tx = makeTx({ cap: null, behavior: "BLOCK" }); + await expect( + recordBookingUtilization(tx as never, { + programAssignmentId: "asg-1", + paymentId: "pay-neg", + engagementsConsumed: -3, + priceAtBookingPaise: 0, + }), + ).rejects.toThrow(/non-negative/); + }); + }); +}); + +describe("reverseBookingUtilization — refund cap reversal (full + partial)", () => { + it("default (no engagementsToReverse) reverses the full count + stamps reversedAt", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, // CLASS that enrolled 8 sessions + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { membershipId: "mem-1" }, + }); + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-class-1", + reason: "Refund", + }); + expect(result).toEqual({ + reversed: true, + engagementsReversed: 8, + fullyReversed: true, + }); + expect(tx.programAssignment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsUsed: { decrement: 8 }, + }), + }), + ); + expect(tx.usageLedgerEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsConsumed: -8, + priceAtBookingPaise: -200_000, + }), + }), + ); + expect(tx.bookingUtilization.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ reversedAt: expect.any(Date) }), + }), + ); + }); + + it("partial reversal: 50% refund of an 8-session class reverses 4 cap units", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { membershipId: "mem-1" }, + }); + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-class-partial", + engagementsToReverse: 4, // caller computed Math.round(8 * 0.5) + reason: "Partial refund (100000/200000)", + }); + expect(result).toEqual({ + reversed: true, + engagementsReversed: 4, + fullyReversed: false, + }); + expect(tx.programAssignment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsUsed: { decrement: 4 }, + }), + }), + ); + // Price reversal is prorated: 200_000 * (4/8) = 100_000 + expect(tx.usageLedgerEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsConsumed: -4, + priceAtBookingPaise: -100_000, + }), + }), + ); + // reversedAt NOT stamped — partial reversal leaves the row open + expect(tx.bookingUtilization.update).not.toHaveBeenCalled(); + }); + + it("partial reversal: subsequent calls accumulate, last one stamps reversedAt", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { membershipId: "mem-1" }, + }); + // Simulate 3 already reversed (e.g., a prior partial refund of 37.5%) + tx.usageLedgerEntry.aggregate = jest.fn().mockResolvedValue({ + _sum: { engagementsConsumed: -3 }, + }); + // Caller wants to reverse 5 more (the remaining 62.5%) + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-class-2nd-partial", + engagementsToReverse: 5, + }); + expect(result).toEqual({ + reversed: true, + engagementsReversed: 5, + fullyReversed: true, // 3 + 5 = 8 = full + }); + expect(tx.bookingUtilization.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ reversedAt: expect.any(Date) }), + }), + ); + }); + + it("partial reversal clamps to remaining: caller asks for 10, only 2 remain → reverses 2", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { membershipId: "mem-1" }, + }); + tx.usageLedgerEntry.aggregate = jest.fn().mockResolvedValue({ + _sum: { engagementsConsumed: -6 }, // 6 already reversed; 2 remaining + }); + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-clamp", + engagementsToReverse: 10, // caller over-asks; helper clamps + }); + expect(result.engagementsReversed).toBe(2); + expect(result.fullyReversed).toBe(true); + }); + + it("idempotent: already fully reversed via ledger sum returns reversed=false", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: new Date(), + programAssignment: { membershipId: "mem-1" }, + }); + tx.usageLedgerEntry.aggregate = jest.fn().mockResolvedValue({ + _sum: { engagementsConsumed: -8 }, // fully reversed already + }); + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-already-reversed", + }); + expect(result).toEqual({ + reversed: false, + engagementsReversed: 0, + fullyReversed: true, + }); + expect(tx.programAssignment.update).not.toHaveBeenCalled(); + }); + + it("missing utilization row: returns reversed=false (PERSONAL booking that never wrote a util)", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue(null); + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-personal", + }); + expect(result.reversed).toBe(false); + expect(result.fullyReversed).toBe(false); + }); + + it("zero / negative engagementsToReverse: no-op, no DB writes", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { membershipId: "mem-1" }, + }); + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-zero", + engagementsToReverse: 0, + }); + expect(result.reversed).toBe(false); + expect(tx.programAssignment.update).not.toHaveBeenCalled(); + expect(tx.usageLedgerEntry.create).not.toHaveBeenCalled(); + }); + + it("overageCount only decrements on the LAST (fully-reversing) reversal", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 8, + priceAtBookingPaise: 200_000, + wasOverage: true, // booking went over cap + reversedAt: null, + programAssignment: { membershipId: "mem-1" }, + }); + // First partial: 4 of 8 — overageCount should NOT decrement + const partial = await reverseBookingUtilization(tx as never, { + paymentId: "pay-overage", + engagementsToReverse: 4, + }); + expect(partial.fullyReversed).toBe(false); + expect(tx.programAssignment.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.not.objectContaining({ + overageCount: expect.anything(), + }), + }), + ); + // Now the second half — overageCount SHOULD decrement + tx.usageLedgerEntry.aggregate = jest.fn().mockResolvedValue({ + _sum: { engagementsConsumed: -4 }, + }); + const final = await reverseBookingUtilization(tx as never, { + paymentId: "pay-overage", + engagementsToReverse: 4, + }); + expect(final.fullyReversed).toBe(true); + expect(tx.programAssignment.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + overageCount: { decrement: 1 }, + }), + }), + ); + }); +}); diff --git a/__tests__/enterprise/org-activation.test.ts b/__tests__/enterprise/org-activation.test.ts new file mode 100644 index 000000000..a6fc9b6f3 --- /dev/null +++ b/__tests__/enterprise/org-activation.test.ts @@ -0,0 +1,143 @@ +/** + * @jest-environment node + */ + +/** + * #777 §A + #779 §F — the pure activation checklist + action-center derivation. + * Pins the capability-aware step list and the condition banners so the home + * surface stays driven by real state, not hardcoded copy. + */ + +import { + deriveActivationChecklist, + deriveActionCenter, + isOrgActivated, + type OrgActivationSnapshot, +} from "@/lib/enterprise/org-activation"; + +const ORG = "org_1"; + +const fresh: OrgActivationSnapshot = { + status: "PENDING_VERIFICATION", + canSponsor: true, + canHost: false, + fundingSource: "INVOICE", + memberCount: 1, + activeProgramCount: 0, + activeAssignmentCount: 0, + billingConfigured: false, + hasContract: false, + hasActiveContract: false, + kybVerified: false, + pastDueInvoiceCount: 0, + outstandingInvoicePaise: 0, + contractExpiringSoonCount: 0, + pendingOverageCount: 0, + pendingOveragePaise: 0, + stuckPayoutCount: 0, + walletLowBalancePaise: null, + creditPoolMaxUtilizationPct: null, +}; + +describe("deriveActivationChecklist", () => { + it("INVOICE sponsor org gets verify→kyb→billing→contract→program→invite→assign", () => { + const steps = deriveActivationChecklist(fresh, ORG).map((s) => s.key); + expect(steps).toEqual([ + "verify", + "kyb", + "billing", + "contract", + "program", + "invite", + "assign", + ]); + expect(deriveActivationChecklist(fresh, ORG).every((s) => !s.done)).toBe( + true, + ); + }); + + it("HOST-only org collapses to verify→invite (no sponsor steps)", () => { + const host: OrgActivationSnapshot = { + ...fresh, + canSponsor: false, + canHost: true, + fundingSource: null, + }; + expect(deriveActivationChecklist(host, ORG).map((s) => s.key)).toEqual([ + "verify", + "invite", + ]); + }); + + it("non-INVOICE sponsor org omits the KYB step", () => { + const wallet: OrgActivationSnapshot = { ...fresh, fundingSource: "WALLET" }; + expect( + deriveActivationChecklist(wallet, ORG).map((s) => s.key), + ).not.toContain("kyb"); + }); + + it("isOrgActivated true only when every applicable step is done", () => { + const done: OrgActivationSnapshot = { + ...fresh, + status: "ACTIVE", + kybVerified: true, + billingConfigured: true, + hasContract: true, + activeProgramCount: 1, + memberCount: 5, + activeAssignmentCount: 3, + }; + expect(isOrgActivated(done, ORG)).toBe(true); + expect(isOrgActivated(fresh, ORG)).toBe(false); + }); +}); + +describe("deriveActionCenter", () => { + it("a healthy active org has no action items", () => { + const healthy: OrgActivationSnapshot = { ...fresh, status: "ACTIVE" }; + expect(deriveActionCenter(healthy, ORG)).toHaveLength(0); + }); + + it("surfaces pending verification + only banners whose data exists", () => { + const keys = deriveActionCenter( + { + ...fresh, + status: "PENDING_VERIFICATION", + pastDueInvoiceCount: 2, + creditPoolMaxUtilizationPct: 91, + contractExpiringSoonCount: 1, + pendingOverageCount: 3, + stuckPayoutCount: 4, + walletLowBalancePaise: 50_00, + }, + ORG, + ).map((a) => a.key); + expect(keys).toEqual( + expect.arrayContaining([ + "pending-verification", + "past-due-invoices", + "credit-pool-near-cap", + "contract-expiring", + "pending-overages", + "stuck-payouts", + "wallet-low", + ]), + ); + }); + + it("credit-pool banner only fires at ≥80%", () => { + const under = deriveActionCenter( + { ...fresh, status: "ACTIVE", creditPoolMaxUtilizationPct: 79 }, + ORG, + ); + expect(under.find((a) => a.key === "credit-pool-near-cap")).toBeUndefined(); + }); + + it("past-due invoices is critical severity", () => { + const item = deriveActionCenter( + { ...fresh, status: "ACTIVE", pastDueInvoiceCount: 1 }, + ORG, + ).find((a) => a.key === "past-due-invoices"); + expect(item?.severity).toBe("critical"); + }); +}); diff --git a/__tests__/enterprise/org-error-humanization.test.ts b/__tests__/enterprise/org-error-humanization.test.ts new file mode 100644 index 000000000..fadcbc723 --- /dev/null +++ b/__tests__/enterprise/org-error-humanization.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ + +/** + * Coverage guarantee for `humanizeOrgError` — every typed error code + * emitted from `app/api/organizations/**` must have a corresponding + * friendly sentence here. The dashboard renders these via toasts and + * inline form errors, so an un-mapped code surfaces a raw machine + * string ("ROLE_TRANSITION_BLOCKED") instead of the intended copy. + * + * The unknown-code fall-through is also pinned — free-form server + * messages (Zod validation, ad-hoc 4xx bodies) must continue to reach + * the user verbatim. See UI.M.4 in enterprise-test-findings.txt. + */ + +import { humanizeOrgError, ORG_ERROR_COPY } from "@/lib/labels/org-errors"; + +describe("humanizeOrgError", () => { + const KNOWN_CODES: Array<[code: string, mustContain: RegExp]> = [ + // (code, a substring or regex the friendly copy MUST contain) + ["ORG_NOT_VERIFIED", /awaiting platform review/i], + ["ROLE_TRANSITION_BLOCKED", /Learner and Expert roles/i], + ["USER_NOT_FOUND", /sign up at Familiarise/i], + ["EXPERT_REQUIRES_CANHOST", /host-capable organizations/i], + ["PO_BALANCE_EXCEEDED", /remaining budget/i], + ["PO_BALANCE_INSUFFICIENT", /remaining budget/i], + ["DOMAIN_NOT_OWNED", /Add it under Settings → SSO → Domains/i], + ["DOMAIN_NOT_VERIFIED", /TXT-record/i], + ["SSO_PROVIDER_MISCONFIGURED", /X\.509 PEM/i], + ]; + + test.each(KNOWN_CODES)( + "maps %s to friendly copy that mentions the recovery hint", + (code, mustContain) => { + const out = humanizeOrgError(code); + expect(out).not.toBe(code); + expect(out).toMatch(mustContain); + }, + ); + + it("returns unknown codes verbatim (free-form server messages)", () => { + expect(humanizeOrgError("Something blew up at line 42")).toBe( + "Something blew up at line 42", + ); + // Lookalike strings that aren't in the table must also pass through + // unchanged — never coerced into one of the known sentences. + expect(humanizeOrgError("PO_BALANCE_UNKNOWN")).toBe("PO_BALANCE_UNKNOWN"); + }); + + it("the alias PO_BALANCE_INSUFFICIENT renders the same copy as PO_BALANCE_EXCEEDED", () => { + // The server only emits EXCEEDED today, but the INSUFFICIENT alias is + // pinned so a future route rename doesn't silently change UI copy. + expect(humanizeOrgError("PO_BALANCE_INSUFFICIENT")).toBe( + humanizeOrgError("PO_BALANCE_EXCEEDED"), + ); + }); + + it("snapshot of the full mapping table", () => { + // Why: forces every PR that adds/removes a code to either explain + // itself in a snapshot diff or update this single guard. Pairs with + // the human-readable per-code assertions above. + expect(ORG_ERROR_COPY).toMatchSnapshot(); + }); +}); diff --git a/__tests__/enterprise/org-maintenance-window.test.ts b/__tests__/enterprise/org-maintenance-window.test.ts new file mode 100644 index 000000000..a11b5a6d9 --- /dev/null +++ b/__tests__/enterprise/org-maintenance-window.test.ts @@ -0,0 +1,83 @@ +/** + * @jest-environment node + */ + +/** + * Coverage for the per-org maintenance read helper + * (`getActiveOrgMaintenanceWindow`). + * + * The schema column `MaintenanceWindow.organizationId` shipped in PR #655 + * as Tier 1 multi-tenant scaffolding; the read helper added here is the + * first piece of runtime enforcement. The payout-batch script consults + * this helper to skip a single tenant during a planned OFFLINE window + * without affecting the rest of the batch. + */ + +import { getActiveOrgMaintenanceWindow } from "@/lib/maintenance"; +import prisma from "@/lib/prisma"; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + maintenanceWindow: { + findFirst: jest.fn(), + }, + }, +})); + +const findFirst = prisma.maintenanceWindow.findFirst as jest.MockedFunction< + typeof prisma.maintenanceWindow.findFirst +>; + +describe("getActiveOrgMaintenanceWindow", () => { + beforeEach(() => { + findFirst.mockReset(); + }); + + it("returns the active row when an org-specific OFFLINE window exists", async () => { + const row = { + phase: "OFFLINE" as const, + reason: "Annual maintenance", + estimatedEnd: new Date("2026-06-01T00:00:00.000Z"), + }; + findFirst.mockResolvedValueOnce(row as never); + + const result = await getActiveOrgMaintenanceWindow("org-1"); + expect(result).toEqual(row); + expect(findFirst).toHaveBeenCalledWith({ + where: { + organizationId: "org-1", + phase: { not: "OFF" }, + }, + orderBy: { createdAt: "desc" }, + select: { phase: true, reason: true, estimatedEnd: true }, + }); + }); + + it("returns null when no org-specific window is active", async () => { + findFirst.mockResolvedValueOnce(null); + expect(await getActiveOrgMaintenanceWindow("org-2")).toBeNull(); + }); + + it("scopes by organizationId so platform-wide (NULL) windows are excluded", async () => { + // The query filter is the contract: `organizationId: "org-3"` + // excludes rows where organizationId IS NULL (platform-wide). + findFirst.mockResolvedValueOnce(null); + await getActiveOrgMaintenanceWindow("org-3"); + const call = findFirst.mock.calls[0][0]!; + expect((call.where as { organizationId: string }).organizationId).toBe( + "org-3", + ); + }); + + it("treats DEGRADED as active (caller decides whether to block)", async () => { + const row = { + phase: "DEGRADED" as const, + reason: "Partial outage", + estimatedEnd: null, + }; + findFirst.mockResolvedValueOnce(row as never); + const result = await getActiveOrgMaintenanceWindow("org-4"); + expect(result?.phase).toBe("DEGRADED"); + }); +}); diff --git a/__tests__/enterprise/org-payout-service.test.ts b/__tests__/enterprise/org-payout-service.test.ts new file mode 100644 index 000000000..7ba086c99 --- /dev/null +++ b/__tests__/enterprise/org-payout-service.test.ts @@ -0,0 +1,56 @@ +/** + * @jest-environment node + */ + +/** + * Issue #713-2 / #700 LED-4: org-payout-service rewrite. + * + * The service was a stub before this PR. These tests cover the public + * surface the cron + the route both call: + * - getOrgPayoutEligibility — read-only probe + * - PayoutLockError + PayoutValidationError carry useful httpStatus + * + * The full createOrgPayoutBatch / processOrgPayout flows hit Postgres + * directly (Serializable tx, real conditional UPDATEs) and live in the + * integration smoke. Pure-logic checks here are about the service's + * error envelope + read-side semantics so the route layer can rely on + * stable error codes. + */ + +import { + PayoutLockError, + PayoutValidationError, +} from "@/lib/payments/payouts/org-payout-service"; + +describe("org-payout-service errors", () => { + it("PayoutLockError exposes a stable code", () => { + const err = new PayoutLockError(); + expect(err.code).toBe("PAYOUT_LOCK_CONFLICT"); + expect(err.name).toBe("PayoutLockError"); + }); + + it("PayoutLockError accepts a custom message", () => { + const err = new PayoutLockError("Too busy, try later"); + expect(err.message).toBe("Too busy, try later"); + }); + + it("PayoutValidationError carries httpStatus (default 409)", () => { + const err = new PayoutValidationError("Bad period"); + expect(err.code).toBe("PAYOUT_VALIDATION_FAILED"); + expect(err.httpStatus).toBe(409); + }); + + it("PayoutValidationError httpStatus is overridable", () => { + const err = new PayoutValidationError("Bad input", 400); + expect(err.httpStatus).toBe(400); + }); + + it("Both errors are throwable + identifiable via instanceof", () => { + const a = new PayoutLockError(); + const b = new PayoutValidationError("x"); + expect(a instanceof PayoutLockError).toBe(true); + expect(b instanceof PayoutValidationError).toBe(true); + expect(a instanceof PayoutValidationError).toBe(false); + expect(b instanceof PayoutLockError).toBe(false); + }); +}); diff --git a/__tests__/enterprise/org-status.test.ts b/__tests__/enterprise/org-status.test.ts new file mode 100644 index 000000000..f43fe2411 --- /dev/null +++ b/__tests__/enterprise/org-status.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment node + */ + +/** + * Issue #699 ENT-3: org-status helpers — single source of truth for + * "is this org billable" / "is onboarding blocked" filters. + */ + +import { + ADDRESSABLE_ORG_STATUSES, + BILLABLE_ORG_STATUSES, + OPERATIONAL_ORG_STATUSES, + isBillable, + isOnboardingBlocked, +} from "@/lib/enterprise/org-status"; + +describe("org-status helpers", () => { + it("BILLABLE only includes ACTIVE — pending/suspended/deactivated do NOT bill", () => { + expect(BILLABLE_ORG_STATUSES).toEqual(["ACTIVE"]); + }); + + it("OPERATIONAL allows PENDING_VERIFICATION + ACTIVE — not SUSPENDED/DEACTIVATED", () => { + expect(OPERATIONAL_ORG_STATUSES.sort()).toEqual( + ["ACTIVE", "PENDING_VERIFICATION"].sort(), + ); + }); + + it("ADDRESSABLE includes SUSPENDED so OWNERs can resolve issues", () => { + expect(ADDRESSABLE_ORG_STATUSES).toContain("SUSPENDED"); + expect(ADDRESSABLE_ORG_STATUSES).not.toContain("DEACTIVATED"); + }); + + it("isOnboardingBlocked: SUSPENDED + DEACTIVATED block, others allow", () => { + expect(isOnboardingBlocked("SUSPENDED")).toBe(true); + expect(isOnboardingBlocked("DEACTIVATED")).toBe(true); + expect(isOnboardingBlocked("ACTIVE")).toBe(false); + expect(isOnboardingBlocked("PENDING_VERIFICATION")).toBe(false); + }); + + it("isBillable: only ACTIVE", () => { + expect(isBillable("ACTIVE")).toBe(true); + expect(isBillable("PENDING_VERIFICATION")).toBe(false); + expect(isBillable("SUSPENDED")).toBe(false); + expect(isBillable("DEACTIVATED")).toBe(false); + }); +}); diff --git a/__tests__/enterprise/overage-base-carve.test.ts b/__tests__/enterprise/overage-base-carve.test.ts new file mode 100644 index 000000000..0faf25024 --- /dev/null +++ b/__tests__/enterprise/overage-base-carve.test.ts @@ -0,0 +1,190 @@ +/** + * @jest-environment node + */ + +/** + * #812 §P0 — basePaise carve bookkeeping. FAILing a CHARGE_MEMBER side-charge + * must return the carved basePaise to the org's parent accrual (restore); + * the recovery edges (retry / late capture) must take it back out (recarve). + * An already-invoiced parent must never have its leg silently diverged from + * the issued document — those return "invoiced" for the caller to surface. + */ + +import { + restoreOverageBaseCarve, + recarveOverageBase, +} from "@/lib/payments/billing/overage-base-carve"; +import { transitionOverage } from "@/lib/payments/billing/overage-transitions"; + +interface MockOpts { + event?: object | null; + parent?: object | null; + legUpdateManyCount?: number; + parentUpdateManyCount?: number; +} + +function mockTx(opts: MockOpts = {}) { + const event = + opts.event === undefined + ? { + id: "ov1", + basePaise: 300, + overageBehavior: "CHARGE_MEMBER", + payment: { id: "side1", parentPaymentId: "parent1" }, + } + : opts.event; + const parent = + opts.parent === undefined + ? { id: "parent1", billableToOrgInvoiceId: null } + : opts.parent; + const legUpdate = jest.fn().mockResolvedValue({}); + const legUpdateMany = jest + .fn() + .mockResolvedValue({ count: opts.legUpdateManyCount ?? 1 }); + const paymentUpdate = jest.fn().mockResolvedValue({}); + const paymentUpdateMany = jest + .fn() + .mockResolvedValue({ count: opts.parentUpdateManyCount ?? 1 }); + return { + legUpdate, + legUpdateMany, + paymentUpdate, + paymentUpdateMany, + tx: { + overageEvent: { + findFirst: jest.fn().mockResolvedValue(event), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + payment: { + findUnique: jest.fn().mockResolvedValue(parent), + update: paymentUpdate, + updateMany: paymentUpdateMany, + }, + paymentLeg: { update: legUpdate, updateMany: legUpdateMany }, + } as never, + }; +} + +describe("restoreOverageBaseCarve", () => { + it("increments the parent (guarded on uninvoiced) and the leg by basePaise", async () => { + const m = mockTx(); + await expect( + restoreOverageBaseCarve(m.tx, { overageEventId: "ov1" }), + ).resolves.toBe("restored"); + // TOCTOU guard: the invoiced check rides the parent UPDATE's WHERE. + expect(m.paymentUpdateMany).toHaveBeenCalledWith({ + where: { id: "parent1", billableToOrgInvoiceId: null }, + data: { amount: { increment: 300 } }, + }); + expect(m.legUpdate).toHaveBeenCalledWith({ + where: { + paymentId_source: { paymentId: "parent1", source: "INVOICE_ACCRUAL" }, + }, + data: { amountPaise: { increment: 300 } }, + }); + }); + + it("returns invoiced (no leg writes) when the guarded parent update matches no row", async () => { + const m = mockTx({ + parent: { id: "parent1", billableToOrgInvoiceId: "inv1" }, + parentUpdateManyCount: 0, + }); + await expect( + restoreOverageBaseCarve(m.tx, { overageEventId: "ov1" }), + ).resolves.toBe("invoiced"); + expect(m.legUpdate).not.toHaveBeenCalled(); + }); + + it.each([ + ["zero basePaise", { id: "ov1", basePaise: 0, overageBehavior: "CHARGE_MEMBER", payment: { id: "s", parentPaymentId: "p" } }], + ["CHARGE_ORG event", { id: "ov1", basePaise: 300, overageBehavior: "CHARGE_ORG", payment: { id: "s", parentPaymentId: "p" } }], + ["no parent linkage", { id: "ov1", basePaise: 300, overageBehavior: "CHARGE_MEMBER", payment: { id: "s", parentPaymentId: null } }], + ["missing event", null], + ])("is a no-op for %s", async (_label, event) => { + const m = mockTx({ event }); + await expect( + restoreOverageBaseCarve(m.tx, { overageEventId: "ov1" }), + ).resolves.toBe("none"); + expect(m.legUpdate).not.toHaveBeenCalled(); + }); + + it("resolves the event via the side payment id too", async () => { + const m = mockTx(); + await restoreOverageBaseCarve(m.tx, { sidePaymentId: "side1" }); + expect( + (m.tx as never as { overageEvent: { findFirst: jest.Mock } }).overageEvent + .findFirst, + ).toHaveBeenCalledWith( + expect.objectContaining({ where: { paymentId: "side1" } }), + ); + }); +}); + +describe("recarveOverageBase", () => { + it("decrements the parent (guarded on uninvoiced) then the leg (guarded)", async () => { + const m = mockTx(); + await expect( + recarveOverageBase(m.tx, { overageEventId: "ov1" }), + ).resolves.toBe("recarved"); + expect(m.paymentUpdateMany).toHaveBeenCalledWith({ + where: { id: "parent1", billableToOrgInvoiceId: null }, + data: { amount: { decrement: 300 } }, + }); + expect(m.legUpdateMany).toHaveBeenCalledWith({ + where: { + paymentId: "parent1", + source: "INVOICE_ACCRUAL", + amountPaise: { gte: 300 }, + }, + data: { amountPaise: { decrement: 300 } }, + }); + }); + + it("returns invoiced when the guarded parent update matches no row", async () => { + const m = mockTx({ + parent: { id: "parent1", billableToOrgInvoiceId: "inv1" }, + parentUpdateManyCount: 0, + }); + await expect( + recarveOverageBase(m.tx, { overageEventId: "ov1" }), + ).resolves.toBe("invoiced"); + expect(m.legUpdateMany).not.toHaveBeenCalled(); + }); + + it("throws (rolling the tx back) when the leg lacks the restored base on an uninvoiced parent", async () => { + const m = mockTx({ legUpdateManyCount: 0 }); + await expect( + recarveOverageBase(m.tx, { overageEventId: "ov1" }), + ).rejects.toThrow(/missing the restored basePaise/); + }); +}); + +describe("transitionOverage fromIn narrowing (#812)", () => { + function txWithSpy() { + const updateMany = jest.fn().mockResolvedValue({ count: 1 }); + return { updateMany, tx: { overageEvent: { updateMany } } as never }; + } + + it("narrows the guard to the requested subset", async () => { + const m = txWithSpy(); + await transitionOverage(m.tx, { paymentId: "s1" }, "CHARGED", undefined, { + fromIn: ["FAILED"], + }); + expect(m.updateMany).toHaveBeenCalledWith({ + where: { paymentId: "s1", chargeStatus: { in: ["FAILED"] } }, + data: { chargeStatus: "CHARGED" }, + }); + }); + + it("never widens beyond ALLOWED_FROM — illegal entries are intersected away", async () => { + const m = txWithSpy(); + // REVERSED is not a legal source for CHARGED; only FAILED survives. + await transitionOverage(m.tx, { paymentId: "s1" }, "CHARGED", undefined, { + fromIn: ["FAILED", "REVERSED" as never], + }); + expect(m.updateMany).toHaveBeenCalledWith({ + where: { paymentId: "s1", chargeStatus: { in: ["FAILED"] } }, + data: { chargeStatus: "CHARGED" }, + }); + }); +}); diff --git a/__tests__/enterprise/overage-calculator.test.ts b/__tests__/enterprise/overage-calculator.test.ts new file mode 100644 index 000000000..2fd3f245e --- /dev/null +++ b/__tests__/enterprise/overage-calculator.test.ts @@ -0,0 +1,259 @@ +/** + * @jest-environment node + */ + +/** + * #775 — pure overage calculator: pass-through marginal + surcharge bps, + * CREDIT_POOL money-meter, and the per-cycle circuit breaker. + * + * The booking-path wiring (recordBookingUtilization + checkout) is covered by + * the cap tests; this pins the pure math the whole feature rests on. + */ + +import { computeOverage } from "@/lib/payments/billing/overage"; + +describe("computeOverage — LICENSED_SEAT pass-through", () => { + const base = { + programType: "LICENSED_SEAT" as const, + bookingPricePaise: 100_000, + engagementsConsumed: 1, + overageBehavior: "CHARGE_ORG" as const, + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + }; + + it("within cap → fully covered, no marginal", () => { + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 2, + }); + expect(r.marginalPaise).toBe(0); + expect(r.decision).toBe("PROCEED"); + expect(r.chargeTo).toBeNull(); + }); + + it("over cap → marginal is the real booking price (pass-through, not a flat tier)", () => { + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, // no seats left + }); + expect(r.marginalPaise).toBe(100_000); + expect(r.chargeTo).toBe("ORG"); + }); + + it("priceCapPerEngagementPaise caps the base marginal", () => { + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + priceCapPerEngagementPaise: 40_000, + }); + expect(r.marginalPaise).toBe(40_000); + }); + + it("surcharge bps marks up the marginal AFTER the price cap", () => { + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + priceCapPerEngagementPaise: 40_000, + overageSurchargeBps: 1000, // +10% + }); + // 40_000 capped, then +10% = 44_000 (may exceed the cap by the surcharge). + expect(r.marginalPaise).toBe(44_000); + // #778 elegance — base/surcharge split; marginal == base + surcharge. + expect(r.basePaise).toBe(40_000); + expect(r.surchargePaise).toBe(4_000); + expect(r.basePaise + r.surchargePaise).toBe(r.marginalPaise); + }); + + it("surcharge with no price cap marks up the full pass-through price", () => { + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + overageSurchargeBps: 2500, // +25% + }); + expect(r.marginalPaise).toBe(125_000); + expect(r.basePaise).toBe(100_000); + expect(r.surchargePaise).toBe(25_000); + // covered + base == price (the surcharge rides on top). + expect(r.coveredPaise + r.basePaise).toBe(100_000); + }); + + it("unlimited cap (LICENSE-funded, null) never overages", () => { + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: null, + engagementsUsed: 999, + }); + expect(r.marginalPaise).toBe(0); + expect(r.decision).toBe("PROCEED"); + }); + + it("surcharge FLOORs to the paise — residual to the payer's benefit (#778 §C)", () => { + const r = computeOverage({ + ...base, + bookingPricePaise: 99_999, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + overageSurchargeBps: 1500, // 99_999 × 15% = 14_999.85 → 14_999, never 15_000 + }); + expect(r.basePaise).toBe(99_999); + expect(r.surchargePaise).toBe(14_999); + expect(r.marginalPaise).toBe(114_998); + }); + + it("multi-session split: only the over-cap sessions are marginal (#710)", () => { + // cap 5, used 4 → 1 covered seat; a 3-session CLASS at 100_000 → 2 over-cap + // sessions × floor(100_000/3) = 66_666 marginal; per-session floor leaves + // the 2-paise residual in coveredPaise (payer's benefit, #778 §C). + const r = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 4, + engagementsConsumed: 3, + }); + expect(r.basePaise).toBe(66_666); + expect(r.coveredPaise).toBe(33_334); + expect(r.coveredPaise + r.basePaise).toBe(100_000); + }); + + it("engagementsConsumed floors to 1 and never NaN-poisons the split (#710/#713)", () => { + const zero = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + engagementsConsumed: 0, + }); + expect(zero.marginalPaise).toBe(100_000); // treated as 1 engagement, fully over cap + const nan = computeOverage({ + ...base, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + engagementsConsumed: Number.NaN, + }); + expect(Number.isInteger(nan.marginalPaise)).toBe(true); + expect(nan.marginalPaise).toBe(100_000); + }); +}); + +describe("computeOverage — CREDIT_POOL money-meter", () => { + const base = { + programType: "CREDIT_POOL" as const, + engagementsConsumed: 1, + overageBehavior: "CHARGE_ORG" as const, + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + }; + + it("within budget → fully covered", () => { + const r = computeOverage({ + ...base, + bookingPricePaise: 30_000, + creditBudgetPaise: 100_000, + consumedPaise: 50_000, // 50k remaining ≥ 30k + }); + expect(r.marginalPaise).toBe(0); + }); + + it("partially over budget → marginal is the over-budget remainder", () => { + const r = computeOverage({ + ...base, + bookingPricePaise: 30_000, + creditBudgetPaise: 100_000, + consumedPaise: 90_000, // only 10k remaining → 20k marginal + }); + expect(r.marginalPaise).toBe(20_000); + expect(r.chargeTo).toBe("ORG"); + }); + + it("fully over budget (₹5,000 vs ₹500 session burn different paise)", () => { + const big = computeOverage({ + ...base, + bookingPricePaise: 500_000, + creditBudgetPaise: 100_000, + consumedPaise: 100_000, + }); + const small = computeOverage({ + ...base, + bookingPricePaise: 50_000, + creditBudgetPaise: 100_000, + consumedPaise: 100_000, + }); + // Proof the meter is money, not a flat per-session credit: a ₹5,000 and a + // ₹500 session over the same exhausted budget produce different marginals. + expect(big.marginalPaise).toBe(500_000); + expect(small.marginalPaise).toBe(50_000); + expect(big.marginalPaise).not.toBe(small.marginalPaise); + }); + + it("surcharge applies to the credit-pool marginal", () => { + const r = computeOverage({ + ...base, + bookingPricePaise: 50_000, + creditBudgetPaise: 100_000, + consumedPaise: 100_000, + overageSurchargeBps: 1000, // +10% + }); + expect(r.marginalPaise).toBe(55_000); + expect(r.basePaise).toBe(50_000); + expect(r.surchargePaise).toBe(5_000); + }); +}); + +describe("computeOverage — circuit breaker + behavior routing", () => { + const over = { + programType: "LICENSED_SEAT" as const, + bookingPricePaise: 100_000, + engagementsConsumed: 1, + coveredEngagementsPerCycle: 1, + engagementsUsed: 1, // over cap + }; + + it("BLOCKs when cycle overage-so-far + marginal exceeds the ceiling", () => { + const r = computeOverage({ + ...over, + overageBehavior: "CHARGE_ORG", + maxOveragePerCyclePaise: 120_000, + cycleOverageSoFarPaise: 50_000, // 50k + 100k = 150k > 120k + }); + expect(r.decision).toBe("BLOCK"); + expect(r.chargeTo).toBeNull(); + }); + + it("ceiling counts the surcharged marginal", () => { + const r = computeOverage({ + ...over, + overageBehavior: "CHARGE_ORG", + overageSurchargeBps: 5000, // +50% → marginal 150k + maxOveragePerCyclePaise: 120_000, + cycleOverageSoFarPaise: 0, + }); + expect(r.decision).toBe("BLOCK"); + }); + + it("routes CHARGE_MEMBER → MEMBER", () => { + const r = computeOverage({ + ...over, + overageBehavior: "CHARGE_MEMBER", + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + }); + expect(r.decision).toBe("PROCEED"); + expect(r.chargeTo).toBe("MEMBER"); + }); + + it("BLOCK behavior → BLOCK decision", () => { + const r = computeOverage({ + ...over, + overageBehavior: "BLOCK", + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + }); + expect(r.decision).toBe("BLOCK"); + expect(r.chargeTo).toBeNull(); + }); +}); diff --git a/__tests__/enterprise/overage-member-webhook.test.ts b/__tests__/enterprise/overage-member-webhook.test.ts new file mode 100644 index 000000000..acf81cce1 --- /dev/null +++ b/__tests__/enterprise/overage-member-webhook.test.ts @@ -0,0 +1,154 @@ +/** + * @jest-environment node + */ + +/** + * #775/#782 — CHARGE_MEMBER side-charge capture webhook. + * + * Pins the settlement contract the OVERAGE_SETTLEMENT_MISMATCH reconcile + * invariant asserts: a capture flips the side-Payment → SUCCEEDED, moves the + * event PENDING/FAILED → CHARGED, and ONLY THEN posts the org-relief journal + * (`overage:`: Dr CASH / Cr ORG_PAYABLE == marginalPaise). + * If the event can't legally reach CHARGED (REVERSED mid-flight), no org + * credit is posted and the collected money is escalated for refund. + */ + +jest.mock("../../lib/prisma", () => { + const tx = { + payment: { findUnique: jest.fn(), update: jest.fn() }, + paymentLeg: { upsert: jest.fn() }, + }; + return { + __esModule: true, + default: { + $transaction: (fn: (t: typeof tx) => Promise) => fn(tx), + __tx: tx, + }, + }; +}); +jest.mock("../../lib/payments/ledger/post", () => ({ + postLedgerTxn: jest.fn().mockResolvedValue(undefined), +})); +jest.mock("../../lib/payments/billing/overage-transitions", () => ({ + transitionOverage: jest.fn(), +})); +jest.mock("../../lib/payments/billing/overage-base-carve", () => ({ + restoreOverageBaseCarve: jest.fn().mockResolvedValue("restored"), + recarveOverageBase: jest.fn().mockResolvedValue("recarved"), +})); +jest.mock("../../lib/enterprise/system-events", () => ({ + recordSystemError: jest.fn().mockResolvedValue(undefined), +})); + +import prisma from "../../lib/prisma"; +import { postLedgerTxn } from "../../lib/payments/ledger/post"; +import { transitionOverage } from "../../lib/payments/billing/overage-transitions"; +import { recordSystemError } from "../../lib/enterprise/system-events"; +import { handleOverageMemberSuccess } from "../../lib/payments/webhooks/overage-handlers"; + +const tx = ( + prisma as unknown as { + __tx: { + payment: { findUnique: jest.Mock; update: jest.Mock }; + paymentLeg: { upsert: jest.Mock }; + }; + } +).__tx; +const mockTransition = transitionOverage as jest.Mock; +const mockPost = postLedgerTxn as jest.Mock; +const mockSystemError = recordSystemError as jest.Mock; + +const side = { + id: "side1", + amount: 125_000, + organizationId: "org1", + paymentStatus: "PENDING", + parentPaymentId: "parent1", +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("handleOverageMemberSuccess", () => { + it("capture: SUCCEEDED + CHARGED, then Dr CASH / Cr ORG_PAYABLE == marginal", async () => { + tx.payment.findUnique.mockResolvedValue(side); + mockTransition.mockResolvedValue(1); + + await handleOverageMemberSuccess("order_abc"); + + expect(tx.payment.update).toHaveBeenCalledWith({ + where: { id: "side1" }, + data: { paymentStatus: "SUCCEEDED" }, + }); + // funding-invariant CARD leg, idempotent upsert + expect(tx.paymentLeg.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + source: "CARD", + amountPaise: 125_000, + }), + update: {}, + }), + ); + // #812 — two-step CAS: the still-carved edge is tried first. + expect(mockTransition).toHaveBeenCalledWith( + tx, + { paymentId: "side1" }, + "CHARGED", + { settledAt: expect.any(Date) }, + { fromIn: ["PENDING", "ACCRUED"] }, + ); + // the journal the CHARGE_MEMBER reconcile invariant joins on + expect(mockPost).toHaveBeenCalledWith(tx, { + idempotencyKey: "overage:side1", + kind: "OVERAGE_MEMBER", + paymentId: "side1", + postings: [ + { account: { kind: "CASH" }, direction: "DEBIT", amountPaise: 125_000 }, + { + account: { kind: "ORG_PAYABLE", organizationId: "org1" }, + direction: "CREDIT", + amountPaise: 125_000, + }, + ], + }); + expect(mockSystemError).not.toHaveBeenCalled(); + }); + + it("webhook redelivery (already SUCCEEDED) is a no-op", async () => { + tx.payment.findUnique.mockResolvedValue({ + ...side, + paymentStatus: "SUCCEEDED", + }); + + await handleOverageMemberSuccess("order_abc"); + + expect(tx.payment.update).not.toHaveBeenCalled(); + expect(mockTransition).not.toHaveBeenCalled(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("capture racing a reversal (event REVERSED): no org credit, escalates for refund", async () => { + tx.payment.findUnique.mockResolvedValue(side); + mockTransition.mockResolvedValue(0); // REVERSED not in CHARGED's allowed-from + + await handleOverageMemberSuccess("order_abc"); + + expect(mockPost).not.toHaveBeenCalled(); + expect(mockSystemError).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: "org1", + category: "OVERAGE", + context: expect.objectContaining({ sidePaymentId: "side1" }), + }), + ); + }); + + it("non-overage payment (no parentPaymentId) is ignored", async () => { + tx.payment.findUnique.mockResolvedValue({ ...side, parentPaymentId: null }); + + await handleOverageMemberSuccess("order_abc"); + + expect(tx.payment.update).not.toHaveBeenCalled(); + expect(mockPost).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/overage-preview.test.ts b/__tests__/enterprise/overage-preview.test.ts new file mode 100644 index 000000000..a83f99861 --- /dev/null +++ b/__tests__/enterprise/overage-preview.test.ts @@ -0,0 +1,161 @@ +/** + * @jest-environment node + */ + +/** + * #777 §C — the pre-checkout preview and the at-checkout recorder MUST agree. + * Both now funnel through `computeOverageForBooking`, so this pins: + * (1) the shared mapper == the raw `computeOverage` for the same inputs + * (no drift in the context → input translation), and + * (2) a behavior table (covered / CHARGE_MEMBER / CHARGE_ORG / BLOCK / + * circuit-breaker / CREDIT_POOL money-meter) so a future edit can't + * silently change what a member is warned about vs what they're charged. + */ + +import { + computeOverage, + computeOverageForBooking, + type OverageContext, +} from "@/lib/payments/billing/overage"; + +describe("computeOverageForBooking — parity with computeOverage", () => { + it("LICENSED_SEAT context maps identically to the raw input", () => { + const ctx: OverageContext = { + programType: "LICENSED_SEAT", + overageBehavior: "CHARGE_ORG", + overageSurchargeBps: 1000, + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + priceCapPerEngagementPaise: null, + }; + const viaMapper = computeOverageForBooking(ctx, { + bookingPricePaise: 100_000, + engagementsConsumed: 1, + }); + const viaRaw = computeOverage({ + programType: "LICENSED_SEAT", + bookingPricePaise: 100_000, + engagementsConsumed: 1, + overageBehavior: "CHARGE_ORG", + overageSurchargeBps: 1000, + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + coveredEngagementsPerCycle: 5, + engagementsUsed: 5, + priceCapPerEngagementPaise: null, + }); + expect(viaMapper).toEqual(viaRaw); + }); + + it("CREDIT_POOL context maps identically to the raw input", () => { + const ctx: OverageContext = { + programType: "CREDIT_POOL", + overageBehavior: "CHARGE_MEMBER", + overageSurchargeBps: null, + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + creditBudgetPaise: 50_000, + consumedPaise: 50_000, + }; + const viaMapper = computeOverageForBooking(ctx, { + bookingPricePaise: 30_000, + engagementsConsumed: 1, + }); + const viaRaw = computeOverage({ + programType: "CREDIT_POOL", + bookingPricePaise: 30_000, + engagementsConsumed: 1, + overageBehavior: "CHARGE_MEMBER", + overageSurchargeBps: null, + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + creditBudgetPaise: 50_000, + consumedPaise: 50_000, + }); + expect(viaMapper).toEqual(viaRaw); + }); +}); + +describe("computeOverageForBooking — preview behavior table", () => { + const seat = (over: Partial = {}): OverageContext => ({ + programType: "LICENSED_SEAT", + overageBehavior: "CHARGE_MEMBER", + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + coveredEngagementsPerCycle: 5, + engagementsUsed: 2, + priceCapPerEngagementPaise: null, + ...over, + }); + + it("within cap → not an overage (nothing to warn)", () => { + const r = computeOverageForBooking(seat(), { + bookingPricePaise: 100_000, + engagementsConsumed: 1, + }); + expect(r.marginalPaise).toBe(0); + expect(r.decision).toBe("PROCEED"); + }); + + it("cap exhausted + CHARGE_MEMBER → member owes the marginal", () => { + const r = computeOverageForBooking( + seat({ engagementsUsed: 5, overageBehavior: "CHARGE_MEMBER" }), + { bookingPricePaise: 100_000, engagementsConsumed: 1 }, + ); + expect(r.marginalPaise).toBe(100_000); + expect(r.chargeTo).toBe("MEMBER"); + expect(r.decision).toBe("PROCEED"); + }); + + it("cap exhausted + CHARGE_ORG → org owes the marginal", () => { + const r = computeOverageForBooking( + seat({ engagementsUsed: 5, overageBehavior: "CHARGE_ORG" }), + { bookingPricePaise: 100_000, engagementsConsumed: 1 }, + ); + expect(r.chargeTo).toBe("ORG"); + expect(r.decision).toBe("PROCEED"); + }); + + it("cap exhausted + BLOCK → booking refused", () => { + const r = computeOverageForBooking( + seat({ engagementsUsed: 5, overageBehavior: "BLOCK" }), + { bookingPricePaise: 100_000, engagementsConsumed: 1 }, + ); + expect(r.decision).toBe("BLOCK"); + expect(r.chargeTo).toBeNull(); + }); + + it("circuit breaker ceiling reached → BLOCK regardless of behavior", () => { + const r = computeOverageForBooking( + seat({ + engagementsUsed: 5, + overageBehavior: "CHARGE_ORG", + maxOveragePerCyclePaise: 50_000, + cycleOverageSoFarPaise: 0, + }), + { bookingPricePaise: 100_000, engagementsConsumed: 1 }, + ); + expect(r.decision).toBe("BLOCK"); + expect(r.reason).toContain("circuit breaker"); + }); + + it("CREDIT_POOL money-meter → marginal is the over-budget paise", () => { + const r = computeOverageForBooking( + { + programType: "CREDIT_POOL", + overageBehavior: "CHARGE_MEMBER", + maxOveragePerCyclePaise: null, + cycleOverageSoFarPaise: 0, + creditBudgetPaise: 50_000, + consumedPaise: 40_000, + }, + { bookingPricePaise: 30_000, engagementsConsumed: 1 }, + ); + // 10_000 of the 30_000 booking is covered; 20_000 is overage. + expect(r.coveredPaise).toBe(10_000); + expect(r.marginalPaise).toBe(20_000); + expect(r.chargeTo).toBe("MEMBER"); + }); +}); diff --git a/__tests__/enterprise/overage-settlement-legsum.test.ts b/__tests__/enterprise/overage-settlement-legsum.test.ts new file mode 100644 index 000000000..978f06b86 --- /dev/null +++ b/__tests__/enterprise/overage-settlement-legsum.test.ts @@ -0,0 +1,240 @@ +/** + * @jest-environment node + */ + +/** + * #785 — CHARGE_ORG overage must keep Σ(legs) == Payment.amount. + * + * The over-cap pass-through (basePaise) is already inside the base + * INVOICE_ACCRUAL leg (coveredPaise + basePaise == price) AND the rollup sums + * BOTH leg sources into the invoice — so the overage leg must CARVE basePaise + * out of the base leg, not pile on top (which double-billed the org by basePaise + * and broke the leg-sum invariant). Only the surcharge is genuinely-additional. + */ + +import { recordOverageAtCheckout } from "@/lib/payments/billing/overage-settlement"; +import type { Tx } from "@/lib/prisma"; + +// jest.mock resolves via jest's resolver (no `@/` path mapping) — use relative +// paths that resolve to the same module files the SUT imports as `@/…`. +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + // only the MEMBER notify fire-and-forget touches the outer client; null ctx + // short-circuits it (.then(ctx => if(!ctx) return)). + default: { programAssignment: { findUnique: () => Promise.resolve(null) } }, +})); +jest.mock("../../lib/novu/org-workflows", () => ({ + notifyOrgProgramOverageDue: jest.fn().mockResolvedValue(undefined), +})); + +type Leg = { source: string; amountPaise: number }; + +/** Stateful mock tx that maintains the payment's legs + amount in memory. */ +function makeTx(opts: { + price: number; + cap: number; + used: number; + surchargeBps?: number | null; + priceCap?: number | null; + overageBehavior?: "CHARGE_ORG" | "CHARGE_MEMBER"; +}) { + const legs: Leg[] = [{ source: "INVOICE_ACCRUAL", amountPaise: opts.price }]; + const payment = { amount: opts.price }; + const children: { amount: number }[] = []; + let childSeq = 0; + return { + state: { legs, payment, children }, + tx: { + program: { + findFirst: jest.fn().mockResolvedValue({ + licensedSeatConfig: { + overageBehavior: opts.overageBehavior ?? "CHARGE_ORG", + priceCapPerEngagementPaise: opts.priceCap ?? null, + coveredEngagementsPerCycle: opts.cap, + overageSurchargeBps: opts.surchargeBps ?? null, + maxOveragePerCyclePaise: null, + }, + creditPoolConfig: null, + }), + }, + overageEvent: { + aggregate: jest.fn().mockResolvedValue({ _sum: { marginalPaise: 0 } }), + create: jest.fn().mockResolvedValue({ id: "ev1" }), + }, + bookingUtilization: { + findUnique: jest.fn().mockResolvedValue({ id: "bu1" }), + }, + paymentLeg: { + findUnique: jest.fn(async ({ where }: any) => { + const src = where.paymentId_source.source; + return legs.find((l) => l.source === src) ?? null; + }), + update: jest.fn(async ({ where, data }: any) => { + const src = where.paymentId_source.source; + const leg = legs.find((l) => l.source === src)!; + if (data.amountPaise?.decrement != null) + leg.amountPaise -= data.amountPaise.decrement; + }), + create: jest.fn(async ({ data }: any) => { + legs.push({ source: data.source, amountPaise: data.amountPaise }); + }), + }, + payment: { + create: jest.fn(async ({ data }: any) => { + children.push({ amount: data.amount }); + return { id: `child${++childSeq}` }; + }), + update: jest.fn(async ({ data }: any) => { + if (data.amount?.increment != null) + payment.amount += data.amount.increment; + if (data.amount?.decrement != null) + payment.amount -= data.amount.decrement; + }), + findUnique: jest.fn(async () => ({ amount: payment.amount })), + }, + }, + }; +} + +const callArgs = (price: number) => ({ + programAssignmentId: "asg1", + utilization: { + programType: "LICENSED_SEAT" as const, + engagementsConsumedDelta: 1, + engagementsUsedAfter: 6, + consumedPaiseAfter: 0, + creditBudgetPaise: null, + }, + bookingPricePaise: price, + currency: "INR" as const, + paymentId: "pay1", + userId: "user1", + organizationId: "org1", + paymentGateway: "RAZORPAY" as const, +}); + +const sum = (legs: Leg[]) => legs.reduce((s, l) => s + l.amountPaise, 0); + +describe("recordOverageAtCheckout — CHARGE_ORG leg-sum invariant (#785)", () => { + it("no surcharge: carves basePaise out of the base leg, amount unchanged", async () => { + const { state, tx } = makeTx({ price: 500_000, cap: 5, used: 5 }); + await recordOverageAtCheckout({ tx: tx as any, ...callArgs(500_000) }); + + // base leg carved to 0 (whole over-cap engagement), overage holds the marginal + expect(state.legs).toEqual([ + { source: "INVOICE_ACCRUAL", amountPaise: 0 }, + { source: "OVERAGE_INVOICE_ACCRUAL", amountPaise: 500_000 }, + ]); + expect(state.payment.amount).toBe(500_000); // surcharge=0 → no bump + expect(sum(state.legs)).toBe(state.payment.amount); // Σlegs == amount + // the rollup sums BOTH sources → must equal price, NOT 2×price + expect(sum(state.legs)).toBe(500_000); + }); + + it("with surcharge: carves base, bumps amount by the surcharge only", async () => { + const { state, tx } = makeTx({ + price: 100_000, + cap: 5, + used: 5, + surchargeBps: 2500, // +25% + }); + await recordOverageAtCheckout({ tx: tx as any, ...callArgs(100_000) }); + + // base carved to 0; overage = base+surcharge = 125_000; amount bumped by 25_000 + expect(state.legs).toEqual([ + { source: "INVOICE_ACCRUAL", amountPaise: 0 }, + { source: "OVERAGE_INVOICE_ACCRUAL", amountPaise: 125_000 }, + ]); + expect(state.payment.amount).toBe(125_000); // price + surcharge + expect(sum(state.legs)).toBe(state.payment.amount); + }); + + it("partial over-cap (priceCap < price): base leg keeps the covered remainder", async () => { + // priceCap caps the marginal at 40_000 of a 100_000 booking → covered 60_000. + const { state, tx } = makeTx({ + price: 100_000, + cap: 5, + used: 5, + priceCap: 40_000, + }); + await recordOverageAtCheckout({ tx: tx as any, ...callArgs(100_000) }); + + expect(state.legs).toEqual([ + { source: "INVOICE_ACCRUAL", amountPaise: 60_000 }, // 100k − 40k carved + { source: "OVERAGE_INVOICE_ACCRUAL", amountPaise: 40_000 }, + ]); + expect(state.payment.amount).toBe(100_000); // no surcharge → unchanged + expect(sum(state.legs)).toBe(state.payment.amount); // covered + overage == price + }); +}); + +describe("recordOverageAtCheckout — CHARGE_MEMBER parent carve (#785)", () => { + it("carves basePaise off the org parent; member child pays the marginal (no double-collect)", async () => { + const { state, tx } = makeTx({ + price: 100_000, + cap: 5, + used: 5, + surchargeBps: 2500, // +25% → member owes 125_000 + overageBehavior: "CHARGE_MEMBER", + }); + await recordOverageAtCheckout({ tx: tx as any, ...callArgs(100_000) }); + + // org parent: base leg + amount shed basePaise (100_000) → org pays coveredPaise (0) + expect(state.legs).toEqual([{ source: "INVOICE_ACCRUAL", amountPaise: 0 }]); + expect(state.payment.amount).toBe(0); + expect(sum(state.legs)).toBe(state.payment.amount); // parent stays consistent + // member side-charge holds the full marginal (base + surcharge) + expect(state.children).toEqual([{ amount: 125_000 }]); + // total collected = org(0) + member(125_000) = price(100_000) + surcharge(25_000), + // NOT price + marginal (200_000) — basePaise is no longer double-collected. + const totalCollected = sum(state.legs) + state.children[0].amount; + expect(totalCollected).toBe(125_000); + }); + + it("partial over-cap: org parent keeps the covered remainder, member pays the capped marginal", async () => { + const { state, tx } = makeTx({ + price: 100_000, + cap: 5, + used: 5, + priceCap: 40_000, // marginal capped at 40_000 → covered 60_000 + overageBehavior: "CHARGE_MEMBER", + }); + await recordOverageAtCheckout({ tx: tx as any, ...callArgs(100_000) }); + + expect(state.legs).toEqual([ + { source: "INVOICE_ACCRUAL", amountPaise: 60_000 }, // covered remainder + ]); + expect(state.payment.amount).toBe(60_000); + expect(state.children).toEqual([{ amount: 40_000 }]); // member pays the overage + // org(60_000) + member(40_000) == price(100_000), no double-collect. + expect(sum(state.legs) + state.children[0].amount).toBe(100_000); + }); + + it("OverageEvent + side-Payment mirror the booking currency (no hardcoded INR)", async () => { + const { tx } = makeTx({ + price: 100_000, + cap: 5, + used: 5, + overageBehavior: "CHARGE_MEMBER", + }); + await recordOverageAtCheckout({ + tx: tx as unknown as Tx, + ...callArgs(100_000), + currency: "USD" as const, + }); + + expect(tx.payment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ currency: "USD" }), + }), + ); + expect(tx.overageEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + currency: "USD", + overageBehavior: "CHARGE_MEMBER", + }), + }), + ); + }); +}); diff --git a/__tests__/enterprise/owner-role-escalation-guard.test.ts b/__tests__/enterprise/owner-role-escalation-guard.test.ts new file mode 100644 index 000000000..ccf6a988a --- /dev/null +++ b/__tests__/enterprise/owner-role-escalation-guard.test.ts @@ -0,0 +1,106 @@ +/** + * @jest-environment node + */ + +/** + * #789 — privilege-escalation guard. The members PATCH route already refuses to + * assign the OWNER role unless the actor is an OWNER, but the POST /members and + * POST /invitations routes did not, so a MAINTAINER could mint or invite an + * OWNER and gain the security-sensitive surface by proxy. These tests assert + * the guard on both POST routes: a MAINTAINER actor is rejected with 403 before + * any write, while an OWNER actor passes the guard. + */ + +import { POST as membersPost } from "../../app/api/organizations/[orgId]/members/route"; +import { POST as invitationsPost } from "../../app/api/organizations/[orgId]/invitations/route"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import prisma from "@/lib/prisma"; +import { applyRateLimit } from "@/lib/rate-limit"; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + user: { findUnique: jest.fn() }, + }, +})); + +jest.mock("../../lib/auth-helpers", () => ({ + __esModule: true, + requireOrgAccess: jest.fn(), +})); + +jest.mock("../../lib/rate-limit", () => ({ + __esModule: true, + applyRateLimit: jest.fn().mockResolvedValue(null), + orgInviteLimiter: {}, +})); + +const mockedRequireOrgAccess = requireOrgAccess as jest.Mock; +const mockedUserFindUnique = (prisma as unknown as { + user: { findUnique: jest.Mock }; +}).user.findUnique; +const mockedApplyRateLimit = applyRateLimit as jest.Mock; + +function access(role: string) { + return { + error: null, + session: { user: { id: "u-actor", email: "actor@test.com" } }, + member: { id: "m-actor", role }, + org: { id: "org-1", canHost: true, canSponsor: true, status: "ACTIVE" }, + }; +} + +function req(body: unknown) { + return new Request("http://localhost/api/organizations/org-1/members", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }) as never; +} + +const params = { params: Promise.resolve({ orgId: "org-1" }) }; + +beforeEach(() => { + jest.clearAllMocks(); + mockedApplyRateLimit.mockResolvedValue(null); +}); + +describe("OWNER role-escalation guard", () => { + describe("POST /members", () => { + it("rejects a MAINTAINER assigning role=OWNER with 403 and no write", async () => { + mockedRequireOrgAccess.mockResolvedValue(access("MAINTAINER")); + const res = await membersPost( + req({ email: "victim@test.com", role: "OWNER" }), + params, + ); + expect(res.status).toBe(403); + expect((await res.json()).code).toBe("OWNER_ROLE_REQUIRES_OWNER"); + expect(mockedUserFindUnique).not.toHaveBeenCalled(); + }); + + it("lets an OWNER past the guard (proceeds to the user lookup)", async () => { + mockedRequireOrgAccess.mockResolvedValue(access("OWNER")); + mockedUserFindUnique.mockResolvedValue(null); // → 404 USER_NOT_FOUND + const res = await membersPost( + req({ email: "newowner@test.com", role: "OWNER" }), + params, + ); + // The guard did NOT short-circuit: the handler reached the user lookup + // and returned 404, not the 403 escalation block. + expect(res.status).toBe(404); + expect(mockedUserFindUnique).toHaveBeenCalled(); + }); + }); + + describe("POST /invitations", () => { + it("rejects a MAINTAINER inviting role=OWNER with 403", async () => { + mockedRequireOrgAccess.mockResolvedValue(access("MAINTAINER")); + const res = await invitationsPost( + req({ email: "victim@test.com", role: "OWNER" }), + params, + ); + expect(res.status).toBe(403); + expect((await res.json()).code).toBe("OWNER_ROLE_REQUIRES_OWNER"); + }); + }); +}); diff --git a/__tests__/enterprise/payment-leg-invariant.test.ts b/__tests__/enterprise/payment-leg-invariant.test.ts new file mode 100644 index 000000000..4ba115aeb --- /dev/null +++ b/__tests__/enterprise/payment-leg-invariant.test.ts @@ -0,0 +1,148 @@ +/** + * @jest-environment node + */ + +/** + * PaymentLeg sum invariant tests. + * + * Covers the two helpers added in the close-bundle commit: + * - checkPaymentLegsSumToAmount — log-only soft check for the hot + * checkout path; returns a mismatch payload rather than throwing. + * - assertPaymentLegsSumToAmount — hard-throwing sibling for tests + + * reconciliation jobs. + * + * The invariant: `sum(legs.amountPaise) === Payment.amount`. LICENSE + * legs intentionally carry zero amount (cost absorbed at contract time) + * and are still part of the sum. Referral-credit flows mix a CARD leg + * (post-credit gateway charge) with a REFERRAL_CREDIT leg (the credit + * value) whose sum equals Payment.amount (post-credit). + */ + +import { + checkPaymentLegsSumToAmount, + assertPaymentLegsSumToAmount, +} from "@/lib/payments/payment-legs"; + +describe("checkPaymentLegsSumToAmount", () => { + it("returns null when a single CARD leg matches the amount", () => { + expect( + checkPaymentLegsSumToAmount({ + paymentAmountPaise: 150000, + legs: [{ source: "CARD", amountPaise: 150000 }], + }), + ).toBeNull(); + }); + + it("returns null when a WALLET leg equals the full amount (org-sponsored, no gateway)", () => { + expect( + checkPaymentLegsSumToAmount({ + paymentAmountPaise: 50000, + legs: [{ source: "WALLET", amountPaise: 50000 }], + }), + ).toBeNull(); + }); + + it("returns null for a LICENSE leg at zero amount on a zero-amount Payment", () => { + // LICENSE flows: cost is sunk at contract time, Payment.amount = 0, + // leg.amountPaise = 0. The invariant still holds (0 === 0). + expect( + checkPaymentLegsSumToAmount({ + paymentAmountPaise: 0, + legs: [{ source: "LICENSE", amountPaise: 0 }], + }), + ).toBeNull(); + }); + + it("returns null when CARD + REFERRAL_CREDIT legs sum to Payment.amount", () => { + // Post-credit scenario: gateway charged 100 paise, credits covered + // 50, Payment.amount = 150 (pre-credit total). + expect( + checkPaymentLegsSumToAmount({ + paymentAmountPaise: 150, + legs: [ + { source: "CARD", amountPaise: 100 }, + { source: "REFERRAL_CREDIT", amountPaise: 50 }, + ], + }), + ).toBeNull(); + }); + + it("returns a mismatch payload when legs sum is less than the amount", () => { + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 100, + legs: [{ source: "CARD", amountPaise: 60 }], + }); + expect(mismatch).not.toBeNull(); + expect(mismatch?.paymentAmountPaise).toBe(100); + expect(mismatch?.legSumPaise).toBe(60); + expect(mismatch?.deltaPaise).toBe(-40); // signed: sum - amount + }); + + it("returns a mismatch payload when legs sum exceeds the amount", () => { + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 100, + legs: [ + { source: "CARD", amountPaise: 80 }, + { source: "REFERRAL_CREDIT", amountPaise: 40 }, + ], + }); + expect(mismatch?.deltaPaise).toBe(20); + expect(mismatch?.legs).toHaveLength(2); + }); + + it("treats an empty leg array as sum = 0 (mismatch if amount != 0)", () => { + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 500, + legs: [], + }); + expect(mismatch?.legSumPaise).toBe(0); + expect(mismatch?.deltaPaise).toBe(-500); + }); + + it("allows negative leg amounts (refund adjustments)", () => { + // Refund ledger writes a negative leg without deleting the original + // positive leg; the check must net them signed, not via abs(). + expect( + checkPaymentLegsSumToAmount({ + paymentAmountPaise: 0, + legs: [ + { source: "CARD", amountPaise: 100 }, + { source: "CARD", amountPaise: -100 }, + ], + }), + ).toBeNull(); + }); +}); + +describe("assertPaymentLegsSumToAmount", () => { + it("does not throw on a matching sum", () => { + expect(() => + assertPaymentLegsSumToAmount({ + paymentAmountPaise: 100, + legs: [{ source: "CARD", amountPaise: 100 }], + }), + ).not.toThrow(); + }); + + it("throws with a descriptive message on mismatch", () => { + expect(() => + assertPaymentLegsSumToAmount({ + paymentAmountPaise: 100, + legs: [{ source: "CARD", amountPaise: 50 }], + }), + ).toThrow(/PaymentLeg sum invariant violated/); + }); + + it("error message includes the delta for ops visibility", () => { + try { + assertPaymentLegsSumToAmount({ + paymentAmountPaise: 100, + legs: [{ source: "WALLET", amountPaise: 60 }], + }); + throw new Error("expected assertion to throw"); + } catch (err) { + if (!(err instanceof Error)) throw err; + expect(err.message).toContain("delta -40"); + } + }); +}); diff --git a/__tests__/enterprise/payout-webhook-reconciler.test.ts b/__tests__/enterprise/payout-webhook-reconciler.test.ts new file mode 100644 index 000000000..e7c7d7a64 --- /dev/null +++ b/__tests__/enterprise/payout-webhook-reconciler.test.ts @@ -0,0 +1,285 @@ +/** + * @jest-environment node + */ + +/** + * PR-3 (live payout submission) — RazorpayX webhook reconciler. + * + * Covers the dispatch logic in `handleRazorpayPayoutWebhook` + * (app/api/webhooks/utils.ts): + * + * - payout.processed → looks up OrganizationPayout by gatewayPayoutId, + * persists gatewayUtr, calls markOrgPayoutCompleted. + * - payout.failed → calls markOrgPayoutFailed. + * - payout.reversed → calls markOrgPayoutReversed. + * - Orphan event (no OrganizationPayout AND no consultant Payout) + * → soft-skip (logged, no throw, no side effects). + * + * Webhook-level concerns we keep separate (covered by the route-level + * integration test where the request crosses the HTTP boundary): + * - Signature verification (HMAC compare in route.ts). + * - WebhookEvent dedup (logWebhookEvent in route.ts). + * The duplicate-skip and invalid-signature behaviours are exercised + * end-to-end against the route in `__tests__/payments/razorpay-webhook + * -duplicate-skip.test.ts` etc.; here we focus on the reconciler unit. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + organizationPayout: { + findUnique: jest.fn(), + update: jest.fn().mockResolvedValue({}), + }, + }, +})); + +jest.mock("../../lib/payments/payouts", () => ({ + __esModule: true, + handlePayoutWebhook: jest.fn().mockResolvedValue(undefined), + refundEarnings: jest.fn(), + markOrgPayoutCompleted: jest.fn().mockResolvedValue({ + wasNoOp: false, + status: "COMPLETED", + }), + markOrgPayoutFailed: jest.fn().mockResolvedValue({ + wasNoOp: false, + status: "FAILED", + }), + markOrgPayoutReversed: jest.fn().mockResolvedValue({ + wasNoOp: false, + status: "FAILED", + }), +})); + +// Other utils.ts imports we don't exercise — stub minimally so the +// module load doesn't blow up. +jest.mock("../../lib/payments/core/stripe", () => ({ stripeClient: null })); +jest.mock("../../lib/payments/core/razorpay", () => ({ razorpayClient: null })); +jest.mock("../../lib/novu", () => ({ + notifyRefundProcessed: jest.fn(), + notifyDisputeCreated: jest.fn(), + notifyDisputeResolved: jest.fn(), +})); +jest.mock("../../lib/novu/org-workflows", () => ({ + notifyOrgInvoicePaid: jest.fn(), + notifyOrgWalletTopupConfirmed: jest.fn(), +})); +jest.mock("../../lib/referrals/service", () => ({ + reverseCreditsForPayment: jest.fn(), +})); +jest.mock("../../lib/api/organizations/wallet", () => ({ + confirmTopUp: jest.fn(), + walletCredit: jest.fn(), +})); +jest.mock("../../lib/api/organizations/program-helpers", () => ({ + reverseBookingUtilization: jest.fn(), +})); +jest.mock("../../lib/payments/webhooks/handlers", () => ({ + handlePaymentSuccess: jest.fn(), + handlePaymentFailure: jest.fn(), +})); + +import prisma from "@/lib/prisma"; +import { + handlePayoutWebhook, + markOrgPayoutCompleted, + markOrgPayoutFailed, + markOrgPayoutReversed, +} from "@/lib/payments/payouts"; +import { handleRazorpayPayoutWebhook } from "@/app/api/webhooks/utils"; + +const mockedPrisma = prisma as unknown as { + organizationPayout: { findUnique: jest.Mock; update: jest.Mock }; +}; +const mockedHandlePayoutWebhook = handlePayoutWebhook as jest.Mock; +const mockedMarkCompleted = markOrgPayoutCompleted as jest.Mock; +const mockedMarkFailed = markOrgPayoutFailed as jest.Mock; +const mockedMarkReversed = markOrgPayoutReversed as jest.Mock; + +const ORG_PAYOUT_ID = "op_internal_123"; +const GATEWAY_PAYOUT_ID = "pout_NXXXX"; +const ORG_ID = "org-1"; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("handleRazorpayPayoutWebhook — OrganizationPayout reconciliation", () => { + it("payout.processed → looks up by gatewayPayoutId, persists UTR, calls markOrgPayoutCompleted", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.processed", { + id: GATEWAY_PAYOUT_ID, + status: "processed", + utr: "ABCDEF1234567890", + }); + + // Lookup uses the gateway id, not the internal id. + expect(mockedPrisma.organizationPayout.findUnique).toHaveBeenCalledWith({ + where: { gatewayPayoutId: GATEWAY_PAYOUT_ID }, + select: { id: true, status: true, organizationId: true }, + }); + // UTR persisted before the state flip. + expect(mockedPrisma.organizationPayout.update).toHaveBeenCalledWith({ + where: { id: ORG_PAYOUT_ID }, + data: { gatewayUtr: "ABCDEF1234567890" }, + }); + // markOrgPayoutCompleted called with the INTERNAL id, not the + // gateway id (regression guard — the helper only knows internal ids). + expect(mockedMarkCompleted).toHaveBeenCalledWith(ORG_PAYOUT_ID); + // The B2C consultant path must NOT have been invoked. + expect(mockedHandlePayoutWebhook).not.toHaveBeenCalled(); + }); + + it("payout.processed without UTR → no gatewayUtr update, but still completes", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.processed", { + id: GATEWAY_PAYOUT_ID, + status: "processed", + // No utr field + }); + + expect(mockedPrisma.organizationPayout.update).not.toHaveBeenCalled(); + expect(mockedMarkCompleted).toHaveBeenCalledWith(ORG_PAYOUT_ID); + }); + + it("payout.failed → calls markOrgPayoutFailed with failure reason", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.failed", { + id: GATEWAY_PAYOUT_ID, + status: "failed", + failure_reason: "Insufficient bank balance", + }); + + expect(mockedMarkFailed).toHaveBeenCalledWith( + ORG_PAYOUT_ID, + "Insufficient bank balance", + ); + expect(mockedMarkCompleted).not.toHaveBeenCalled(); + expect(mockedMarkReversed).not.toHaveBeenCalled(); + expect(mockedHandlePayoutWebhook).not.toHaveBeenCalled(); + }); + + it("payout.failed without failure_reason → falls back to generic reason", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.failed", { + id: GATEWAY_PAYOUT_ID, + status: "failed", + }); + + expect(mockedMarkFailed).toHaveBeenCalledWith( + ORG_PAYOUT_ID, + "RazorpayX failure", + ); + }); + + it("payout.reversed → calls markOrgPayoutReversed (distinct from failed)", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.reversed", { + id: GATEWAY_PAYOUT_ID, + status: "reversed", + failure_reason: "Account closed", + }); + + expect(mockedMarkReversed).toHaveBeenCalledWith( + ORG_PAYOUT_ID, + "Account closed", + ); + expect(mockedMarkFailed).not.toHaveBeenCalled(); + expect(mockedMarkCompleted).not.toHaveBeenCalled(); + }); + + it("orphan event (gatewayPayoutId not on any OrganizationPayout) → falls through to consultant path; consultant lookup also returns nothing → silent no-op (returns 200 to prevent retry storm)", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue(null); + + // The consultant fallback (handlePayoutWebhook) is itself + // soft-skipping when no Payout row matches — we mock it to + // resolve undefined so the test explicitly captures the + // "no throw" contract. + mockedHandlePayoutWebhook.mockResolvedValue(undefined); + + await expect( + handleRazorpayPayoutWebhook("payout.processed", { + id: "pout_orphan", + status: "processed", + utr: "X", + }), + ).resolves.toBeUndefined(); + + // Org helpers must NOT have been called. + expect(mockedMarkCompleted).not.toHaveBeenCalled(); + expect(mockedMarkFailed).not.toHaveBeenCalled(); + expect(mockedMarkReversed).not.toHaveBeenCalled(); + // Consultant fallback was attempted (with mapped status). + expect(mockedHandlePayoutWebhook).toHaveBeenCalledWith( + "RAZORPAY", + "pout_orphan", + "COMPLETED", + undefined, + ); + }); + + it("payout.queued (informational in-flight event for an org payout) → no state-change helpers invoked", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.queued", { + id: GATEWAY_PAYOUT_ID, + status: "queued", + }); + + expect(mockedMarkCompleted).not.toHaveBeenCalled(); + expect(mockedMarkFailed).not.toHaveBeenCalled(); + expect(mockedMarkReversed).not.toHaveBeenCalled(); + expect(mockedHandlePayoutWebhook).not.toHaveBeenCalled(); + // Also no UTR persistence on a non-terminal event. + expect(mockedPrisma.organizationPayout.update).not.toHaveBeenCalled(); + }); + + it("payout.rejected → routed to markOrgPayoutFailed (gateway never accepted)", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue({ + id: ORG_PAYOUT_ID, + status: "PROCESSING", + organizationId: ORG_ID, + }); + + await handleRazorpayPayoutWebhook("payout.rejected", { + id: GATEWAY_PAYOUT_ID, + status: "rejected", + failure_reason: "Beneficiary blocked", + }); + + expect(mockedMarkFailed).toHaveBeenCalledWith( + ORG_PAYOUT_ID, + "Beneficiary blocked", + ); + }); +}); diff --git a/__tests__/enterprise/po-balance-enforcement.test.ts b/__tests__/enterprise/po-balance-enforcement.test.ts new file mode 100644 index 000000000..04c0f3b33 --- /dev/null +++ b/__tests__/enterprise/po-balance-enforcement.test.ts @@ -0,0 +1,400 @@ +/** + * @jest-environment node + */ + +/** + * Regression coverage for the PurchaseOrder balance invariant on the + * `POST /api/organizations/[orgId]/billing-account/invoices` route and + * its `PATCH /[invoiceId]` restoration counterpart. + * + * The invariant + * ------------- + * When `body.purchaseOrderId` is set, the invoice MUST atomically + * decrement `PurchaseOrder.remainingAmountPaise` and refuse with + * `409 PO_BALANCE_EXCEEDED` if either the PO is non-ACTIVE or has + * insufficient remaining budget. The route uses a CAS-style + * `updateMany` predicate (`status: "ACTIVE"` + `remainingAmountPaise: + * { gte: amount }`) so two concurrent POSTs against the same PO can + * never both succeed beyond the PO's balance. On VOID / CANCELLED, + * the PATCH restores the consumed budget by incrementing + * `remainingAmountPaise` back. See enterprise-test-findings.txt PO.2 / PO.4. + * + * These cases pin the contract — the code itself lives at + * `app/api/organizations/[orgId]/billing-account/invoices/route.ts:198-225` + * (POST) and `[invoiceId]/route.ts:146-166` (PATCH restoration). + */ + +import { NextRequest } from "next/server"; + +// ---- Mocks ------------------------------------------------------------ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + organization: { findUnique: jest.fn() }, + purchaseOrder: { + findUnique: jest.fn(), + updateMany: jest.fn(), + update: jest.fn(), + }, + contract: { findUnique: jest.fn() }, + organizationInvoice: { + create: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + // Status moves now go through the CAS helper (#476 audit): guarded + // updateMany + in-tx findUniqueOrThrow re-read for the response body. + updateMany: jest.fn(), + findUniqueOrThrow: jest.fn(), + }, + settlementLedgerEntry: { create: jest.fn() }, + orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, + // Why: the invoice POST route now emits an `invoice.issued` webhook + // via dispatchWebhookEvent, which queries WebhookEndpoint then + // optionally inserts OutboundWebhookDelivery rows. Stub both so the + // dispatch helper short-circuits cleanly when no endpoints exist + // (the canonical case for this test). Empty findMany → no createMany. + webhookEndpoint: { findMany: jest.fn().mockResolvedValue([]) }, + outboundWebhookDelivery: { createMany: jest.fn() }, + $transaction: jest.fn(), + }, +})); + +jest.mock("../../lib/auth-helpers", () => ({ + requireOrgAccess: jest.fn(), + orgRoleSatisfies: () => true, +})); + +jest.mock("../../lib/compliance/gst", () => ({ + // Why: we don't want the GST math to be the test surface — invoke + // with the totalPaise the test asks for so the assertions read like + // the route handler sees them. Real GST resolution is covered by + // its own suite. + // Why: the route passes `{ subtotalPaise, supplierStateCode, + // buyerStateCode, buyerCountry, hsnCode }` — NOT the raw items + // array. Echo the subtotal back as the total so the CAS predicate + // assertions read with the same number the caller supplied. + deriveGstBreakdown: jest.fn( + ({ subtotalPaise, hsnCode }: { subtotalPaise: number; hsnCode: string }) => ({ + subtotalPaise, + cgstPaise: 0, + sgstPaise: 0, + igstPaise: 0, + totalPaise: subtotalPaise, + hsnCode, + placeOfSupply: "06", + reverseCharge: false, + }), + ), +})); + +jest.mock("../../lib/payments/billing/invoice-numbering", () => ({ + generateOrgInvoiceNumber: jest.fn(async () => ({ + invoiceNumber: "ACME-2026-00001", + fiscalYear: 2026, + })), +})); + +jest.mock("../../lib/novu/org-workflows", () => ({ + notifyOrgInvoiceIssued: jest.fn(async () => {}), +})); + +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { POST as createInvoice } from "@/app/api/organizations/[orgId]/billing-account/invoices/route"; + +// ---- Fixtures --------------------------------------------------------- + +const mockedPrisma = prisma as unknown as { + organization: { findUnique: jest.Mock }; + purchaseOrder: { + findUnique: jest.Mock; + updateMany: jest.Mock; + update: jest.Mock; + }; + contract: { findUnique: jest.Mock }; + organizationInvoice: { + create: jest.Mock; + findUnique: jest.Mock; + findFirst: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + findUniqueOrThrow: jest.Mock; + }; + settlementLedgerEntry: { create: jest.Mock }; + orgAuditLog: { create: jest.Mock }; + $transaction: jest.Mock; +}; +const mockedRequireOrgAccess = requireOrgAccess as jest.Mock; + +function ownerAccess() { + return { + error: null, + session: { user: { id: "u-owner", email: "owner@test.com" } }, + member: { id: "m-owner", role: "OWNER" }, + org: { id: "org-1", name: "Acme" }, + }; +} + +function makeRequest(body: unknown) { + return new NextRequest( + "http://localhost/api/organizations/org-1/billing-account/invoices", + { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }, + ); +} + +function wireTxShim() { + // Forward `tx` proxy to the same module-level Prisma mocks so the + // route's tx.purchaseOrder.updateMany / tx.organizationInvoice.create + // (POST) and tx.organizationInvoice.findFirst / tx.organizationInvoice.update + // (PATCH) all land on jest.fn()s the tests can assert against. + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => { + const tx = { + purchaseOrder: mockedPrisma.purchaseOrder, + organizationInvoice: mockedPrisma.organizationInvoice, + settlementLedgerEntry: mockedPrisma.settlementLedgerEntry, + orgAuditLog: mockedPrisma.orgAuditLog, + // Forwarded so `dispatchWebhookEvent` running inside the route's + // transaction lands on the same module-level stubs. + webhookEndpoint: ( + mockedPrisma as unknown as { + webhookEndpoint: { findMany: jest.Mock }; + } + ).webhookEndpoint, + outboundWebhookDelivery: ( + mockedPrisma as unknown as { + outboundWebhookDelivery: { createMany: jest.Mock }; + } + ).outboundWebhookDelivery, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(tx); + }); +} + +function setupOrg() { + mockedPrisma.organization.findUnique.mockResolvedValue({ + id: "org-1", + name: "Acme", + slug: "acme", + gstStateCode: "06", + gstin: "06ABCDE1234F1Z5", + hsnDefault: "9982", + dataResidencyRegion: "IN", + billingAccountId: "ba-1", + invoiceNumberPrefix: "ACME", + }); +} + +/** + * The POST route runs a pre-flight `purchaseOrder.findUnique` BEFORE + * starting the $transaction (to surface "PO belongs to another org" or + * "PO is not ACTIVE" as a friendly 400/409 before any locks). Default + * to a PO that passes both gates so each test only has to override the + * CAS-step outcome via `updateMany.mockResolvedValue`. + */ +function setupActivePo(orgId = "org-1") { + mockedPrisma.purchaseOrder.findUnique.mockResolvedValue({ + organizationId: orgId, + status: "ACTIVE", + }); +} + +// ---- Tests ------------------------------------------------------------ + +describe("POST /api/organizations/[orgId]/billing-account/invoices — PO balance enforcement", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedRequireOrgAccess.mockResolvedValue(ownerAccess()); + setupOrg(); + setupActivePo(); + wireTxShim(); + mockedPrisma.organizationInvoice.create.mockResolvedValue({ + id: "inv-1", + invoiceNumber: "ACME-2026-00001", + totalPaise: 0, + }); + }); + + const paramsP = { params: Promise.resolve({ orgId: "org-1" }) }; + + it("decrements PO balance and writes invoice when sufficient budget", async () => { + // CAS succeeds → claim.count === 1 → invoice creation proceeds. + mockedPrisma.purchaseOrder.updateMany.mockResolvedValue({ count: 1 }); + + const res = await createInvoice( + makeRequest({ + purchaseOrderId: "po-1", + items: [{ description: "seats", quantity: 1, unitPrice: 5000 }], + dueDate: "2026-06-30", + }), + paramsP, + ); + + expect(res.status).toBe(201); + expect(mockedPrisma.purchaseOrder.updateMany).toHaveBeenCalledWith({ + where: { + id: "po-1", + organizationId: "org-1", + status: "ACTIVE", + remainingAmountPaise: { gte: 5000 }, + }, + data: { remainingAmountPaise: { decrement: 5000 } }, + }); + expect(mockedPrisma.organizationInvoice.create).toHaveBeenCalled(); + }); + + it("accepts an invoice that exactly drains the PO balance", async () => { + // The route doesn't read the remaining balance — it lets the CAS + // predicate be the gate. The simulated CAS hit (count = 1) proves + // the route doesn't reject on equality. + mockedPrisma.purchaseOrder.updateMany.mockResolvedValue({ count: 1 }); + + const res = await createInvoice( + makeRequest({ + purchaseOrderId: "po-1", + items: [{ description: "wrap-up", quantity: 1, unitPrice: 5000 }], + dueDate: "2026-06-30", + }), + paramsP, + ); + expect(res.status).toBe(201); + }); + + it("rejects with 409 PO_BALANCE_EXCEEDED when CAS does not match", async () => { + // Either remainingAmountPaise < amount OR status != ACTIVE. + // updateMany matches no rows → count: 0 → route throws typed error. + mockedPrisma.purchaseOrder.updateMany.mockResolvedValue({ count: 0 }); + + const res = await createInvoice( + makeRequest({ + purchaseOrderId: "po-1", + items: [{ description: "overshoot", quantity: 1, unitPrice: 5001 }], + dueDate: "2026-06-30", + }), + paramsP, + ); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body).toEqual({ + error: "PurchaseOrder balance insufficient or no longer ACTIVE", + code: "PO_BALANCE_EXCEEDED", + }); + // Critical: the invoice row must NOT have been created. + expect(mockedPrisma.organizationInvoice.create).not.toHaveBeenCalled(); + }); + + it("skips PO logic entirely when purchaseOrderId is absent", async () => { + // A plain invoice (no PO) must not even touch purchaseOrder.updateMany. + const res = await createInvoice( + makeRequest({ + items: [{ description: "ad-hoc", quantity: 1, unitPrice: 2500 }], + dueDate: "2026-06-30", + }), + paramsP, + ); + expect(res.status).toBe(201); + expect(mockedPrisma.purchaseOrder.updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/organizations/[orgId]/billing-account/invoices/[invoiceId] — PO balance restoration", () => { + // The PATCH handler is its own route module; importing it lazily inside + // the test keeps the POST suite above from accidentally importing the + // detail-route's exports during module init. + let patchHandler: typeof import("@/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/route").PATCH; + + beforeAll(async () => { + const mod = await import( + "@/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/route" + ); + patchHandler = mod.PATCH; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockedRequireOrgAccess.mockResolvedValue(ownerAccess()); + wireTxShim(); + }); + + function patchParams(invoiceId = "inv-1") { + return { params: Promise.resolve({ orgId: "org-1", invoiceId }) }; + } + + it("restores PO balance when an ISSUED invoice transitions to VOID", async () => { + mockedPrisma.organizationInvoice.findFirst.mockResolvedValue({ + id: "inv-1", + organizationId: "org-1", + purchaseOrderId: "po-1", + status: "ISSUED", + totalPaise: 5000, + invoiceNumber: "ACME-2026-00001", + pdfStoragePath: null, + }); + mockedPrisma.organizationInvoice.updateMany.mockResolvedValue({ + count: 1, + }); + mockedPrisma.organizationInvoice.findUniqueOrThrow.mockResolvedValue({ + id: "inv-1", + status: "VOID", + }); + mockedPrisma.purchaseOrder.update.mockResolvedValue({ id: "po-1" }); + + const req = new NextRequest( + "http://localhost/api/organizations/org-1/billing-account/invoices/inv-1", + { + method: "PATCH", + body: JSON.stringify({ status: "VOID" }), + headers: { "Content-Type": "application/json" }, + }, + ); + const res = await patchHandler(req, patchParams()); + + expect(res.status).toBe(200); + expect(mockedPrisma.purchaseOrder.update).toHaveBeenCalledWith({ + where: { id: "po-1" }, + data: { remainingAmountPaise: { increment: 5000 } }, + }); + }); + + it("does NOT restore PO balance when invoice has no purchase order", async () => { + // Use ISSUED → VOID since that's the only allowed exit from ISSUED. + // (DRAFT → CANCELLED would also work; the salient property is that no + // PO is attached, so the route's restoration branch must be skipped.) + mockedPrisma.organizationInvoice.findFirst.mockResolvedValue({ + id: "inv-2", + organizationId: "org-1", + purchaseOrderId: null, + status: "ISSUED", + totalPaise: 3000, + invoiceNumber: "ACME-2026-00002", + pdfStoragePath: null, + }); + mockedPrisma.organizationInvoice.updateMany.mockResolvedValue({ + count: 1, + }); + mockedPrisma.organizationInvoice.findUniqueOrThrow.mockResolvedValue({ + id: "inv-2", + status: "VOID", + }); + + const req = new NextRequest( + "http://localhost/api/organizations/org-1/billing-account/invoices/inv-2", + { + method: "PATCH", + body: JSON.stringify({ status: "VOID" }), + headers: { "Content-Type": "application/json" }, + }, + ); + const res = await patchHandler(req, patchParams("inv-2")); + + expect(res.status).toBe(200); + expect(mockedPrisma.purchaseOrder.update).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/process-payouts-false-failed.test.ts b/__tests__/enterprise/process-payouts-false-failed.test.ts new file mode 100644 index 000000000..c8fe67b36 --- /dev/null +++ b/__tests__/enterprise/process-payouts-false-failed.test.ts @@ -0,0 +1,109 @@ +/** + * @jest-environment node + */ + +/** + * #785 (task #24) — false-FAILED payout guard. If the gateway ALREADY accepted a + * payout (providerPayoutId set) but a later DB write throws, marking the payout + * FAILED + unlinking its earnings would re-batch them into a DOUBLE disbursement. + * The catch must instead quarantine the row PROCESSING with earnings LINKED. + */ +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + consultantPayout: { + findMany: jest.fn(), + updateMany: jest.fn(), + update: jest.fn(), + }, + consultantEarnings: { updateMany: jest.fn() }, + }, +})); +jest.mock("../../lib/payments/payouts/org-payout-service", () => ({ + processOrgPayout: jest.fn(), +})); + +import prisma from "../../lib/prisma"; +import { processApprovedPayouts } from "../../scripts/payouts/process-payouts"; + +const cp = ( + prisma as unknown as { + consultantPayout: { findMany: jest.Mock; updateMany: jest.Mock; update: jest.Mock }; + consultantEarnings: { updateMany: jest.Mock }; + } +).consultantPayout; +const ce = ( + prisma as unknown as { consultantEarnings: { updateMany: jest.Mock } } +).consultantEarnings; + +const APPROVED = { + id: "po_1", + amount: 500000, + currency: "INR", + provider: "RAZORPAY", + retryCount: 0, + consultantProfile: { + payoutAccounts: [{ razorpayFundAccId: "fa_x" }], + user: { name: "Priya", email: "p@x.com" }, + }, +}; + +const unlinkedEarnings = () => + ce.updateMany.mock.calls.some( + ([arg]: [{ data?: { payoutId?: unknown } }]) => arg?.data?.payoutId === null, + ); +const markedFailed = () => + cp.update.mock.calls.some( + ([arg]: [{ data?: { status?: unknown } }]) => + arg?.data?.status === "FAILED", + ); + +beforeEach(() => { + jest.clearAllMocks(); + process.env.RAZORPAY_KEY_ID = "k"; + process.env.RAZORPAY_KEY_SECRET = "s"; + process.env.RAZORPAYX_ACCOUNT_NUMBER = "acc"; + (global as unknown as { fetch: unknown }).fetch = jest + .fn() + .mockResolvedValue({ ok: true, json: async () => ({ id: "pout_x" }) }); + cp.updateMany.mockResolvedValue({ count: 1 }); // claim APPROVED→PROCESSING + ce.updateMany.mockResolvedValue({ count: 0 }); +}); + +describe("processApprovedPayouts — #785 false-FAILED guard", () => { + it("gateway accepted + post-submit DB write fails → does NOT FAIL or unlink earnings (no double-pay)", async () => { + cp.findMany.mockResolvedValue([APPROVED]); + // the persist-after-gateway throws; the catch's re-persist succeeds. + cp.update + .mockRejectedValueOnce(new Error("DB write failed")) + .mockResolvedValue({}); + + await processApprovedPayouts(); + + expect((global as unknown as { fetch: jest.Mock }).fetch).toHaveBeenCalled(); + // the double-pay vector — earnings must STAY linked. + expect(unlinkedEarnings()).toBe(false); + expect(markedFailed()).toBe(false); + // quarantined PROCESSING with the gateway id so handle-stuck-payouts reconciles. + const quarantined = cp.update.mock.calls.some( + ([arg]: [{ data?: { providerPayoutId?: unknown; status?: unknown } }]) => + arg?.data?.providerPayoutId === "pout_x" && + arg?.data?.status === "PROCESSING", + ); + expect(quarantined).toBe(true); + }); + + it("genuine pre-gateway failure (gateway rejects) → FAILs + unlinks earnings", async () => { + cp.findMany.mockResolvedValue([APPROVED]); + cp.update.mockResolvedValue({}); + (global as unknown as { fetch: jest.Mock }).fetch.mockResolvedValue({ + ok: false, + json: async () => ({ error: "bad fund account" }), + }); + + await processApprovedPayouts(); + + expect(markedFailed()).toBe(true); + expect(unlinkedEarnings()).toBe(true); + }); +}); diff --git a/__tests__/enterprise/rate-card-ownerorg.test.ts b/__tests__/enterprise/rate-card-ownerorg.test.ts new file mode 100644 index 000000000..3d5aeadf1 --- /dev/null +++ b/__tests__/enterprise/rate-card-ownerorg.test.ts @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ + +/** + * Issue #728 regression guard: bumpRateCard must persist `ownerOrgId` + * verbatim from the caller's `scope.ownerOrgId`. The wizard's + * ReviewStep POSTs to `/api/organizations/[orgId]/rate-cards`, the + * route handler reads the orgId from the URL, and forwards it as + * `scope: { ownerOrgId: orgId }`. The originally-reported bug (CUID- + * formatted user.id sneaking into ownerOrgId) is fixed; this test pins + * the contract so a future refactor of the callers cannot regress it. + * + * Pure-mock unit test in line with the rest of __tests__/enterprise. + * The DB-level FK added in 20260427000000_ratecard_ownerorg_fk catches + * a mis-bind at write time; this test catches it at the call-site + * level. + */ + +import { bumpRateCard } from "@/lib/api/organizations/rate-card"; + +type CapturedCreate = { data: Record }; + +function makeTx() { + const captured: CapturedCreate[] = []; + const tx = { + rateCard: { + findFirst: jest.fn().mockResolvedValue(null), + update: jest.fn(), + create: jest.fn(async ({ data }: CapturedCreate) => { + captured.push({ data }); + return { id: "rc-new", ...data }; + }), + }, + }; + return { tx, captured }; +} + +describe("bumpRateCard — ownerOrgId binding (issue #728)", () => { + it("persists ownerOrgId verbatim from scope", async () => { + const { tx, captured } = makeTx(); + const ORG_ID = "org-cuid-abc-123"; + + await bumpRateCard(tx as never, { + scope: { ownerOrgId: ORG_ID }, + next: { platformBps: 1000, orgBps: 1000, consultantBps: 8000 }, + }); + + expect(captured).toHaveLength(1); + expect(captured[0].data.ownerOrgId).toBe(ORG_ID); + expect(captured[0].data.ownerContractId).toBeNull(); + expect(captured[0].data.platformBps).toBe(1000); + expect(captured[0].data.orgBps).toBe(1000); + expect(captured[0].data.consultantBps).toBe(8000); + }); + + it("persists ownerContractId without leaking ownerOrgId", async () => { + const { tx, captured } = makeTx(); + const CONTRACT_ID = "contract-uuid-xyz"; + + await bumpRateCard(tx as never, { + scope: { ownerContractId: CONTRACT_ID }, + next: { platformBps: 1500, orgBps: 500, consultantBps: 8000 }, + }); + + expect(captured).toHaveLength(1); + expect(captured[0].data.ownerOrgId).toBeNull(); + expect(captured[0].data.ownerContractId).toBe(CONTRACT_ID); + }); + + it("falls back to null when neither owner is provided", async () => { + // Defensive case — the route handler always supplies one or the + // other, but the helper should not invent a value. + const { tx, captured } = makeTx(); + + await bumpRateCard(tx as never, { + scope: {}, + next: { platformBps: 1000, orgBps: 1000, consultantBps: 8000 }, + }); + + expect(captured[0].data.ownerOrgId).toBeNull(); + expect(captured[0].data.ownerContractId).toBeNull(); + }); +}); diff --git a/__tests__/enterprise/reachable-paths.test.ts b/__tests__/enterprise/reachable-paths.test.ts new file mode 100644 index 000000000..5741da067 --- /dev/null +++ b/__tests__/enterprise/reachable-paths.test.ts @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ + +/** + * #768 lockdown #17 — pin the 7 reachable funding-program paths. + * + * Any drift to the (capability x fundingSource x programType) matrix + * (e.g., re-introducing Programs v2 or adding a new fundingSource) must + * update both the constant AND this test. + */ + +import { + REACHABLE_ORG_FUNDING_PATHS, + isReachableOrgFundingPath, + capabilityOf, +} from "@/lib/enterprise/reachable-paths"; + +describe("REACHABLE_ORG_FUNDING_PATHS — v0 lockdown matrix", () => { + it("contains exactly 7 reachable shapes", () => { + expect(REACHABLE_ORG_FUNDING_PATHS.length).toBe(7); + }); + + it("rejects Programs v2 fundingSource values", () => { + // Reachable paths must not reference PROJECT/RETAINER/AOR/EOR. + for (const path of REACHABLE_ORG_FUNDING_PATHS) { + expect(["PERSONAL", "WALLET", "INVOICE", "LICENSE", null, "any"]).toContain( + path.fundingSource as unknown, + ); + expect(["LICENSED_SEAT", "CREDIT_POOL", null, "any"]).toContain( + path.programType as unknown, + ); + } + }); + + describe("isReachableOrgFundingPath", () => { + it("accepts SPONSOR + WALLET + CREDIT_POOL", () => { + expect( + isReachableOrgFundingPath("SPONSOR", "WALLET", "CREDIT_POOL"), + ).toBe(true); + }); + + it("accepts SPONSOR + LICENSE + LICENSED_SEAT", () => { + expect( + isReachableOrgFundingPath("SPONSOR", "LICENSE", "LICENSED_SEAT"), + ).toBe(true); + }); + + it("rejects SPONSOR + LICENSE + CREDIT_POOL (bogus combo)", () => { + // A flat-fee LICENSE pays for unmetered usage; a per-cycle credit + // pool on top is internal-accounting noise. See LicensedSeatConfig + // docstring + the route's BOGUS_LICENSE_CREDIT_POOL gate. + expect( + isReachableOrgFundingPath("SPONSOR", "LICENSE", "CREDIT_POOL"), + ).toBe(false); + }); + + it("accepts HOST with null funding (consultant-earnings flow)", () => { + expect(isReachableOrgFundingPath("HOST", null, null)).toBe(true); + }); + + it("accepts HYBRID with any reachable pair", () => { + expect( + isReachableOrgFundingPath("HYBRID", "WALLET", "CREDIT_POOL"), + ).toBe(true); + expect( + isReachableOrgFundingPath("HYBRID", "LICENSE", "LICENSED_SEAT"), + ).toBe(true); + }); + }); + + describe("capabilityOf", () => { + it.each([ + [true, false, "SPONSOR"], + [false, true, "HOST"], + [true, true, "HYBRID"], + [false, false, null], + ])("(%s, %s) → %s", (canSponsor, canHost, expected) => { + expect(capabilityOf(canSponsor as boolean, canHost as boolean)).toBe( + expected, + ); + }); + }); +}); diff --git a/__tests__/enterprise/refund-credit-note.test.ts b/__tests__/enterprise/refund-credit-note.test.ts new file mode 100644 index 000000000..637c9b6ce --- /dev/null +++ b/__tests__/enterprise/refund-credit-note.test.ts @@ -0,0 +1,175 @@ +/** + * @jest-environment node + */ + +/** + * Shared refund credit-note minting (#776 / #778 §D). `mintRefundCreditNote` is + * called from BOTH applyRefundCascade (app/cron) and the gateway-refund webhook, + * so it must be idempotent on refundId (one CN per refund, no duplicate, no + * burned sequence number) and a no-op for non-invoiced payments. Mocked tx. + */ + +import { mintRefundCreditNote } from "@/lib/payments/operations/refund"; + +function mockTx(opts: { + payment: { + id: string; + amount: number; + organizationId: string | null; + billableToOrgInvoiceId: string | null; + legs: Array<{ source: string; amountPaise: number }>; + } | null; + existingCreditNote?: { id: string } | null; + invoice?: Record | null; +}) { + const creditNoteCreate = jest + .fn() + .mockImplementation(async () => ({ id: "cn-new" })); + return { + _creditNoteCreate: creditNoteCreate, + payment: { findUnique: jest.fn().mockResolvedValue(opts.payment) }, + creditNote: { + findUnique: jest.fn().mockResolvedValue(opts.existingCreditNote ?? null), + create: creditNoteCreate, + }, + organizationInvoice: { + findUnique: jest.fn().mockResolvedValue( + opts.invoice ?? { + id: "inv1", + // #776 — credit notes only mint against an issued invoice. + status: "ISSUED", + issuedAt: new Date("2026-05-01T00:00:00.000Z"), + // #812 — subtotalPaise drives the gross-up (tax over SUBTOTAL, not + // total); omitting it silently zeroed taxFraction and the test + // affirmed the old under-credit bug. + subtotalPaise: 1000, + totalPaise: 1180, + igstPaise: 0, + cgstPaise: 90, + sgstPaise: 90, + }, + ), + }, + organization: { + findUnique: jest.fn().mockResolvedValue({ + id: "org1", + slug: "acme", + invoiceNumberPrefix: "ACME", + }), + }, + orgCreditNoteCounter: { + upsert: jest.fn().mockResolvedValue({ nextSeq: 2 }), + }, + }; +} + +const INVOICED_PAYMENT = { + id: "p1", + amount: 1000, + organizationId: "org1", + billableToOrgInvoiceId: "inv1", + legs: [{ source: "INVOICE_ACCRUAL", amountPaise: 1000 }], +}; + +describe("mintRefundCreditNote", () => { + it("mints a credit note for an invoiced refund", async () => { + const tx = mockTx({ payment: INVOICED_PAYMENT }); + const res = await mintRefundCreditNote(tx as never, { + paymentId: "p1", + refundId: "ref1", + amountPaise: 1000, + reason: "test", + }); + expect(res.creditNoteId).toBe("cn-new"); + expect(tx._creditNoteCreate).toHaveBeenCalledTimes(1); + const data = tx._creditNoteCreate.mock.calls[0][0].data; + expect(data.refundId).toBe("ref1"); + expect(data.invoiceId).toBe("inv1"); + // #812 — a full refund of a ₹1180 invoice (₹1000 + 18% GST) must mint a + // ₹1180 credit note: the reversed accrual legs are tax-EXCLUSIVE, so GST + // is grossed up on top (CGST Sec 34 reverses output tax proportionally). + expect(data.subtotalPaise).toBe(1000); + expect(data.cgstPaise).toBe(90); + expect(data.sgstPaise).toBe(90); + expect(data.igstPaise).toBe(0); + expect(data.totalPaise).toBe(1180); + // #789 — the prefix is capped so the number satisfies CGST Rule 53's + // 16-character limit; "ACME-CN-2026-0001" (17 chars) was itself a breach. + expect(data.creditNoteNumber).toBe("ACM-CN-2026-0001"); + expect(data.creditNoteNumber.length).toBeLessThanOrEqual(16); + }); + + it("#776 — does NOT mint a credit note against a DRAFT invoice", async () => { + const tx = mockTx({ + payment: INVOICED_PAYMENT, + invoice: { + id: "inv1", + status: "DRAFT", + issuedAt: null, + totalPaise: 1180, + igstPaise: 0, + cgstPaise: 90, + sgstPaise: 90, + }, + }); + const res = await mintRefundCreditNote(tx as never, { + paymentId: "p1", + refundId: "ref1", + amountPaise: 1000, + reason: "test", + }); + expect(res.creditNoteId).toBeNull(); + expect(tx._creditNoteCreate).not.toHaveBeenCalled(); + }); + + it("is idempotent — returns the existing note and does NOT create a second", async () => { + const tx = mockTx({ + payment: INVOICED_PAYMENT, + existingCreditNote: { id: "cn-existing" }, + }); + const res = await mintRefundCreditNote(tx as never, { + paymentId: "p1", + refundId: "ref1", + amountPaise: 1000, + reason: "test", + }); + expect(res.creditNoteId).toBe("cn-existing"); + expect(tx._creditNoteCreate).not.toHaveBeenCalled(); + expect(tx.orgCreditNoteCounter.upsert).not.toHaveBeenCalled(); + }); + + it("is a no-op for a non-invoiced payment", async () => { + const tx = mockTx({ + payment: { + ...INVOICED_PAYMENT, + billableToOrgInvoiceId: null, + organizationId: null, + }, + }); + const res = await mintRefundCreditNote(tx as never, { + paymentId: "p1", + refundId: "ref1", + amountPaise: 1000, + reason: "test", + }); + expect(res.creditNoteId).toBeNull(); + expect(tx._creditNoteCreate).not.toHaveBeenCalled(); + }); + + it("is a no-op when no invoice-accrual legs are present", async () => { + const tx = mockTx({ + payment: { + ...INVOICED_PAYMENT, + legs: [{ source: "CARD", amountPaise: 1000 }], + }, + }); + const res = await mintRefundCreditNote(tx as never, { + paymentId: "p1", + refundId: "ref1", + amountPaise: 1000, + reason: "test", + }); + expect(res.creditNoteId).toBeNull(); + expect(tx._creditNoteCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/reversal-engine.test.ts b/__tests__/enterprise/reversal-engine.test.ts new file mode 100644 index 000000000..2be236cc3 --- /dev/null +++ b/__tests__/enterprise/reversal-engine.test.ts @@ -0,0 +1,149 @@ +/** + * @jest-environment node + */ + +/** + * Unified reversal engine dispatch (#776 §C / ARCH #4). Asserts the front door + * routes each source kind correctly and that CLASS_MULTI fans a single logical + * refund across child payments proportionally (the genuinely-new capability — + * consolidated CLASS purchases have no single paymentId). The deep booking + * cascade is mocked; it's tested in its own suite. + */ + +import { applyReversal } from "@/lib/payments/operations/reversal-engine"; +import { applyRefundCascade } from "../../lib/payments/operations/refund"; + +jest.mock("../../lib/payments/operations/refund", () => ({ + applyRefundCascade: jest.fn().mockResolvedValue({ + legsReversed: 1, + consultantEarningsReversed: 1, + organizationEarningsReversed: 0, + clawbackInitiated: false, + }), +})); + +const mockedCascade = applyRefundCascade as jest.MockedFunction< + typeof applyRefundCascade +>; + +beforeEach(() => mockedCascade.mockClear()); + +describe("applyReversal — BOOKING", () => { + it("delegates to applyRefundCascade with the payment id", async () => { + const tx = {} as never; + const res = await applyReversal(tx, { + source: { kind: "BOOKING", paymentId: "pay-1" }, + amountPaise: 5000, + reason: "test", + refundId: "ref-1", + }); + expect(res.kind).toBe("BOOKING"); + expect(res.cascades).toHaveLength(1); + expect(mockedCascade).toHaveBeenCalledTimes(1); + expect(mockedCascade).toHaveBeenCalledWith( + tx, + expect.objectContaining({ paymentId: "pay-1", amountPaise: 5000 }), + ); + }); +}); + +describe("applyReversal — CLASS_MULTI", () => { + function mockTx(payments: Array<{ id: string; amount: number }>) { + return { + payment: { + findMany: jest.fn().mockResolvedValue( + payments.map((p) => ({ + id: p.id, + amount: p.amount, + currency: "INR", + paymentGateway: "RAZORPAY", + })), + ), + }, + refund: { + create: jest + .fn() + .mockImplementation(async ({ data }) => ({ id: `cn-${data.paymentId}` })), + update: jest.fn().mockResolvedValue({}), + }, + }; + } + + it("fans the refund across children proportionally; last absorbs remainder", async () => { + // Two equal payments, total 100, refund 51 → 25 + 26 (last absorbs +1). + const tx = mockTx([ + { id: "p1", amount: 50 }, + { id: "p2", amount: 50 }, + ]); + const res = await applyReversal(tx as never, { + source: { kind: "CLASS_MULTI", paymentIds: ["p1", "p2"] }, + amountPaise: 51, + reason: "class refund", + refundId: "parent-ref", + }); + + expect(res.kind).toBe("CLASS_MULTI"); + expect(mockedCascade).toHaveBeenCalledTimes(2); + const shares = mockedCascade.mock.calls.map((c) => c[1].amountPaise).sort(); + expect(shares).toEqual([25, 26]); + // Each child got its own Refund row created + marked SUCCEEDED. + expect(tx.refund.create).toHaveBeenCalledTimes(2); + expect(tx.refund.update).toHaveBeenCalledTimes(2); + }); + + it("never lets a child's share exceed its own amount (remainder distribution)", async () => { + // Pathological: three ₹0.01 children, refund 2 paise. A naive + // "last absorbs remainder" would push the last child to 2 > its amount 1 + // and crash applyRefundCascade's refundable guard. The distribution caps + // each child at its own amount. + const tx = mockTx([ + { id: "p1", amount: 1 }, + { id: "p2", amount: 1 }, + { id: "p3", amount: 1 }, + ]); + await applyReversal(tx as never, { + source: { kind: "CLASS_MULTI", paymentIds: ["p1", "p2", "p3"] }, + amountPaise: 2, + reason: "r", + refundId: "ref", + }); + const shares = mockedCascade.mock.calls.map((c) => c[1].amountPaise); + expect(shares.reduce((a, b) => a + b, 0)).toBe(2); + expect(Math.max(...shares)).toBeLessThanOrEqual(1); + // Third child's share nets to 0 → skipped. + expect(mockedCascade).toHaveBeenCalledTimes(2); + }); + + it("throws fast on an over-refund (amount > class total) without touching children", async () => { + const tx = mockTx([ + { id: "p1", amount: 100 }, + { id: "p2", amount: 100 }, + ]); + await expect( + applyReversal(tx as never, { + source: { kind: "CLASS_MULTI", paymentIds: ["p1", "p2"] }, + amountPaise: 250, + reason: "r", + refundId: "ref", + }), + ).rejects.toThrow(/exceeds class total/); + expect(mockedCascade).not.toHaveBeenCalled(); + expect(tx.refund.create).not.toHaveBeenCalled(); + }); + + it("skips zero-share children", async () => { + const tx = mockTx([ + { id: "p1", amount: 100 }, + { id: "p2", amount: 0 }, + ]); + await applyReversal(tx as never, { + source: { kind: "CLASS_MULTI", paymentIds: ["p1", "p2"] }, + amountPaise: 100, + reason: "r", + refundId: "ref", + }); + // p2 (amount 0) gets share 0 → no cascade for it. + expect(mockedCascade).toHaveBeenCalledTimes(1); + expect(mockedCascade.mock.calls[0][1].amountPaise).toBe(100); + }); +}); diff --git a/__tests__/enterprise/role-transitions.test.ts b/__tests__/enterprise/role-transitions.test.ts new file mode 100644 index 000000000..dc209e0ec --- /dev/null +++ b/__tests__/enterprise/role-transitions.test.ts @@ -0,0 +1,35 @@ +import { isBlockedRoleTransition } from "@/lib/enterprise/role-transitions"; + +describe("isBlockedRoleTransition", () => { + it("blocks LEARNER -> EXPERT", () => { + expect(isBlockedRoleTransition("LEARNER", "EXPERT")).toBe(true); + }); + + it("blocks EXPERT -> LEARNER", () => { + expect(isBlockedRoleTransition("EXPERT", "LEARNER")).toBe(true); + }); + + it("allows same-role no-op", () => { + expect(isBlockedRoleTransition("LEARNER", "LEARNER")).toBe(false); + expect(isBlockedRoleTransition("EXPERT", "EXPERT")).toBe(false); + }); + + it("allows MAINTAINER <-> MANAGER", () => { + expect(isBlockedRoleTransition("MAINTAINER", "MANAGER")).toBe(false); + expect(isBlockedRoleTransition("MANAGER", "MAINTAINER")).toBe(false); + }); + + it("allows MANAGER -> LEARNER and MAINTAINER -> EXPERT", () => { + // Non-LEARNER/EXPERT roles can downgrade into either consumer or + // provider role without tripping the guard — that path still + // requires OWNER authorization upstream, but the transition itself + // is not blocked by policy. + expect(isBlockedRoleTransition("MANAGER", "LEARNER")).toBe(false); + expect(isBlockedRoleTransition("MAINTAINER", "EXPERT")).toBe(false); + }); + + it("allows OWNER transitions in either direction", () => { + expect(isBlockedRoleTransition("OWNER", "MAINTAINER")).toBe(false); + expect(isBlockedRoleTransition("MAINTAINER", "OWNER")).toBe(false); + }); +}); diff --git a/__tests__/enterprise/scim/auth.test.ts b/__tests__/enterprise/scim/auth.test.ts new file mode 100644 index 000000000..354c306fd --- /dev/null +++ b/__tests__/enterprise/scim/auth.test.ts @@ -0,0 +1,165 @@ +/** + * @jest-environment node + */ + +/** + * `requireScimAuth` is the gate for every SCIM call. The tests below + * pin: + * + * - Missing / malformed Authorization header → 401 SCIM error. + * - Unknown token → 401 with the documented detail string. + * - Revoked token → 401 + an audit row recording the attempt. + * - Rate-limit exceeded → 429 with a SCIM error envelope. + * - Happy path → returns the (organizationId, tokenId) grant. + */ + +import { NextRequest } from "next/server"; +import { createHash } from "node:crypto"; + +jest.mock("../../../lib/prisma", () => ({ + __esModule: true, + default: { + scimToken: { findUnique: jest.fn(), update: jest.fn() }, + orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, + }, +})); + +jest.mock("../../../lib/rate-limit", () => ({ + scimLimiter: {} as Record, + applyRateLimit: jest.fn(), +})); + +import prisma from "@/lib/prisma"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { requireScimAuth } from "@/lib/scim/auth"; + +const mockedPrisma = prisma as unknown as { + scimToken: { findUnique: jest.Mock; update: jest.Mock }; + orgAuditLog: { create: jest.Mock }; +}; +const mockedApplyRateLimit = applyRateLimit as jest.Mock; + +function makeReq(authHeader?: string) { + const headers = new Headers(); + if (authHeader) headers.set("authorization", authHeader); + return new NextRequest("http://localhost/scim/v2/Users", { headers }); +} + +function hashOf(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +describe("requireScimAuth", () => { + beforeEach(() => { + mockedPrisma.scimToken.findUnique.mockReset(); + mockedPrisma.scimToken.update.mockResolvedValue({}); + mockedPrisma.orgAuditLog.create.mockClear(); + mockedApplyRateLimit.mockReset(); + }); + + it("401 when Authorization header is missing", async () => { + const result = await requireScimAuth(makeReq()); + expect(result.response?.status).toBe(401); + }); + + it("401 when scheme is not 'Bearer'", async () => { + const result = await requireScimAuth(makeReq("Basic abc")); + expect(result.response?.status).toBe(401); + }); + + it("401 when the bearer token is unknown", async () => { + mockedPrisma.scimToken.findUnique.mockResolvedValue(null); + const result = await requireScimAuth(makeReq("Bearer not-real")); + expect(result.response?.status).toBe(401); + expect(mockedPrisma.scimToken.findUnique).toHaveBeenCalledWith({ + where: { tokenHash: hashOf("not-real") }, + // #789 — expiresAt is now selected so the deadline can be enforced. + select: { + id: true, + organizationId: true, + status: true, + expiresAt: true, + }, + }); + }); + + it("#789 — 401 when an ACTIVE token is past its expiresAt", async () => { + mockedPrisma.scimToken.findUnique.mockResolvedValue({ + id: "tok-1", + organizationId: "org-1", + status: "ACTIVE", + expiresAt: new Date(Date.now() - 60_000), + }); + const result = await requireScimAuth(makeReq("Bearer expired-token")); + expect(result.response?.status).toBe(401); + expect(result.grant).toBeUndefined(); + }); + + it("#789 — allows an ACTIVE token whose expiresAt is still in the future", async () => { + mockedPrisma.scimToken.findUnique.mockResolvedValue({ + id: "tok-1", + organizationId: "org-1", + status: "ACTIVE", + expiresAt: new Date(Date.now() + 60_000), + }); + mockedApplyRateLimit.mockResolvedValue(null); + const result = await requireScimAuth(makeReq("Bearer live-token")); + expect(result.grant).toEqual({ + organizationId: "org-1", + tokenId: "tok-1", + }); + }); + + it("401 when the token is REVOKED and audits the misuse", async () => { + mockedPrisma.scimToken.findUnique.mockResolvedValue({ + id: "tok-1", + organizationId: "org-1", + status: "REVOKED", + }); + const result = await requireScimAuth(makeReq("Bearer revoked-token")); + expect(result.response?.status).toBe(401); + // Critical: the SCIM_TOKEN_USED_AFTER_REVOKE audit must fire so the + // operator sees the rotation gap on the audit-log dashboard. + expect(mockedPrisma.orgAuditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + organizationId: "org-1", + action: "SCIM_TOKEN_USED_AFTER_REVOKE", + }), + }), + ); + }); + + it("429 when the rate limit is exceeded", async () => { + mockedPrisma.scimToken.findUnique.mockResolvedValue({ + id: "tok-1", + organizationId: "org-1", + status: "ACTIVE", + }); + // applyRateLimit returns a NextResponse when over budget — emulate + // that by returning a truthy object. + mockedApplyRateLimit.mockResolvedValue({ status: 429 }); + const result = await requireScimAuth(makeReq("Bearer ok-token")); + expect(result.response?.status).toBe(429); + }); + + it("happy path returns the grant + bumps lastUsedAt", async () => { + mockedPrisma.scimToken.findUnique.mockResolvedValue({ + id: "tok-1", + organizationId: "org-1", + status: "ACTIVE", + }); + mockedApplyRateLimit.mockResolvedValue(null); + const result = await requireScimAuth(makeReq("Bearer ok-token")); + expect(result.grant).toEqual({ + organizationId: "org-1", + tokenId: "tok-1", + }); + // lastUsedAt is best-effort fire-and-forget; we don't await its + // promise inside the function, but the call must be queued. + expect(mockedPrisma.scimToken.update).toHaveBeenCalledWith({ + where: { id: "tok-1" }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); +}); diff --git a/__tests__/enterprise/scim/resource-user.test.ts b/__tests__/enterprise/scim/resource-user.test.ts new file mode 100644 index 000000000..e080d2934 --- /dev/null +++ b/__tests__/enterprise/scim/resource-user.test.ts @@ -0,0 +1,113 @@ +/** + * @jest-environment node + */ + +/** + * Pin the SCIM resource shape + group-to-role resolution semantics. + * The mapper is pure (no Prisma dependency); the route handlers + * inject the join data, so the unit boundary is exactly here. + */ + +import { + parseDisplayName, + resolveRoleFromGroupNames, + toScimUser, +} from "@/lib/scim/resource-user"; + +describe("toScimUser", () => { + const base = { + membership: { + id: "mem-1", + externalScimId: "okta-user-42", + status: "ACTIVE" as const, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-05-15T10:00:00Z"), + }, + user: { id: "u-1", name: "Alice Doe", email: "alice@acme.com" }, + baseUrl: "https://app.familiarise.work", + }; + + it("emits the canonical core User schema URN + resource id from externalScimId", () => { + const out = toScimUser(base); + expect(out.schemas).toEqual([ + "urn:ietf:params:scim:schemas:core:2.0:User", + ]); + // The resource id MUST be the externalScimId when present so the + // IdP can round-trip its own identifier without surprise. + expect(out.id).toBe("okta-user-42"); + expect(out.userName).toBe("alice@acme.com"); + }); + + it("falls back to membership.id when externalScimId is null", () => { + const out = toScimUser({ + ...base, + membership: { ...base.membership, externalScimId: null }, + }); + expect(out.id).toBe("mem-1"); + }); + + it("maps Membership.status=SUSPENDED → active=false", () => { + const out = toScimUser({ + ...base, + membership: { ...base.membership, status: "SUSPENDED" }, + }); + expect(out.active).toBe(false); + }); + + it("constructs a meta.location URL the IdP can retry against", () => { + const out = toScimUser(base); + expect(out.meta.location).toBe( + "https://app.familiarise.work/scim/v2/Users/okta-user-42", + ); + }); +}); + +describe("parseDisplayName", () => { + it("splits on the LAST space (handles middle names)", () => { + expect(parseDisplayName("Alice Mary Doe")).toEqual({ + givenName: "Alice Mary", + familyName: "Doe", + }); + }); + it("handles a single-word name (no family name)", () => { + expect(parseDisplayName("Madonna")).toEqual({ + givenName: "Madonna", + familyName: "", + }); + }); +}); + +describe("resolveRoleFromGroupNames", () => { + const mappings = [ + { scimGroupName: "IT-Admins", role: "MAINTAINER" as const }, + { scimGroupName: "Finance-Leads", role: "BILLING_ADMIN" as const }, + { scimGroupName: "All-Employees", role: "LEARNER" as const }, + ]; + + it("defaults to LEARNER when no groups match (least-privilege)", () => { + expect(resolveRoleFromGroupNames(["Random-Group"], mappings)).toBe( + "LEARNER", + ); + expect(resolveRoleFromGroupNames([], mappings)).toBe("LEARNER"); + }); + + it("picks the highest-rank role when the user is in multiple mapped groups", () => { + // BILLING_ADMIN (70) beats LEARNER (20). + expect( + resolveRoleFromGroupNames(["Finance-Leads", "All-Employees"], mappings), + ).toBe("BILLING_ADMIN"); + // MAINTAINER (80) beats BILLING_ADMIN (70). + expect( + resolveRoleFromGroupNames( + ["IT-Admins", "Finance-Leads", "All-Employees"], + mappings, + ), + ).toBe("MAINTAINER"); + }); + + it("ignores unknown group names without throwing", () => { + expect( + resolveRoleFromGroupNames(["IT-Admins", "Phantom-Group"], mappings), + ).toBe("MAINTAINER"); + }); +}); diff --git a/__tests__/enterprise/seat-count.test.ts b/__tests__/enterprise/seat-count.test.ts new file mode 100644 index 000000000..e3540347a --- /dev/null +++ b/__tests__/enterprise/seat-count.test.ts @@ -0,0 +1,167 @@ +/** + * @jest-environment node + */ + +/** + * Issue #699 ENT-1: BillingSubscription.activeSeatCount writer. + * + * Covers: + * - +1 / -1 deltas applied via raw SQL conditional UPDATE + * - LICENSED_SEAT programs increment, others no-op + * - Programs without an associated subscription (e.g. unbundled + * contracts) silently no-op + * - delta=0 returns applied:false without DB I/O + * - Underflow guard: -1 against count=0 throws SeatCountUnderflowError + * (the conditional UPDATE matches 0 rows) + */ + +import { + adjustActiveSeatCount, + SeatCountUnderflowError, +} from "@/lib/api/organizations/seat-count"; + +type SubLookup = { id: string; activeSeatCount: number }; + +type MockTx = { + program: { findUnique: jest.Mock }; + billingSubscription: { + findUniqueOrThrow: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; +}; + +function makeTx(opts: { + programType: "LICENSED_SEAT" | "CREDIT_POOL" | "PROJECT" | "RETAINER"; + subscription: SubLookup | null; + /** Rows touched by the conditional updateMany; 0 simulates underflow. */ + updateRows?: number; + /** Post-update activeSeatCount returned to the caller. */ + postValue?: number; +}): MockTx { + return { + program: { + findUnique: jest.fn().mockResolvedValue({ + type: opts.programType, + contract: { + subscription: opts.subscription, + }, + }), + }, + // #776 — seat-count writer now uses the ORM (atomic update / conditional + // updateMany) instead of raw SQL. + billingSubscription: { + findUniqueOrThrow: jest.fn().mockResolvedValue({ + activeSeatCount: opts.postValue ?? 0, + }), + update: jest.fn().mockResolvedValue({ + activeSeatCount: opts.postValue ?? 0, + }), + updateMany: jest.fn().mockResolvedValue({ count: opts.updateRows ?? 1 }), + }, + }; +} + +describe("adjustActiveSeatCount — BillingSubscription.activeSeatCount writer", () => { + it("+1 on a LICENSED_SEAT program increments the subscription counter", async () => { + const tx = makeTx({ + programType: "LICENSED_SEAT", + subscription: { id: "sub-1", activeSeatCount: 3 }, + postValue: 4, + }); + const result = await adjustActiveSeatCount(tx as never, { + programId: "prog-1", + delta: +1, + }); + expect(result.applied).toBe(true); + expect(result.balanceAfter).toBe(4); + expect(tx.billingSubscription.update).toHaveBeenCalledTimes(1); + }); + + it("-1 with sufficient balance decrements", async () => { + const tx = makeTx({ + programType: "LICENSED_SEAT", + subscription: { id: "sub-1", activeSeatCount: 3 }, + updateRows: 1, + postValue: 2, + }); + const result = await adjustActiveSeatCount(tx as never, { + programId: "prog-1", + delta: -1, + }); + expect(result.applied).toBe(true); + expect(result.balanceAfter).toBe(2); + }); + + it("-1 with zero balance throws SeatCountUnderflowError", async () => { + const tx = makeTx({ + programType: "LICENSED_SEAT", + subscription: { id: "sub-1", activeSeatCount: 0 }, + updateRows: 0, // conditional UPDATE matched no rows + }); + await expect( + adjustActiveSeatCount(tx as never, { + programId: "prog-1", + delta: -1, + }), + ).rejects.toBeInstanceOf(SeatCountUnderflowError); + }); + + it("CREDIT_POOL programs no-op (not LICENSED_SEAT)", async () => { + const tx = makeTx({ + programType: "CREDIT_POOL", + subscription: { id: "sub-1", activeSeatCount: 3 }, + }); + const result = await adjustActiveSeatCount(tx as never, { + programId: "prog-1", + delta: +1, + }); + expect(result.applied).toBe(false); + expect(result.balanceAfter).toBeNull(); + expect(tx.billingSubscription.update).not.toHaveBeenCalled(); + expect(tx.billingSubscription.updateMany).not.toHaveBeenCalled(); + }); + + it("LICENSED_SEAT with no subscription on the contract no-ops", async () => { + const tx = makeTx({ + programType: "LICENSED_SEAT", + subscription: null, + }); + const result = await adjustActiveSeatCount(tx as never, { + programId: "prog-1", + delta: +1, + }); + expect(result.applied).toBe(false); + expect(tx.billingSubscription.update).not.toHaveBeenCalled(); + }); + + it("delta=0 short-circuits without any DB lookup", async () => { + const tx = makeTx({ + programType: "LICENSED_SEAT", + subscription: { id: "sub-1", activeSeatCount: 3 }, + }); + const result = await adjustActiveSeatCount(tx as never, { + programId: "prog-1", + delta: 0, + }); + expect(result.applied).toBe(false); + expect(tx.program.findUnique).not.toHaveBeenCalled(); + }); + + it("missing program no-ops (caller passed a stale id)", async () => { + const tx: MockTx = { + program: { findUnique: jest.fn().mockResolvedValue(null) }, + billingSubscription: { + findUniqueOrThrow: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + }; + const result = await adjustActiveSeatCount(tx as never, { + programId: "prog-missing", + delta: +1, + }); + expect(result.applied).toBe(false); + expect(tx.billingSubscription.update).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/serializable-retry.test.ts b/__tests__/enterprise/serializable-retry.test.ts new file mode 100644 index 000000000..772bacca7 --- /dev/null +++ b/__tests__/enterprise/serializable-retry.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment node + */ + +import { withSerializableRetry } from "@/lib/db/serializable-retry"; +import { IllegalTransitionError } from "@/lib/enterprise/transitions"; +import { Prisma } from "@prisma/client"; + +function p2034() { + return new Prisma.PrismaClientKnownRequestError("serialization failure", { + code: "P2034", + clientVersion: "test", + }); +} + +describe("withSerializableRetry", () => { + it("returns the result on first success without retrying", async () => { + const fn = jest.fn().mockResolvedValue("ok"); + await expect(withSerializableRetry(fn)).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries P2034 and succeeds on a later attempt", async () => { + const fn = jest + .fn() + .mockRejectedValueOnce(p2034()) + .mockRejectedValueOnce(p2034()) + .mockResolvedValue("ok"); + await expect(withSerializableRetry(fn)).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("gives up after maxRetries and rethrows the P2034", async () => { + const fn = jest.fn().mockRejectedValue(p2034()); + await expect(withSerializableRetry(fn, 2)).rejects.toMatchObject({ + code: "P2034", + }); + expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + it("does not retry non-P2034 errors", async () => { + const fn = jest.fn().mockRejectedValue(new Error("boom")); + await expect(withSerializableRetry(fn)).rejects.toThrow("boom"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("does not retry business rejections (IllegalTransitionError)", async () => { + // A 409 means the state machine said no — retrying would turn a correct + // rejection into a corrupting second attempt. + const fn = jest + .fn() + .mockRejectedValue(new IllegalTransitionError("Contract", "ACTIVE")); + await expect(withSerializableRetry(fn)).rejects.toBeInstanceOf( + IllegalTransitionError, + ); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/enterprise/sweep-abandoned-overage-charges.test.ts b/__tests__/enterprise/sweep-abandoned-overage-charges.test.ts new file mode 100644 index 000000000..715af5fa6 --- /dev/null +++ b/__tests__/enterprise/sweep-abandoned-overage-charges.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + */ + +/** + * #785 (task #25) — abandoned overage-charge sweeper. Never-paid PENDING + * CHARGE_MEMBER side-charges count toward the per-cycle circuit-breaker ceiling + * (cycleOverageSoFarPaise excludes only REVERSED/BLOCKED/FAILED). This job FAILs + * the abandoned ones so they stop blocking legit bookings. + */ +jest.mock("../../lib/prisma", () => { + const client = { + overageEvent: { findMany: jest.fn() }, + // #812 — the sweep claims per-event in a tx (FAIL + basePaise restore). + $transaction: jest.fn( + (fn: (tx: unknown) => Promise): Promise => fn(client), + ), + }; + return { __esModule: true, default: client }; +}); +jest.mock("../../lib/payments/billing/overage-transitions", () => ({ + transitionOverage: jest.fn(), +})); +jest.mock("../../lib/payments/billing/overage-base-carve", () => ({ + restoreOverageBaseCarve: jest.fn().mockResolvedValue("restored"), +})); +jest.mock("../../lib/enterprise/system-events", () => ({ + recordSystemError: jest.fn().mockResolvedValue(undefined), +})); + + +// #476 — the sweep cores are now wrapped in withCronLock; pass through so +// these unit tests exercise the sweep logic, not the lock (covered in +// with-cron-lock.test.ts). +jest.mock("../../lib/cron/with-cron-lock", () => ({ + withCronLock: jest.fn((_job: string, _opts: unknown, fn: () => unknown) => fn()), + CronLockHeldError: class CronLockHeldError extends Error {}, + CronLockUnavailableError: class CronLockUnavailableError extends Error {}, + LONG_JOB_TTL_MS: 35 * 60 * 1000, +})); + +import prisma from "../../lib/prisma"; +import { transitionOverage } from "../../lib/payments/billing/overage-transitions"; +import { sweepAbandonedOverageCharges } from "../../scripts/cleanup/sweep-abandoned-overage-charges"; + +const mockFindMany = ( + prisma as unknown as { overageEvent: { findMany: jest.Mock } } +).overageEvent.findMany; +const mockTransition = transitionOverage as jest.Mock; + +beforeEach(() => jest.clearAllMocks()); + +describe("sweepAbandonedOverageCharges (#785)", () => { + it("FAILs abandoned PENDING CHARGE_MEMBER charges to free the ceiling", async () => { + mockFindMany.mockResolvedValue([{ id: "ov_1" }, { id: "ov_2" }]); + mockTransition.mockResolvedValue(2); + + const r = await sweepAbandonedOverageCharges({ ageDays: 7 }); + + expect(r).toMatchObject({ scanned: 2, failed: 2 }); + const where = mockFindMany.mock.calls[0][0].where; + expect(where.chargeStatus).toBe("PENDING"); + expect(where.overageBehavior).toBe("CHARGE_MEMBER"); + expect(where.createdAt).toHaveProperty("lt"); + // Only never-STARTED side-charges: no payment, or a non-SUCCEEDED payment + // whose paymentIntent is still the synthetic `overage:` (the order + // route overwrites it with the real gateway id once the member opens + // checkout). #785 — a charge whose intent was replaced may be captured-but- + // webhook-stuck, so it must NOT be swept (FAILing it would strand money). + expect(where.OR).toEqual( + expect.arrayContaining([ + { paymentId: null }, + { + payment: { + is: { + paymentStatus: { not: "SUCCEEDED" }, + paymentIntent: { startsWith: "overage:" }, + }, + }, + }, + ]), + ); + // #812 — per-event tx now: PENDING→FAILED claim (transitionOverage + // appends the legal-from guard) + basePaise restore land together, + // stamping an auditable write-off reason (#779 §A). + expect(mockTransition).toHaveBeenCalledTimes(2); + for (const id of ["ov_1", "ov_2"]) { + expect(mockTransition).toHaveBeenCalledWith( + expect.anything(), + { id }, + "FAILED", + { chargeFailureReason: expect.stringContaining("swept at 7d") }, + ); + } + }); + + it("empty scan → no transition", async () => { + mockFindMany.mockResolvedValue([]); + const r = await sweepAbandonedOverageCharges(); + expect(r).toMatchObject({ scanned: 0, failed: 0 }); + expect(mockTransition).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/sweep-orphaned-topup-captures.test.ts b/__tests__/enterprise/sweep-orphaned-topup-captures.test.ts new file mode 100644 index 000000000..f4b15c7cf --- /dev/null +++ b/__tests__/enterprise/sweep-orphaned-topup-captures.test.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment node + */ + +/** + * #785 (task #23) — captured-but-uncredited wallet top-up reconciler. Re-runs the + * idempotent confirmTopUp for top-ups whose confirm/ledger post rolled back + * (capturedAt + providerPaymentId set outside the tx, still PENDING). Pins the + * query shape + the recredit/already-confirmed/failure accounting. + */ +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { walletTopUp: { findMany: jest.fn() } }, +})); +jest.mock("../../lib/api/organizations/wallet", () => ({ + confirmTopUp: jest.fn(), +})); + + +// #476 — the sweep cores are now wrapped in withCronLock; pass through so +// these unit tests exercise the sweep logic, not the lock (covered in +// with-cron-lock.test.ts). +jest.mock("../../lib/cron/with-cron-lock", () => ({ + withCronLock: jest.fn((_job: string, _opts: unknown, fn: () => unknown) => fn()), + CronLockHeldError: class CronLockHeldError extends Error {}, + CronLockUnavailableError: class CronLockUnavailableError extends Error {}, + LONG_JOB_TTL_MS: 35 * 60 * 1000, +})); + +import prisma from "../../lib/prisma"; +import { confirmTopUp } from "../../lib/api/organizations/wallet"; +import { sweepOrphanedTopupCaptures } from "../../scripts/cleanup/sweep-orphaned-topup-captures"; + +const mockFindMany = ( + prisma as unknown as { walletTopUp: { findMany: jest.Mock } } +).walletTopUp.findMany; +const mockConfirm = confirmTopUp as jest.Mock; + +const orphan = (o: Record = {}) => ({ + providerOrderId: "we_1", + providerPaymentId: "pay_1", + amountPaise: 50000, + ...o, +}); + +beforeEach(() => jest.clearAllMocks()); + +describe("sweepOrphanedTopupCaptures (#785)", () => { + it("re-credits a captured-but-uncredited top-up via the idempotent confirm", async () => { + mockFindMany.mockResolvedValue([orphan()]); + mockConfirm.mockResolvedValue({ confirmed: true, balanceAfter: 100000 }); + + const r = await sweepOrphanedTopupCaptures({ graceMinutes: 5 }); + + expect(r).toMatchObject({ scanned: 1, recredited: 1, stillFailing: 0 }); + expect(mockConfirm).toHaveBeenCalledWith(expect.anything(), { + providerOrderId: "we_1", + providerPaymentId: "pay_1", + amountPaise: 50000, + }); + // query: PENDING + captured (capturedAt range, which excludes null) + paid id + const where = mockFindMany.mock.calls[0][0].where; + expect(where.status).toBe("PENDING"); + expect(where.providerPaymentId).toEqual({ not: null }); + expect(where.capturedAt).toHaveProperty("lt"); + }); + + it("already-CONFIRMED (confirmed=false) is not counted as recredited", async () => { + mockFindMany.mockResolvedValue([orphan()]); + mockConfirm.mockResolvedValue({ confirmed: false }); + + const r = await sweepOrphanedTopupCaptures(); + + expect(r.recredited).toBe(0); + expect(r.stillFailing).toBe(0); + }); + + it("a confirm throw counts as stillFailing", async () => { + mockFindMany.mockResolvedValue([orphan()]); + mockConfirm.mockRejectedValue(new Error("ledger down")); + + const r = await sweepOrphanedTopupCaptures(); + + expect(r.stillFailing).toBe(1); + expect(r.errors[0]).toContain("ledger down"); + }); + + it("empty scan → no-op", async () => { + mockFindMany.mockResolvedValue([]); + const r = await sweepOrphanedTopupCaptures(); + expect(r).toMatchObject({ scanned: 0, recredited: 0 }); + expect(mockConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/enterprise/sweep-stuck-webhook-events.test.ts b/__tests__/enterprise/sweep-stuck-webhook-events.test.ts new file mode 100644 index 000000000..10cbc4caf --- /dev/null +++ b/__tests__/enterprise/sweep-stuck-webhook-events.test.ts @@ -0,0 +1,172 @@ +/** + * @jest-environment node + */ + +/** + * #785 (task #10) — B5 stuck-webhook sweeper. Re-drives WebhookEvent rows left + * processed=false after an after()-callback crash, reconstructing the envelope + * the per-event schemas require (entity/account_id/contains/created_at) and + * routing through the real dispatch. Pins the loop + reconstruction + the + * success/fail/throw accounting. + */ + +// Factories create the jest.fn()s inline (retrieved via the mocked modules +// below) — referencing outer consts here would hit import-hoisting init order. +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + webhookEvent: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn().mockResolvedValue({}), + }, + }, +})); +jest.mock("../../app/api/webhooks/razorpay-dispatch", () => ({ + processRazorpayWebhookEvent: jest.fn(), +})); + + +// #476 — the sweep cores are now wrapped in withCronLock; pass through so +// these unit tests exercise the sweep logic, not the lock (covered in +// with-cron-lock.test.ts). +jest.mock("../../lib/cron/with-cron-lock", () => ({ + withCronLock: jest.fn((_job: string, _opts: unknown, fn: () => unknown) => fn()), + CronLockHeldError: class CronLockHeldError extends Error {}, + CronLockUnavailableError: class CronLockUnavailableError extends Error {}, + LONG_JOB_TTL_MS: 35 * 60 * 1000, +})); + +import prisma from "../../lib/prisma"; +import { processRazorpayWebhookEvent } from "../../app/api/webhooks/razorpay-dispatch"; +import { sweepStuckWebhookEvents } from "../../scripts/cleanup/sweep-stuck-webhook-events"; + +const mockWe = (prisma as unknown as { + webhookEvent: { findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock }; +}).webhookEvent; +const mockProcess = processRazorpayWebhookEvent as jest.Mock; + +const stuckRow = (over: Record = {}) => ({ + eventId: "payment.captured:pay_1", + eventType: "payment.captured", + payload: { payment: { entity: { id: "pay_1" } } }, + receivedAt: new Date("2026-06-01T00:00:00Z"), + ...over, +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockWe.update.mockResolvedValue({}); +}); + +describe("sweepStuckWebhookEvents (#785)", () => { + it("re-drives a stuck event and reconstructs the full envelope", async () => { + mockWe.findMany.mockResolvedValue([stuckRow()]); + mockProcess.mockResolvedValue(undefined); + mockWe.findUnique.mockResolvedValue({ error: null, processed: true }); + + const r = await sweepStuckWebhookEvents({ staleMinutes: 6 }); + + expect(r).toMatchObject({ scanned: 1, recovered: 1, stillFailing: 0 }); + // only razorpay, only the after()-crash signature (processed=false+error=null) + expect(mockWe.findMany.mock.calls[0][0].where).toMatchObject({ + provider: "razorpay", + processed: false, + error: null, + }); + // envelope reconstruction supplies the fields the schemas demand + const [env, evType, evId] = mockProcess.mock.calls[0]; + expect(env).toMatchObject({ + entity: "event", + event: "payment.captured", + contains: ["payment"], // top-level payload keys + payload: { payment: { entity: { id: "pay_1" } } }, + }); + expect(typeof env.account_id).toBe("string"); + expect(typeof env.created_at).toBe("number"); + expect(evType).toBe("payment.captured"); + expect(evId).toBe("payment.captured:pay_1"); + }); + + it("a re-drive that still errors counts as stillFailing, not recovered", async () => { + mockWe.findMany.mockResolvedValue([stuckRow()]); + mockProcess.mockResolvedValue(undefined); + mockWe.findUnique.mockResolvedValue({ error: "handler boom", processed: true }); + + const r = await sweepStuckWebhookEvents({ staleMinutes: 6 }); + + expect(r.recovered).toBe(0); + expect(r.stillFailing).toBe(1); + expect(r.errors[0]).toContain("handler boom"); + }); + + it("a throw mid-dispatch is caught + the row force-marked (never re-swept forever)", async () => { + mockWe.findMany.mockResolvedValue([stuckRow()]); + mockProcess.mockRejectedValue(new Error("kaboom")); + + const r = await sweepStuckWebhookEvents({ staleMinutes: 6 }); + + expect(r.stillFailing).toBe(1); + expect(mockWe.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { eventId: "payment.captured:pay_1" }, + data: expect.objectContaining({ processed: true }), + }), + ); + }); + + it("empty scan → no-op", async () => { + mockWe.findMany.mockResolvedValue([]); + const r = await sweepStuckWebhookEvents(); + expect(r).toMatchObject({ scanned: 0, recovered: 0, stillFailing: 0 }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + // #813 — a defer-sentinel handler (refund-before-capture) leaves the row in + // the same processed=false/error=null signature it started with. The sweeper + // must NOT count that as recovered, and must NOT terminally mark it until it + // ages past the give-up cap. + it("a re-drive that stays deferred is counted as deferred, not recovered", async () => { + const recent = new Date(Date.now() - 60 * 60_000); // 1h old, under the cap + mockWe.findMany.mockResolvedValue([ + stuckRow({ eventId: "refund.created:rfnd_1", receivedAt: recent }), + ]); + mockProcess.mockResolvedValue(undefined); + // dispatch deferred → it skipped the mark, row unchanged + mockWe.findUnique.mockResolvedValue({ error: null, processed: false }); + + const r = await sweepStuckWebhookEvents({ staleMinutes: 6 }); + + expect(r).toMatchObject({ + scanned: 1, + recovered: 0, + stillFailing: 0, + deferred: 1, + gaveUp: 0, + }); + expect(mockWe.update).not.toHaveBeenCalled(); + }); + + it("a deferred event past the give-up cap is terminally marked + counted", async () => { + const old = new Date(Date.now() - 200 * 60 * 60_000); // 200h > 168h cap + mockWe.findMany.mockResolvedValue([ + stuckRow({ eventId: "refund.created:rfnd_2", receivedAt: old }), + ]); + mockProcess.mockResolvedValue(undefined); + mockWe.findUnique.mockResolvedValue({ error: null, processed: false }); + + const r = await sweepStuckWebhookEvents({ staleMinutes: 6 }); + + expect(r).toMatchObject({ deferred: 0, gaveUp: 1, recovered: 0 }); + expect(mockWe.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { eventId: "refund.created:rfnd_2" }, + data: expect.objectContaining({ + processed: true, + error: "gave up: payment never arrived", + }), + }), + ); + expect(r.errors[0]).toContain("gave up"); + }); +}); diff --git a/__tests__/enterprise/tds-derivation.test.ts b/__tests__/enterprise/tds-derivation.test.ts new file mode 100644 index 000000000..8dbcc9555 --- /dev/null +++ b/__tests__/enterprise/tds-derivation.test.ts @@ -0,0 +1,161 @@ +/** + * @jest-environment node + */ + +/** + * Live TDS derivation — covers the precedence rules the audit team will + * inspect first when a quarterly Form 26Q / 27Q reconciliation kicks + * back. Each case mirrors a row the cron at + * `jobs/compliance/derive-tds-msme.ts` will encounter in production. + */ + +import { + computeTdsForPayout, + isValidPan, + NO_PAN_RATE_194O, + TDS_SECTION_DEFAULTS, + type TdsConsultantInput, +} from "@/lib/compliance/tds"; + +/** Minimal helper — pads only the fields the derivation reads. */ +function profile(overrides: Partial = {}): TdsConsultantInput { + return { + panNumber: "ABCDE1234F", + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: "IN", + ...overrides, + }; +} + +describe("computeTdsForPayout — section precedence", () => { + it("resident with valid PAN defaults to 194-O at 0.1%", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, // ₹1,000 + consultant: profile(), + }); + expect(result.tdsSection).toBe("194O"); + expect(result.tdsRate).toBe(TDS_SECTION_DEFAULTS["194O"]); + expect(result.tdsRate).toBeCloseTo(0.001); + expect(result.tdsAmountPaise).toBe(100); + expect(result.fallbackApplied).toBe(false); + expect(result.dtaaRateApplied).toBeNull(); + expect(result.reason).toMatch(/194O/); + }); + + it("honours an explicit 194J section override at 10%", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ tdsSection: "194J" }), + }); + expect(result.tdsSection).toBe("194J"); + expect(result.tdsRate).toBeCloseTo(0.1); + expect(result.tdsAmountPaise).toBe(10_000); + expect(result.reason).toMatch(/194J/); + expect(result.reason).toMatch(/override/i); + }); +}); + +describe("computeTdsForPayout — PAN fallback (Section 194-O no-PAN carve-out)", () => { + it("withholds 5% (194-O no-PAN carve-out) when PAN is null", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ panNumber: null }), + }); + expect(result.tdsRate).toBe(NO_PAN_RATE_194O); + expect(result.tdsAmountPaise).toBe(5_000); + expect(result.fallbackApplied).toBe(true); + expect(result.reason).toMatch(/194-O no-PAN fallback/i); + }); + + it("withholds 5% (194-O no-PAN carve-out) when PAN is malformed", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ panNumber: "NOT-A-PAN" }), + }); + expect(result.tdsRate).toBe(NO_PAN_RATE_194O); + expect(result.tdsAmountPaise).toBe(5_000); + expect(result.fallbackApplied).toBe(true); + expect(result.reason).toMatch(/194-O no-PAN fallback/i); + }); +}); + +describe("computeTdsForPayout — DTAA (NON_RESIDENT)", () => { + it("US non-resident under 194-O default keeps the 0.1% section rate (DTAA 10% is higher)", () => { + // Spec: DTAA only overrides if strictly LOWER than the section + // default. US treaty rate is 10%, default 194-O is 0.1% → section + // wins. + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ + residencyStatus: "NON_RESIDENT", + providerCountry: "US", + }), + }); + expect(result.tdsSection).toBe("194O"); + expect(result.tdsRate).toBeCloseTo(0.001); + expect(result.tdsAmountPaise).toBe(100); + expect(result.dtaaRateApplied).toBeNull(); + expect(result.reason).toMatch(/not lower/i); + }); + + it("US non-resident with 194J override keeps section default (DTAA 10% == section 10%, not lower)", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ + residencyStatus: "NON_RESIDENT", + providerCountry: "US", + tdsSection: "194J", + }), + }); + expect(result.tdsSection).toBe("194J"); + expect(result.tdsRate).toBeCloseTo(0.1); + expect(result.tdsAmountPaise).toBe(10_000); + expect(result.dtaaRateApplied).toBeNull(); + }); + + it("non-resident from a country outside the DTAA table falls back to section default", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ + residencyStatus: "NON_RESIDENT", + providerCountry: "ZZ", // not in dtaa-rates.json + }), + }); + expect(result.tdsSection).toBe("194O"); + expect(result.tdsRate).toBeCloseTo(0.001); + expect(result.dtaaRateApplied).toBeNull(); + expect(result.reason).toMatch(/no dtaa entry/i); + }); +}); + +describe("computeTdsForPayout — Section 197 lower-rate certificate", () => { + it("uses cert rate (5%) when both cert ref and tdsRateBps are set", () => { + const result = computeTdsForPayout({ + grossAmountPaise: 100_000, + consultant: profile({ + tdsLowerRateCert: "CERT-2025-00042", + tdsRateBps: 500, // #781 §C — 5% as integer bps + }), + }); + expect(result.tdsRate).toBeCloseTo(0.05); + expect(result.tdsAmountPaise).toBe(5_000); + expect(result.fallbackApplied).toBe(false); + expect(result.reason).toMatch(/197/); + }); +}); + +describe("isValidPan", () => { + it("accepts a well-formed PAN", () => { + expect(isValidPan("ABCDE1234F")).toBe(true); + }); + it("rejects null / empty / mis-shapen values", () => { + expect(isValidPan(null)).toBe(false); + expect(isValidPan(undefined)).toBe(false); + expect(isValidPan("")).toBe(false); + expect(isValidPan("abcde1234f")).toBe(false); // lowercase + expect(isValidPan("ABCDE12345")).toBe(false); // wrong shape + }); +}); diff --git a/__tests__/enterprise/tds-org-payout-input.test.ts b/__tests__/enterprise/tds-org-payout-input.test.ts new file mode 100644 index 000000000..bd8856560 --- /dev/null +++ b/__tests__/enterprise/tds-org-payout-input.test.ts @@ -0,0 +1,127 @@ +/** + * @jest-environment node + */ + +/** + * Validates the TDS inputs that `org-payout-service.ts` constructs for + * `computeTdsForPayout` produce the expected withholding shape. The + * full `computeTdsForPayout` matrix is covered in tds-derivation.test.ts; + * this file pins down the org-payout-specific shape: + * - host orgs are RESIDENT in v1 + * - PAN is encrypted at rest → signalled via `panOnFile` (#785), NOT by + * passing the ciphertext as `panNumber` + * - Section 194-O default (0.1%) applies when a PAN is on file + * - missing PAN → Section 194-O 5% no-PAN carve-out + * + * Together these guard against the regression where the org pipeline + * silently ships `tdsAmountPaise=0` because the old code never called + * the TDS helper at all — and the #785 regression where the encrypted-PAN + * ciphertext was passed as `panNumber` and wrongly hit the 5% fallback. + */ + +import { computeTdsForPayout } from "@/lib/compliance/tds"; + +describe("org payout TDS construction", () => { + it("Resident host org with valid PAN → 194-O 0.1%", () => { + const r = computeTdsForPayout({ + grossAmountPaise: 1_000_000, + consultant: { + panNumber: "AAACA1234B", + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: null, + }, + }); + expect(r.tdsSection).toBe("194O"); + expect(r.tdsRate).toBeCloseTo(0.001, 6); + expect(r.tdsAmountPaise).toBe(1_000); // 0.1% of 10L paise + expect(r.fallbackApplied).toBe(false); + }); + + it("Resident host org with missing PAN → 194-O 5% no-PAN carve-out", () => { + const r = computeTdsForPayout({ + grossAmountPaise: 1_000_000, + consultant: { + panNumber: null, + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: null, + }, + }); + expect(r.tdsRate).toBeCloseTo(0.05, 6); + expect(r.tdsAmountPaise).toBe(50_000); + expect(r.fallbackApplied).toBe(true); + }); + + it("Resident host org with malformed PAN → 194-O 5% no-PAN carve-out", () => { + const r = computeTdsForPayout({ + grossAmountPaise: 1_000_000, + consultant: { + panNumber: "invalid", + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: null, + }, + }); + expect(r.fallbackApplied).toBe(true); + expect(r.tdsAmountPaise).toBe(50_000); + }); + + it("#785 — encrypted PAN on file (the REAL org-payout input) → 0.1%, not 5%", () => { + // org-payout-service passes panNumber:null + panOnFile:!!panEncrypted. + const r = computeTdsForPayout({ + grossAmountPaise: 4_000_000, + consultant: { + panNumber: null, + panOnFile: true, + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: null, + }, + }); + expect(r.tdsSection).toBe("194O"); + expect(r.tdsRate).toBeCloseTo(0.001, 6); + expect(r.tdsAmountPaise).toBe(4_000); // 0.1% — NOT 200_000 (the 5% bug) + expect(r.fallbackApplied).toBe(false); + }); + + it("#785 — passing the ciphertext as panNumber WOULD wrongly fall back (documents the bug)", () => { + const r = computeTdsForPayout({ + grossAmountPaise: 4_000_000, + consultant: { + panNumber: "ENCRYPTED", // the old, wrong shape — ciphertext as PAN + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: null, + }, + }); + // This is why callers MUST use panOnFile: the ciphertext fails isValidPan. + expect(r.fallbackApplied).toBe(true); + expect(r.tdsAmountPaise).toBe(200_000); // 5% — the over-withholding + }); + + it("rounds down (Math.floor) — never over-withholds", () => { + const r = computeTdsForPayout({ + grossAmountPaise: 9_999, // 0.1% = 9.999 paise → 9 + consultant: { + panNumber: "AAACA1234B", + residencyStatus: "RESIDENT", + tdsSection: null, + tdsRateBps: null, + tdsLowerRateCert: null, + providerCountry: null, + }, + }); + expect(r.tdsAmountPaise).toBe(9); + }); +}); diff --git a/__tests__/enterprise/transitions.test.ts b/__tests__/enterprise/transitions.test.ts new file mode 100644 index 000000000..7609a766b --- /dev/null +++ b/__tests__/enterprise/transitions.test.ts @@ -0,0 +1,216 @@ +/** + * @jest-environment node + */ + +/** + * The central transition helper bakes each enum's allowed-from set into the + * UPDATE WHERE clause, so an illegal transition matches zero rows and throws + * instead of corrupting state. These tests assert the maps (every legal edge, + * representative terminal re-entries) and the helper contract (throw on count + * 0, audit only after a successful CAS) against a mocked delegate — no DB. + */ + +import { + ASSIGNMENT_ALLOWED_FROM, + CONTRACT_ALLOWED_FROM, + INVOICE_ALLOWED_FROM, + IllegalTransitionError, + MEMBER_ALLOWED_FROM, + ORG_ALLOWED_FROM, + ORG_PAYOUT_ACCOUNT_ALLOWED_FROM, + PAYOUT_ALLOWED_FROM, + PO_ALLOWED_FROM, + PROGRAM_ALLOWED_FROM, + TERMINAL_STATES, + WALLET_TOPUP_ALLOWED_FROM, + transitionContract, + transitionMembership, + transitionOrgInvoice, + transitionOrgPayout, + transitionOrgPayoutAccount, + transitionOrganization, + transitionProgram, + transitionProgramAssignment, + transitionPurchaseOrder, +} from "@/lib/enterprise/transitions"; + +function mockTx(count: number) { + const updateMany = jest.fn().mockResolvedValue({ count }); + const create = jest.fn().mockResolvedValue({}); + return { + updateMany, + create, + tx: { + organization: { updateMany }, + contract: { updateMany }, + program: { updateMany }, + programAssignment: { updateMany }, + membership: { updateMany }, + organizationInvoice: { updateMany }, + purchaseOrder: { updateMany }, + organizationPayoutAccount: { updateMany }, + organizationPayout: { updateMany }, + orgAuditLog: { create }, + // Wrappers only touch their Pick'd delegates; the cast keeps the mock flat. + } as never, + }; +} + +// [wrapper, map] table so every legal edge of every enum is exercised. +const WRAPPERS = [ + ["Organization", transitionOrganization, ORG_ALLOWED_FROM], + ["Contract", transitionContract, CONTRACT_ALLOWED_FROM], + ["Program", transitionProgram, PROGRAM_ALLOWED_FROM], + ["ProgramAssignment", transitionProgramAssignment, ASSIGNMENT_ALLOWED_FROM], + ["Membership", transitionMembership, MEMBER_ALLOWED_FROM], + ["OrganizationInvoice", transitionOrgInvoice, INVOICE_ALLOWED_FROM], + ["PurchaseOrder", transitionPurchaseOrder, PO_ALLOWED_FROM], + ["OrganizationPayoutAccount", transitionOrgPayoutAccount, ORG_PAYOUT_ACCOUNT_ALLOWED_FROM], + ["OrganizationPayout", transitionOrgPayout, PAYOUT_ALLOWED_FROM], +] as const; + +describe("transition wrappers — CAS contract", () => { + describe.each(WRAPPERS)("%s", (entity, wrapper, map) => { + const reachableTargets = Object.entries(map) + .filter(([, froms]) => (froms as string[]).length > 0) + .map(([to]) => to); + + it.each(reachableTargets)( + "to %s compiles the allowed-from set into the WHERE and resolves on count 1", + async (to) => { + const m = mockTx(1); + await expect( + (wrapper as never as (tx: never, args: object) => Promise)(m.tx, { + where: { id: "row-1" }, + to, + }), + ).resolves.toBeUndefined(); + expect(m.updateMany).toHaveBeenCalledWith({ + where: { + id: "row-1", + status: { in: map[to as keyof typeof map] }, + }, + data: { status: to }, + }); + }, + ); + + it(`throws IllegalTransitionError (httpStatus 409) on count 0`, async () => { + const m = mockTx(0); + const to = reachableTargets[0]; + const err = await ( + wrapper as never as (tx: never, args: object) => Promise + )(m.tx, { where: { id: "row-1" }, to }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(IllegalTransitionError); + expect((err as IllegalTransitionError).httpStatus).toBe(409); + expect((err as IllegalTransitionError).entity).toBe(entity); + }); + + it("does not write the audit row when the CAS matched zero rows", async () => { + const m = mockTx(0); + await (wrapper as never as (tx: never, args: object) => Promise)( + m.tx, + { + where: { id: "row-1" }, + to: reachableTargets[0], + audit: { + organizationId: "org-1", + actorMembershipId: null, + category: "SYSTEM", + action: "X", + description: "x", + }, + }, + ).catch(() => undefined); + expect(m.create).not.toHaveBeenCalled(); + }); + + it("writes the audit row in-tx after a successful CAS", async () => { + const m = mockTx(1); + await (wrapper as never as (tx: never, args: object) => Promise)( + m.tx, + { + where: { id: "row-1" }, + to: reachableTargets[0], + audit: { + organizationId: "org-1", + actorMembershipId: "mem-1", + category: "SYSTEM", + action: "X", + description: "x", + }, + }, + ); + expect(m.create).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe("terminal re-entry is structurally impossible", () => { + it("a terminal state never appears in any allowed-from set of its enum", () => { + const pairs = [ + [ORG_ALLOWED_FROM, TERMINAL_STATES.OrgStatus], + [CONTRACT_ALLOWED_FROM, TERMINAL_STATES.ContractStatus], + [PROGRAM_ALLOWED_FROM, TERMINAL_STATES.ProgramStatus], + [ASSIGNMENT_ALLOWED_FROM, TERMINAL_STATES.AssignmentStatus], + [MEMBER_ALLOWED_FROM, TERMINAL_STATES.MemberStatus], + [INVOICE_ALLOWED_FROM, TERMINAL_STATES.OrgInvoiceStatus], + [PO_ALLOWED_FROM, TERMINAL_STATES.PoStatus], + [PAYOUT_ALLOWED_FROM, TERMINAL_STATES.PayoutStatus], + [WALLET_TOPUP_ALLOWED_FROM, TERMINAL_STATES.WalletTopUpStatus], + ] as const; + for (const [map, terminals] of pairs) { + const froms = new Set(Object.values(map).flat()); + for (const t of Array.from(terminals)) expect(froms.has(t)).toBe(false); + } + }); + + it("expected terminal sets (audit S3 — resurrectable states are the bug class)", () => { + expect(Array.from(TERMINAL_STATES.OrgStatus).sort()).toEqual(["DEACTIVATED"]); + expect(Array.from(TERMINAL_STATES.ContractStatus).sort()).toEqual([ + "EXPIRED", + "TERMINATED", + ]); + expect(Array.from(TERMINAL_STATES.ProgramStatus).sort()).toEqual([ + "CANCELLED", + "EXPIRED", + ]); + expect(Array.from(TERMINAL_STATES.AssignmentStatus).sort()).toEqual([ + "CANCELLED", + "CLOSED", + "ROLLED", + ]); + expect(Array.from(TERMINAL_STATES.MemberStatus).sort()).toEqual(["ERASED"]); + expect(Array.from(TERMINAL_STATES.OrgInvoiceStatus).sort()).toEqual([ + "CANCELLED", + "REFUNDED", + "VOID", + ]); + expect(Array.from(TERMINAL_STATES.PoStatus).sort()).toEqual([ + "CANCELLED", + "CLOSED", + ]); + // Bank-detail changes can re-verify from any payout-account state. + expect(Array.from(TERMINAL_STATES.OrgPayoutAccountStatus)).toEqual([]); + expect(Array.from(TERMINAL_STATES.PayoutStatus).sort()).toEqual([ + "CANCELLED", + "FAILED", + "REVERSED", + ]); + expect(Array.from(TERMINAL_STATES.WalletTopUpStatus).sort()).toEqual([ + "CONFIRMED", + "FAILED", + ]); + }); + + it.each([ + ["TERMINATED contract → ACTIVE", CONTRACT_ALLOWED_FROM.ACTIVE, "TERMINATED"], + ["CANCELLED program → ACTIVE", PROGRAM_ALLOWED_FROM.ACTIVE, "CANCELLED"], + ["CLOSED PO → ACTIVE", PO_ALLOWED_FROM.ACTIVE, "CLOSED"], + ["DEACTIVATED org → ACTIVE", ORG_ALLOWED_FROM.ACTIVE, "DEACTIVATED"], + ["REFUNDED invoice → PAID", INVOICE_ALLOWED_FROM.PAID, "REFUNDED"], + ["REVERSED payout → COMPLETED", PAYOUT_ALLOWED_FROM.COMPLETED, "REVERSED"], + ])("%s is not a legal edge", (_label, froms, from) => { + expect(froms).not.toContain(from); + }); +}); diff --git a/__tests__/enterprise/webhook-dispatch-gaps.test.ts b/__tests__/enterprise/webhook-dispatch-gaps.test.ts new file mode 100644 index 000000000..5eb5a49a1 --- /dev/null +++ b/__tests__/enterprise/webhook-dispatch-gaps.test.ts @@ -0,0 +1,97 @@ +/** + * @jest-environment node + */ + +/** + * Regression coverage for the webhook dispatch gaps found in the #789 audit. + * + * Each block asserts the CORRECT routing. Before the dispatch-switch fixes in + * razorpay-dispatch.ts these assertions fail (the events fall through to the + * `default` "unhandled" branch and the handler is never called); after the fix + * they pass. The handlers themselves are mocked so we are testing routing only. + */ + +import { processRazorpayWebhookEvent } from "../../app/api/webhooks/razorpay-dispatch"; + +const handleRazorpayPayoutWebhook = jest.fn().mockResolvedValue(undefined); +const handleDisputeUpdated = jest.fn().mockResolvedValue(undefined); + +jest.mock("../../app/api/webhooks/utils", () => ({ + __esModule: true, + handlePaymentFailure: jest.fn(), + handlePaymentSuccess: jest.fn(), + handleOrgPaymentSuccess: jest.fn(), + handleOrgPaymentFailure: jest.fn(), + handleRefundCreated: jest.fn(), + handleDisputeCreated: jest.fn(), + handleDisputeUpdated: (...args: unknown[]) => handleDisputeUpdated(...args), + markWebhookEventProcessed: jest.fn().mockResolvedValue(undefined), + handleRazorpayPayoutWebhook: (...args: unknown[]) => + handleRazorpayPayoutWebhook(...args), +})); + +jest.mock("../../lib/payments/webhooks/overage-handlers", () => ({ + __esModule: true, + handleOverageMemberSuccess: jest.fn(), + handleOverageMemberFailure: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +function payoutEnvelope(status: string) { + return { + event: `payout.${status}`, + payload: { payout: { entity: { id: "pout_test_1", status } } }, + }; +} + +function disputeEnvelope(suffix: string, status: string) { + return { + event: `payment.dispute.${suffix}`, + payload: { dispute: { entity: { id: "disp_test_1", status } } }, + }; +} + +describe("RazorpayX payout.failed routing", () => { + it("routes payout.failed to the payout reconciler (not the default drop)", async () => { + await processRazorpayWebhookEvent( + payoutEnvelope("failed") as never, + "payout.failed", + "payout.failed:pout_test_1", + ); + expect(handleRazorpayPayoutWebhook).toHaveBeenCalledWith( + "payout.failed", + expect.objectContaining({ id: "pout_test_1", status: "failed" }), + ); + }); +}); + +describe("dispute lifecycle event routing", () => { + it("routes payment.dispute.under_review to handleDisputeUpdated", async () => { + await processRazorpayWebhookEvent( + disputeEnvelope("under_review", "under_review") as never, + "payment.dispute.under_review", + "payment.dispute.under_review:disp_test_1", + ); + expect(handleDisputeUpdated).toHaveBeenCalledWith( + "disp_test_1", + "under_review", + null, + ); + }); + + it("routes payment.dispute.action_required to handleDisputeUpdated", async () => { + await processRazorpayWebhookEvent( + disputeEnvelope("action_required", "action_required") as never, + "payment.dispute.action_required", + "payment.dispute.action_required:disp_test_1", + ); + expect(handleDisputeUpdated).toHaveBeenCalledWith( + "disp_test_1", + "action_required", + null, + ); + }); +}); diff --git a/__tests__/enterprise/webhooks/dispatch.test.ts b/__tests__/enterprise/webhooks/dispatch.test.ts new file mode 100644 index 000000000..730a9848e --- /dev/null +++ b/__tests__/enterprise/webhooks/dispatch.test.ts @@ -0,0 +1,123 @@ +/** + * @jest-environment node + */ + +/** + * `dispatchWebhookEvent` is a fan-out helper — given an + * organizationId + eventType, it must: + * + * 1. SELECT only ACTIVE endpoints whose `eventSubscriptions` array + * contains the eventType. + * 2. INSERT one `OutboundWebhookDelivery` row per matching endpoint + * with status=PENDING and the supplied payload as JSONB. + * + * The route handlers fire-and-forget against this helper, so the + * happy-path and zero-subscriber path are the load-bearing cases. + */ + +import { dispatchWebhookEvent } from "@/lib/enterprise/outbound-webhooks/dispatch"; + +function makePrismaStub() { + const findMany = jest.fn(); + const createMany = jest.fn(); + return { + prisma: { + webhookEndpoint: { findMany }, + outboundWebhookDelivery: { createMany }, + }, + findMany, + createMany, + }; +} + +describe("dispatchWebhookEvent", () => { + it("enqueues a delivery row per subscribed ACTIVE endpoint", async () => { + const { prisma, findMany, createMany } = makePrismaStub(); + findMany.mockResolvedValue([{ id: "ep-1" }, { id: "ep-2" }]); + createMany.mockResolvedValue({ count: 2 }); + + const result = await dispatchWebhookEvent({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + organizationId: "org-1", + eventType: "invoice.issued", + payload: { invoiceId: "inv-1", totalPaise: 5000 }, + }); + + expect(result.enqueuedCount).toBe(2); + // The subscriber-lookup MUST filter on status=ACTIVE and the + // eventSubscriptions `has` predicate; without these two conditions + // a PAUSED endpoint would receive events. + expect(findMany).toHaveBeenCalledWith({ + where: { + organizationId: "org-1", + status: "ACTIVE", + eventSubscriptions: { has: "invoice.issued" }, + }, + select: { id: true }, + }); + expect(createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + webhookEndpointId: "ep-1", + eventType: "invoice.issued", + status: "PENDING", + }), + expect.objectContaining({ + webhookEndpointId: "ep-2", + eventType: "invoice.issued", + status: "PENDING", + }), + ], + }); + }); + + it("short-circuits cleanly when no endpoints match (zero subscribers)", async () => { + const { prisma, findMany, createMany } = makePrismaStub(); + findMany.mockResolvedValue([]); + const result = await dispatchWebhookEvent({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + organizationId: "org-1", + eventType: "member.added", + payload: { membershipId: "m-1" }, + }); + expect(result.enqueuedCount).toBe(0); + // Critical: we must NOT call createMany with empty data — Prisma + // would no-op, but the explicit early return keeps the SQL log + // clean and makes the route's audit trail less noisy. + expect(createMany).not.toHaveBeenCalled(); + }); + + it("persists the payload verbatim so receivers see the producer's shape", async () => { + const { prisma, findMany, createMany } = makePrismaStub(); + findMany.mockResolvedValue([{ id: "ep-1" }]); + createMany.mockResolvedValue({ count: 1 }); + + const payload = { + invoiceId: "inv-1", + totalPaise: 5000, + // Nested structure + non-trivial scalar shapes get serialized to + // JSONB byte-for-byte; the test pins that we don't accidentally + // re-shape before insertion. + nested: { currency: "INR", buyer: { gstin: "06ABCDE1234F1Z5" } }, + tags: ["enterprise", "annual"], + }; + + await dispatchWebhookEvent({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + organizationId: "org-1", + eventType: "invoice.issued", + payload, + }); + + expect(createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + payload, // referential equality is enough — same object + }), + ], + }); + }); +}); diff --git a/__tests__/enterprise/webhooks/signing.test.ts b/__tests__/enterprise/webhooks/signing.test.ts new file mode 100644 index 000000000..9c0ac3c9c --- /dev/null +++ b/__tests__/enterprise/webhooks/signing.test.ts @@ -0,0 +1,97 @@ +/** + * @jest-environment node + */ + +/** + * Pin the HMAC-SHA256 signing contract. Receivers depend on these + * invariants — any drift here breaks every integrator's signature + * check at once, so the assertions are intentionally low-level. + */ + +import { + DEFAULT_REPLAY_WINDOW_SECONDS, + generateEndpointSecret, + signPayload, + verifySignature, + SIGNATURE_HEADER, +} from "@/lib/enterprise/outbound-webhooks/signing"; + +describe("signPayload / verifySignature roundtrip", () => { + const secret = "test-secret-very-long-32-bytes-or-more-abcdef"; + const body = JSON.stringify({ id: "evt_1", type: "invoice.issued" }); + + it("verifies a signature produced for the same body + secret + timestamp", () => { + const ts = 1_700_000_000; + const header = signPayload(secret, body, ts); + const result = verifySignature(secret, body, header, { + now: () => ts, + }); + expect(result.valid).toBe(true); + }); + + it("rejects a body mutation (the signature does not commute with edits)", () => { + const ts = 1_700_000_000; + const header = signPayload(secret, body, ts); + const result = verifySignature(secret, `${body} `, header, { + now: () => ts, + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("SIGNATURE_MISMATCH"); + }); + + it("rejects a different secret (forgery attempt)", () => { + const ts = 1_700_000_000; + const header = signPayload(secret, body, ts); + const result = verifySignature("wrong-secret", body, header, { + now: () => ts, + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("SIGNATURE_MISMATCH"); + }); + + it("rejects timestamps outside the replay window", () => { + const ts = 1_700_000_000; + const header = signPayload(secret, body, ts); + // Simulate "now" being 12h ahead → far outside the 9h default window. + const tooLate = ts + DEFAULT_REPLAY_WINDOW_SECONDS + 1; + const result = verifySignature(secret, body, header, { + now: () => tooLate, + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("REPLAY_WINDOW_EXCEEDED"); + }); + + it("rejects malformed header values gracefully (no throw)", () => { + expect(verifySignature(secret, body, "not-a-sig-header").valid).toBe( + false, + ); + // Missing t= component. + expect(verifySignature(secret, body, "v1=abcd").valid).toBe(false); + // Missing v1= component. + expect(verifySignature(secret, body, "t=12345").valid).toBe(false); + }); + + it("produces a header in the documented shape (t=,v1=)", () => { + const header = signPayload(secret, body, 1_700_000_000); + expect(header).toMatch(/^t=1700000000,v1=[a-f0-9]{64}$/); + }); +}); + +describe("generateEndpointSecret", () => { + it("returns 64 hex chars (32 bytes of entropy)", () => { + const secret = generateEndpointSecret(); + expect(secret).toMatch(/^[a-f0-9]{64}$/); + }); + + it("is non-deterministic across calls", () => { + const a = generateEndpointSecret(); + const b = generateEndpointSecret(); + expect(a).not.toBe(b); + }); +}); + +describe("SIGNATURE_HEADER", () => { + it("matches the documented header name (integrators copy-paste this)", () => { + expect(SIGNATURE_HEADER).toBe("X-Familiarise-Signature"); + }); +}); diff --git a/__tests__/enterprise/webhooks/worker.test.ts b/__tests__/enterprise/webhooks/worker.test.ts new file mode 100644 index 000000000..53e49de95 --- /dev/null +++ b/__tests__/enterprise/webhooks/worker.test.ts @@ -0,0 +1,292 @@ +/** + * @jest-environment node + */ + +/** + * `runDispatchTick` is the only thing that hits the network on the + * outbound webhook path, so its retry / backoff / status semantics + * carry the integrator contract. The tests below pin: + * + * - 2xx → SUCCESS with deliveredAt + endpoint.lastSuccessAt. + * - Permanent 4xx (400/403/404/...) → FAILED with no retry slot. + * - 5xx / 408 / 429 / network error → RETRY with the next backoff + * unless we just used attempt #5, in which case → DEAD_LETTER + * (delivery-starved, operator-replayable; FAILED stays receiver-rejected). + * - Endpoint flipped to PAUSED/DISABLED after enqueue → row marked + * FAILED with an explicit reason (operator pause wins). + */ + +import { runDispatchTick } from "@/lib/enterprise/outbound-webhooks/worker"; + +function makeRow(overrides: Partial> = {}) { + return { + id: "del-1", + webhookEndpointId: "ep-1", + eventType: "invoice.issued", + payload: { id: "inv-1" }, + signature: null, + status: "PENDING", + httpStatusCode: null, + attempts: 0, + nextRetryAt: null, + lastError: null, + createdAt: new Date("2026-05-15T10:00:00Z"), + deliveredAt: null, + endpoint: { + id: "ep-1", + url: "https://receiver.example/webhook", + secret: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + status: "ACTIVE", + }, + ...overrides, + }; +} + +// #812 — `claimCount` lets a test simulate another tick winning the guarded +// atomic claim (updateMany returns {count:0}); the claimed row is then skipped +// without any dispatch. Default {count:1} = this tick owns the row. +function makePrismaStub( + initialRow: ReturnType, + { claimCount = 1 }: { claimCount?: number } = {}, +) { + const updates: Array> = []; + const claims: Array> = []; + const endpointUpdates: Array> = []; + return { + prisma: { + outboundWebhookDelivery: { + findMany: jest.fn().mockResolvedValue([initialRow]), + // #812 — the IN_FLIGHT soft lock is now a guarded atomic claim. + updateMany: jest.fn().mockImplementation((args) => { + claims.push(args); + return Promise.resolve({ count: claimCount }); + }), + update: jest.fn().mockImplementation((args) => { + updates.push(args); + return Promise.resolve({ id: initialRow.id }); + }), + }, + webhookEndpoint: { + update: jest.fn().mockImplementation((args) => { + endpointUpdates.push(args); + return Promise.resolve({ id: initialRow.endpoint.id }); + }), + }, + }, + updates, + claims, + endpointUpdates, + }; +} + +function mockFetch(impl: (url: string, init?: RequestInit) => Promise | Response) { + // Cast through `unknown` because the global `fetch` signature carries + // request-input overloads we don't need here. + return jest.fn(impl) as unknown as typeof fetch; +} + +const FROZEN_NOW_MS = new Date("2026-05-15T12:00:00Z").getTime(); + +describe("runDispatchTick — success path", () => { + it("marks 2xx as SUCCESS, bumps endpoint.lastSuccessAt, resets failureCount", async () => { + const row = makeRow(); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => new Response("", { status: 200 })); + + const result = await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + + expect(result.succeeded).toBe(1); + // The guarded claim (updateMany) flips PENDING → IN_FLIGHT; the lone + // `update` then finalizes. + expect(stub.claims[0]).toMatchObject({ + where: { id: "del-1", status: "PENDING" }, + data: { status: "IN_FLIGHT" }, + }); + const finalUpdate = stub.updates[0]; + expect(finalUpdate).toMatchObject({ + where: { id: "del-1" }, + data: expect.objectContaining({ + status: "SUCCESS", + httpStatusCode: 200, + attempts: 1, + }), + }); + expect(stub.endpointUpdates[0]).toMatchObject({ + where: { id: "ep-1" }, + data: expect.objectContaining({ + failureCount: 0, + }), + }); + }); +}); + +describe("runDispatchTick — permanent client error", () => { + it("marks a 400/403/404 as FAILED without scheduling a retry", async () => { + const row = makeRow(); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => new Response("bad", { status: 400 })); + + const result = await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + + expect(result.failed).toBe(1); + expect(result.retried).toBe(0); + const finalUpdate = stub.updates[0]; + expect(finalUpdate.data).toMatchObject({ + status: "FAILED", + httpStatusCode: 400, + lastError: expect.stringContaining("Permanent client error"), + }); + }); +}); + +describe("runDispatchTick — transient error / retry schedule", () => { + it("5xx → RETRY with attempt 2's backoff (5 minutes) on the first failure", async () => { + const row = makeRow(); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => new Response("", { status: 503 })); + + await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + + const finalUpdate = stub.updates[0]; + expect(finalUpdate.data).toMatchObject({ + status: "RETRY", + httpStatusCode: 503, + attempts: 1, + }); + // Attempt 1 just failed → schedule attempt 2 at +5min. + const expectedNext = new Date(FROZEN_NOW_MS + 5 * 60_000); + expect((finalUpdate.data as { nextRetryAt: Date }).nextRetryAt.toISOString()).toBe( + expectedNext.toISOString(), + ); + }); + + it("429 (rate-limit) is treated as transient (NOT a permanent client error)", async () => { + const row = makeRow(); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => new Response("", { status: 429 })); + + await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + expect(stub.updates[0].data).toMatchObject({ + status: "RETRY", + httpStatusCode: 429, + }); + }); + + it("after attempt 5 fails, flips to DEAD_LETTER instead of scheduling attempt 6", async () => { + const row = makeRow({ attempts: 4 }); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => new Response("", { status: 502 })); + + await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + expect(stub.updates[0].data).toMatchObject({ + status: "DEAD_LETTER", + attempts: 5, + lastError: expect.stringContaining("Exhausted retries"), + }); + }); + + it("network error (fetch throws) follows the same retry path as a 5xx", async () => { + const row = makeRow(); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => { + throw new Error("ECONNREFUSED"); + }); + + await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + expect(stub.updates[0].data).toMatchObject({ + status: "RETRY", + lastError: "ECONNREFUSED", + }); + }); +}); + +describe("runDispatchTick — operator pause", () => { + it("aborts delivery when the endpoint flipped to PAUSED after enqueue", async () => { + const row = makeRow({ + endpoint: { + id: "ep-1", + url: "https://receiver.example/webhook", + secret: "x".repeat(64), + status: "PAUSED", + }, + }); + const stub = makePrismaStub(row); + const fetchFn = mockFetch(async () => { + // Should never be invoked — operator pause must short-circuit. + throw new Error("fetch should not be called"); + }); + + await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + + expect(fetchFn).not.toHaveBeenCalled(); + expect(stub.updates[0].data).toMatchObject({ + status: "FAILED", + lastError: expect.stringContaining("PAUSED"), + }); + }); +}); + +describe("runDispatchTick — guarded atomic claim (#812)", () => { + it("skips a row another tick already claimed (updateMany count===0) without dispatching", async () => { + const row = makeRow(); + // Simulate the race: this tick selected the row, but a concurrent tick + // flipped it to IN_FLIGHT first, so our guarded claim matches 0 rows. + const stub = makePrismaStub(row, { claimCount: 0 }); + const fetchFn = mockFetch(async () => { + throw new Error("fetch should not be called for a lost claim"); + }); + + const result = await runDispatchTick({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: stub.prisma as any, + fetchFn, + now: () => FROZEN_NOW_MS, + }); + + // The row was scanned but the claim was lost → no HTTP, no finalize update. + expect(result.scanned).toBe(1); + expect(result.succeeded).toBe(0); + expect(result.retried).toBe(0); + expect(result.failed).toBe(0); + expect(fetchFn).not.toHaveBeenCalled(); + expect(stub.claims[0]).toMatchObject({ + where: { id: "del-1", status: "PENDING" }, + }); + expect(stub.updates).toHaveLength(0); + }); +}); diff --git a/__tests__/enterprise/with-cron-lock.test.ts b/__tests__/enterprise/with-cron-lock.test.ts new file mode 100644 index 000000000..55eb542c7 --- /dev/null +++ b/__tests__/enterprise/with-cron-lock.test.ts @@ -0,0 +1,112 @@ +/** + * @jest-environment node + */ + +import { + withCronLock, + CronLockHeldError, + CronLockUnavailableError, + LONG_JOB_TTL_MS, +} from "../../lib/cron/with-cron-lock"; +import { + acquireLock, + releaseLock, + isMockRedis, + checkRedisHealth, +} from "../../lib/redis"; + +jest.mock("../../lib/redis", () => ({ + acquireLock: jest.fn(), + releaseLock: jest.fn().mockResolvedValue(undefined), + isMockRedis: jest.fn(), + checkRedisHealth: jest.fn(), +})); + +const mockAcquire = acquireLock as jest.Mock; +const mockRelease = releaseLock as jest.Mock; +const mockIsMock = isMockRedis as jest.Mock; +const mockHealth = checkRedisHealth as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockIsMock.mockReturnValue(false); + mockHealth.mockResolvedValue(true); + mockAcquire.mockResolvedValue("token-1"); +}); + +describe("withCronLock", () => { + it("acquires with the #476 key shape, runs, and releases", async () => { + const fn = jest.fn().mockResolvedValue("done"); + await expect( + withCronLock("dunning", { failMode: "closed" }, fn), + ).resolves.toBe("done"); + expect(mockAcquire).toHaveBeenCalledWith("cron:lock:dunning", 15 * 60 * 1000); + expect(mockRelease).toHaveBeenCalledWith("cron:lock:dunning", "token-1"); + }); + + it("honours a custom TTL", async () => { + await withCronLock( + "create-payout-batch", + { failMode: "closed", ttlMs: LONG_JOB_TTL_MS }, + async () => null, + ); + expect(mockAcquire).toHaveBeenCalledWith( + "cron:lock:create-payout-batch", + LONG_JOB_TTL_MS, + ); + }); + + it("throws CronLockHeldError (409) when the lock is held — job skips", async () => { + mockAcquire.mockResolvedValue(null); + const fn = jest.fn(); + const err = await withCronLock("dunning", { failMode: "closed" }, fn).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(CronLockHeldError); + expect((err as CronLockHeldError).httpStatus).toBe(409); + expect(fn).not.toHaveBeenCalled(); + }); + + it("releases even when the job throws", async () => { + const boom = new Error("boom"); + await expect( + withCronLock("dunning", { failMode: "closed" }, async () => { + throw boom; + }), + ).rejects.toBe(boom); + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + + it("fail-closed: refuses to run on mock Redis (pages via exit 1)", async () => { + mockIsMock.mockReturnValue(true); + const fn = jest.fn(); + await expect( + withCronLock("dunning", { failMode: "closed" }, fn), + ).rejects.toBeInstanceOf(CronLockUnavailableError); + expect(fn).not.toHaveBeenCalled(); + }); + + it("fail-closed: refuses to run when Redis is unhealthy (circuit open)", async () => { + mockHealth.mockResolvedValue(false); + await expect( + withCronLock("dunning", { failMode: "closed" }, async () => null), + ).rejects.toBeInstanceOf(CronLockUnavailableError); + expect(mockAcquire).not.toHaveBeenCalled(); + }); + + it("fail-open: runs unlocked on mock Redis with a warning", async () => { + mockIsMock.mockReturnValue(true); + const fn = jest.fn().mockResolvedValue(42); + await expect( + withCronLock("cleanup-auth-tokens", { failMode: "open" }, fn), + ).resolves.toBe(42); + expect(mockAcquire).not.toHaveBeenCalled(); + }); + + it("fail-open: still skips when the lock is genuinely held", async () => { + mockAcquire.mockResolvedValue(null); + await expect( + withCronLock("cleanup-auth-tokens", { failMode: "open" }, async () => 1), + ).rejects.toBeInstanceOf(CronLockHeldError); + }); +}); diff --git a/__tests__/fixtures/createBookingWithEarnings.ts b/__tests__/fixtures/createBookingWithEarnings.ts new file mode 100644 index 000000000..5f5600d47 --- /dev/null +++ b/__tests__/fixtures/createBookingWithEarnings.ts @@ -0,0 +1,85 @@ +/** + * Test fixture: produce the full object graph the deferred Round-3 + * booking tests need (Appointment → Payment → OrganizationEarnings → + * ledger entries) without rebuilding the entire booking UI flow. + * + * Why this is a TEST-ONLY fixture (not added to prisma/seed.ts) + * ------------------------------------------------------------ + * Production booking flow is exercised by the live B2C path + * (`POST /api/checkout/...` → Razorpay → webhook → settlement). This + * fixture exists for Round-3 enterprise tests that need a + * deterministic earnings row to assert against — tests for TDS + * derivation, MSME deadline alerts, payout cascade refunds, etc. + * Adding it to seed.ts would generate phantom earnings rows in dev + * environments that don't have the corresponding booking surface. + * + * The fixture intentionally mirrors `createEarningsFromPayment` at + * lib/payments/payouts/earnings-service.ts — if that helper's + * invariants change, this fixture must be updated in lockstep. The + * test files that use it should assert against the SAME columns the + * production code path writes. + * + * Usage: + * + * const { appointment, payment, earnings } = + * await createBookingWithEarnings({ + * consultantUserId, consulteeUserId, organizationId, + * totalPaise: 5_000_00, + * }); + * // ... assertions on earnings.netToOrgPaise, etc. + * + * Cleanup is the caller's responsibility — `prisma.appointment.delete` + * cascades to the rest. Most tests run inside an `afterEach` wipe. + */ + +import type { PrismaClient } from "@prisma/client"; + +export interface FixtureInput { + prisma: PrismaClient; + consultantUserId: string; + consulteeUserId: string; + /// HOST org that owns the rate-card + receives the earnings split. + organizationId: string; + totalPaise: number; + /// Optional rate-card override; if omitted, falls back to the + /// platform's default split (80/20). Useful when the test asserts + /// per-org bps math. + rateCardId?: string; +} + +export interface FixtureResult { + appointmentId: string; + paymentId: string; + earningsId: string | null; +} + +/** + * NOTE: The full implementation requires importing + * `createEarningsFromPayment` and a real Appointment + SlotOfAppointment + * graph. Production booking creates these via the + * `SlotAllocationService` — recreating that here would couple the + * fixture to internal scheduling APIs that drift independently of the + * earnings flow. + * + * The recommended path (tracked in #674 follow-up) is to extract the + * minimal "earnings-only" code path into a helper that takes a + * pre-existing Appointment + Payment id and writes only the + * OrganizationEarnings + ledger rows. This fixture is the placeholder + * for that extraction. + * + * Until then, tests that need this should seed via Supabase MCP + * directly (see `docs/enterprise/50-operations/03-runbooks.md` "Running cron jobs + * locally" → "Seeding test data" section) and call + * `createEarningsFromPayment` themselves. + */ +export async function createBookingWithEarnings( + input: FixtureInput, +): Promise { + // Implementation lands in a follow-up — see top-of-file note. The + // signature is stable so dependent tests can be written against it + // today. + void input; + throw new Error( + "createBookingWithEarnings is awaiting the earnings-only helper extraction; see __tests__/fixtures/createBookingWithEarnings.ts for the open task.", + ); +} diff --git a/__tests__/labels/org-errors.test.ts b/__tests__/labels/org-errors.test.ts new file mode 100644 index 000000000..b340434e3 --- /dev/null +++ b/__tests__/labels/org-errors.test.ts @@ -0,0 +1,25 @@ +import { + humanizeOrgError, + ORG_ERROR_COPY, +} from "@/lib/labels/org-errors"; + +describe("humanizeOrgError", () => { + it("maps known codes to human copy", () => { + expect(humanizeOrgError("ORG_NOT_VERIFIED")).toBe( + ORG_ERROR_COPY.ORG_NOT_VERIFIED, + ); + expect(humanizeOrgError("ROLE_TRANSITION_BLOCKED")).toBe( + ORG_ERROR_COPY.ROLE_TRANSITION_BLOCKED, + ); + }); + + it("passes unknown strings through unchanged", () => { + expect(humanizeOrgError("Something went wrong on the server")).toBe( + "Something went wrong on the server", + ); + }); + + it("never returns undefined for a present input", () => { + expect(humanizeOrgError("")).toBe(""); + }); +}); diff --git a/__tests__/labels/personal-dashboard.test.ts b/__tests__/labels/personal-dashboard.test.ts new file mode 100644 index 000000000..5d0d782aa --- /dev/null +++ b/__tests__/labels/personal-dashboard.test.ts @@ -0,0 +1,53 @@ +import { resolvePersonalDashboardHref } from "@/lib/labels/personal-dashboard"; + +describe("resolvePersonalDashboardHref", () => { + it("returns the org-workspace surface when orgWorkspaceProfileId is set", () => { + expect( + resolvePersonalDashboardHref({ + orgWorkspaceProfileId: "ow-1", + consultantProfileId: "cp-1", + consulteeProfileId: "ce-1", + }), + ).toBe("/dashboard/org-workspace/ow-1/home"); + }); + + it("falls back to consultant when no orgWorkspace exists", () => { + expect( + resolvePersonalDashboardHref({ + orgWorkspaceProfileId: null, + consultantProfileId: "cp-9", + consulteeProfileId: "ce-1", + }), + ).toBe("/dashboard/consultant/cp-9/home"); + }); + + it("falls back to consultee when only consulteeProfileId is set", () => { + expect( + resolvePersonalDashboardHref({ + orgWorkspaceProfileId: null, + consultantProfileId: null, + consulteeProfileId: "ce-7", + }), + ).toBe("/dashboard/consultee/ce-7/home"); + }); + + it("returns null when no profile ids are set", () => { + expect( + resolvePersonalDashboardHref({ + orgWorkspaceProfileId: null, + consultantProfileId: null, + consulteeProfileId: null, + }), + ).toBeNull(); + }); + + it("treats undefined and null the same way", () => { + expect( + resolvePersonalDashboardHref({ + orgWorkspaceProfileId: undefined, + consultantProfileId: undefined, + consulteeProfileId: "ce-only", + }), + ).toBe("/dashboard/consultee/ce-only/home"); + }); +}); diff --git a/__tests__/lib/feature-flags.test.ts b/__tests__/lib/feature-flags.test.ts new file mode 100644 index 000000000..a95e44b7c --- /dev/null +++ b/__tests__/lib/feature-flags.test.ts @@ -0,0 +1,57 @@ +/** + * Regression guard for the Arch-4 terminology purge: the feature flag + * renamed from `ENABLE_PROVIDER_ORGS` to `ENABLE_HOST_ORGS`. If someone + * reverts the rename by accident, the named export disappears and this + * file fails to type-check. + * + * The env-var behaviour is tested dynamically via `jest.isolateModules` + * because the constant reads `process.env` at module-load time; setting + * the env then importing gives us a fresh binding for each assertion. + */ + +describe("ENABLE_HOST_ORGS feature flag", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + delete process.env.ENABLE_HOST_ORGS; + delete process.env.ENABLE_PROVIDER_ORGS; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it("resolves to true when ENABLE_HOST_ORGS=true", () => { + process.env.ENABLE_HOST_ORGS = "true"; + jest.isolateModules(() => { + const mod = require("../../lib/feature-flags"); + expect(mod.ENABLE_HOST_ORGS).toBe(true); + }); + }); + + it("resolves to false when ENABLE_HOST_ORGS is unset", () => { + jest.isolateModules(() => { + const mod = require("../../lib/feature-flags"); + expect(mod.ENABLE_HOST_ORGS).toBe(false); + }); + }); + + it("does NOT honour the old ENABLE_PROVIDER_ORGS env var (no back-compat shim)", () => { + // Pre-launch app, single-release rename. If ops forgets to flip the + // env var the flag stays false — intentional fail-closed. + process.env.ENABLE_PROVIDER_ORGS = "true"; + jest.isolateModules(() => { + const mod = require("../../lib/feature-flags"); + expect(mod.ENABLE_HOST_ORGS).toBe(false); + }); + }); + + it("does not export the old ENABLE_PROVIDER_ORGS name", () => { + jest.isolateModules(() => { + const mod = require("../../lib/feature-flags"); + expect(mod.ENABLE_PROVIDER_ORGS).toBeUndefined(); + }); + }); +}); diff --git a/__tests__/lib/prisma-money-extension.test.ts b/__tests__/lib/prisma-money-extension.test.ts new file mode 100644 index 000000000..c59cb48e3 --- /dev/null +++ b/__tests__/lib/prisma-money-extension.test.ts @@ -0,0 +1,45 @@ +/** + * #780 drift guard — every BigInt column in the schema must appear in the + * lib/prisma.ts result-extension map. A new money column left out of the map + * would leak `bigint` past the JS boundary (JSON.stringify throws, arithmetic + * silently mixes types). Source-parses both files so the check needs no DB. + */ +import fs from "fs"; +import path from "path"; + +const root = path.join(__dirname, "..", ".."); +const schema = fs.readFileSync(path.join(root, "prisma", "schema.prisma"), "utf8"); +const client = fs.readFileSync(path.join(root, "lib", "prisma.ts"), "utf8"); + +function bigIntFields(): Array<{ model: string; field: string }> { + const out: Array<{ model: string; field: string }> = []; + const modelRe = /^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm; + let m: RegExpExecArray | null; + while ((m = modelRe.exec(schema))) { + const [, name, body] = m; + const fieldRe = /^\s*(\w+)\s+BigInt\??/gm; + let f: RegExpExecArray | null; + while ((f = fieldRe.exec(body))) out.push({ model: name, field: f[1] }); + } + return out; +} + +// Prisma client model property = model name with the first letter lowered +// (TDSRecord → tDSRecord). +const clientKey = (model: string) => model[0].toLowerCase() + model.slice(1); + +describe("#780 money-boundary extension drift", () => { + it("finds BigInt columns in the schema at all", () => { + expect(bigIntFields().length).toBeGreaterThanOrEqual(84); + }); + + it("covers every BigInt column in the extension map", () => { + const missing = bigIntFields().filter(({ model, field }) => { + const entry = new RegExp( + `${clientKey(model)}:\\s*\\{[^}]*\\b${field}:\\s*fn?\\("${field}"\\)`, + ); + return !entry.test(client); + }); + expect(missing).toEqual([]); + }); +}); diff --git a/__tests__/payments/multi-party-booking-journal.test.ts b/__tests__/payments/multi-party-booking-journal.test.ts new file mode 100644 index 000000000..d7dc41f50 --- /dev/null +++ b/__tests__/payments/multi-party-booking-journal.test.ts @@ -0,0 +1,418 @@ +/** + * @jest-environment node + */ + +/** + * #773 — multi-collaborator BOOKING journal coverage. + * + * `createEarningsFromPayment` must post ONE balanced `booking:` + * transaction covering owner + every collaborator + their host orgs: + * + * Dr funding legs (CASH/…/DISCOUNT plug) + * == Cr PLATFORM_FEE (primary fee + settled collabs' org-card fee slices) + * + Cr CONSULTANT_PAYABLE per party (settled collabs NET of org cut) + * + Cr ORG_PAYABLE per host org (primary + per-collab) + * + Cr GST_PAYABLE. + * + * Unlike collaborator-org-earnings.test.ts (which stubs postLedgerTxn), this + * suite runs the REAL postLedgerTxn against the mock tx, so the Σdebit == + * Σcredit invariant is genuinely enforced — an unbalanced posting fails the + * test the same way it would roll back a production booking (#812). + */ + +const ORG_LEARNPRO = "org-learnpro"; +const ORG_ANOTHER = "org-another-agency"; +const PRIMARY_PROFILE = "consultant-primary"; +const COLLAB_HOST_PROFILE = "consultant-collab-hosted"; +const COLLAB_INDEP_PROFILE = "consultant-collab-independent"; +const PLAN_ID = "plan-webinar-1"; +const PAYMENT_ID = "payment-1"; + +jest.mock("../../lib/feature-flags", () => ({ + ENABLE_HOST_ORGS: true, +})); + +jest.mock("../../lib/collaborators/service", () => ({ + calculateRevenueSplit: jest.fn(), +})); + +jest.mock("../../lib/api/organizations/rate-card", () => ({ + resolveEffectiveRateCard: jest.fn(), +})); + +type CapturedLedgerCreate = { + idempotencyKey: string; + kind: string; + paymentId: string | null; + entries: { + create: Array<{ + accountId: string; + direction: "DEBIT" | "CREDIT"; + amountPaise: bigint; + }>; + }; +}; + +type CapturedEarningsCreate = { + consultantProfileId: string; + platformFeePaise: number; + consultantSharePaise: number; + role?: string; +}; + +type CapturedOrgEarningsCreate = { + organizationId: string; + grossAmountPaise: number; + platformFeePaise: number; + orgSharePaise: number; + consultantSharePaise: number; +}; + +let capturedLedgerTxns: CapturedLedgerCreate[] = []; +let capturedEarnings: CapturedEarningsCreate[] = []; +let capturedOrgEarnings: CapturedOrgEarningsCreate[] = []; + +jest.mock("../../lib/prisma", () => { + const mockTx = { + ledgerTransaction: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation(async ({ data }: { data: CapturedLedgerCreate }) => { + capturedLedgerTxns.push(data); + return { id: "ltxn-" + capturedLedgerTxns.length }; + }), + }, + ledgerAccount: { + upsert: jest + .fn() + .mockImplementation(async ({ where }: { where: { id: string } }) => ({ + id: where.id, + })), + }, + ledgerAccountBalance: { upsert: jest.fn().mockResolvedValue({}) }, + paymentLeg: { findMany: jest.fn().mockResolvedValue([]) }, + consultantEarnings: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation( + async ({ data }: { data: CapturedEarningsCreate }) => { + capturedEarnings.push(data); + return { id: "earn-" + capturedEarnings.length, ...data }; + }, + ), + }, + organization: { + findUnique: jest.fn().mockResolvedValue({ status: "ACTIVE" }), + }, + organizationInvoice: { count: jest.fn().mockResolvedValue(1) }, + organizationEarnings: { + create: jest + .fn() + .mockImplementation( + async ({ data }: { data: CapturedOrgEarningsCreate }) => { + capturedOrgEarnings.push(data); + return { id: "org-earn-" + capturedOrgEarnings.length, ...data }; + }, + ), + }, + membership: { findFirst: jest.fn() }, + }; + return { + __esModule: true, + default: { + $transaction: jest + .fn() + .mockImplementation(async (fn: (tx: unknown) => Promise) => { + return await fn(mockTx); + }), + __mockTx: mockTx, + }, + }; +}); + +import prisma from "@/lib/prisma"; +import { calculateRevenueSplit } from "@/lib/collaborators/service"; +import { resolveEffectiveRateCard } from "@/lib/api/organizations/rate-card"; +import { createEarningsFromPayment } from "@/lib/payments/payouts/earnings-service"; + +const mockedTx = ( + prisma as unknown as { + __mockTx: { + membership: { findFirst: jest.Mock }; + consultantEarnings: { findFirst: jest.Mock; create: jest.Mock }; + ledgerTransaction: { findUnique: jest.Mock; create: jest.Mock }; + }; + } +).__mockTx; + +const mockedCalculateSplit = calculateRevenueSplit as jest.MockedFunction< + typeof calculateRevenueSplit +>; +const mockedResolveRateCard = resolveEffectiveRateCard as jest.MockedFunction< + typeof resolveEffectiveRateCard +>; + +function makePayment( + overrides: Partial<{ + id: string; + amount: number; + originalAmount: number; + taxAmount: number; + }> = {}, +) { + return { + id: overrides.id ?? PAYMENT_ID, + amount: overrides.amount ?? 118_000, + originalAmount: overrides.originalAmount ?? 100_000, + taxAmount: overrides.taxAmount ?? 18_000, + createdAt: new Date("2026-04-01T00:00:00Z"), + appointment: { + consultantProfile: { id: PRIMARY_PROFILE }, + webinar: { webinarPlanId: PLAN_ID }, + class: null, + }, + } as unknown as Parameters[0]["payment"]; +} + +function setMembershipMap( + map: Record< + string, + { orgId: string; payoutRecipient?: "SELF" | "ORGANIZATION" } | null + >, +) { + mockedTx.membership.findFirst.mockImplementation( + async (args: { where: { consultantProfileId: string } }) => { + const cfg = map[args.where.consultantProfileId]; + if (!cfg) return null; + return { + id: `mem-${args.where.consultantProfileId}`, + rateCardOverrideId: null, + payoutRecipient: cfg.payoutRecipient ?? "SELF", + organization: { id: cfg.orgId }, + }; + }, + ); +} + +/** Standard rate card: 10% platform / 5% org / 85% consultant. */ +function setStandardRateCard() { + mockedResolveRateCard.mockImplementation(async (_tx, params) => ({ + rateCardId: `rc-${params.orgId}`, + platformBps: 1000, + orgBps: 500, + consultantBps: 8500, + })); +} + +/** Decode the captured postings into { accountId, direction, paise } rows. */ +function legsOf(txn: CapturedLedgerCreate) { + return txn.entries.create.map((e) => ({ + accountId: e.accountId, + direction: e.direction, + paise: Number(e.amountPaise), + })); +} + +function legAmount( + txn: CapturedLedgerCreate, + direction: "DEBIT" | "CREDIT", + accountId: string, +): number { + return legsOf(txn) + .filter((l) => l.direction === direction && l.accountId === accountId) + .reduce((s, l) => s + l.paise, 0); +} + +beforeEach(() => { + jest.clearAllMocks(); + capturedLedgerTxns = []; + capturedEarnings = []; + capturedOrgEarnings = []; + mockedTx.consultantEarnings.findFirst.mockResolvedValue(null); + mockedTx.ledgerTransaction.findUnique.mockResolvedValue(null); + setStandardRateCard(); +}); + +describe("#773 multi-party booking journal", () => { + it("posts ONE balanced booking txn with per-party credit legs (hosted collab NET, org + fee slices split out)", async () => { + setMembershipMap({ + [PRIMARY_PROFILE]: { orgId: ORG_LEARNPRO }, + [COLLAB_HOST_PROFILE]: { orgId: ORG_ANOTHER }, + [COLLAB_INDEP_PROFILE]: null, // independent — full share, no org legs + }); + + // 100_000 gross + 18_000 GST, charged 118_000. Primary 10/5/85 card: + // fee 10_000, LearnPro 5_000, pool 85_000. Odd-paise shares exercise the + // #778 §C floors: the owner's 42_501 is the pool remainder after the + // collaborator floors (residual-to-OWNER, calculateRevenueSplit), and the + // hosted collab's 25_499 re-splits on ORG_ANOTHER's card with floors — + // fee 2_549, net 21_674, org absorbs the remainder 1_276. + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 42_501, role: "OWNER" }, + { consultantProfileId: COLLAB_HOST_PROFILE, share: 25_499, role: "CO_HOST" }, + { consultantProfileId: COLLAB_INDEP_PROFILE, share: 17_000, role: "CO_HOST" }, + ]); + + await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + // Exactly ONE booking txn, keyed booking:. + expect(capturedLedgerTxns).toHaveLength(1); + const txn = capturedLedgerTxns[0]; + expect(txn.idempotencyKey).toBe(`booking:${PAYMENT_ID}`); + expect(txn.kind).toBe("BOOKING"); + expect(txn.paymentId).toBe(PAYMENT_ID); + + // Balanced to the paise (postLedgerTxn would have thrown otherwise, but + // assert explicitly so a stub regression can't mask it). + const legs = legsOf(txn); + const debit = legs + .filter((l) => l.direction === "DEBIT") + .reduce((s, l) => s + l.paise, 0); + const credit = legs + .filter((l) => l.direction === "CREDIT") + .reduce((s, l) => s + l.paise, 0); + expect(debit).toBe(118_000); + expect(credit).toBe(118_000); + + // Funding side: legacy CASH (no PaymentLegs), no discount gap. + expect(legAmount(txn, "DEBIT", "CASH|_|_|INR")).toBe(118_000); + + // Per-party credits. PLATFORM_FEE = primary 10_000 + hosted collab's + // org-card slice 2_549 (one summed leg). + expect(legAmount(txn, "CREDIT", "PLATFORM_FEE|_|_|INR")).toBe(12_549); + expect( + legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${PRIMARY_PROFILE}|INR`), + ).toBe(42_501); + // Hosted collaborator: NET of ORG_ANOTHER's cut. + expect( + legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${COLLAB_HOST_PROFILE}|INR`), + ).toBe(21_674); + // Independent collaborator: full share. + expect( + legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${COLLAB_INDEP_PROFILE}|INR`), + ).toBe(17_000); + expect(legAmount(txn, "CREDIT", `ORG_PAYABLE|${ORG_LEARNPRO}|_|INR`)).toBe( + 5_000, + ); + expect(legAmount(txn, "CREDIT", `ORG_PAYABLE|${ORG_ANOTHER}|_|INR`)).toBe( + 1_276, + ); + expect(legAmount(txn, "CREDIT", "GST_PAYABLE|_|_|INR")).toBe(18_000); + + // The cached rows mirror the journal (EARNINGS_LEDGER_DRIFT contract): + // hosted collab's ConsultantEarnings carries the NET + the fee slice. + const hostedRow = capturedEarnings.find( + (e) => e.consultantProfileId === COLLAB_HOST_PROFILE, + ); + expect(hostedRow).toMatchObject({ + consultantSharePaise: 21_674, + platformFeePaise: 2_549, + }); + const indepRow = capturedEarnings.find( + (e) => e.consultantProfileId === COLLAB_INDEP_PROFILE, + ); + expect(indepRow).toMatchObject({ + consultantSharePaise: 17_000, + platformFeePaise: 0, + }); + const anotherOrgRow = capturedOrgEarnings.find( + (r) => r.organizationId === ORG_ANOTHER, + ); + expect(anotherOrgRow).toMatchObject({ + grossAmountPaise: 25_499, + platformFeePaise: 2_549, + orgSharePaise: 1_276, + consultantSharePaise: 21_674, + }); + }); + + it("floors the marketplace platform fee (#778 §C-2) — the consultant pool absorbs the remainder", async () => { + // No HOST orgs anywhere → flat PLATFORM_FEE_PERCENTAGE (20%) path. + setMembershipMap({ + [PRIMARY_PROFILE]: null, + [COLLAB_INDEP_PROFILE]: null, + }); + + // 99_999 × 20% = 19_999.8 → floor 19_999 (round would mint 20_000 and + // shave the pool); pool = 80_000, split 60_000 owner + 20_000 collab. + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 60_000, role: "OWNER" }, + { consultantProfileId: COLLAB_INDEP_PROFILE, share: 20_000, role: "CO_HOST" }, + ]); + + await createEarningsFromPayment({ + payment: makePayment({ + amount: 99_999, + originalAmount: 99_999, + taxAmount: 0, + }), + appointmentType: "WEBINAR", + }); + + expect(capturedLedgerTxns).toHaveLength(1); + const txn = capturedLedgerTxns[0]; + expect(legAmount(txn, "CREDIT", "PLATFORM_FEE|_|_|INR")).toBe(19_999); + expect( + legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${PRIMARY_PROFILE}|INR`), + ).toBe(60_000); + expect( + legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${COLLAB_INDEP_PROFILE}|INR`), + ).toBe(20_000); + const legs = legsOf(txn); + const debit = legs + .filter((l) => l.direction === "DEBIT") + .reduce((s, l) => s + l.paise, 0); + const credit = legs + .filter((l) => l.direction === "CREDIT") + .reduce((s, l) => s + l.paise, 0); + expect(debit).toBe(99_999); + expect(credit).toBe(99_999); + }); + + it("is idempotent at the journal level — an existing booking: txn is never re-posted", async () => { + setMembershipMap({ + [PRIMARY_PROFILE]: { orgId: ORG_LEARNPRO }, + [COLLAB_HOST_PROFILE]: { orgId: ORG_ANOTHER }, + }); + mockedCalculateSplit.mockResolvedValue([ + { consultantProfileId: PRIMARY_PROFILE, share: 59_501, role: "OWNER" }, + { consultantProfileId: COLLAB_HOST_PROFILE, share: 25_499, role: "CO_HOST" }, + ]); + // Crash-recovery shape: the journal txn survived but the earnings tx is + // being replayed — postLedgerTxn's fast-path must dedupe on the key. + mockedTx.ledgerTransaction.findUnique.mockResolvedValue({ + id: "ltxn-existing", + }); + + const ownerId = await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + expect(ownerId).not.toBeNull(); + expect(mockedTx.ledgerTransaction.create).not.toHaveBeenCalled(); + expect(capturedLedgerTxns).toHaveLength(0); + }); + + it("is idempotent on redelivery — existing earnings short-circuit before any journal work", async () => { + setMembershipMap({ [PRIMARY_PROFILE]: { orgId: ORG_LEARNPRO } }); + mockedCalculateSplit.mockResolvedValue([]); + mockedTx.consultantEarnings.findFirst.mockResolvedValue({ + id: "earn-existing", + }); + + const result = await createEarningsFromPayment({ + payment: makePayment(), + appointmentType: "WEBINAR", + }); + + expect(result).toBe("earn-existing"); + expect(mockedTx.consultantEarnings.create).not.toHaveBeenCalled(); + expect(mockedTx.ledgerTransaction.create).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/payments/razorpay-prefill.test.ts b/__tests__/payments/razorpay-prefill.test.ts new file mode 100644 index 000000000..e6bc37922 --- /dev/null +++ b/__tests__/payments/razorpay-prefill.test.ts @@ -0,0 +1,131 @@ +/** + * @jest-environment node + */ + +/** + * Issue #717 regression — `normalizeRazorpayContact` must reject + * repeated-digit phone strings (e.g. "9999999999") because Razorpay's + * test-mode gateway rejects them as "Invalid mobile number" and the + * user sees an opaque modal failure. Catching here lets the caller + * (WalletTab and similar) prompt the user to set a real phone before + * the checkout opens. + * + * E.164 validation is otherwise unchanged: optional leading "+", 1-9 + * leading digit, 10-15 digits total. + */ + +import { + buildRazorpayPrefill, + normalizeRazorpayContact, +} from "@/lib/payments/razorpay-prefill"; + +describe("normalizeRazorpayContact", () => { + describe("happy paths", () => { + it("accepts a 10-digit Indian mobile", () => { + expect(normalizeRazorpayContact("9876543210")).toBe("9876543210"); + }); + + it("accepts E.164 with leading +", () => { + expect(normalizeRazorpayContact("+919876543210")).toBe("+919876543210"); + }); + + it("strips spaces, hyphens, and parentheses", () => { + expect(normalizeRazorpayContact("+91 (987) 654-3210")).toBe( + "+919876543210", + ); + }); + + it("accepts a 15-digit international number (E.164 max)", () => { + expect(normalizeRazorpayContact("+123456789012345")).toBe( + "+123456789012345", + ); + }); + }); + + describe("rejects repeated-digit numbers (#717)", () => { + it("rejects 9999999999 (the canonical test-mode failure)", () => { + expect(normalizeRazorpayContact("9999999999")).toBeNull(); + }); + + it("rejects 1111111111", () => { + expect(normalizeRazorpayContact("1111111111")).toBeNull(); + }); + + it("rejects +919999999999 (with leading +)", () => { + expect(normalizeRazorpayContact("+919999999999")).toBeNull(); + }); + + it("rejects after stripping formatting", () => { + // After strip, this collapses to "9999999999" and must reject. + expect(normalizeRazorpayContact("(999) 999-9999")).toBeNull(); + }); + + it("does NOT reject a number with mostly-repeating digits but at least one variation", () => { + // 9876543210 has all distinct digits — must pass. Boundary check + // ensures the repeated-digit guard doesn't over-match. + expect(normalizeRazorpayContact("9876543210")).toBe("9876543210"); + }); + }); + + describe("E.164 baseline rejections (unchanged)", () => { + it("rejects null/undefined/empty", () => { + expect(normalizeRazorpayContact(null)).toBeNull(); + expect(normalizeRazorpayContact(undefined)).toBeNull(); + expect(normalizeRazorpayContact("")).toBeNull(); + }); + + it("rejects too-short numbers (< 10 digits)", () => { + expect(normalizeRazorpayContact("12345")).toBeNull(); + }); + + it("rejects too-long numbers (> 15 digits)", () => { + expect(normalizeRazorpayContact("1234567890123456")).toBeNull(); + }); + + it("rejects numbers with a leading 0", () => { + expect(normalizeRazorpayContact("0123456789")).toBeNull(); + }); + + it("rejects alphabetical input", () => { + expect(normalizeRazorpayContact("hello-world")).toBeNull(); + }); + }); +}); + +describe("buildRazorpayPrefill", () => { + it("includes contact when phone passes normalisation", () => { + const prefill = buildRazorpayPrefill({ + name: "Alice", + email: "alice@example.com", + phone: "+919876543210", + }); + expect(prefill).toEqual({ + name: "Alice", + email: "alice@example.com", + contact: "+919876543210", + }); + }); + + it("omits contact when phone fails normalisation (#717 repeated digits)", () => { + const prefill = buildRazorpayPrefill({ + name: "Alice", + email: "alice@example.com", + phone: "9999999999", + }); + // Omitted entirely so Razorpay falls back to its own guess instead + // of receiving an invalid value. + expect(prefill).toEqual({ + name: "Alice", + email: "alice@example.com", + }); + }); + + it("omits empty fields", () => { + const prefill = buildRazorpayPrefill({ + name: null, + email: "", + phone: undefined, + }); + expect(prefill).toEqual({}); + }); +}); diff --git a/__tests__/payments/refund-operation.test.ts b/__tests__/payments/refund-operation.test.ts new file mode 100644 index 000000000..9d3aa0607 --- /dev/null +++ b/__tests__/payments/refund-operation.test.ts @@ -0,0 +1,1171 @@ +/** + * @jest-environment node + */ + +/** + * C1 — Canonical refund operation tests. + * + * Covers `refundPayment()` (app-initiated) and `applyRefundCascade()` + * (gateway-cron-initiated) in `lib/payments/operations/refund.ts`. + * + * Strategy: stub the prisma client with a tiny in-memory state-machine + * keyed off the test's seeded fixtures. We do NOT spin a real DB — + * the cascade's logic (proportional split, last-leg-absorbs-remainder, + * status flip thresholds, clawback gating) is pure math on top of the + * model rows, and a faithful in-memory store is enough to exercise + * every branch. Schema-level concerns (FK enforcement, Serializable + * isolation) are validated separately by the integration suite. + * + * Each test seeds its own state (no cross-test leakage) by calling + * `seed(state)` in `beforeEach`. The store mutates in place, so + * assertions read straight off the same `state` object the cascade + * modified. + */ + +import type { Prisma } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// In-memory prisma stub +// --------------------------------------------------------------------------- + +type Row = Record; +type Store = { + payments: Map; + paymentLegs: Row[]; + refunds: Row[]; + disputes: Row[]; + consultantEarnings: Map; + organizationEarnings: Map; + organizationPayouts: Map; + bookingUtilizations: Map; // keyed by paymentId + usageLedgerEntries: Row[]; + programAssignments: Map; + walletEntries: Row[]; + fundingLedgerEntries: Row[]; + billingAccounts: Map; + organizationInvoices: Map; + orgAuditLogs: Row[]; +}; + +let state: Store; + +function newStore(): Store { + return { + payments: new Map(), + paymentLegs: [], + refunds: [], + disputes: [], + consultantEarnings: new Map(), + organizationEarnings: new Map(), + organizationPayouts: new Map(), + bookingUtilizations: new Map(), + usageLedgerEntries: [], + programAssignments: new Map(), + walletEntries: [], + fundingLedgerEntries: [], + billingAccounts: new Map(), + organizationInvoices: new Map(), + orgAuditLogs: [], + }; +} + +let uuidCounter = 0; +const stableUuid = (): string => `uuid-${++uuidCounter}`; + +function txStub() { + return { + // #812 — the refund cascade now posts a balanced ledger txn inside the tx and + // the ledger BLOCKS on failure, so the stub must satisfy postLedgerTxn: + // idempotency miss → upsert accounts → create txn → upsert balance snapshot. + // These are no-ops; the ledger journal itself is covered by ledger-specific + // tests, not this cascade test. + ledgerTransaction: { + findUnique: jest.fn(async () => null), + create: jest.fn(async () => ({ id: stableUuid() })), + }, + ledgerAccount: { + upsert: jest.fn(async ({ where }: any) => ({ id: where.id })), + }, + ledgerAccountBalance: { + upsert: jest.fn(async () => ({})), + }, + systemEvent: { create: jest.fn(async () => ({})) }, + payment: { + findUnique: jest.fn(async ({ where, select, include }: any) => { + const p = state.payments.get(where.id); + if (!p) return null; + if (include) return hydratePayment(p); + if (select) return projectSelect(hydratePayment(p), select); + return p; + }), + findUniqueOrThrow: jest.fn(async ({ where, include }: any) => { + const p = state.payments.get(where.id); + if (!p) throw new Error(`Payment ${where.id} not found`); + if (include) return hydratePayment(p); + return p; + }), + }, + refund: { + findMany: jest.fn(async ({ where, select }: any) => { + const rows = state.refunds.filter((r) => { + if (where.paymentId && r.paymentId !== where.paymentId) return false; + if (where.status?.in && !where.status.in.includes(r.status)) + return false; + return true; + }); + if (select) return rows.map((r) => projectSelect(r, select)); + return rows; + }), + create: jest.fn(async ({ data }: any) => { + const created = { + id: stableUuid(), + createdAt: new Date(), + updatedAt: new Date(), + ...data, + }; + state.refunds.push(created); + return created; + }), + update: jest.fn(async ({ where, data }: any) => { + const r = state.refunds.find((x) => x.id === where.id); + if (!r) throw new Error(`Refund ${where.id} not found`); + Object.assign(r, data); + return r; + }), + // #776 — applyRefundCascade's atomic idempotency claim (cascadedAt null→now). + updateMany: jest.fn(async ({ where, data }: any) => { + let count = 0; + for (const r of state.refunds) { + if (where.id && r.id !== where.id) continue; + // cascadedAt: null matches an unset (undefined/null) stamp. + if (where.cascadedAt === null && r.cascadedAt != null) continue; + Object.assign(r, data); + count++; + } + return { count }; + }), + }, + // #785 — refundPayment now nets refundable against prior lost chargebacks. + dispute: { + aggregate: jest.fn(async ({ where }: any) => { + const sum = state.disputes + .filter( + (d) => + d.paymentId === where.paymentId && + where.status?.in?.includes(d.status), + ) + .reduce((acc, d) => acc + (d.amountPaise as number), 0); + return { _sum: { amountPaise: sum } }; + }), + }, + paymentLeg: { + create: jest.fn(async ({ data }: any) => { + const created = { id: stableUuid(), createdAt: new Date(), ...data }; + state.paymentLegs.push(created); + return created; + }), + // #786 — mirrors @@unique([paymentId, source]): one reversal leg per + // source; partials net via decrement. + upsert: jest.fn(async ({ where, create, update }: any) => { + const key = where.paymentId_source; + const existing = state.paymentLegs.find( + (l) => l.paymentId === key.paymentId && l.source === key.source, + ); + if (existing) { + if (update.amountPaise?.decrement !== undefined) { + existing.amountPaise = + (existing.amountPaise as number) - update.amountPaise.decrement; + } else if (update.amountPaise !== undefined) { + existing.amountPaise = update.amountPaise; + } + return existing; + } + const created = { id: stableUuid(), createdAt: new Date(), ...create }; + state.paymentLegs.push(created); + return created; + }), + }, + consultantEarnings: { + update: jest.fn(async ({ where, data }: any) => { + const e = state.consultantEarnings.get(where.id); + if (!e) throw new Error(`ConsultantEarnings ${where.id} not found`); + Object.assign(e, data); + return e; + }), + }, + // #813 — recordTdsReversal reads/writes TDSRecord through the tx. Fixtures + // here carry no payoutId so the helper is never invoked, but the surface + // must exist for the gate to be callable without a TypeError. + tDSRecord: { + findFirst: jest.fn(async () => null), + findMany: jest.fn(async () => []), + create: jest.fn(async ({ data }: any) => ({ id: stableUuid(), ...data })), + }, + organizationEarnings: { + update: jest.fn(async ({ where, data }: any) => { + const e = state.organizationEarnings.get(where.id); + if (!e) throw new Error(`OrganizationEarnings ${where.id} not found`); + Object.assign(e, data); + return e; + }), + }, + organizationPayout: { + update: jest.fn(async ({ where, data }: any) => { + const p = state.organizationPayouts.get(where.id); + if (!p) throw new Error(`OrganizationPayout ${where.id} not found`); + if (data.clawbackAmountPaise?.increment !== undefined) { + p.clawbackAmountPaise = + (p.clawbackAmountPaise as number) + + data.clawbackAmountPaise.increment; + } + if (data.clawbackInitiatedAt !== undefined) { + p.clawbackInitiatedAt = data.clawbackInitiatedAt; + } + return p; + }), + }, + organizationInvoice: { + findUnique: jest.fn(async ({ where, select }: any) => { + const inv = state.organizationInvoices.get(where.id); + if (!inv) return null; + if (select) return projectSelect(inv, select); + return inv; + }), + }, + orgAuditLog: { + create: jest.fn(async ({ data }: any) => { + const created = { id: stableUuid(), createdAt: new Date(), ...data }; + state.orgAuditLogs.push(created); + return created; + }), + }, + bookingUtilization: { + findUnique: jest.fn(async ({ where, select }: any) => { + const u = state.bookingUtilizations.get(where.paymentId); + if (!u) return null; + if (select) return projectSelect(u, select); + return u; + }), + update: jest.fn(async ({ where, data }: any) => { + const u = state.bookingUtilizations.get(where.paymentId); + if (!u) throw new Error("util not found"); + Object.assign(u, data); + return u; + }), + }, + programAssignment: { + update: jest.fn(async ({ where, data }: any) => { + const a = state.programAssignments.get(where.id); + if (!a) throw new Error("assignment not found"); + if (data.engagementsUsed?.decrement !== undefined) { + a.engagementsUsed = + (a.engagementsUsed as number) - data.engagementsUsed.decrement; + } + if (data.overageCount?.decrement !== undefined) { + a.overageCount = + (a.overageCount as number) - data.overageCount.decrement; + } + return a; + }), + }, + usageLedgerEntry: { + aggregate: jest.fn(async ({ where, _sum }: any) => { + const rows = state.usageLedgerEntries.filter((r) => { + if (where.paymentId && r.paymentId !== where.paymentId) return false; + if ( + where.engagementsConsumed?.lt !== undefined && + !((r.engagementsConsumed as number) < where.engagementsConsumed.lt) + ) + return false; + return true; + }); + const sum = rows.reduce( + (acc, r) => acc + (r.engagementsConsumed as number), + 0, + ); + return { _sum: { engagementsConsumed: sum } }; + }), + create: jest.fn(async ({ data }: any) => { + const created = { id: stableUuid(), createdAt: new Date(), ...data }; + state.usageLedgerEntries.push(created); + return created; + }), + }, + walletEntry: { + create: jest.fn(async ({ data }: any) => { + const created = { id: stableUuid(), createdAt: new Date(), ...data }; + state.walletEntries.push(created); + return created; + }), + }, + fundingLedgerEntry: { + create: jest.fn(async ({ data }: any) => { + const created = { id: stableUuid(), createdAt: new Date(), ...data }; + state.fundingLedgerEntries.push(created); + return created; + }), + }, + billingAccount: { + findUniqueOrThrow: jest.fn(async ({ where, select }: any) => { + const a = state.billingAccounts.get(where.id); + if (!a) throw new Error("billingAccount not found"); + if (select) return projectSelect(a, select); + return a; + }), + // #776 — walletCredit now uses the ORM (atomic increment) instead of raw SQL. + update: jest.fn(async ({ where, data, select }: any) => { + const a = state.billingAccounts.get(where.id); + if (!a) throw new Error("billingAccount not found"); + if (data.walletBalance?.increment !== undefined) { + a.walletBalance = + ((a.walletBalance as number) ?? 0) + data.walletBalance.increment; + } + if (data.walletBalance?.decrement !== undefined) { + a.walletBalance = + ((a.walletBalance as number) ?? 0) - data.walletBalance.decrement; + } + return select ? projectSelect(a, select) : a; + }), + // #776 — walletDebit now uses the ORM (atomic conditional decrement). + updateMany: jest.fn(async ({ where, data }: any) => { + const a = state.billingAccounts.get(where.id); + if (!a) return { count: 0 }; + if ( + where.walletBalance?.gte !== undefined && + ((a.walletBalance as number) ?? -1) < where.walletBalance.gte + ) { + return { count: 0 }; + } + if (data.walletBalance?.decrement !== undefined) { + a.walletBalance = + ((a.walletBalance as number) ?? 0) - data.walletBalance.decrement; + } + if (data.walletBalance?.increment !== undefined) { + a.walletBalance = + ((a.walletBalance as number) ?? 0) + data.walletBalance.increment; + } + return { count: 1 }; + }), + }, + $executeRaw: jest.fn(async (..._parts: any[]) => { + // walletCredit: increments BillingAccount.walletBalance. + // We parse the template-literal to know which account by reading + // the parameter list — Prisma passes interpolated values as + // sequential args after the template-strings array. For our + // tests we simply locate the BA by the only seeded id and bump + // it; tests assert on the resulting balance. + // The walletCredit helper passes (amountPaise, billingAccountId) + // as the two interpolated values. + const args = (_parts as any[]).slice(1); + const amount = args[0] as number; + const baId = args[1] as string; + const acct = state.billingAccounts.get(baId); + if (!acct) return 0; + acct.walletBalance = ((acct.walletBalance as number) ?? 0) + amount; + return 1; + }), + }; +} + +// Hydrate a payment with related rows for `include` queries. +function hydratePayment(p: Row): Row { + return { + ...p, + legs: state.paymentLegs + .filter((l) => l.paymentId === p.id) + .sort( + (a, b) => + (a.createdAt as Date).getTime() - (b.createdAt as Date).getTime(), + ), + earnings: Array.from(state.consultantEarnings.values()).filter( + (e) => e.paymentId === p.id, + ), + organizationEarnings: Array.from(state.organizationEarnings.values()) + .filter((e) => e.paymentId === p.id) + .map((e) => ({ + ...e, + orgPayout: e.orgPayoutId + ? (state.organizationPayouts.get(e.orgPayoutId as string) ?? null) + : null, + })), + bookingUtilization: state.bookingUtilizations.get(p.id as string) ?? null, + refunds: state.refunds.filter((r) => r.paymentId === p.id), + }; +} + +function projectSelect(row: Row, select: Record): Row { + const out: Row = {}; + for (const [k, v] of Object.entries(select)) { + if (!v) continue; + out[k] = (row as Row)[k]; + } + return out; +} + +// --------------------------------------------------------------------------- +// Mock the prisma module — same client returned for both top-level + tx. +// --------------------------------------------------------------------------- + +jest.mock("../../lib/prisma", () => { + const stub = txStub(); + return { + __esModule: true, + default: { + ...stub, + // #812 — a real $transaction is atomic: a throw inside the callback MUST + // roll back every write. The old `fn(stub)` mutated `state` in place with + // no rollback, so a regression dropping refund.ts's `throw err` would still + // pass. Snapshot `state` on entry, run the callback, and on throw restore + // every collection IN PLACE (tests hold a live `state` reference), then + // re-throw — mirroring Postgres rollback semantics. + $transaction: async (fn: any) => { + const snapshot = snapshotState(); + try { + return await fn(stub); + } catch (err) { + restoreState(snapshot); + throw err; + } + }, + }, + }; +}); + +// structuredClone handles the store shape (Maps/arrays of plain objects with +// Date values — no functions), giving a detached snapshot of every collection. +function snapshotState(): Store { + return structuredClone(state); +} + +// Restore IN PLACE: clear + repopulate the SAME Map/array instances so the +// test's captured `state` reference still points at the rolled-back data. +function restoreState(snapshot: Store): void { + for (const key of Object.keys(state) as Array) { + const live = state[key]; + const saved = snapshot[key]; + if (live instanceof Map) { + live.clear(); + // forEach: tsconfig lacks downlevelIteration, so no for..of over Maps + (saved as Map).forEach((v, k) => live.set(k, v)); + } else if (Array.isArray(live)) { + live.length = 0; + live.push(...(saved as Row[])); + } + } +} + +// Audit-actions module is pure constants; no mock needed. + +// --------------------------------------------------------------------------- +// Imports under test (after the prisma mock is registered). +// --------------------------------------------------------------------------- + +import { + refundPayment, + applyRefundCascade, + RefundValidationError, +} from "@/lib/payments/operations/refund"; +import prisma from "@/lib/prisma"; + +const tx: any = prisma; // the stub IS the tx in our setup + +// --------------------------------------------------------------------------- +// Seed helpers +// --------------------------------------------------------------------------- + +function seedSinglePartyWalletPayment({ + paymentId = "pay-1", + amount = 10000, + consultantSharePaise = 8000, + orgShare = 1000, + platformFeePaise = 1000, + billingAccountId = "ba-1", + organizationId = "org-1", + walletBalance = 50000, + withOrgEarnings = true, + orgEarningsStatus = "PENDING" as const, +}: Partial<{ + paymentId: string; + amount: number; + consultantSharePaise: number; + orgShare: number; + platformFeePaise: number; + billingAccountId: string; + organizationId: string; + walletBalance: number; + withOrgEarnings: boolean; + orgEarningsStatus: "PENDING" | "PAID"; +}>) { + state.payments.set(paymentId, { + id: paymentId, + amount, + originalAmount: amount, + // #812 — realistic payments always carry taxAmount (0 here); omitting it made + // the refund posting's gstRev NaN and the (now-blocking) ledger reject it. + taxAmount: 0, + currency: "INR", + paymentStatus: "SUCCEEDED", + paymentGateway: "RAZORPAY", + displayCurrencyAtCheckout: null, + exchangeRateAtCheckout: null, + organizationId, + billingAccountId, + billableToOrgInvoiceId: null, + }); + state.paymentLegs.push({ + id: "leg-w-1", + paymentId, + source: "WALLET", + amountPaise: amount, + sourceRef: "asg-1", + createdAt: new Date(), + }); + state.consultantEarnings.set("ce-1", { + id: "ce-1", + paymentId, + consultantProfileId: "cp-1", + consultantSharePaise, + grossAmount: amount, + platformFeePaise, + refundedShareAmount: 0, + status: "PENDING", + }); + if (withOrgEarnings) { + state.organizationEarnings.set("oe-1", { + id: "oe-1", + paymentId, + organizationId, + grossAmountPaise: amount, + platformFeePaise: platformFeePaise, + orgSharePaise: orgShare, + consultantSharePaise: consultantSharePaise, + refundedAmountPaise: 0, + status: orgEarningsStatus, + orgPayoutId: null, + }); + } + state.billingAccounts.set(billingAccountId, { + id: billingAccountId, + walletBalance, + currency: "INR", + }); +} + +beforeEach(() => { + state = newStore(); + uuidCounter = 0; + jest.clearAllMocks(); +}); + +// =========================================================================== +// Tests +// =========================================================================== + +describe("refundPayment — full single-leg WALLET refund", () => { + it("reverses leg, credits wallet, marks ConsultantEarnings + OrganizationEarnings REFUNDED", async () => { + seedSinglePartyWalletPayment({}); + + const result = await refundPayment({ + paymentId: "pay-1", + reason: "customer-request", + initiatedByUserId: "user-admin", + }); + + expect(result.amountRefundedPaise).toBe(10000); + expect(result.legsReversed).toBe(1); + expect(result.consultantEarningsReversed).toBe(1); + expect(result.organizationEarningsReversed).toBe(1); + expect(result.clawbackInitiated).toBe(false); + + // Wallet credited. + expect(state.billingAccounts.get("ba-1")?.walletBalance).toBe(60000); + // ConsultantEarnings fully refunded. + const ce = state.consultantEarnings.get("ce-1"); + expect(ce?.refundedShareAmount).toBe(8000); + expect(ce?.status).toBe("REFUNDED"); + // OrganizationEarnings fully refunded. + const oe = state.organizationEarnings.get("oe-1"); + // #776 — refundedAmountPaise tracks the ORG share only (org-payout nets it + // against orgSharePaise; the consultant slice lives on ConsultantEarnings). + expect(oe?.refundedAmountPaise).toBe(1000); // org share only + expect(oe?.status).toBe("REFUNDED"); + // Refund row flipped to SUCCEEDED. + expect(state.refunds[0]?.status).toBe("SUCCEEDED"); + }); +}); + +describe("refundPayment — multi-collaborator refund balances the ledger (#813 comment)", () => { + // Proves the Gemini "rounding imbalance" comment is a false alarm: consRev IS + // Σ proportion(consultantSharePaise) (Math.floor), and the per-collaborator + // debits are exactly those same floored proportions, so they sum to consRev + // and the posting balances — even for awkward shares + an odd partial refund. + // The real postLedgerTxn runs here; an imbalance would throw and the cascade + // would roll back, so a successful refund == a balanced posting. + function seedMultiCollaborator() { + state.payments.set("pay-mc", { + id: "pay-mc", + amount: 10000, + originalAmount: 10000, + taxAmount: 0, + currency: "INR", + paymentStatus: "SUCCEEDED", + paymentGateway: "RAZORPAY", + displayCurrencyAtCheckout: null, + exchangeRateAtCheckout: null, + organizationId: null, + billingAccountId: "ba-mc", + billableToOrgInvoiceId: null, + }); + state.paymentLegs.push({ + id: "leg-mc", + paymentId: "pay-mc", + source: "WALLET", + amountPaise: 10000, + sourceRef: "asg-mc", + createdAt: new Date(), + }); + // Three collaborators with deliberately awkward shares (sum 8000), platform + // fee 2000 → gross 10000. + const shares: Array<[string, string, number, string]> = [ + ["ce-mc-1", "cp-1", 4001, "OWNER"], + ["ce-mc-2", "cp-2", 3333, "COLLABORATOR"], + ["ce-mc-3", "cp-3", 666, "COLLABORATOR"], + ]; + for (const [id, cp, share, role] of shares) { + state.consultantEarnings.set(id, { + id, + paymentId: "pay-mc", + consultantProfileId: cp, + consultantSharePaise: share, + grossAmount: role === "OWNER" ? 10000 : 0, + platformFeePaise: role === "OWNER" ? 2000 : 0, + role, + refundedShareAmount: 0, + status: "PENDING", + }); + } + state.billingAccounts.set("ba-mc", { + id: "ba-mc", + walletBalance: 50000, + currency: "INR", + }); + } + + it("an odd partial refund across 3 collaborators completes without LedgerImbalanceError", async () => { + seedMultiCollaborator(); + // 3334/10000 forces a non-clean floor on every collaborator's proportion. + const result = await refundPayment({ + paymentId: "pay-mc", + amountPaise: 3334, + reason: "multi-collab partial", + }); + // If the posting imbalanced, postLedgerTxn would have thrown and rolled the + // cascade back; reaching here with all three reversed proves it balanced. + expect(result.amountRefundedPaise).toBe(3334); + expect(result.consultantEarningsReversed).toBe(3); + expect( + state.consultantEarnings.get("ce-mc-1")?.refundedShareAmount, + ).toBeGreaterThan(0); + expect( + state.consultantEarnings.get("ce-mc-2")?.refundedShareAmount, + ).toBeGreaterThan(0); + expect( + state.consultantEarnings.get("ce-mc-3")?.refundedShareAmount, + ).toBeGreaterThan(0); + }); + + it("a full multi-collaborator refund also balances", async () => { + seedMultiCollaborator(); + const result = await refundPayment({ + paymentId: "pay-mc", + reason: "multi-collab full", + }); + expect(result.amountRefundedPaise).toBe(10000); + expect(result.consultantEarningsReversed).toBe(3); + }); + + // #812 — the invariant test: if the ledger posting fails, the WHOLE cascade + // must roll back. This is the test that goes RED if anyone reverts refund.ts's + // `throw err` in the cascade's catch (half-applied earnings/legs with no + // balanced journal). We inject the failure at postLedgerTxn's create call. + it("rolls back the entire cascade when the ledger posting throws (atomicity invariant)", async () => { + seedMultiCollaborator(); + // postLedgerTxn: findUnique (miss) → ledgerAccount.upsert → ledgerTransaction.create. + // Fail the create exactly once → the cascade's catch re-throws → tx rolls back. + (tx.ledgerTransaction.create as jest.Mock).mockRejectedValueOnce( + new Error("simulated ledger write failure"), + ); + + await expect( + refundPayment({ paymentId: "pay-mc", reason: "ledger-fail" }), + ).rejects.toThrow("simulated ledger write failure"); + + // Every cascade write must be undone (TDS path omitted — owned by a + // concurrent change; this asserts the ledger/legs/earnings/wallet rollback). + // ConsultantEarnings.refundedShareAmount back to 0 for all collaborators. + for (const id of ["ce-mc-1", "ce-mc-2", "ce-mc-3"]) { + expect(state.consultantEarnings.get(id)?.refundedShareAmount).toBe(0); + expect(state.consultantEarnings.get(id)?.status).toBe("PENDING"); + } + // Wallet credit reversed — balance back to the seeded 50000. + expect(state.billingAccounts.get("ba-mc")?.walletBalance).toBe(50000); + // No wallet-credit entry persisted. + expect(state.walletEntries).toHaveLength(0); + // No leg reversal persisted — only the original WALLET leg remains. + expect(state.paymentLegs).toHaveLength(1); + expect(state.paymentLegs[0]?.id).toBe("leg-mc"); + // No refund row persisted (created PENDING inside the tx, then rolled back). + expect(state.refunds).toHaveLength(0); + // Payment row untouched (no amountRefundedPaise written by the cascade). + expect(state.payments.get("pay-mc")?.amountRefundedPaise).toBeUndefined(); + }); +}); + +describe("refundPayment — partial 50% refund proportional split", () => { + it("splits proportional, leaves earnings non-REFUNDED", async () => { + seedSinglePartyWalletPayment({ + amount: 10000, + consultantSharePaise: 8000, + orgShare: 1000, + }); + + const result = await refundPayment({ + paymentId: "pay-1", + amountPaise: 5000, // 50% + reason: "partial-refund", + }); + + expect(result.amountRefundedPaise).toBe(5000); + expect(state.billingAccounts.get("ba-1")?.walletBalance).toBe(55000); + + const ce = state.consultantEarnings.get("ce-1"); + expect(ce?.refundedShareAmount).toBe(4000); // 50% of 8000 + expect(ce?.status).toBe("PENDING"); // not fully refunded + + const oe = state.organizationEarnings.get("oe-1"); + // #776 — org share only: floor(1000 × 5000/10000) = 500. + expect(oe?.refundedAmountPaise).toBe(500); + expect(oe?.status).toBe("PENDING"); + }); +}); + +describe("refundPayment — multi-leg WALLET + REFERRAL_CREDIT", () => { + it("reverses each leg proportionally, last leg absorbs remainder", async () => { + state.payments.set("pay-2", { + id: "pay-2", + amount: 10001, // odd amount forces a remainder + originalAmount: 10001, + taxAmount: 0, // #812 — realistic payments carry taxAmount + currency: "INR", + paymentStatus: "SUCCEEDED", + paymentGateway: "RAZORPAY", + displayCurrencyAtCheckout: null, + exchangeRateAtCheckout: null, + organizationId: null, + billingAccountId: "ba-2", + billableToOrgInvoiceId: null, + }); + state.paymentLegs.push( + { + id: "leg-w-2", + paymentId: "pay-2", + source: "WALLET", + amountPaise: 7001, + sourceRef: "asg-2", + createdAt: new Date(2026, 0, 1), + }, + { + id: "leg-rc-1", + paymentId: "pay-2", + source: "REFERRAL_CREDIT", + amountPaise: 3000, + sourceRef: "rcu-1", + createdAt: new Date(2026, 0, 2), + }, + ); + state.billingAccounts.set("ba-2", { + id: "ba-2", + walletBalance: 0, + currency: "INR", + }); + + const result = await refundPayment({ + paymentId: "pay-2", + reason: "full", + }); + + expect(result.amountRefundedPaise).toBe(10001); + expect(result.legsReversed).toBe(2); + // Wallet credited with the wallet leg's full original amount (last + // leg absorbs remainder; in this case the WALLET leg appears first + // chronologically but the cascade picks up the *last* positive leg + // for the remainder, which is the REFERRAL_CREDIT leg. So the + // wallet credit is exactly floor(7001 * 10001 / 10001) = 7001. + expect(state.billingAccounts.get("ba-2")?.walletBalance).toBe(7001); + }); +}); + +describe("refundPayment — clawback when payout already COMPLETED", () => { + it("increments OrganizationPayout.clawbackAmountPaise and stamps clawbackInitiatedAt", async () => { + seedSinglePartyWalletPayment({ orgEarningsStatus: "PAID" }); + // Promote the org earnings: link to a COMPLETED payout. + state.organizationPayouts.set("op-1", { + id: "op-1", + organizationId: "org-1", + status: "COMPLETED", + clawbackAmountPaise: 0, + clawbackInitiatedAt: null, + }); + const oe = state.organizationEarnings.get("oe-1")!; + oe.orgPayoutId = "op-1"; + + const before = Date.now(); + const result = await refundPayment({ + paymentId: "pay-1", + reason: "post-payout-refund", + }); + + expect(result.clawbackInitiated).toBe(true); + const op = state.organizationPayouts.get("op-1")!; + expect(op.clawbackAmountPaise).toBe(1000); // = orgShare + expect(op.clawbackInitiatedAt).toBeInstanceOf(Date); + expect((op.clawbackInitiatedAt as Date).getTime()).toBeGreaterThanOrEqual( + before, + ); + + // PAYOUT_CLAWBACK audit row written. + const audit = state.orgAuditLogs.find( + (l) => l.action === "PAYOUT_CLAWBACK", + ); + expect(audit).toBeDefined(); + expect((audit?.details as any)?.clawbackAmountPaise).toBe(1000); + }); + + it("preserves earliest clawbackInitiatedAt across multiple partial refunds", async () => { + seedSinglePartyWalletPayment({ orgEarningsStatus: "PAID" }); + const firstStamp = new Date(2026, 0, 1); + state.organizationPayouts.set("op-1", { + id: "op-1", + organizationId: "org-1", + status: "COMPLETED", + clawbackAmountPaise: 200, + clawbackInitiatedAt: firstStamp, + }); + state.organizationEarnings.get("oe-1")!.orgPayoutId = "op-1"; + + await refundPayment({ + paymentId: "pay-1", + amountPaise: 5000, + reason: "second-clawback", + }); + + const op = state.organizationPayouts.get("op-1")!; + expect(op.clawbackAmountPaise).toBe(200 + 500); // 50% of orgShare + expect(op.clawbackInitiatedAt).toBe(firstStamp); // not overwritten + }); +}); + +describe("refundPayment — validation guards", () => { + it("rejects refund > refundable", async () => { + seedSinglePartyWalletPayment({ amount: 10000 }); + await expect( + refundPayment({ paymentId: "pay-1", amountPaise: 99999, reason: "x" }), + ).rejects.toBeInstanceOf(RefundValidationError); + }); + + it("#785 rejects a refund after a lost chargeback already pulled the full amount", async () => { + seedSinglePartyWalletPayment({ amount: 10000 }); + // a lost chargeback already reversed the whole payment via the dispute path + state.disputes.push({ + paymentId: "pay-1", + amountPaise: 10000, + status: "LOST", + }); + await expect( + refundPayment({ + paymentId: "pay-1", + amountPaise: 10000, + reason: "double", + }), + ).rejects.toBeInstanceOf(RefundValidationError); + }); + + it("#785 allows a refund up to the un-charged-back remainder", async () => { + seedSinglePartyWalletPayment({ amount: 10000 }); + state.disputes.push({ + paymentId: "pay-1", + amountPaise: 6000, + status: "LOST", + }); + // refundable = 10000 − 6000 chargeback = 4000 + const result = await refundPayment({ + paymentId: "pay-1", + amountPaise: 4000, + reason: "remainder", + }); + expect(result.amountRefundedPaise).toBe(4000); + }); + + it("rejects refund on non-SUCCEEDED payment", async () => { + seedSinglePartyWalletPayment({}); + state.payments.get("pay-1")!.paymentStatus = "PENDING"; + await expect( + refundPayment({ paymentId: "pay-1", reason: "x" }), + ).rejects.toBeInstanceOf(RefundValidationError); + }); + + it("rejects refund on missing payment", async () => { + await expect( + refundPayment({ paymentId: "missing", reason: "x" }), + ).rejects.toBeInstanceOf(RefundValidationError); + }); + + it("rejects refund on already-fully-refunded payment", async () => { + seedSinglePartyWalletPayment({ amount: 10000 }); + state.refunds.push({ + id: "r-existing", + paymentId: "pay-1", + // #772 renamed Refund.amount → amountPaise; refund.ts sums r.amountPaise. + // Seeding the old field left the "already refunded" sum NaN → guard never + // tripped. Use the live field name. + amountPaise: 10000, + status: "SUCCEEDED", + }); + await expect( + refundPayment({ paymentId: "pay-1", reason: "x" }), + ).rejects.toBeInstanceOf(RefundValidationError); + }); +}); + +describe("refundPayment — #778 §C-1 negative platform plug posts, never skips", () => { + // Referral-credit shape: earnings were allocated off originalAmount (10000) + // while funding legs carry the post-credit amount (8000). On a full refund + // the reversed shares (8500) exceed the funding credits (8000) → plug −500. + // The pre-#812 code silently SKIPPED the journal here (EARNINGS_LEDGER_DRIFT + // the reconciler could not repair); the fix posts a balancing PLATFORM_FEE + // CREDIT and a failure rolls the cascade back. This test pins the posting. + it("posts a balanced txn with a PLATFORM_FEE credit absorbing the negative plug", async () => { + state.payments.set("pay-neg", { + id: "pay-neg", + amount: 8000, + originalAmount: 10000, + taxAmount: 0, + currency: "INR", + paymentStatus: "SUCCEEDED", + paymentGateway: "RAZORPAY", + displayCurrencyAtCheckout: null, + exchangeRateAtCheckout: null, + organizationId: null, + billingAccountId: "ba-neg", + billableToOrgInvoiceId: null, + }); + state.paymentLegs.push({ + id: "leg-neg", + paymentId: "pay-neg", + source: "WALLET", + amountPaise: 8000, + sourceRef: "asg-neg", + createdAt: new Date(), + }); + state.consultantEarnings.set("ce-neg", { + id: "ce-neg", + paymentId: "pay-neg", + consultantProfileId: "cp-neg", + consultantSharePaise: 8500, // allocated off originalAmount + grossAmount: 10000, + platformFeePaise: 1500, + refundedShareAmount: 0, + status: "PENDING", + }); + state.billingAccounts.set("ba-neg", { + id: "ba-neg", + walletBalance: 50000, + currency: "INR", + }); + + const result = await refundPayment({ + paymentId: "pay-neg", + reason: "negative-plug full refund", + }); + expect(result.amountRefundedPaise).toBe(8000); + + // The journal was POSTED (not skipped) and balances including the plug: + // Cr WALLET 8000 + Cr PLATFORM_FEE 500 vs Dr CONSULTANT_PAYABLE 8500. + const txnCreates = (tx.ledgerTransaction.create as jest.Mock).mock.calls; + expect(txnCreates.length).toBeGreaterThan(0); + const refundTxn = txnCreates + .map((c: any[]) => c[0]?.data) + .find((d: any) => d?.idempotencyKey?.startsWith("refund:")); + expect(refundTxn).toBeDefined(); + const entries: Array<{ + accountId: string; + direction: string; + amountPaise: number | bigint; + }> = refundTxn.entries.create; + const plugEntry = entries.find( + (e) => e.accountId.includes("PLATFORM_FEE") && e.direction === "CREDIT", + ); + expect(plugEntry).toBeDefined(); + expect(Number(plugEntry!.amountPaise)).toBe(500); + const total = (dir: string) => + entries + .filter((e) => e.direction === dir) + .reduce((s, e) => s + Number(e.amountPaise), 0); + expect(total("DEBIT")).toBe(total("CREDIT")); + }); +}); + +describe("applyRefundCascade — #786 reversal legs for unbilled accruals", () => { + function seedInvoiceFundedPayment(amount = 10000) { + state.payments.set("pay-inv", { + id: "pay-inv", + amount, + originalAmount: amount, + taxAmount: 0, + currency: "INR", + paymentStatus: "SUCCEEDED", + paymentGateway: "RAZORPAY", + displayCurrencyAtCheckout: null, + exchangeRateAtCheckout: null, + organizationId: "org-1", + billingAccountId: "ba-1", + billableToOrgInvoiceId: null, // unbilled — the #786 case + }); + state.paymentLegs.push({ + id: "leg-inv-1", + paymentId: "pay-inv", + source: "INVOICE_ACCRUAL", + amountPaise: amount, + sourceRef: "asg-1", + createdAt: new Date(), + }); + state.consultantEarnings.set("ce-inv", { + id: "ce-inv", + paymentId: "pay-inv", + consultantProfileId: "cp-1", + consultantSharePaise: 8000, + grossAmount: amount, + platformFeePaise: 1000, + refundedShareAmount: 0, + status: "PENDING", + }); + state.organizationEarnings.set("oe-inv", { + id: "oe-inv", + paymentId: "pay-inv", + organizationId: "org-1", + grossAmountPaise: amount, + platformFeePaise: 1000, + orgSharePaise: 1000, + consultantSharePaise: 8000, + refundedAmountPaise: 0, + status: "PENDING", + orgPayoutId: null, + }); + state.billingAccounts.set("ba-1", { + id: "ba-1", + walletBalance: 0, + currency: "INR", + }); + } + + it("appends ONE negative reversal sibling and never mutates the original leg", async () => { + seedInvoiceFundedPayment(10000); + state.refunds.push({ + id: "r-1", + paymentId: "pay-inv", + amount: 4000, + status: "SUCCEEDED", + refundId: "rfnd_1", + }); + + await applyRefundCascade(tx, { + paymentId: "pay-inv", + refundId: "r-1", + amountPaise: 4000, + reason: "partial-1", + }); + + const original = state.paymentLegs.find( + (l) => l.paymentId === "pay-inv" && l.source === "INVOICE_ACCRUAL", + ); + const reversals = state.paymentLegs.filter( + (l) => + l.paymentId === "pay-inv" && l.source === "INVOICE_ACCRUAL_REVERSAL", + ); + expect(original?.amountPaise).toBe(10000); // immutable + expect(reversals).toHaveLength(1); + expect(reversals[0]?.amountPaise).toBe(-4000); + expect(reversals[0]?.sourceRef).toBe("asg-1"); // pairs with the original + }); + + it("second partial refund nets into the existing reversal leg (no P2002 second row)", async () => { + seedInvoiceFundedPayment(10000); + state.refunds.push( + { + id: "r-1", + paymentId: "pay-inv", + amount: 4000, + status: "SUCCEEDED", + refundId: "rfnd_1", + }, + { + id: "r-2", + paymentId: "pay-inv", + amount: 6000, + status: "SUCCEEDED", + refundId: "rfnd_2", + }, + ); + + await applyRefundCascade(tx, { + paymentId: "pay-inv", + refundId: "r-1", + amountPaise: 4000, + reason: "partial-1", + }); + await applyRefundCascade(tx, { + paymentId: "pay-inv", + refundId: "r-2", + amountPaise: 6000, + reason: "partial-2", + }); + + const original = state.paymentLegs.find( + (l) => l.paymentId === "pay-inv" && l.source === "INVOICE_ACCRUAL", + ); + const reversals = state.paymentLegs.filter( + (l) => + l.paymentId === "pay-inv" && l.source === "INVOICE_ACCRUAL_REVERSAL", + ); + expect(original?.amountPaise).toBe(10000); // still immutable + expect(reversals).toHaveLength(1); // netted, not duplicated + expect(reversals[0]?.amountPaise).toBe(-10000); // -(4000 + 6000) + }); +}); + +describe("applyRefundCascade — gateway-cron entry path", () => { + it("runs cascade against an existing Refund row without creating one", async () => { + seedSinglePartyWalletPayment({}); + // Seed an existing Refund (gateway-created via webhook). + state.refunds.push({ + id: "r-gateway", + paymentId: "pay-1", + amount: 10000, + status: "SUCCEEDED", + refundId: "rfnd_xyz", + }); + + const refundsBefore = state.refunds.length; + const result = await applyRefundCascade(tx, { + paymentId: "pay-1", + refundId: "r-gateway", + amountPaise: 10000, + reason: "gateway-refund", + }); + + expect(state.refunds).toHaveLength(refundsBefore); // no new Refund row + expect(result.consultantEarningsReversed).toBe(1); + expect(result.organizationEarningsReversed).toBe(1); + expect(state.consultantEarnings.get("ce-1")?.status).toBe("REFUNDED"); + }); +}); diff --git a/__tests__/payments/tds-reversal.test.ts b/__tests__/payments/tds-reversal.test.ts new file mode 100644 index 000000000..cf10f8348 --- /dev/null +++ b/__tests__/payments/tds-reversal.test.ts @@ -0,0 +1,134 @@ +/** + * @jest-environment node + */ + +/** + * #813 review follow-up — pins recordTdsReversal's three safety properties: + * integer-floor proportion (no float drift, tiny slices round DOWN to zero), + * the dedup/cap against double-reversal on a second cascade, and the + * filed-aware (FY, quarter) policy. Pure unit test over a stubbed tx — the + * helper's reads/writes are the only prisma surface it touches. + */ + +import { + recordTdsReversal, + getIndianFinancialYear, + getIndianFYQuarter, +} from "@/lib/payments/tax/tds-service"; + +type Row = Record; + +function makeTx(original: Row | null, priorReversals: Row[] = []) { + const created: Row[] = []; + const adjustments: Row[] = []; + const tx = { + tDSRecord: { + findFirst: jest.fn(async () => original), + findMany: jest.fn(async () => priorReversals), + create: jest.fn(async ({ data }: { data: Row }) => { + created.push(data); + return { id: "tdsr_new", ...data }; + }), + }, + // #778 §D — every reversal also emits the TdsAdjustment filing artifact. + tdsAdjustment: { + create: jest.fn(async ({ data }: { data: Row }) => { + adjustments.push(data); + return { id: "tdsadj_new", ...data }; + }), + }, + }; + return { tx: tx as never, created, adjustments }; +} + +const ORIGINAL = { + id: "tdsr_1", + payoutId: "po_1", + consultantProfileId: "cp_1", + financialYear: "2025-26", + quarter: 2, + cumulativeAmountCredited: 100_000, + tdsDeducted: 1_000, + tdsRateBps: 1000, + tdsSection: "194J", + isReversal: false, + reportedInForm26Q: false, +}; + +const PARAMS = { + payoutId: "po_1", + consultantProfileId: "cp_1", + earningsId: "earn_1", + refundAmountPaise: 10_000, + paymentAmountPaise: 10_000, +}; + +describe("recordTdsReversal", () => { + it("full refund reverses the full withholding, copying FY+quarter from an unfiled original", async () => { + const { tx, created } = makeTx({ ...ORIGINAL }); + const result = await recordTdsReversal(tx, PARAMS); + + expect(result).not.toBeNull(); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + tdsDeducted: -1_000, + isReversal: true, + // unfiled original → corrected in place: original's FY/quarter pair + financialYear: "2025-26", + quarter: 2, + tdsSection: "194J", + payoutId: "po_1", + earningsId: "earn_1", + }); + }); + + it("stamps the CURRENT IST FY+quarter when the original is already filed in 26Q", async () => { + const { tx, created } = makeTx({ ...ORIGINAL, reportedInForm26Q: true }); + await recordTdsReversal(tx, PARAMS); + + expect(created).toHaveLength(1); + // filed original → adjust-against-future-liability: both fields from now + expect(created[0].financialYear).toBe(getIndianFinancialYear()); + expect(created[0].quarter).toBe(getIndianFYQuarter()); + }); + + it("caps a second cascade at zero — no double reversal after a full reversal exists", async () => { + const { tx, created } = makeTx({ ...ORIGINAL }, [{ tdsDeducted: -1_000 }]); + const result = await recordTdsReversal(tx, PARAMS); + + expect(result).toBeNull(); + expect(created).toHaveLength(0); + }); + + it("caps accumulated partial reversals at the original withholding", async () => { + // 600 already reversed; a 70% refund wants floor(700) but only 400 remains. + const { tx, created } = makeTx({ ...ORIGINAL }, [{ tdsDeducted: -600 }]); + await recordTdsReversal(tx, { + ...PARAMS, + refundAmountPaise: 7_000, + }); + + expect(created).toHaveLength(1); + expect(created[0].tdsDeducted).toBe(-400); + }); + + it("floors tiny slices to zero instead of float-rounding up (3334/10000 of 1 paisa)", async () => { + const { tx, created } = makeTx({ ...ORIGINAL, tdsDeducted: 1 }); + const result = await recordTdsReversal(tx, { + ...PARAMS, + refundAmountPaise: 3_334, + }); + + // Math.round(0.3334) would have created a 0-paise record path; floor no-ops. + expect(result).toBeNull(); + expect(created).toHaveLength(0); + }); + + it("no-ops when there is no original withholding", async () => { + const { tx, created } = makeTx(null); + const result = await recordTdsReversal(tx, PARAMS); + + expect(result).toBeNull(); + expect(created).toHaveLength(0); + }); +}); diff --git a/__tests__/sso/derive-urls.test.ts b/__tests__/sso/derive-urls.test.ts new file mode 100644 index 000000000..8a6897874 --- /dev/null +++ b/__tests__/sso/derive-urls.test.ts @@ -0,0 +1,59 @@ +/** + * Guards against regressions in the IdP-setup URLs surfaced in the Add + * Provider dialog. If BetterAuth's default endpoint templates ever drift + * from these, the URLs we hand to IT admins would no longer match the ACS + * BetterAuth actually mounts, and SAML assertions / OIDC callbacks would + * be rejected silently. + * + * See also: scripts/verify-sso-invariants.sh (grep-level check). + */ + +import { deriveAcsUrl, deriveMetadataUrl } from "@/lib/sso/derive-urls"; + +const BASE = "http://localhost:3000"; + +describe("deriveAcsUrl", () => { + test("SAML uses the /saml2/sp/acs/{providerId} endpoint", () => { + expect(deriveAcsUrl("acme-okta", "saml", BASE)).toBe( + "http://localhost:3000/api/auth/sso/saml2/sp/acs/acme-okta", + ); + }); + + test("OIDC uses the /callback/{providerId} endpoint", () => { + expect(deriveAcsUrl("acme-auth0", "oidc", BASE)).toBe( + "http://localhost:3000/api/auth/sso/callback/acme-auth0", + ); + }); + + test("null type falls back to the SAML template (add-dialog preview case)", () => { + expect(deriveAcsUrl("acme", null, BASE)).toBe( + "http://localhost:3000/api/auth/sso/saml2/sp/acs/acme", + ); + }); + + test("empty providerId substitutes a placeholder (empty form state)", () => { + expect(deriveAcsUrl("", "saml", BASE)).toBe( + "http://localhost:3000/api/auth/sso/saml2/sp/acs/", + ); + }); + + test("baseUrl is injected verbatim (no trailing-slash normalisation)", () => { + expect(deriveAcsUrl("x", "oidc", "https://app.prod.example.com")).toBe( + "https://app.prod.example.com/api/auth/sso/callback/x", + ); + }); +}); + +describe("deriveMetadataUrl", () => { + test("always the SAML metadata endpoint with providerId as query param", () => { + expect(deriveMetadataUrl("acme-okta", BASE)).toBe( + "http://localhost:3000/api/auth/sso/saml2/sp/metadata?providerId=acme-okta", + ); + }); + + test("empty providerId substitutes a placeholder", () => { + expect(deriveMetadataUrl("", BASE)).toBe( + "http://localhost:3000/api/auth/sso/saml2/sp/metadata?providerId=", + ); + }); +}); diff --git a/__tests__/sso/domain-check-misconfigured-cert.test.ts b/__tests__/sso/domain-check-misconfigured-cert.test.ts new file mode 100644 index 000000000..5723a8548 --- /dev/null +++ b/__tests__/sso/domain-check-misconfigured-cert.test.ts @@ -0,0 +1,133 @@ +/** + * @jest-environment node + */ + +/** + * Pre-auth guard: `/api/auth/sso/domain-check` must refuse to hand the + * client an `ssoBody` (which the signin page would feed into + * BetterAuth's SAML flow) when the stored provider cert is unparseable. + * + * Without this guard, BetterAuth's SAML adapter crashes inside + * `validatePostResponse` and the user sees an empty-body 500 with no + * actionable error. The route should instead return + * `{ enforceSSO: true, providerMisconfigured: true, + * errorCode: "SSO_PROVIDER_MISCONFIGURED" }` so the signin page can + * surface a friendly toast. + * + * Closes bug SSO.1 from enterprise-test-findings.txt. + */ + +import { NextRequest } from "next/server"; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + ssoProvider: { findFirst: jest.fn() }, + organization: { findUnique: jest.fn() }, + }, +})); + +jest.mock("../../lib/sso/enforce-session", () => ({ + lookupEnforcedOrg: jest.fn(), +})); + +import prisma from "@/lib/prisma"; +import { lookupEnforcedOrg } from "@/lib/sso/enforce-session"; +import { GET } from "@/app/api/auth/sso/domain-check/route"; + +const mockedPrisma = prisma as unknown as { + ssoProvider: { findFirst: jest.Mock }; + organization: { findUnique: jest.Mock }; +}; +const mockedLookup = lookupEnforcedOrg as jest.Mock; + +function makeRequest(email: string) { + return new NextRequest( + `http://localhost/api/auth/sso/domain-check?email=${encodeURIComponent(email)}`, + ); +} + +describe("GET /api/auth/sso/domain-check — provider cert pre-flight", () => { + beforeEach(() => { + mockedLookup.mockReset(); + mockedPrisma.ssoProvider.findFirst.mockReset(); + mockedPrisma.organization.findUnique.mockReset(); + // Common happy-path enforcement scaffolding. + mockedLookup.mockResolvedValue({ + organizationId: "org-1", + registeredProviderIds: ["acme-saml"], + }); + mockedPrisma.organization.findUnique.mockResolvedValue({ name: "Acme" }); + }); + + it("returns providerMisconfigured when stored SAML cert is unparseable", async () => { + mockedPrisma.ssoProvider.findFirst.mockResolvedValue({ + providerId: "acme-saml", + // Placeholder body — passes the BEGIN/END marker shape but not the + // X509 parse, exactly the case that crashes BetterAuth's adapter. + samlConfig: JSON.stringify({ + issuer: "https://idp.acme.com", + entryPoint: "https://idp.acme.com/saml", + cert: + "-----BEGIN CERTIFICATE-----\nMIIC...not-valid-base64\n-----END CERTIFICATE-----", + }), + oidcConfig: null, + }); + + const res = await GET(makeRequest("user@acme.com")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ + enforceSSO: true, + providerMisconfigured: true, + errorCode: "SSO_PROVIDER_MISCONFIGURED", + }); + expect(body).not.toHaveProperty("ssoBody"); + }); + + it("returns providerMisconfigured when samlConfig is not parseable JSON", async () => { + mockedPrisma.ssoProvider.findFirst.mockResolvedValue({ + providerId: "acme-saml", + samlConfig: "{this-is-not-json", + oidcConfig: null, + }); + + const res = await GET(makeRequest("user@acme.com")); + const body = await res.json(); + expect(body).toEqual({ + enforceSSO: true, + providerMisconfigured: true, + errorCode: "SSO_PROVIDER_MISCONFIGURED", + }); + }); + + it("still hands out ssoBody for an OIDC provider with no SAML cert", async () => { + // OIDC providers don't have a cert; they hit different failure + // modes (discoveryEndpoint unreachable, clientSecret rejected) that + // are out of scope for this guard. The route must NOT classify them + // as misconfigured purely on the basis of `samlConfig` being null. + mockedPrisma.ssoProvider.findFirst.mockResolvedValue({ + providerId: "acme-oidc", + samlConfig: null, + oidcConfig: JSON.stringify({ + issuer: "https://acme.auth0.com/", + clientId: "abc", + clientSecret: "shh", + discoveryEndpoint: + "https://acme.auth0.com/.well-known/openid-configuration", + pkce: true, + }), + }); + + const res = await GET(makeRequest("user@acme.com")); + const body = await res.json(); + expect(body.enforceSSO).toBe(true); + expect(body.providerMisconfigured).toBeUndefined(); + expect(body.ssoBody).toEqual({ + providerId: "acme-oidc", + domain: "acme.com", + callbackURL: expect.stringContaining("/auth/signin"), + }); + }); +}); diff --git a/__tests__/sso/enforce-session.test.ts b/__tests__/sso/enforce-session.test.ts new file mode 100644 index 000000000..4eb66c0d5 --- /dev/null +++ b/__tests__/sso/enforce-session.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the SSO session-creation enforcement decision. + * + * This is the server-side veto that closes issue #673. The actual Prisma + * queries are injected, so these tests cover the decision logic without + * needing a live database. + */ + +import { + shouldRejectSession, + type EnforceInputs, +} from "@/lib/sso/enforce-session"; + +function makeInputs( + overrides: Partial & { + email?: string | null; + userId?: string; + enforcedOrg?: { organizationId: string; registeredProviderIds: string[] } | null; + linkedProviderIds?: string[]; + }, +): EnforceInputs { + const linkedProviderIds = overrides.linkedProviderIds ?? []; + return { + email: overrides.email ?? "user@acme.com", + userId: overrides.userId ?? "user-1", + lookupEnforcedOrg: + overrides.lookupEnforcedOrg ?? + (async () => overrides.enforcedOrg ?? null), + hasAccountInProviders: + overrides.hasAccountInProviders ?? + (async (_userId, providerIds) => + providerIds.some((p) => linkedProviderIds.includes(p))), + }; +} + +describe("shouldRejectSession", () => { + test("non-enforced domain → allow", async () => { + const decision = await shouldRejectSession( + makeInputs({ email: "user@free.com", enforcedOrg: null }), + ); + expect(decision.reject).toBe(false); + }); + + test("missing email → allow (can't make a decision)", async () => { + const decision = await shouldRejectSession(makeInputs({ email: null })); + expect(decision.reject).toBe(false); + }); + + test("enforced domain + credential-only account → REJECT", async () => { + const decision = await shouldRejectSession( + makeInputs({ + email: "user@acme.com", + enforcedOrg: { organizationId: "org-1", registeredProviderIds: ["acme-okta"] }, + linkedProviderIds: ["credential"], + }), + ); + expect(decision.reject).toBe(true); + if (decision.reject) { + expect(decision.reason).toBe("SSO_REQUIRED"); + expect(decision.organizationId).toBe("org-1"); + } + }); + + test("enforced domain + personal Google OAuth (not registered for org) → REJECT", async () => { + const decision = await shouldRejectSession( + makeInputs({ + enforcedOrg: { organizationId: "org-1", registeredProviderIds: ["acme-okta"] }, + linkedProviderIds: ["credential", "google"], + }), + ); + expect(decision.reject).toBe(true); + }); + + test("enforced domain + account linked via registered SSO provider → ALLOW", async () => { + const decision = await shouldRejectSession( + makeInputs({ + enforcedOrg: { organizationId: "org-1", registeredProviderIds: ["acme-okta"] }, + linkedProviderIds: ["acme-okta"], + }), + ); + expect(decision.reject).toBe(false); + }); + + test("enforced domain + multiple providers, user linked via any one → ALLOW", async () => { + const decision = await shouldRejectSession( + makeInputs({ + enforcedOrg: { + organizationId: "org-1", + registeredProviderIds: ["acme-okta", "acme-azure"], + }, + linkedProviderIds: ["acme-azure"], + }), + ); + expect(decision.reject).toBe(false); + }); + + test("fail-open: enforced domain but org has zero registered providers → ALLOW", async () => { + // Otherwise an org owner who flipped enforceSSO=true before finishing + // IdP setup would lock themselves out and couldn't recover. + const decision = await shouldRejectSession( + makeInputs({ + enforcedOrg: { organizationId: "org-1", registeredProviderIds: [] }, + linkedProviderIds: ["credential"], + }), + ); + expect(decision.reject).toBe(false); + }); + + test("uppercase email domain normalised → REJECT (domain match is case-insensitive)", async () => { + const decision = await shouldRejectSession( + makeInputs({ + email: "User@ACME.COM", + lookupEnforcedOrg: async (domain) => { + expect(domain).toBe("acme.com"); + return { organizationId: "org-1", registeredProviderIds: ["acme-okta"] }; + }, + linkedProviderIds: ["credential"], + }), + ); + expect(decision.reject).toBe(true); + }); +}); diff --git a/__tests__/sso/provider-schemas.test.ts b/__tests__/sso/provider-schemas.test.ts new file mode 100644 index 000000000..4bad6c568 --- /dev/null +++ b/__tests__/sso/provider-schemas.test.ts @@ -0,0 +1,159 @@ +/** + * Guards against regressions in the SSO provider POST schema. + * + * The most important invariant: `samlConfig` must NOT accept a `callbackUrl`. + * BetterAuth auto-derives the ACS URL; letting admins type a custom value is + * a footgun that silently breaks SAML when it drifts from BetterAuth's + * derived endpoint. See issue #672 Gap 6 for history. + */ + +import { + createProviderSchema, + samlConfigSchema, + oidcConfigSchema, +} from "@/lib/sso/provider-schemas"; + +/** + * Why we inline a real X.509 PEM instead of a placeholder + * -------------------------------------------------------- + * `samlConfigSchema` runs the cert string through Node's `X509Certificate` + * constructor (lib/sso/provider-schemas.ts → `validateSamlCert`) to fail + * closed on garbage at registration time. A `"MIIC..."` placeholder is + * not parseable base64 and the constructor throws + * `ERR_OSSL_ASN1_BAD_OBJECT_HEADER`, so the test must hand the schema a + * cert that actually parses. + * + * This fixture is a self-signed RSA-2048 cert generated once with + * openssl req -x509 -newkey rsa:2048 -nodes -days 36500 -subj /CN=... + * It is **parse-only** — never used to verify a SAML signature — so its + * expiry, key strength, and identity are intentionally irrelevant. + * Re-generate without ceremony if it ever needs replacing. + */ +const TEST_CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIUdIXLLS2I0pg1nRxPsX7Sk2wL7GAwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVZmFtaWxpYXJpc2UtdGVzdC1jZXJ0MCAXDTI2MDUxNjA1 +MzM0M1oYDzIxMjYwNDIyMDUzMzQzWjAgMR4wHAYDVQQDDBVmYW1pbGlhcmlzZS10 +ZXN0LWNlcnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCNzbqR3o7y +ffbVj9X4Ea1TiFnGpXFfu599YDhFmproj05DqnjgAfJH5IfKL4yVE5fnFBy5Sa83 +mc1FZUl9BGtfQGt/5FUK5vQ6wI+yQhCUVT4iN0hldgjiw/VY7XjSdGImBpDOJk+8 +g1qVcfuOB3kRbEBo46smx+ESGjnwEDEYCa11VN0COPYJXrHSDIRwn5IHjskOVdoF +dZ+mu9pM/9MSckXZ+lCEVgC4RvHqZrXkWoY4tTluk4I8G9LkSEbOGo3Q3a+GEKCZ +cXgoUKSLMfCKMt5CTbCvqAvSiDudJja0ACVoDYL6Vf+PnOewBChP0ReZvZinRS1u +ovEi17At4hr7AgMBAAGjUzBRMB0GA1UdDgQWBBQArnYD6KV36Si8Srb+TJ8dwsYy +BjAfBgNVHSMEGDAWgBQArnYD6KV36Si8Srb+TJ8dwsYyBjAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB7yQ8/P1OcC57xNqJsqtpCr/PHcvIsuvAX ++UEcTpJDXgG6M4O7IhF8CGtnFgKhbucAuTj/lWPS8kwCtjnG5wIibgComRlAyiNU +AFGl1+wLkHWMQRwjeQ0LD/Lafd4Mqr3/uLXAdjtri3cco3ZLpq8+b4QxrhZ2sb0t +0CfV+qZful0jbRd/VnaMws2jW7lUA0wYhirka+sFFqWII1P6SjvxGzPs68Ro7JE5 +iuxSGXsz1dHPrWW2FgOlOq8tBcBhGaRHsdd2oBFB6RcCVmKyVth4x/I9OWIuyMXY +zI7Ra8q1TUULKRu7kbkXo8Apyv3nkX+E36UONay6CF7fL3IIjZh5 +-----END CERTIFICATE-----`; + +describe("samlConfigSchema", () => { + const valid = { + issuer: "https://idp.acme.com", + entryPoint: "https://idp.acme.com/sso/saml", + cert: TEST_CERT_PEM, + }; + + test("accepts minimal valid SAML config", () => { + expect(samlConfigSchema.safeParse(valid).success).toBe(true); + }); + + test("strips unknown keys including the forbidden callbackUrl", () => { + const withCallback = { + ...valid, + callbackUrl: "https://attacker.example.com/evil", + }; + const result = samlConfigSchema.safeParse(withCallback); + // The schema is permissive of unknown keys but must NOT expose them + // downstream. If `callbackUrl` ever becomes part of the typed output, + // BetterAuth will honour it and override the derived ACS URL. + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("callbackUrl"); + } + }); + + test("rejects a non-URL entryPoint", () => { + const result = samlConfigSchema.safeParse({ ...valid, entryPoint: "not-a-url" }); + expect(result.success).toBe(false); + }); + + test("rejects an empty cert", () => { + const result = samlConfigSchema.safeParse({ ...valid, cert: "" }); + expect(result.success).toBe(false); + }); + + test("rejects malformed base64 inside PEM markers", () => { + // BEGIN/END framing only — the body is not valid base64-encoded DER, so + // X509Certificate throws ERR_OSSL_ASN1_BAD_OBJECT_HEADER. The refine + // catches this and surfaces the friendly PEM-shape error from the schema. + const result = samlConfigSchema.safeParse({ + ...valid, + cert: "-----BEGIN CERTIFICATE-----\nMIIC...not-valid-base64\n-----END CERTIFICATE-----", + }); + expect(result.success).toBe(false); + }); + + test("rejects a plain string with no PEM markers", () => { + // A bare fingerprint (hex) or thumb-printed value pasted by mistake + // never has BEGIN/END markers; X509Certificate refuses it outright. + const result = samlConfigSchema.safeParse({ + ...valid, + cert: "ab12cd34ef56789012345678901234567890abcd", + }); + expect(result.success).toBe(false); + }); +}); + +describe("oidcConfigSchema", () => { + const valid = { + issuer: "https://tenant.auth0.com/", + clientId: "abc123", + clientSecret: "shh", + discoveryEndpoint: "https://tenant.auth0.com/.well-known/openid-configuration", + pkce: true, + }; + + test("accepts a full OIDC config", () => { + expect(oidcConfigSchema.safeParse(valid).success).toBe(true); + }); + + test("pkce defaults to true when omitted — required to prevent the raw-fetch regression", () => { + const { pkce, ...rest } = valid; + void pkce; + const result = oidcConfigSchema.safeParse(rest); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.pkce).toBe(true); + } + }); +}); + +describe("createProviderSchema", () => { + test("rejects non-alphanumeric providerId (prevents path-injection in auto-derived URLs)", () => { + const result = createProviderSchema.safeParse({ + providerId: "../admin", + domain: "acme.com", + issuer: "https://idp.acme.com", + providerType: "saml", + samlConfig: { + issuer: "https://idp.acme.com", + entryPoint: "https://idp.acme.com/sso", + cert: "cert", + }, + }); + expect(result.success).toBe(false); + }); + + test("providerType must be saml or oidc", () => { + const result = createProviderSchema.safeParse({ + providerId: "x", + domain: "acme.com", + issuer: "https://idp.acme.com", + providerType: "ldap", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/__tests__/stream/__mocks__/stream-mocks.ts b/__tests__/stream/__mocks__/stream-mocks.ts index cfbc5bf37..29c14f45b 100644 --- a/__tests__/stream/__mocks__/stream-mocks.ts +++ b/__tests__/stream/__mocks__/stream-mocks.ts @@ -32,6 +32,17 @@ export const createMockPrisma = () => ({ findFirst: jest.fn(), findMany: jest.fn(), }, + // Why: the Stream upsert pipeline calls `checkConsent` (lib/compliance/dpdp.ts) + // before mirroring identity into Stream.io. Without a `consentArtifact` delegate + // on the mock, the helper crashes with `TypeError: Cannot read properties of + // undefined (reading 'findFirst')`. Default to a truthy artifact so existing + // happy-path tests treat the user as consenting; tests that exercise the + // "no consent" branch override `findFirst` to resolve `null`. + consentArtifact: { + findFirst: jest.fn().mockResolvedValue({ id: "consent-1" }), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + count: jest.fn().mockResolvedValue(0), + }, }); // Stream Chat client mock for channel operations diff --git a/__tests__/stream/channel-actions.test.ts b/__tests__/stream/channel-actions.test.ts index 454b33e94..0616aa97b 100644 --- a/__tests__/stream/channel-actions.test.ts +++ b/__tests__/stream/channel-actions.test.ts @@ -463,6 +463,10 @@ describe("Entity Channel Creation", () => { consultantProfile: { user: { id: "consultant-4" } }, }, requestedBy: { user: { id: "subscriber-1" } }, + // Production includes the org-tagged appointments relation (take:1), so + // it's always an array; the mock must provide it (else appointments[0] + // dereferences undefined). Empty = no org-hosted appointment. + appointments: [], }); const { createSubscriptionChannel } = diff --git a/actions/checkout.action.ts b/actions/checkout.action.ts deleted file mode 100644 index e52595e6d..000000000 --- a/actions/checkout.action.ts +++ /dev/null @@ -1,34 +0,0 @@ -"use server"; - -import { handleCheckout } from "@/lib/payments/operations/checkout"; -import { CheckoutInput, checkoutSchema } from "@/schemas/checkout"; -import { - classifyError, - logClassifiedError, -} from "@/lib/errors/classification/payment-error-classification"; -import { getSession } from "@/lib/auth-server"; - -export async function checkoutAction( - data: CheckoutInput, - isMockPayment: boolean = false, -) { - try { - // Check authentication - const session = await getSession(); - if (!session?.user) { - return { error: "Unauthorized" }; - } - - // Validate input - const validatedData = checkoutSchema.parse(data); - - // Unified checkout flow: Create payment first, then appointment via webhook - // Supports both real and mock payments via isMockPayment flag - return await handleCheckout(validatedData, session.user.id, isMockPayment); - } catch (error) { - const classified = classifyError(error, "Checkout failed"); - logClassifiedError("Checkout Action", classified, error); - - return { error: classified.errorMessage }; - } -} diff --git a/actions/forms/onboarding.action.ts b/actions/forms/onboarding.action.ts index af2545484..938cc703d 100644 --- a/actions/forms/onboarding.action.ts +++ b/actions/forms/onboarding.action.ts @@ -1,7 +1,52 @@ "use server"; +import { z } from "zod"; import { processOnboardingData } from "@/utils/onboarding-server"; import { getSession } from "@/lib/auth-server"; +import prisma from "@/lib/prisma"; +import { UserRole } from "@prisma/client"; + +// Roles a user is allowed to self-select via this action. Privileged +// roles (ADMIN, STAFF) MUST never be reachable from a client-driven +// action — they are assigned by platform operators out-of-band. Today +// only the ORG_WORKSPACE handoff routes through this action; CONSULTANT / +// CONSULTEE selection happens earlier in the form via the regular +// `processOnboardingData` path which does not let the caller pick the +// role string. Keep this list narrow on purpose — if a new self- +// service role needs to flow through here, add it explicitly. +const SELF_SELECTABLE_ONBOARDING_ROLES: ReadonlySet = new Set([ + UserRole.ORG_WORKSPACE, +]); + +// `setOnboardingRoleAction`'s `personalInfo` was previously typed but +// not Zod-validated, so the client could write arbitrary strings into +// `User.name`/`phone`/`timezone` (the only three columns the action +// touches). This schema mirrors the Prisma column constraints: +// - `name` matches FrontendOnboardingBaseSchema (min 1 — the column is +// `String`, not nullable); +// - `phone` is `@unique` in Prisma, so we reject the empty string +// (would collide with anyone who left phone blank); +// - `timezone` accepts any IANA-shaped string, capped to 64 chars +// (longest current IANA zone is 32, leaving headroom for `Etc/...` +// aliases without buying the full IANA database client-side). +// +// `.strict()` so an upstream typo (e.g. `phoneNumber`) fails loud +// rather than silently dropping into `undefined` and bypassing the +// update. +const RoleHandoffPersonalInfoSchema = z + .object({ + name: z.string().trim().min(1, "Name is required").max(200).optional(), + phone: z + .string() + .trim() + .min(1, "Phone cannot be empty") + .max(50) + .optional(), + timezone: z.string().trim().min(1).max(64).optional(), + }) + .strict(); + +const RoleHandoffRoleSchema = z.nativeEnum(UserRole); // #region Main Server Action export async function updateOnboardingInformationAction( @@ -26,3 +71,93 @@ export async function updateOnboardingInformationAction( return await processOnboardingData(userId, body); } // #endregion + +/** + * Persist the user's selected UserRole mid-onboarding. Used by the + * ORG_WORKSPACE path so step 1's `POST /api/organizations` sees a session + * with `role = ORG_WORKSPACE` (the API gate rejects anything else). + * + * This is scoped narrowly on purpose: only the authenticated user + * can flip their own row, the caller's id must match the session, + * and `onboardingCompleted` is left alone — the final Review step + * handles that via `processOnboardingData` once the org is ready. + */ +export async function setOnboardingRoleAction( + userId: string, + role: UserRole, + personalInfo: { name?: string; phone?: string; timezone?: string }, +): Promise<{ success: boolean; error?: string }> { + const session = await getSession(true); + if (!session?.user?.id) { + return { success: false, error: "Unauthorized" }; + } + if (session.user.id !== userId) { + return { success: false, error: "Forbidden" }; + } + + // Validate inputs at the action boundary. TypeScript params don't + // survive the server-action serialiser (the runtime call is a JSON + // POST with `unknown` payload), so the static `UserRole` / + // `personalInfo` types are not load-bearing — Zod is. + const parsedRole = RoleHandoffRoleSchema.safeParse(role); + if (!parsedRole.success) { + return { success: false, error: "Invalid role" }; + } + const parsedInfo = RoleHandoffPersonalInfoSchema.safeParse(personalInfo); + if (!parsedInfo.success) { + const first = parsedInfo.error.issues[0]; + return { + success: false, + error: first + ? `${first.path.join(".") || "personalInfo"}: ${first.message}` + : "Invalid personal info", + }; + } + + // Reject any role outside the self-selection allowlist. The Prisma + // enum type alone is not a security boundary — a malicious caller + // can pass `"ADMIN"` / `"STAFF"` and Zod would happily allow it + // (the enum exists in Prisma), so the runtime allowlist check is + // mandatory after the shape check. + if (!SELF_SELECTABLE_ONBOARDING_ROLES.has(parsedRole.data)) { + return { success: false, error: "Forbidden" }; + } + + await prisma.user.update({ + where: { id: userId }, + data: { + role: parsedRole.data, + name: parsedInfo.data.name, + phone: parsedInfo.data.phone, + timezone: parsedInfo.data.timezone, + }, + }); + + return { success: true }; +} + +/** + * Flip `user.onboardingCompleted = true` after the ORG_WORKSPACE wizard + * finishes launching their first org. Role + personal info were already + * committed by `setOnboardingRoleAction`; the owner Membership was + * created atomically by `POST /api/organizations`. All that's left is + * the onboarding flag so the session no longer redirects to /form/onboarding. + */ +export async function completeOrgWorkspaceOnboardingAction( + userId: string, +): Promise<{ success: boolean; error?: string }> { + const session = await getSession(true); + if (!session?.user?.id) { + return { success: false, error: "Unauthorized" }; + } + if (session.user.id !== userId) { + return { success: false, error: "Forbidden" }; + } + + await prisma.user.update({ + where: { id: userId }, + data: { onboardingCompleted: true }, + }); + + return { success: true }; +} diff --git a/actions/maintenance/freeze-appointments.ts b/actions/maintenance/freeze-appointments.ts index bda89de80..9ff3817d0 100644 --- a/actions/maintenance/freeze-appointments.ts +++ b/actions/maintenance/freeze-appointments.ts @@ -378,7 +378,7 @@ export async function freezeAppointments( }); await prisma.refund.create({ data: { - amount: payment.amount, + amountPaise: payment.amount, currency: payment.currency, reason: "Scheduled platform maintenance", status: result.status, @@ -404,7 +404,7 @@ export async function freezeAppointments( try { await prisma.refund.create({ data: { - amount: payment.amount, + amountPaise: payment.amount, currency: payment.currency, reason: "Scheduled platform maintenance", status: "PENDING", diff --git a/actions/stream/chat/channel.action.ts b/actions/stream/chat/channel.action.ts index aa88a417d..de52896c1 100644 --- a/actions/stream/chat/channel.action.ts +++ b/actions/stream/chat/channel.action.ts @@ -24,6 +24,10 @@ const createChannelSchema = z.object({ members: membersSchema, createdById: memberIdSchema, additionalData: z.record(z.unknown()).optional(), + // #B2 Stream.io org tagging — pre-launch enterprise tag so admins can later + // query Stream API by `custom.organization_id`. Optional so personal + // (non-org) channels keep their existing shape (no stray null field). + organizationId: z.string().min(1).nullable().optional(), }); /** @@ -37,6 +41,15 @@ export async function createChannel(input: { members: string[]; createdById: string; additionalData?: Record; + /** + * Optional enterprise organization stamp. When non-null, written to the + * channel's custom data as `organization_id` (snake_case per Stream's + * convention) so org admins can list / query channels via Stream's + * `queryChannels({filter: {organization_id: {$eq: orgId}}})`. + * `null` / `undefined` → key omitted entirely so existing personal + * channels created before this rollout don't gain a `null` field. + */ + organizationId?: string | null; }) { // Validate input const validated = createChannelSchema.parse(input); @@ -52,18 +65,29 @@ export async function createChannel(input: { channelId: validated.channelId, type: validated.channelType, memberCount: allMembers.length, + organizationId: validated.organizationId ?? undefined, }); // Ensure all members exist in Stream before channel creation await upsertUsersToStream(allMembers); + // Merge the optional org stamp into additionalData. Use snake_case + // (`organization_id`) to match Stream's chat field convention and the + // other event tags in this file (webinar_id, class_id). + const mergedAdditionalData: Record = { + ...(validated.additionalData ?? {}), + ...(validated.organizationId + ? { organization_id: validated.organizationId } + : {}), + }; + // Create the channel with members atomically // Note: Explicitly typing channel data for stream-chat v9 const createChannelData = { name: validated.channelName, created_by_id: validated.createdById, members: allMembers, - ...validated.additionalData, + ...mergedAdditionalData, }; const channel = client.channel( validated.channelType, @@ -112,8 +136,16 @@ export async function createDirectMessageChannel( /** * Create a webinar channel with all participants * Fetches participants from both waitlist and appointments + * + * @param webinarId — Webinar entity id + * @param organizationId — Optional explicit org override. When omitted, the + * helper falls back to `webinarPlan.organizationId` so callers don't have + * to plumb it through. Pass `null` to force-omit the org tag. */ -export async function createWebinarChannel(webinarId: string) { +export async function createWebinarChannel( + webinarId: string, + organizationId?: string | null, +) { channelIdSchema.parse(webinarId); const webinar = await prisma.webinar.findUnique({ @@ -174,6 +206,13 @@ export async function createWebinarChannel(webinarId: string) { // Ensure all members exist in Stream before channel creation await upsertUsersToStream(allMembers); + // Fall back to the plan's org if the caller didn't pass one explicitly. + // `null` is treated as "explicitly no org"; `undefined` triggers fallback. + const resolvedOrgId = + organizationId === undefined + ? webinar.webinarPlan.organizationId ?? null + : organizationId; + return createChannel({ channelType: "team", channelId: `webinar-${webinarId}`, @@ -181,13 +220,21 @@ export async function createWebinarChannel(webinarId: string) { members: allMembers, createdById: consultantUserId, additionalData: { webinar_id: webinarId }, + organizationId: resolvedOrgId, }); } /** * Create a class channel with all participants + * + * @param classId — Class entity id + * @param organizationId — Optional explicit org override. Falls back to + * `classPlan.organizationId` when omitted; `null` force-omits the tag. */ -export async function createClassChannel(classId: string) { +export async function createClassChannel( + classId: string, + organizationId?: string | null, +) { channelIdSchema.parse(classId); const classData = await prisma.class.findUnique({ @@ -244,6 +291,11 @@ export async function createClassChannel(classId: string) { // Ensure all members exist in Stream before channel creation await upsertUsersToStream(allMembers); + const resolvedOrgId = + organizationId === undefined + ? classData.classPlan.organizationId ?? null + : organizationId; + return createChannel({ channelType: "team", channelId: `class-${classId}`, @@ -251,13 +303,30 @@ export async function createClassChannel(classId: string) { members: allMembers, createdById: consultantUserId, additionalData: { class_id: classId }, + organizationId: resolvedOrgId, }); } /** * Create a consultation channel + * + * @param consultationId — Consultation entity id + * @param organizationId — Optional explicit org override. When omitted, the + * resolved org tag falls back through this chain: + * 1. `consultationPlan.organizationId` (plan is hosted by an org) + * 2. `consultation.appointment.organizationId` (booking is funded by + * an org member, even when the plan itself is platform-owned) + * 3. `null` — personal channel, no org tag + * Pass `null` explicitly to force-omit the org tag regardless of fallback. + * + * Note: the underlying DM channel is per consultant-consultee pair, so an + * org tag here reflects the *first booking* — if the same pair later books + * a personal-plan consultation, the existing channel keeps the org tag. */ -export async function createConsultationChannel(consultationId: string) { +export async function createConsultationChannel( + consultationId: string, + organizationId?: string | null, +) { channelIdSchema.parse(consultationId); const consultation = await prisma.consultation.findUnique({ @@ -273,6 +342,10 @@ export async function createConsultationChannel(consultationId: string) { requestedBy: { include: { user: { select: { id: true } } }, }, + // Pull the appointment row so we can fall back to its org tag + // when the plan itself isn't org-hosted but the booker is paying + // through an org-funded membership (C.3 / #674). + appointment: { select: { organizationId: true } }, }, }); @@ -292,6 +365,13 @@ export async function createConsultationChannel(consultationId: string) { // Ensure both users exist in Stream before channel creation await upsertUsersToStream([consultantId, consulteeId]); + const resolvedOrgId = + organizationId === undefined + ? consultation.consultationPlan.organizationId ?? + consultation.appointment?.organizationId ?? + null + : organizationId; + // DM channel is per consultant-consultee pair (not per event). // Per-event IDs are not stored on the channel since multiple // consultations/subscriptions between the same pair share one DM. @@ -304,13 +384,27 @@ export async function createConsultationChannel(consultationId: string) { dm_consultant_user_id: consultantId, dm_consultee_user_id: consulteeId, }, + organizationId: resolvedOrgId, }); } /** * Create a subscription channel + * + * @param subscriptionId — Subscription entity id + * @param organizationId — Optional explicit org override. When omitted, the + * resolved org tag falls back through this chain: + * 1. `subscriptionPlan.organizationId` (plan is hosted by an org) + * 2. `subscription.appointment.organizationId` (booking is funded by + * an org member, even when the plan itself is platform-owned) + * 3. `null` — personal channel, no org tag + * Pass `null` explicitly to force-omit. See `createConsultationChannel` + * for the DM-channel sharing caveat. */ -export async function createSubscriptionChannel(subscriptionId: string) { +export async function createSubscriptionChannel( + subscriptionId: string, + organizationId?: string | null, +) { channelIdSchema.parse(subscriptionId); const subscription = await prisma.subscription.findUnique({ @@ -326,6 +420,18 @@ export async function createSubscriptionChannel(subscriptionId: string) { requestedBy: { include: { user: { select: { id: true } } }, }, + // Pull a single org-tagged appointment so we can fall back to + // its org id when the plan itself isn't org-hosted but the + // subscription is funded through an org-funded membership. + // Subscription has a 1:N appointments relation; all appointments + // in one subscription share the same org context (the org pays + // for the whole subscription upfront) so taking the first is + // sufficient. (C.3 / #674) + appointments: { + where: { organizationId: { not: null } }, + select: { organizationId: true }, + take: 1, + }, }, }); @@ -345,6 +451,13 @@ export async function createSubscriptionChannel(subscriptionId: string) { // Ensure both users exist in Stream before channel creation await upsertUsersToStream([consultantId, consulteeId]); + const resolvedOrgId = + organizationId === undefined + ? subscription.subscriptionPlan.organizationId ?? + subscription.appointments[0]?.organizationId ?? + null + : organizationId; + // DM channel is per consultant-consultee pair (not per event). // Per-event IDs are not stored on the channel since multiple // consultations/subscriptions between the same pair share one DM. @@ -357,6 +470,7 @@ export async function createSubscriptionChannel(subscriptionId: string) { dm_consultant_user_id: consultantId, dm_consultee_user_id: consulteeId, }, + organizationId: resolvedOrgId, }); } diff --git a/actions/stream/chat/user.action.ts b/actions/stream/chat/user.action.ts index b5b3534d4..57b123993 100644 --- a/actions/stream/chat/user.action.ts +++ b/actions/stream/chat/user.action.ts @@ -6,6 +6,7 @@ import { mapRoleToStream } from "@/lib/user"; import { getStreamChatClient } from "@/lib/stream-client"; import { streamLogger } from "@/lib/stream-logger"; import { markUserSynced, isUserSynced } from "@/lib/stream-cache"; +import { checkConsent } from "@/lib/compliance/dpdp"; // Input validation schemas const userIdSchema = z.string().min(1, "User ID is required"); @@ -49,6 +50,24 @@ export const upsertUserToStream = async (userId: string) => { throw new Error(`User not found: ${validatedUserId}`); } + // DPDP Act 2023: refuse to hand user PII over to Stream.io when the + // user has withdrawn (or never granted) consent for STREAM_DATA_PROCESSING. + // The signup hook auto-stamps this; an in-app withdrawal via + // /api/organizations/[orgId]/consent revokes it and `checkConsent` + // returns false, killing future video/chat sessions until re-granted. + const hasStreamConsent = await checkConsent({ + userId: user.id, + purposeCode: "STREAM_DATA_PROCESSING", + }); + if (!hasStreamConsent) { + streamLogger.warn("Refusing Stream upsert — STREAM_DATA_PROCESSING consent absent", { + userId: user.id, + }); + throw new Error( + "Stream video/chat consent is required. Please re-grant data processing consent under Account → Privacy.", + ); + } + const client = getStreamChatClient(); const streamRole = mapRoleToStream(user.role); @@ -118,11 +137,42 @@ export const upsertUsersToStream = async (userIds: string[]) => { return { users: {} }; } + // DPDP gate (batch). Filter out users who have withdrawn — or never + // granted — STREAM_DATA_PROCESSING consent. The non-consenters are + // logged for ops visibility; the channel proceeds with the + // consenters only. The signup auth hook stamps consent at account + // creation so this should only filter when a user explicitly + // withdraws via the in-app /consent route. + const consentResults = await Promise.all( + users.map(async (u) => ({ + user: u, + hasConsent: await checkConsent({ + userId: u.id, + purposeCode: "STREAM_DATA_PROCESSING", + }), + })), + ); + const consenters = consentResults + .filter((r) => r.hasConsent) + .map((r) => r.user); + const droppedIds = consentResults + .filter((r) => !r.hasConsent) + .map((r) => r.user.id); + if (droppedIds.length > 0) { + streamLogger.warn( + "Batch upsert dropping users missing STREAM_DATA_PROCESSING consent", + { droppedIds, droppedCount: droppedIds.length }, + ); + } + if (consenters.length === 0) { + return { users: {} }; + } + const client = getStreamChatClient(); // Prepare users for batch upsert // Note: Using type assertion for custom user data (stream-chat v9) - const streamUsers = users.map((user) => { + const streamUsers = consenters.map((user) => { const streamRole = mapRoleToStream(user.role); return { id: user.id, @@ -136,6 +186,7 @@ export const upsertUsersToStream = async (userIds: string[]) => { streamLogger.debug("Batch upserting users to Stream", { count: streamUsers.length, skipped: validatedIds.length - unsyncedIds.length, + droppedNoConsent: droppedIds.length, }); // Single batch API call @@ -145,7 +196,7 @@ export const upsertUsersToStream = async (userIds: string[]) => { ); // Mark all as synced - users.forEach((user) => markUserSynced(user.id)); + consenters.forEach((user) => markUserSynced(user.id)); return result; } catch (error) { diff --git a/actions/stream/meetings/meeting.action.ts b/actions/stream/meetings/meeting.action.ts index 0a47a31ed..bcc5fdc16 100644 --- a/actions/stream/meetings/meeting.action.ts +++ b/actions/stream/meetings/meeting.action.ts @@ -97,6 +97,15 @@ export async function createDbMeetingSession( streamCallId: validatedStreamCallId, }); + let organizationId: string | null = null; + if (slot.appointmentId) { + const appointment = await prisma.appointment.findUnique({ + where: { id: slot.appointmentId }, + select: { organizationId: true }, + }); + organizationId = appointment?.organizationId ?? null; + } + try { const meetingSession = await prisma.meetingSession.create({ data: { @@ -105,6 +114,9 @@ export async function createDbMeetingSession( slotOfAppointment: { connect: { id: slot.id }, }, + ...(organizationId + ? { organization: { connect: { id: organizationId } } } + : {}), }, }); diff --git a/app/api/admin/analytics/cancellations/route.ts b/app/api/admin/analytics/cancellations/route.ts index db1dc4fa7..bdc3e53fb 100644 --- a/app/api/admin/analytics/cancellations/route.ts +++ b/app/api/admin/analytics/cancellations/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { CancellationReason } from "@prisma/client"; +import { sumPaise } from "@/lib/payments/utils/money"; import { requirePrivilegedAuth } from "@/lib/auth-helpers"; @@ -179,7 +180,7 @@ export async function GET(req: NextRequest) { status: "SUCCEEDED", }, _sum: { - amount: true, + amountPaise: true, }, _count: true, }); @@ -209,7 +210,7 @@ export async function GET(req: NextRequest) { cancellationRate: `${cancellationRate}%`, totalBookingsInPeriod: totalBookings, potentialRefundAmount, - actualRefundedAmount: refunds._sum.amount || 0, + actualRefundedAmount: sumPaise(refunds._sum?.amountPaise), refundCount: refunds._count, }, byReason, diff --git a/app/api/admin/analytics/route.ts b/app/api/admin/analytics/route.ts index 6b098c855..a73c88d91 100644 --- a/app/api/admin/analytics/route.ts +++ b/app/api/admin/analytics/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { UserRole } from "@prisma/client"; import { requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { sumPaise } from "@/lib/payments/utils/money"; export async function GET() { try { @@ -117,12 +118,12 @@ export async function GET() { }), // Refund total prisma.refund.aggregate({ - _sum: { amount: true }, + _sum: { amountPaise: true }, where: { status: "SUCCEEDED" }, }), ]); - const totalRevenue = paymentStats._sum.amount ?? 0; + const totalRevenue = sumPaise(paymentStats._sum.amount); const avgSessionValue = paymentStats._count > 0 ? totalRevenue / paymentStats._count : 0; @@ -144,9 +145,9 @@ export async function GET() { // Revenue stats totalRevenue, - revenueThisMonth: revenueThisMonth._sum.amount ?? 0, + revenueThisMonth: sumPaise(revenueThisMonth._sum.amount), avgSessionValue, - totalRefunds: refundTotal._sum.amount ?? 0, + totalRefunds: sumPaise(refundTotal._sum?.amountPaise), // Top domains topDomains: formattedTopDomains, diff --git a/app/api/admin/disputes/route.ts b/app/api/admin/disputes/route.ts index 866229328..12a7c5e24 100644 --- a/app/api/admin/disputes/route.ts +++ b/app/api/admin/disputes/route.ts @@ -15,6 +15,9 @@ export async function GET(req: NextRequest) { const status = searchParams.get("status") as DisputeStatus | null; const gateway = searchParams.get("gateway") as PaymentGateway | null; const search = searchParams.get("search"); + // #674 comment 7 — optional org-scope filter. Disputes inherit the + // org tag via the joined Payment row. + const orgId = searchParams.get("orgId"); // Build where clause const where: Prisma.DisputeWhereInput = {}; @@ -34,6 +37,10 @@ export async function GET(req: NextRequest) { }; } + if (orgId) { + where.payment = { is: { organizationId: orgId } }; + } + // Fetch disputes with pagination const [disputes, total, urgentDisputes] = await Promise.all([ prisma.dispute.findMany({ diff --git a/app/api/admin/erasure-requests/[id]/process/route.ts b/app/api/admin/erasure-requests/[id]/process/route.ts new file mode 100644 index 000000000..10cb37946 --- /dev/null +++ b/app/api/admin/erasure-requests/[id]/process/route.ts @@ -0,0 +1,80 @@ +/** + * POST /api/admin/erasure-requests/[id]/process + * + * Admin executes the scrub. The route flips the request status to + * IN_PROGRESS (visible to a watching dashboard), invokes `scrubUser`, + * and finalizes the row to COMPLETED on success. Idempotent: a + * second invocation observes `erasedAt IS NOT NULL`, the helper + * short-circuits, and we still mark the request COMPLETED so the + * queue page advances. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireAdminAuth } from "@/lib/auth-helpers"; +import { scrubUser } from "@/lib/compliance/erasure/scrub-user"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + const { id } = await params; + + const request = await prisma.erasureRequest.findUnique({ where: { id } }); + if (!request) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + if (request.status === "COMPLETED" || request.status === "REJECTED") { + return NextResponse.json( + { error: `Request is already ${request.status}` }, + { status: 409 }, + ); + } + + // Move PENDING → IN_PROGRESS so a second admin clicking the same + // button doesn't double-execute. The partial-unique index allows + // both PENDING and IN_PROGRESS to count as "open"; the explicit + // status flip is just for dashboard visibility. + await prisma.erasureRequest.update({ + where: { id }, + data: { status: "IN_PROGRESS", processedByAdminId: auth.session.user.id }, + }); + + try { + const result = await scrubUser(prisma, request.userId); + await prisma.erasureRequest.update({ + where: { id }, + data: { + status: "COMPLETED", + completedAt: new Date(), + notes: result.scrubbed + ? `Scrubbed across ${result.affectedOrganizationIds.length} org(s); pseudonymousId=${result.pseudonymousId.slice(0, 12)}…` + : `User was already erased (idempotent); pseudonymousId=${result.pseudonymousId.slice(0, 12)}…`, + }, + }); + return NextResponse.json({ + ok: true, + affectedOrganizationIds: result.affectedOrganizationIds, + pseudonymousId: result.pseudonymousId, + }); + } catch (err) { + // Flip back to PENDING so the queue picks it up again; record the + // error in `notes` so the admin sees what blew up. + await prisma.erasureRequest.update({ + where: { id }, + data: { + status: "PENDING", + notes: err instanceof Error ? err.message : String(err), + }, + }); + return NextResponse.json( + { + error: "Erasure failed; request returned to PENDING", + details: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ); + } +} diff --git a/app/api/admin/erasure-requests/[id]/reject/route.ts b/app/api/admin/erasure-requests/[id]/reject/route.ts new file mode 100644 index 000000000..62cad788d --- /dev/null +++ b/app/api/admin/erasure-requests/[id]/reject/route.ts @@ -0,0 +1,57 @@ +/** + * POST /api/admin/erasure-requests/[id]/reject + * + * Admin rejects an erasure request (e.g. user is a financial obligor + * with an open dispute). Required `reason` is stored on the row so the + * user can see why; a future automated reply path will surface it. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireAdminAuth } from "@/lib/auth-helpers"; + +const BodySchema = z.object({ + reason: z.string().trim().min(1).max(2000), +}); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + const { id } = await params; + + const raw = await req.json().catch(() => null); + const parsed = BodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "reason is required", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const request = await prisma.erasureRequest.findUnique({ where: { id } }); + if (!request) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + if (request.status === "COMPLETED" || request.status === "REJECTED") { + return NextResponse.json( + { error: `Request is already ${request.status}` }, + { status: 409 }, + ); + } + + const updated = await prisma.erasureRequest.update({ + where: { id }, + data: { + status: "REJECTED", + processedByAdminId: auth.session.user.id, + notes: parsed.data.reason, + completedAt: new Date(), + }, + }); + + return NextResponse.json({ request: updated }); +} diff --git a/app/api/admin/erasure-requests/route.ts b/app/api/admin/erasure-requests/route.ts new file mode 100644 index 000000000..73d2a43fe --- /dev/null +++ b/app/api/admin/erasure-requests/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/admin/erasure-requests + * + * Admin review queue. Surfaces PENDING + IN_PROGRESS requests first + * (the ones that have an SLA clock), then COMPLETED / REJECTED for + * historical context. Admin-only. + */ + +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireAdminAuth } from "@/lib/auth-helpers"; + +export async function GET() { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + + const requests = await prisma.erasureRequest.findMany({ + orderBy: [{ status: "asc" }, { requestedAt: "asc" }], + take: 200, + include: { + user: { select: { id: true, name: true, email: true, erasedAt: true } }, + }, + }); + return NextResponse.json({ data: requests }); +} diff --git a/app/api/admin/invoices/route.ts b/app/api/admin/invoices/route.ts index f505549af..64a59cad3 100644 --- a/app/api/admin/invoices/route.ts +++ b/app/api/admin/invoices/route.ts @@ -24,6 +24,8 @@ export async function GET(req: NextRequest) { const result = await getOperatorInvoices({ status: searchParams.get("status") as PaymentStatus | null, search: searchParams.get("search"), + // #674 comment 7 — optional org-scope filter (Payment.organizationId). + orgId: searchParams.get("orgId"), limit: parseInt(searchParams.get("limit") || "20"), offset: parseInt(searchParams.get("offset") || "0"), }); diff --git a/app/api/admin/maintenance/preflight/route.ts b/app/api/admin/maintenance/preflight/route.ts index fff56e559..f140fdad6 100644 --- a/app/api/admin/maintenance/preflight/route.ts +++ b/app/api/admin/maintenance/preflight/route.ts @@ -31,7 +31,7 @@ export async function GET() { isTentative: false, }, }), - prisma.payout.count({ where: { status: "PENDING" } }), + prisma.consultantPayout.count({ where: { status: "PENDING" } }), prisma.dispute.count({ where: { status: { in: ["NEEDS_RESPONSE", "WARNING_NEEDS_RESPONSE"] }, diff --git a/app/api/admin/maintenance/route.ts b/app/api/admin/maintenance/route.ts index f079fe7e2..ed003ac49 100644 --- a/app/api/admin/maintenance/route.ts +++ b/app/api/admin/maintenance/route.ts @@ -159,7 +159,7 @@ export async function POST(request: NextRequest) { status: { in: ["NEEDS_RESPONSE", "WARNING_NEEDS_RESPONSE"] }, dueBy: { gte: new Date(), lte: bufferEnd }, }, - select: { id: true, dueBy: true, amount: true, currency: true }, + select: { id: true, dueBy: true, amountPaise: true, currency: true }, orderBy: { dueBy: "asc" }, }); if (urgentDisputes.length > 0) { diff --git a/app/api/admin/organizations/[orgId]/verify/route.ts b/app/api/admin/organizations/[orgId]/verify/route.ts new file mode 100644 index 000000000..eb3460f24 --- /dev/null +++ b/app/api/admin/organizations/[orgId]/verify/route.ts @@ -0,0 +1,190 @@ +/** + * POST /api/admin/organizations/[orgId]/verify + * + * Platform-admin action: flip an Organization from PENDING_VERIFICATION + * to ACTIVE. This is the last-mile gate before an org can create + * contracts, invite members, or initiate payouts — an admin has reviewed + * the verification docs (GSTIN, PAN, PO sample) and signed off. + * + * Admins can also SUSPEND an active org or REACTIVATE a suspended one. + * DEACTIVATED is terminal and not reversible from this endpoint. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireAdminAuth } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + IllegalTransitionError, + transitionOrganization, +} from "@/lib/enterprise/transitions"; + +// #779 §A — `REJECT` added (admin bounces a PENDING org back with a reason; +// stays PENDING_VERIFICATION). Action is upper-cased + defaults to VERIFY so +// the legacy `{}`/missing-action callers keep their verify behavior. +const ActionSchema = z.enum([ + "VERIFY", + "REJECT", + "SUSPEND", + "REACTIVATE", + "DEACTIVATE", +]); + +const BodySchema = z + .object({ + action: z + .preprocess( + (v) => (typeof v === "string" ? v.toUpperCase() : v), + ActionSchema, + ) + .default("VERIFY"), + reason: z.string().max(2000).optional(), + }) + // Reject requires a reason — it's what the OWNER sees to fix + resubmit. + .refine((b) => b.action !== "REJECT" || (b.reason && b.reason.length > 0), { + message: "reason is required when rejecting", + path: ["reason"], + }); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + + const { orgId } = await params; + + const raw = await req.json().catch(() => null); + const parsed = BodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.organization.findUnique({ + where: { id: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Organization not found"), { + httpStatus: 404, + }); + } + + // Friendly pre-check only — names the offending action/state in the + // error. Enforcement is the CAS WHERE below: a concurrent admin action + // landing between this read and the update matches zero rows and 409s + // instead of resurrecting a terminal state. + const allowedFrom: Record = { + // #779 §A — REJECT keeps the org PENDING (a sub-state, not a status move). + PENDING_VERIFICATION: ["VERIFY", "REJECT", "DEACTIVATE"], + ACTIVE: ["SUSPEND", "DEACTIVATE"], + SUSPENDED: ["REACTIVATE", "DEACTIVATE"], + DEACTIVATED: [], + }; + const allowed = allowedFrom[current.status] ?? []; + if (!allowed.includes(body.action)) { + throw Object.assign( + new Error( + `Cannot ${body.action} an organization in ${current.status} state`, + ), + { httpStatus: 409 }, + ); + } + + const actionKey = + body.action === "VERIFY" + ? AUDIT_ACTIONS.SYSTEM.VERIFIED + : body.action === "REJECT" + ? AUDIT_ACTIONS.SYSTEM.VERIFICATION_REJECTED + : body.action === "SUSPEND" + ? AUDIT_ACTIONS.SYSTEM.SUSPENDED + : body.action === "REACTIVATE" + ? AUDIT_ACTIONS.SYSTEM.REACTIVATED + : AUDIT_ACTIONS.SYSTEM.DEACTIVATED; + + if (body.action === "REJECT") { + // #779 §A — REJECT stamps the resubmit sub-state, not a status move. + // Guarded on still-PENDING so a concurrent VERIFY can't be overwritten + // back into rejection. + const res = await tx.organization.updateMany({ + where: { id: orgId, status: "PENDING_VERIFICATION" }, + data: { + verificationReason: body.reason, + verificationRejectedAt: new Date(), + }, + }); + if (res.count === 0) { + throw new IllegalTransitionError("Organization", "REJECT"); + } + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: null, + category: "SYSTEM", + action: actionKey, + description: `Admin reject: stays ${current.status}`, + details: { + adminUserId: auth.session.user.id, + from: current.status, + to: current.status, + reason: body.reason ?? null, + }, + }, + }); + } else { + const nextStatus = + body.action === "VERIFY" || body.action === "REACTIVATE" + ? ("ACTIVE" as const) + : body.action === "SUSPEND" + ? ("SUSPENDED" as const) + : ("DEACTIVATED" as const); + + await transitionOrganization(tx, { + where: { id: orgId }, + to: nextStatus, + // #779 §A — VERIFY clears the resubmit-loop sub-state. + data: + body.action === "VERIFY" + ? { + verificationReason: null, + verificationSubmittedAt: null, + verificationRejectedAt: null, + } + : undefined, + audit: { + organizationId: orgId, + actorMembershipId: null, + category: "SYSTEM", + action: actionKey, + description: `Admin ${body.action.toLowerCase()}: ${current.status} → ${nextStatus}`, + details: { + adminUserId: auth.session.user.id, + from: current.status, + to: nextStatus, + reason: body.reason ?? null, + }, + }, + }); + } + + // updateMany returns no row — re-read in-tx for the response body. + return tx.organization.findUniqueOrThrow({ where: { id: orgId } }); + }); + + return NextResponse.json({ organization: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/admin/organizations/route.ts b/app/api/admin/organizations/route.ts new file mode 100644 index 000000000..0c8b87f52 --- /dev/null +++ b/app/api/admin/organizations/route.ts @@ -0,0 +1,85 @@ +/** + * GET /api/admin/organizations + * + * Platform-admin view of all organizations — filterable by status, + * searchable by name/email. Used by the admin org management page to + * surface PENDING_VERIFICATION orgs for review and allow admins to + * verify, suspend, or reactivate without needing DB access. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireAdminAuth } from "@/lib/auth-helpers"; + +const OrgStatusSchema = z.enum([ + "PENDING_VERIFICATION", + "ACTIVE", + "SUSPENDED", + "DEACTIVATED", +]); + +export async function GET(req: NextRequest) { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + + const url = new URL(req.url); + const statusRaw = url.searchParams.get("status"); + const search = url.searchParams.get("search")?.trim() ?? ""; + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10)); + const limit = Math.min( + 100, + Math.max(1, parseInt(url.searchParams.get("limit") ?? "25", 10)), + ); + + const statusFilter = statusRaw + ? OrgStatusSchema.safeParse(statusRaw) + : null; + + const where = { + ...(statusFilter?.success ? { status: statusFilter.data } : {}), + ...(search + ? { + OR: [ + { name: { contains: search, mode: "insensitive" as const } }, + { billingEmail: { contains: search, mode: "insensitive" as const } }, + { slug: { contains: search, mode: "insensitive" as const } }, + ], + } + : {}), + }; + + const [total, organizations] = await Promise.all([ + prisma.organization.count({ where }), + prisma.organization.findMany({ + where, + select: { + id: true, + name: true, + slug: true, + status: true, + canSponsor: true, + canHost: true, + billingEmail: true, + createdAt: true, + billingAccount: { + select: { id: true, fundingSource: true }, + }, + _count: { + select: { + memberships: true, + contracts: true, + }, + }, + }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + skip: (page - 1) * limit, + take: limit, + }), + ]); + + return NextResponse.json({ + data: organizations, + pagination: { total, page, limit, pages: Math.ceil(total / limit) }, + }); +} diff --git a/app/api/admin/payments/route.ts b/app/api/admin/payments/route.ts index c61abc290..445f75c69 100644 --- a/app/api/admin/payments/route.ts +++ b/app/api/admin/payments/route.ts @@ -23,6 +23,10 @@ export async function GET(req: NextRequest) { "appointmentType", ) as AppointmentsType | null; const search = searchParams.get("search"); + // #674 comment 7 — optional org-scope filter for support staff drilling + // into a single tenant's payments. No extra permission gate needed: + // the route is already privileged (requirePrivilegedAuth above). + const orgId = searchParams.get("orgId"); // Build where clause const where: Prisma.PaymentWhereInput = {}; @@ -48,6 +52,10 @@ export async function GET(req: NextRequest) { }; } + if (orgId) { + where.organizationId = orgId; + } + // Fetch payments with pagination const [payments, total] = await Promise.all([ prisma.payment.findMany({ diff --git a/app/api/admin/payouts/[id]/route.ts b/app/api/admin/payouts/[id]/route.ts index b7b5e4299..ccf0832ef 100644 --- a/app/api/admin/payouts/[id]/route.ts +++ b/app/api/admin/payouts/[id]/route.ts @@ -68,7 +68,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { const { action, reason } = actionSchema.parse(body); // Verify payout exists and is pending - const payout = await prisma.payout.findUnique({ + const payout = await prisma.consultantPayout.findUnique({ where: { id }, }); diff --git a/app/api/admin/payouts/route.ts b/app/api/admin/payouts/route.ts index 35823da85..3a6c7c125 100644 --- a/app/api/admin/payouts/route.ts +++ b/app/api/admin/payouts/route.ts @@ -35,6 +35,8 @@ export async function GET(req: NextRequest) { const result = await getOperatorPayouts({ status: searchParams.get("status") as PayoutStatus | null, search: searchParams.get("search"), + // #674 comment 7 — org-scope filter via earnings.payment.organizationId. + orgId: searchParams.get("orgId"), limit: parseInt(searchParams.get("limit") || "50"), offset: parseInt(searchParams.get("offset") || "0"), }); @@ -68,7 +70,7 @@ export async function POST(req: NextRequest) { const batchId = await createPayoutBatch(consultantProfileIds); // Get created payouts - const payouts = await prisma.payout.findMany({ + const payouts = await prisma.consultantPayout.findMany({ where: { batchId }, include: { consultantProfile: { diff --git a/app/api/admin/reconcile-ledgers/route.ts b/app/api/admin/reconcile-ledgers/route.ts new file mode 100644 index 000000000..892442724 --- /dev/null +++ b/app/api/admin/reconcile-ledgers/route.ts @@ -0,0 +1,91 @@ +/** + * POST /api/admin/reconcile-ledgers + * GET /api/admin/reconcile-ledgers + * + * Platform-admin-only ledger auditor. + * + * - `POST` triggers a fresh reconciliation run synchronously and + * returns the resulting report. Body may include `{ organizationId }` + * to scope the run to a single org (faster, for targeted ops + * investigation); omit for a full-scope run (nightly-cron equivalent). + * + * - `GET` lists the most recent reports (paginated). Useful for the + * admin UI to render a timeline of "ledger health" runs. + * + * Access: platform admins only via `requirePrivilegedAuth`. Does NOT + * grant org admins the ability to run reconciliation on their own org — + * intentionally, because the reconcile output exposes cross-org + * aggregate shapes that we don't want leaking through an in-app UI. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { runReconcileLedgers } from "@/scripts/reconcile/reconcile-ledgers"; + +const RunBodySchema = z.object({ + organizationId: z.string().min(1).optional(), +}); + +export async function POST(req: NextRequest) { + const auth = await requirePrivilegedAuth(); + if (auth.error) return auth.error; + + const raw = await req.json().catch(() => ({})); + const parsed = RunBodySchema.safeParse(raw ?? {}); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { organizationId } = parsed.data; + const scope = organizationId ? `org:${organizationId}` : "full"; + // requirePrivilegedAuth returns the privileged session id so we can + // attribute the run in the report row. Fall back to null for service + // accounts / cli if the helper ever widens. + const triggeredById = + (auth as unknown as { session?: { user?: { id?: string } } }).session?.user?.id ?? + null; + + try { + const report = await runReconcileLedgers({ + scope, + organizationId: organizationId ?? undefined, + triggeredById: triggeredById ?? undefined, + }); + + return NextResponse.json({ data: report }); + } catch (err) { + console.error("[admin/reconcile-ledgers] run failed", err); + return NextResponse.json( + { + error: "Reconciliation run failed", + message: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ); + } +} + +export async function GET(req: NextRequest) { + const auth = await requirePrivilegedAuth(); + if (auth.error) return auth.error; + + const url = new URL(req.url); + const limit = Math.min( + Math.max(parseInt(url.searchParams.get("limit") ?? "20", 10) || 20, 1), + 100, + ); + const onlyDirty = url.searchParams.get("onlyDirty") === "true"; + + const reports = await prisma.ledgerReconciliationReport.findMany({ + where: onlyDirty ? { ok: false } : undefined, + orderBy: { runAt: "desc" }, + take: limit, + }); + + return NextResponse.json({ data: reports }); +} diff --git a/app/api/admin/subscriptions/route.ts b/app/api/admin/subscriptions/route.ts index a4c7a90ae..684c31405 100644 --- a/app/api/admin/subscriptions/route.ts +++ b/app/api/admin/subscriptions/route.ts @@ -23,6 +23,8 @@ export async function GET(req: NextRequest) { const search = searchParams.get("search"); const limit = parseInt(searchParams.get("limit") || "20"); const offset = parseInt(searchParams.get("offset") || "0"); + // #674 comment 7 — optional org-scope filter on Payment.organizationId. + const orgId = searchParams.get("orgId"); const now = new Date(); const soonThreshold = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now @@ -70,6 +72,10 @@ export async function GET(req: NextRequest) { ]; } + if (orgId) { + where.organizationId = orgId; + } + // Base where clause for all subscription queries (without status filter) const baseWhere = { paymentStatus: "SUCCEEDED" as const, diff --git a/app/api/admin/system-events/route.ts b/app/api/admin/system-events/route.ts new file mode 100644 index 000000000..55311a2ff --- /dev/null +++ b/app/api/admin/system-events/route.ts @@ -0,0 +1,78 @@ +/** + * GET /api/admin/system-events + * + * Platform-engineering surface for operational events captured by + * `recordSystemEvent` / `recordSystemError`. Separate from `OrgAuditLog` + * — see SystemEvent docstring for the distinction (stack traces / Prisma + * errors live here; clean prose lives in the org-visible audit log). + * + * Admin-only. Cursor-paginated on `(createdAt DESC, id DESC)` using the + * `system_events_createdAt_idx` covering indexes. Query filters: + * - `severity` — INFO | WARN | ERROR + * - `category` — DATA_EXPORT | HRIS_SYNC | WEBHOOK | PAYOUT | CRON ... + * - `organizationId` — scope to one tenant + * - `correlationId` — pull every event for a single job invocation + * - `since` — ISO timestamp lower bound + * - `limit` — page size (default 100, max 500) + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma, SystemEventSeverity } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireAdminAuth } from "@/lib/auth-helpers"; + +const QuerySchema = z.object({ + severity: z.nativeEnum(SystemEventSeverity).optional(), + category: z.string().min(1).max(64).optional(), + organizationId: z.string().min(1).optional(), + correlationId: z.string().min(1).optional(), + since: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + +export async function GET(req: NextRequest) { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const q = parsed.data; + + const where: Prisma.SystemEventWhereInput = { + ...(q.severity ? { severity: q.severity } : {}), + ...(q.category ? { category: q.category } : {}), + ...(q.organizationId ? { organizationId: q.organizationId } : {}), + ...(q.correlationId ? { correlationId: q.correlationId } : {}), + ...(q.since ? { createdAt: { gte: new Date(q.since) } } : {}), + }; + + const events = await prisma.systemEvent.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: q.limit, + select: { + id: true, + organizationId: true, + category: true, + severity: true, + message: true, + context: true, + correlationId: true, + createdAt: true, + }, + }); + + return NextResponse.json({ + data: events.map((e) => ({ + ...e, + createdAt: e.createdAt.toISOString(), + })), + }); +} diff --git a/app/api/admin/tds/route.ts b/app/api/admin/tds/route.ts index c81575414..bba25d0cc 100644 --- a/app/api/admin/tds/route.ts +++ b/app/api/admin/tds/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { requireAdminAuth, requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { ENABLE_TDS_ADMIN_VIEW } from "@/lib/feature-flags"; import { getTDSSummary, getConsultantTDSBreakdown, @@ -13,10 +14,23 @@ import { getIndianFinancialYear, } from "@/lib/payments/tax/tds-service"; +// 404 when the flag is off, mirroring "endpoint doesn't exist" semantics +// rather than 403 — the Form 26Q filing surface is intentionally hidden +// pre-launch. Flip ENABLE_TDS_ADMIN_VIEW=true when finance is ready to +// operate the quarterly filing flow. See lib/feature-flags.ts. +function notFoundIfGated() { + if (!ENABLE_TDS_ADMIN_VIEW) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return null; +} + /** * GET /api/admin/tds?fy=2026-27&view=summary|consultants */ export async function GET(req: NextRequest) { + const gated = notFoundIfGated(); + if (gated) return gated; try { const auth = await requirePrivilegedAuth(); if (auth.error) return auth.error; @@ -56,7 +70,8 @@ export async function GET(req: NextRequest) { financialYear: r.financialYear, quarter: r.quarter, tdsDeducted: r.tdsDeducted, - tdsRate: r.tdsRate, + // 26Q wants a percent column; storage is bps (#781 §C). + tdsRatePercent: r.tdsRateBps / 100, cumulativeAmountCredited: r.cumulativeAmountCredited, isReversal: r.isReversal, consultantPAN: r.consultantProfile.taxInfo?.panEncrypted @@ -90,6 +105,8 @@ export async function GET(req: NextRequest) { * and the `view=form26q` GET above which both expose decrypted PAN data. */ export async function POST(req: NextRequest) { + const gated = notFoundIfGated(); + if (gated) return gated; try { const auth = await requireAdminAuth(); if (auth.error) return auth.error; diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index dfeb92413..ffe952d36 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -14,6 +14,10 @@ export async function GET(req: NextRequest) { const page = parseInt(searchParams.get("page") || "1"); const limit = 20; const skip = (page - 1) * limit; + // #674 comment 7 — optional org-scope filter. Filters to users with + // an ACTIVE Membership at the given org. Useful for support staff + // looking up "all members of Acme." + const orgId = searchParams.get("orgId"); // Build where clause with proper typing const where: Prisma.UserWhereInput = {}; @@ -34,6 +38,12 @@ export async function GET(req: NextRequest) { ]; } + if (orgId) { + where.memberships = { + some: { organizationId: orgId, status: "ACTIVE" }, + }; + } + // Fetch users with pagination const [users, total] = await Promise.all([ prisma.user.findMany({ diff --git a/app/api/appointments/[appointmentId]/cancel/route.ts b/app/api/appointments/[appointmentId]/cancel/route.ts index e3b4f6764..9154bb638 100644 --- a/app/api/appointments/[appointmentId]/cancel/route.ts +++ b/app/api/appointments/[appointmentId]/cancel/route.ts @@ -10,6 +10,11 @@ import { import { getSession } from "@/lib/auth-server"; import { isPrivileged } from "@/lib/auth-helpers"; +import { refundPayment } from "@/lib/payments/operations/refund"; +import { + computeRefundPct, + parsePolicySnapshot, +} from "@/lib/payments/operations/cancellation-policy"; export async function POST( request: NextRequest, { params }: { params: Promise<{ appointmentId: string }> }, @@ -83,7 +88,17 @@ export async function POST( classPlan: true, }, }, - slotsOfAppointment: { take: 1, select: { startsAt: true } }, + // Earliest slot decides the refund tier — without orderBy the DB + // returns an arbitrary slot (review catch on #844). + slotsOfAppointment: { + take: 1, + orderBy: { startsAt: "asc" }, + select: { startsAt: true }, + }, + // B1 — refund terms frozen at booking + the payment to refund. + payment: { + select: { id: true, amount: true, paymentStatus: true }, + }, }, }); @@ -174,30 +189,71 @@ export async function POST( cancelledBy: session.user.id, }; + // Cancellable from-states: never COMPLETED (history), never CANCELLED + // (idempotency — a double-cancel must not re-run refunds), never + // REJECTED/EXPIRED (nothing to cancel). The guard rides the WHERE and is + // re-evaluated under the row lock (B2/B16 — the #825 CAS doctrine), so a + // cancel racing the capture webhook resolves to exactly one winner. + const CANCELLABLE_FROM = [ + "PENDING", + "APPROVED", + "APPROVED_PENDING_PAYMENT", + "SCHEDULED", + ] as const; + // Transaction for critical database operations only (with increased timeout) const result = await prisma.$transaction( async (tx) => { - // Update appointment status based on type + // Update appointment status based on type — CAS-guarded. + let moved = 0; if (appointment.consultation) { - await tx.consultation.update({ - where: { id: appointment.consultation.id }, - data: cancellationData, - }); + moved = ( + await tx.consultation.updateMany({ + where: { + id: appointment.consultation.id, + requestStatus: { in: [...CANCELLABLE_FROM] }, + }, + data: cancellationData, + }) + ).count; } else if (appointment.subscription) { - await tx.subscription.update({ - where: { id: appointment.subscription.id }, - data: cancellationData, - }); + moved = ( + await tx.subscription.updateMany({ + where: { + id: appointment.subscription.id, + requestStatus: { in: [...CANCELLABLE_FROM] }, + }, + data: cancellationData, + }) + ).count; } else if (appointment.webinar) { - await tx.webinar.update({ - where: { id: appointment.webinar.id }, - data: { status: "CANCELLED" }, - }); + moved = ( + await tx.webinar.updateMany({ + where: { + id: appointment.webinar.id, + status: { notIn: ["CANCELLED", "COMPLETED"] }, + }, + data: { status: "CANCELLED" }, + }) + ).count; } else if (appointment.class) { - await tx.class.update({ - where: { id: appointment.class.id }, - data: { status: "CANCELLED" }, - }); + moved = ( + await tx.class.updateMany({ + where: { + id: appointment.class.id, + status: { notIn: ["CANCELLED", "COMPLETED"] }, + }, + data: { status: "CANCELLED" }, + }) + ).count; + } + if (moved === 0) { + throw Object.assign( + new Error( + "This appointment can no longer be cancelled (already cancelled, completed, or expired).", + ), + { httpStatus: 409, code: "NOT_CANCELLABLE" }, + ); } // Soft-cancel: mark slots as CANCELLED instead of deleting. @@ -207,18 +263,22 @@ export async function POST( await tx.slotOfAppointment.updateMany({ where: { appointment: { subscriptionId: appointment.subscription.id }, + completionStatus: "SCHEDULED", }, data: { completionStatus: "CANCELLED" }, }); } else if (appointment.class) { await tx.slotOfAppointment.updateMany({ - where: { appointment: { classId: appointment.class.id } }, + where: { + appointment: { classId: appointment.class.id }, + completionStatus: "SCHEDULED", + }, data: { completionStatus: "CANCELLED" }, }); } else { // Consultation/webinar/trial — single appointment await tx.slotOfAppointment.updateMany({ - where: { appointmentId }, + where: { appointmentId, completionStatus: "SCHEDULED" }, data: { completionStatus: "CANCELLED" }, }); } @@ -237,6 +297,61 @@ export async function POST( }, ); + // B1 — policy-driven refund, AFTER the cancel tx commits (refundPayment + // runs its own Serializable tx; the CAS above guarantees this block runs + // at most once per appointment — a second cancel 409s before reaching it). + // Scope: consultation/subscription. Webinar/class buyer refunds belong to + // the participant-removal flow, not whole-event cancellation. + let refund: { amountRefundedPaise: number; refundPct: number } | null = + null; + const isExclusiveType = + !!appointment.consultation || !!appointment.subscription; + const paidPayment = appointment.payment?.find( + (p) => p.paymentStatus === "SUCCEEDED" && p.amount > 0, + ); + if (isExclusiveType && paidPayment) { + const startsAt = appointment.slotsOfAppointment?.[0]?.startsAt; + const hoursUntilStart = startsAt + ? (startsAt.getTime() - Date.now()) / 3_600_000 + : -1; + const isConsultantInitiated = + session.user.consultantProfileId !== null && + session.user.consultantProfileId !== undefined && + session.user.id !== undefined && + consultantUserId === session.user.id; + const refundPct = computeRefundPct( + parsePolicySnapshot(appointment.cancellationPolicySnapshot), + hoursUntilStart, + isConsultantInitiated, + ); + const refundAmount = Math.floor( + (Number(paidPayment.amount) * refundPct) / 100, + ); + if (refundAmount > 0) { + try { + const r = await refundPayment({ + paymentId: paidPayment.id, + amountPaise: refundAmount, + reason: `cancellation (${refundPct}% per booking-time policy, ${ + isConsultantInitiated ? "consultant" : "consultee" + }-initiated)`, + initiatedByUserId: session.user.id, + }); + refund = { amountRefundedPaise: r.amountRefundedPaise, refundPct }; + } catch (refundErr) { + // The cancellation itself stands; a failed refund must be visible, + // not silently swallowed — surface for ops + tell the caller. + console.error( + `[cancel] refund failed for payment ${paidPayment.id}:`, + refundErr, + ); + refund = { amountRefundedPaise: 0, refundPct }; + } + } else { + refund = { amountRefundedPaise: 0, refundPct }; + } + } + // Notification metadata (for fire-and-forget notifications after transaction) const notificationMeta = { consultantUserId, @@ -313,8 +428,22 @@ export async function POST( // Waitlist notifications should only fire when a participant leaves an // otherwise-active event (handled in participant removal flow). - return NextResponse.json(result); + return NextResponse.json({ ...result, refund }); } catch (error) { + if (error instanceof Error && "httpStatus" in error) { + const status = + typeof (error as { httpStatus?: number }).httpStatus === "number" + ? (error as { httpStatus: number }).httpStatus + : 500; + const code = + "code" in error && typeof (error as { code?: string }).code === "string" + ? (error as { code: string }).code + : undefined; + return NextResponse.json( + { error: error.message, ...(code && { code }) }, + { status }, + ); + } if (error instanceof Error && error.message === "Appointment not found") { return NextResponse.json( { error: "Appointment not found" }, diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index 5878c5447..f044d8944 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -9,6 +9,7 @@ import { AppointmentNotFoundError, } from "@/utils/errors/RescheduleErrors"; import { notifyAppointmentRescheduled } from "@/lib/novu/service"; +import { logActivity } from "@/lib/activity/log-activity"; import { getAppUrl } from "@/lib/url"; const MINIMUM_HOURS_BEFORE_RESCHEDULE = 24; @@ -249,7 +250,7 @@ export async function POST( where: { appointmentId: { in: affectedAppointmentIds }, }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); } else if (derivedType === "SUBSCRIPTION" && appointment.subscription) { // Entire subscription reschedule - mark ALL slots in ALL appointments @@ -262,7 +263,7 @@ export async function POST( await tx.slotOfAppointment.updateMany({ where: { appointmentId: { in: allAppointmentIds } }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); } else if (derivedType === "CLASS" && appointment.class) { // Entire class reschedule - mark ALL slots in ALL appointments @@ -275,37 +276,74 @@ export async function POST( await tx.slotOfAppointment.updateMany({ where: { appointmentId: { in: allAppointmentIds } }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); } else { // Non-multi-appointment: mark all slots in the single appointment await tx.slotOfAppointment.updateMany({ where: { appointmentId }, - data: { isTentative: true }, + data: { isTentative: true, completionStatus: "RESCHEDULED" }, }); } - // Update status based on appointment type + // Update status based on appointment type — CAS-guarded (B2): a + // reschedule racing a cancel/completion must not resurrect the + // booking; count 0 means the from-state was terminal → 409. + const RESCHEDULABLE_FROM = [ + "PENDING", + "APPROVED", + "APPROVED_PENDING_PAYMENT", + "SCHEDULED", + ] as const; + let movedStatus = 1; // group events validated below if (appointment.consultation) { - await tx.consultation.update({ - where: { id: appointment.consultation.id }, - data: { requestStatus: "PENDING" }, - }); + movedStatus = ( + await tx.consultation.updateMany({ + where: { + id: appointment.consultation.id, + requestStatus: { in: [...RESCHEDULABLE_FROM] }, + }, + data: { requestStatus: "PENDING" }, + }) + ).count; } else if (appointment.subscription) { - await tx.subscription.update({ - where: { id: appointment.subscription.id }, - data: { requestStatus: "PENDING" }, - }); + movedStatus = ( + await tx.subscription.updateMany({ + where: { + id: appointment.subscription.id, + requestStatus: { in: [...RESCHEDULABLE_FROM] }, + }, + data: { requestStatus: "PENDING" }, + }) + ).count; } else if (appointment.webinar) { - await tx.webinar.update({ - where: { id: appointment.webinar.id }, - data: { status: "SCHEDULED" }, - }); + movedStatus = ( + await tx.webinar.updateMany({ + where: { + id: appointment.webinar.id, + status: { notIn: ["CANCELLED", "COMPLETED"] }, + }, + data: { status: "SCHEDULED" }, + }) + ).count; } else if (appointment.class) { - await tx.class.update({ - where: { id: appointment.class.id }, - data: { status: "SCHEDULED" }, - }); + movedStatus = ( + await tx.class.updateMany({ + where: { + id: appointment.class.id, + status: { notIn: ["CANCELLED", "COMPLETED"] }, + }, + data: { status: "SCHEDULED" }, + }) + ).count; + } + if (movedStatus === 0) { + throw Object.assign( + new Error( + "This appointment can no longer be rescheduled (already cancelled or completed).", + ), + { httpStatus: 409, code: "NOT_RESCHEDULABLE" }, + ); } // Determine reschedule type for response @@ -334,6 +372,21 @@ export async function POST( rescheduleType === "entire_booking" ? "All sessions marked for rescheduling. Please select new times." : `${slotsToReschedule.length} session(s) marked for rescheduling. Please select new time(s).`, + // B14 — context for the post-tx activity log (appointment is only + // in scope inside this callback). + logContext: { + cpId: + appointment.consultation?.consultationPlan?.consultantProfileId ?? + appointment.subscription?.subscriptionPlan?.consultantProfileId ?? + appointment.webinar?.webinarPlan?.consultantProfileId ?? + appointment.class?.classPlan?.consultantProfileId ?? + null, + appointmentType: appointment.appointmentType, + consultationId: appointment.consultation?.id, + subscriptionId: appointment.subscription?.id, + webinarId: appointment.webinar?.id, + classId: appointment.class?.id, + }, }; }, { @@ -341,7 +394,24 @@ export async function POST( }, ); + // B14 — reschedule now leaves an activity-log entry (cancel always did). + if (result.logContext.cpId) { + await logActivity({ + activityType: "APPOINTMENT_RESCHEDULED", + description: `Appointment reschedule requested (${result.logContext.appointmentType.toLowerCase()})`, + actorId: session.user.id, + actorName: session.user.name || "User", + actorImage: session.user.image, + consultantProfileId: result.logContext.cpId, + consultationId: result.logContext.consultationId, + subscriptionId: result.logContext.subscriptionId, + webinarId: result.logContext.webinarId, + classId: result.logContext.classId, + }); + } + // Fire-and-forget: notify both parties about reschedule + // FIX #624: Include webinar/class so group event participants are also notified. try { const appointment = await prisma.appointment.findUnique({ @@ -442,8 +512,11 @@ export async function POST( } } - // Deduplicate - const uniqueUserIds = Array.from(new Set(userIds)); + // Deduplicate; exclude the initiator — you don't need a notification + // about your own reschedule (B15). + const uniqueUserIds = Array.from(new Set(userIds)).filter( + (id) => id !== session.user.id, + ); const appointmentType = consultation ? "consultation" @@ -472,6 +545,21 @@ export async function POST( return NextResponse.json(result); } catch (error) { + // B2 — the CAS guard's structured 409 (NOT_RESCHEDULABLE). + if (error instanceof Error && "httpStatus" in error) { + const status = + typeof (error as { httpStatus?: number }).httpStatus === "number" + ? (error as { httpStatus: number }).httpStatus + : 500; + const code = + "code" in error && typeof (error as { code?: string }).code === "string" + ? (error as { code: string }).code + : undefined; + return NextResponse.json( + { error: error.message, ...(code && { code }) }, + { status }, + ); + } // Type-safe error handling using custom error classes if (error instanceof RescheduleAuthorizationError) { return NextResponse.json({ error: error.message }, { status: 403 }); diff --git a/app/api/appointments/route.ts b/app/api/appointments/route.ts new file mode 100644 index 000000000..c0b822dec --- /dev/null +++ b/app/api/appointments/route.ts @@ -0,0 +1,85 @@ +/** + * GET /api/appointments + * + * Personal appointments list with optional `?orgScope=` filter + * (#674 / B1-hybrid). The org-scoped sibling lives at + * `/api/organizations/[orgId]/appointments`. Both call the same shared + * `listAppointmentsScoped` service. + * + * Auth: any signed-in user via `getServerSession`. The scope dimension + * is then validated by `resolveOrgScope` against the caller's + * memberships (rejects `` if the caller isn't a member; rejects + * `all` if not ADMIN/STAFF). + * + * Default scope is `personal` — backwards-compatible with any caller + * that doesn't pass the param. + * + * NOT TO BE CONFUSED WITH `/api/dashboard/{consultee|consultant}/[id]/events` + * which is the pre-existing 5-type-union "all my bookings widget" + * endpoint. That one returns + * `{consultations, subscriptions, webinars, classes, trials}` flattened + * for a per-user dashboard tab — not paginated, scope-aware. This + * endpoint returns a paginated `Appointment[]` (parent rows only) for + * use by org-side dashboards + the new scope-toggle UX. Both endpoints + * accept `?orgScope=` since the B1-personal-retrofit; pick the one that + * matches the response shape you need. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { listAppointmentsScoped } from "@/lib/api/scope/list-appointments"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + appointmentType: z + .enum(["CONSULTATION", "SUBSCRIPTION", "WEBINAR", "CLASS", "TRIAL"]) + .optional(), +}); + +export async function GET(req: NextRequest) { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + const session = auth.session; + + // Resolve scope against the caller's active memberships. + const memberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + + const url = new URL(req.url); + const scopeResolution = resolveOrgScope({ + raw: url.searchParams.get("orgScope"), + memberships, + userRole: (session.user as { role?: string }).role, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + + const filters = QuerySchema.safeParse({ + appointmentType: url.searchParams.get("appointmentType") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listAppointmentsScoped({ + scope: scopeResolution.scope, + userId: session.user.id, + appointmentType: filters.data.appointmentType, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/auth/sso/domain-check/route.ts b/app/api/auth/sso/domain-check/route.ts new file mode 100644 index 000000000..fb4046387 --- /dev/null +++ b/app/api/auth/sso/domain-check/route.ts @@ -0,0 +1,120 @@ +/** + * GET /api/auth/sso/domain-check?email= + * + * Pre-auth discovery endpoint. The signin/signup pages call this on + * email blur so an enforce-SSO domain can short-circuit the credentials + * form and redirect to the IdP via BetterAuth's `signIn.sso()`. + * + * Lookup chain (all Arch 4-Modified — no legacy org profile tables): + * 1. Parse + narrow the email query param (Zod). + * 2. Match the email's domain against `OrgDomainClaim`. + * 3. For the owning org, read `OrganizationSSOSettings` + the first + * active `SsoProvider`. + * 4. Return `{ enforceSSO, organizationName?, ssoBody? }`. If the + * domain isn't claimed, or the org doesn't enforce SSO, or no + * provider is configured, return `{ enforceSSO: false }` so the + * client falls through to the normal credentials flow. + * + * Intentionally does NOT use `requireApiAuth` — this runs before login. + * The response payload is shaped to be minimal (no PII, no provider + * internals) so leaking it to unauthenticated callers is safe. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { lookupEnforcedOrg } from "@/lib/sso/enforce-session"; +import { validateSamlCert } from "@/lib/sso/provider-schemas"; + +const QuerySchema = z.object({ + email: z.string().email(), +}); + +const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? ""; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const parsed = QuerySchema.safeParse({ + email: url.searchParams.get("email"), + }); + if (!parsed.success) { + return NextResponse.json({ enforceSSO: false }); + } + + const domain = parsed.data.email.split("@")[1]?.toLowerCase(); + if (!domain) return NextResponse.json({ enforceSSO: false }); + + // Single source of truth for "is this domain enforced + by which org?" + // (audit B.6). Returns null when any precondition fails: no verified + // claim, inactive org, allowlist mismatch, enforceSSO=false. The + // previous inline lookup here, in `lib/auth.ts:session.create.before`, + // and in `lib/auth.ts:customSession` each had subtle drift — see + // issue #673. + const enforced = await lookupEnforcedOrg(prisma, domain); + if (!enforced) { + return NextResponse.json({ enforceSSO: false }); + } + + // Provider lookup is scoped to BOTH (domain, organizationId). The + // domain-claim is the authoritative "who owns this email domain" + // record — a stray SsoProvider row for the same domain under a + // different org (misconfigured tenant, stale data) must not route + // users to the wrong IdP. (B.4's composite unique now enforces + // this at the DB level too.) + const provider = await prisma.ssoProvider.findFirst({ + where: { domain, organizationId: enforced.organizationId }, + select: { providerId: true, samlConfig: true, oidcConfig: true }, + }); + if (!provider) { + return NextResponse.json({ enforceSSO: false }); + } + + // Pre-flight integrity check on the stored cert. Legacy SsoProvider rows + // registered before `validateSamlCert` landed in `provider-schemas.ts` may + // carry a malformed PEM (or none at all). If we hand BetterAuth's SAML + // adapter a bad cert, it crashes inside `validatePostResponse` with an + // empty-body 500 — the user clicks "Sign in with SSO" and sees a blank + // page with no error to act on. Returning the typed + // `SSO_PROVIDER_MISCONFIGURED` response keeps the signin page on the + // credentials form and shows a friendly toast. + // + // OIDC providers don't have a cert; they fail differently (discoveryEndpoint + // unreachable, etc.) and are out of scope for this guard. + if (provider.samlConfig) { + try { + const parsed = JSON.parse(provider.samlConfig) as { cert?: string }; + if (!parsed.cert || !validateSamlCert(parsed.cert)) { + return NextResponse.json({ + enforceSSO: true, + providerMisconfigured: true, + errorCode: "SSO_PROVIDER_MISCONFIGURED", + }); + } + } catch { + // Stored config is not parseable JSON — also a misconfiguration. + return NextResponse.json({ + enforceSSO: true, + providerMisconfigured: true, + errorCode: "SSO_PROVIDER_MISCONFIGURED", + }); + } + } + + // The org name is the only extra field this endpoint emits beyond + // `lookupEnforcedOrg`'s return shape; fetch it now so the SSO button + // label can show "Sign in with Wipro Limited SSO →". + const org = await prisma.organization.findUnique({ + where: { id: enforced.organizationId }, + select: { name: true }, + }); + + return NextResponse.json({ + enforceSSO: true, + organizationName: org?.name ?? null, + ssoBody: { + providerId: provider.providerId, + domain, + callbackURL: `${APP_URL}/auth/signin?ssoCallback=1`, + }, + }); +} diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index c352ae4eb..cfe5ac352 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -8,16 +8,23 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth-server"; import { checkoutLimiter, applyRateLimit } from "@/lib/rate-limit"; import { ZodError } from "zod"; +import { Prisma } from "@prisma/client"; +import { replayByIdempotencyKey } from "@/lib/payments/operations/checkout-replay"; import { routeGateway } from "@/lib/payments/gateway-router"; import { resolveCheckoutTaxContext } from "@/lib/payments/tax/checkout-context"; export async function POST(req: NextRequest) { + // #828 — hoisted so the P2002 catch can replay without re-reading the + // (already consumed) request body. + let replayUserId: string | undefined; + let replayKey: string | undefined; try { // Check authentication const session = await getSession(); if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + replayUserId = session.user.id; // Rate limit: 5 checkouts per minute per user const rl = await applyRateLimit(checkoutLimiter, session.user.id); @@ -30,6 +37,15 @@ export async function POST(req: NextRequest) { const isMockPayment = body.isMockPayment === true && process.env.NODE_ENV === "development"; + // #828 — fast-path replay: a double-click / network retry / second tab + // with the same key gets the original attempt's response, never a second + // Payment + tentative slots + gateway order. + replayKey = validatedData.clientIdempotencyKey; + if (replayKey) { + const replay = await replayByIdempotencyKey(session.user.id, replayKey); + if (replay) return replay; + } + const { buyerCountry } = await resolveCheckoutTaxContext({ userId: session.user.id, headers: req.headers, @@ -63,8 +79,24 @@ export async function POST(req: NextRequest) { isMockPayment, buyerCountry, ); + if (!result.success) { + return NextResponse.json(result, { status: 400 }); + } return NextResponse.json(result); } catch (error) { + // #828 — two concurrent identical requests can both miss the replay + // lookup; the loser's Payment.create dies on the unique key. Replay the + // winner's response instead of surfacing a 500. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" && + String(error.meta?.target ?? "").includes("clientIdempotencyKey") && + replayUserId && + replayKey + ) { + const replay = await replayByIdempotencyKey(replayUserId, replayKey); + if (replay) return replay; + } // ZodError from checkoutSchema.parse() — extract first human-readable message if (error instanceof ZodError) { const firstMessage = error.issues[0]?.message ?? "Invalid request"; diff --git a/app/api/cleanup/abandoned-org-top-ups/route.ts b/app/api/cleanup/abandoned-org-top-ups/route.ts new file mode 100644 index 000000000..0ea24100a --- /dev/null +++ b/app/api/cleanup/abandoned-org-top-ups/route.ts @@ -0,0 +1,69 @@ +/** + * POST /api/cleanup/abandoned-org-top-ups + * + * Enterprise sibling of `/api/cleanup/abandoned-payments`. Reaps pending + * `WalletEntry` placeholder rows that never received a webhook confirmation + * within the grace window. See scripts/cleanup/cleanup-abandoned-org-top-ups.ts + * for the full invariant + rationale. + * + * This route is a thin wrapper around the shared script so the same code + * runs in both the GitHub Actions job (jobs/cleanup/cleanup-abandoned-org-top-ups.ts) + * and the on-demand HTTP endpoint, matching the rest of /api/cleanup/*. + * + * Gated by CRON_SECRET (or VERCEL_CRON_SECRET) just like every other + * cleanup route — the Authorization header must carry the bearer token. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { cleanupAbandonedOrgTopUps } from "@/scripts/cleanup/cleanup-abandoned-org-top-ups"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + console.warn("Unauthorized abandoned-org-top-ups cleanup attempt"); + return NextResponse.json( + { + error: "Unauthorized", + message: + "Please provide a valid authorization header with the CRON_SECRET", + }, + { status: 401 }, + ); + } + + try { + console.log("🧹 Starting abandoned org top-up cleanup via API..."); + const result = await cleanupAbandonedOrgTopUps(); + + console.log("✅ Abandoned org top-up cleanup completed:", { + reaped: result.reaped, + graceHours: result.graceHours, + success: result.success, + }); + + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("[cleanup/abandoned-org-top-ups] failed:", error); + return NextResponse.json( + { + error: "Cleanup failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +// Some external schedulers (e.g. legacy Vercel Cron) only emit GET. +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/abandoned-payments/route.ts b/app/api/cleanup/abandoned-payments/route.ts index 8a68dc724..94c43ddef 100644 --- a/app/api/cleanup/abandoned-payments/route.ts +++ b/app/api/cleanup/abandoned-payments/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; import { cleanupAbandonedPayments, cleanupExpiredApprovalPendingPayments, @@ -31,6 +32,11 @@ export async function POST(req: NextRequest) { overallSuccess: paymentResult.success && consultationResult.success, }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Cleanup API route failed:", error); return NextResponse.json( { diff --git a/app/api/cleanup/alert-dispute-deadlines/route.ts b/app/api/cleanup/alert-dispute-deadlines/route.ts index c45d1f959..0b0b9fcd8 100644 --- a/app/api/cleanup/alert-dispute-deadlines/route.ts +++ b/app/api/cleanup/alert-dispute-deadlines/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { alertDisputeDeadlines } from "@/scripts/disputes/alert-dispute-deadlines"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -36,6 +37,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error checking dispute deadlines:", error); return NextResponse.json( { diff --git a/app/api/cleanup/alert-orphaned-payments/route.ts b/app/api/cleanup/alert-orphaned-payments/route.ts index 11745596e..1499abab8 100644 --- a/app/api/cleanup/alert-orphaned-payments/route.ts +++ b/app/api/cleanup/alert-orphaned-payments/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { alertOrphanedPayments } from "@/scripts/alerts/alert-orphaned-payments"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -37,6 +38,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in orphaned payments alert:", error); return NextResponse.json( { diff --git a/app/api/cleanup/appointment-reminders/route.ts b/app/api/cleanup/appointment-reminders/route.ts index 507c8d42c..6af6e1db4 100644 --- a/app/api/cleanup/appointment-reminders/route.ts +++ b/app/api/cleanup/appointment-reminders/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { sendAppointmentReminders } from "@/scripts/appointments/send-appointment-reminders"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -33,6 +34,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in appointment reminders:", error); return NextResponse.json( { diff --git a/app/api/cleanup/approval-payments/route.ts b/app/api/cleanup/approval-payments/route.ts index e78b78e9f..c467d505a 100644 --- a/app/api/cleanup/approval-payments/route.ts +++ b/app/api/cleanup/approval-payments/route.ts @@ -18,7 +18,7 @@ */ import { NextRequest, NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { PaymentStatus, Prisma, RequestStatus } from "@prisma/client"; /** @@ -29,7 +29,7 @@ import { PaymentStatus, Prisma, RequestStatus } from "@prisma/client"; * user completes payment between initial query and transaction execution. */ async function revertApprovalStatus( - tx: Prisma.TransactionClient, + tx: Tx, entityType: "consultation" | "subscription", entityId: string, ): Promise { diff --git a/app/api/cleanup/archive-webhook-events/route.ts b/app/api/cleanup/archive-webhook-events/route.ts index c66113aaf..163d46355 100644 --- a/app/api/cleanup/archive-webhook-events/route.ts +++ b/app/api/cleanup/archive-webhook-events/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { archiveWebhookEvents } from "@/scripts/cleanup/archive-webhook-events"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -34,6 +35,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in webhook event archive:", error); return NextResponse.json( { diff --git a/app/api/cleanup/auth-tokens/route.ts b/app/api/cleanup/auth-tokens/route.ts index 34d6bce55..2cb72d4f4 100644 --- a/app/api/cleanup/auth-tokens/route.ts +++ b/app/api/cleanup/auth-tokens/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { cleanupAuthTokens } from "@/scripts/cleanup/cleanup-auth-tokens"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -35,6 +36,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in auth token cleanup:", error); return NextResponse.json( { diff --git a/app/api/cleanup/auto-complete-appointments/route.ts b/app/api/cleanup/auto-complete-appointments/route.ts index 92c79ae50..d2d3e95a6 100644 --- a/app/api/cleanup/auto-complete-appointments/route.ts +++ b/app/api/cleanup/auto-complete-appointments/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { autoCompleteAppointments } from "@/scripts/appointments/auto-complete-appointments"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -35,6 +36,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in auto-complete appointments:", error); return NextResponse.json( { diff --git a/app/api/cleanup/auto-complete-trials/route.ts b/app/api/cleanup/auto-complete-trials/route.ts index b2d6c5d35..94425b2f1 100644 --- a/app/api/cleanup/auto-complete-trials/route.ts +++ b/app/api/cleanup/auto-complete-trials/route.ts @@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from "next/server"; import { TrialSessionStatus } from "@prisma/client"; import prisma from "@/lib/prisma"; +import { withCronLock, CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -26,22 +27,29 @@ export async function GET(req: NextRequest): Promise { const now = new Date(); - const result = await prisma.trialSession.updateMany({ - where: { - status: TrialSessionStatus.SCHEDULED, - appointment: { - slotsOfAppointment: { - some: { - endsAt: { lt: now }, + // #476 — entry-level cron lock; fail-open (idempotent updateMany). + const result = await withCronLock( + "auto-complete-trials", + { failMode: "open" }, + async () => { + return prisma.trialSession.updateMany({ + where: { + status: TrialSessionStatus.SCHEDULED, + appointment: { + slotsOfAppointment: { + some: { + endsAt: { lt: now }, + }, + }, }, }, - }, - }, - data: { - status: TrialSessionStatus.COMPLETED, - completedAt: now, + data: { + status: TrialSessionStatus.COMPLETED, + completedAt: now, + }, + }); }, - }); + ); console.log( JSON.stringify({ @@ -56,6 +64,11 @@ export async function GET(req: NextRequest): Promise { trialsCompleted: result.count, }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in auto-complete-trials cleanup:", error); return NextResponse.json( { success: false, error: "Failed to auto-complete trial sessions" }, diff --git a/app/api/cleanup/cascade-refund-earnings/route.ts b/app/api/cleanup/cascade-refund-earnings/route.ts index 840fabc1b..f8168fe75 100644 --- a/app/api/cleanup/cascade-refund-earnings/route.ts +++ b/app/api/cleanup/cascade-refund-earnings/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { cascadeRefundToEarnings } from "@/scripts/refunds/cascade-refund-earnings"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -36,6 +37,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in refund-earning cascade:", error); return NextResponse.json( { diff --git a/app/api/cleanup/deactivate-expired-discounts/route.ts b/app/api/cleanup/deactivate-expired-discounts/route.ts index 1fa75c98f..d08de6683 100644 --- a/app/api/cleanup/deactivate-expired-discounts/route.ts +++ b/app/api/cleanup/deactivate-expired-discounts/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { deactivateExpiredDiscounts } from "@/scripts/cleanup/deactivate-expired-discounts"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -34,6 +35,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in discount deactivation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/dispatch-outbound-webhooks/route.ts b/app/api/cleanup/dispatch-outbound-webhooks/route.ts new file mode 100644 index 000000000..aead04bcb --- /dev/null +++ b/app/api/cleanup/dispatch-outbound-webhooks/route.ts @@ -0,0 +1,59 @@ +/** + * POST /api/cleanup/dispatch-outbound-webhooks + * + * Cron tick for the outbound webhook delivery worker. Gated by + * `CRON_SECRET` like every other route under /api/cleanup — the worker + * itself is idempotent so a casual extra invocation is harmless, but + * the bearer gate keeps random callers from running it. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; +import { + dispatchOutboundWebhooks, + disconnectDatabase, +} from "@/scripts/cleanup/dispatch-outbound-webhooks"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json( + { + error: "Unauthorized", + message: "Provide a valid Bearer CRON_SECRET", + }, + { status: 401 }, + ); + } + + try { + const result = await dispatchOutboundWebhooks(); + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("[cleanup/dispatch-outbound-webhooks] failed:", error); + return NextResponse.json( + { + error: "Dispatch tick failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } finally { + // The HTTP route does NOT disconnect — `prisma` is the global + // singleton shared with the rest of the Next runtime. We only + // disconnect in the standalone job wrapper (jobs/cleanup/*). + void disconnectDatabase; + } +} + +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/expire-stale-requests/route.ts b/app/api/cleanup/expire-stale-requests/route.ts index 321403335..c252812a9 100644 --- a/app/api/cleanup/expire-stale-requests/route.ts +++ b/app/api/cleanup/expire-stale-requests/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { expireStaleRequests } from "@/scripts/appointments/expire-stale-requests"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -34,6 +35,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in stale request expiration:", error); return NextResponse.json( { diff --git a/app/api/cleanup/handle-lost-disputes/route.ts b/app/api/cleanup/handle-lost-disputes/route.ts index 2261e4867..c75217988 100644 --- a/app/api/cleanup/handle-lost-disputes/route.ts +++ b/app/api/cleanup/handle-lost-disputes/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { handleLostDisputes } from "@/scripts/disputes/handle-lost-disputes"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -41,6 +42,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in lost dispute handler:", error); return NextResponse.json( { diff --git a/app/api/cleanup/handle-stuck-payouts/route.ts b/app/api/cleanup/handle-stuck-payouts/route.ts index 37393428f..653d0e33d 100644 --- a/app/api/cleanup/handle-stuck-payouts/route.ts +++ b/app/api/cleanup/handle-stuck-payouts/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { handleStuckPayouts } from "@/scripts/payouts/handle-stuck-payouts"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -35,6 +36,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in stuck payouts handler:", error); return NextResponse.json( { diff --git a/app/api/cleanup/invalid-appointments/route.ts b/app/api/cleanup/invalid-appointments/route.ts index 9745b5b3e..f845a4c3b 100644 --- a/app/api/cleanup/invalid-appointments/route.ts +++ b/app/api/cleanup/invalid-appointments/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "crypto"; import { runAllCleanupTasks } from "@/scripts/appointments/cleanup-invalid-appointments"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function POST(req: NextRequest) { try { @@ -43,6 +44,11 @@ export async function POST(req: NextRequest) { timestamp: new Date().toISOString(), }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Invalid appointments cleanup API route failed:", error); return NextResponse.json( { diff --git a/app/api/cleanup/mark-expired-recordings/route.ts b/app/api/cleanup/mark-expired-recordings/route.ts index 648c8549d..6f55b1870 100644 --- a/app/api/cleanup/mark-expired-recordings/route.ts +++ b/app/api/cleanup/mark-expired-recordings/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { RecordingTransferService } from "@/lib/stream/recording-transfer-service"; import { streamLogger } from "@/lib/stream-logger"; +import { withCronLock, CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -23,13 +24,23 @@ export async function GET(req: NextRequest): Promise { streamLogger.info("Starting mark-expired-recordings cron"); - const expiredCount = await RecordingTransferService.markExpiredRecordings(); + // #476 — same lock key as the GH Actions entry (jobs/stream/...). + const expiredCount = await withCronLock( + "mark-expired-recordings", + { failMode: "open" }, + () => RecordingTransferService.markExpiredRecordings(), + ); return NextResponse.json({ success: true, expiredCount, }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } streamLogger.error("Mark expired recordings cron failed", error); return NextResponse.json( { error: "Cron job failed" }, diff --git a/app/api/cleanup/old-stream-recordings/route.ts b/app/api/cleanup/old-stream-recordings/route.ts new file mode 100644 index 000000000..c7a1e3daa --- /dev/null +++ b/app/api/cleanup/old-stream-recordings/route.ts @@ -0,0 +1,44 @@ +/** + * POST /api/cleanup/old-stream-recordings + * + * HTTP shim around scripts/cleanup/cleanup-old-stream-recordings.ts. + * Gated by CRON_SECRET like every other route under /api/cleanup. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { cleanupOldStreamRecordings } from "@/scripts/cleanup/cleanup-old-stream-recordings"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 }, + ); + } + try { + const result = await cleanupOldStreamRecordings(); + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return NextResponse.json( + { + error: "Stream retention sweep failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/process-data-exports/route.ts b/app/api/cleanup/process-data-exports/route.ts new file mode 100644 index 000000000..f6319402e --- /dev/null +++ b/app/api/cleanup/process-data-exports/route.ts @@ -0,0 +1,39 @@ +/** + * POST /api/cleanup/process-data-exports + * + * CRON_SECRET-gated HTTP entry for the data-export worker. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { processDataExports } from "@/scripts/cleanup/process-data-exports"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const result = await processDataExports(); + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return NextResponse.json( + { + error: "Data export tick failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/prune-audit-logs/route.ts b/app/api/cleanup/prune-audit-logs/route.ts new file mode 100644 index 000000000..87f09b093 --- /dev/null +++ b/app/api/cleanup/prune-audit-logs/route.ts @@ -0,0 +1,40 @@ +/** + * POST /api/cleanup/prune-audit-logs + * + * HTTP shim. CRON_SECRET-gated like every other cleanup route. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { pruneAuditLogs } from "@/scripts/cleanup/prune-audit-logs"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const result = await pruneAuditLogs(); + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return NextResponse.json( + { + error: "Audit prune failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/reconcile-disputes/route.ts b/app/api/cleanup/reconcile-disputes/route.ts index a8aaa8ec3..5d2acae56 100644 --- a/app/api/cleanup/reconcile-disputes/route.ts +++ b/app/api/cleanup/reconcile-disputes/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcileDisputes } from "@/scripts/disputes/reconcile-disputes"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -42,6 +43,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in dispute reconciliation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/reconcile-document-storage/route.ts b/app/api/cleanup/reconcile-document-storage/route.ts index a07565858..9914233ea 100644 --- a/app/api/cleanup/reconcile-document-storage/route.ts +++ b/app/api/cleanup/reconcile-document-storage/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcileDocumentStorage } from "@/scripts/cleanup/reconcile-document-storage"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -38,6 +39,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in document storage reconciliation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/reconcile-payment-status/route.ts b/app/api/cleanup/reconcile-payment-status/route.ts index 502861a9f..75b77e3ad 100644 --- a/app/api/cleanup/reconcile-payment-status/route.ts +++ b/app/api/cleanup/reconcile-payment-status/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcilePaymentStatus } from "@/scripts/payments/reconcile-payment-status"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -38,6 +39,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in payment reconciliation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/reconcile-payout-status/route.ts b/app/api/cleanup/reconcile-payout-status/route.ts index 18fec7db6..888714384 100644 --- a/app/api/cleanup/reconcile-payout-status/route.ts +++ b/app/api/cleanup/reconcile-payout-status/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcilePayoutStatus } from "@/scripts/payouts/reconcile-payout-status"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -40,6 +41,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in payout reconciliation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/reconcile-refunds/route.ts b/app/api/cleanup/reconcile-refunds/route.ts index 66825fce9..0b1649d0e 100644 --- a/app/api/cleanup/reconcile-refunds/route.ts +++ b/app/api/cleanup/reconcile-refunds/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcilePendingRefunds } from "@/scripts/refunds/reconcile-pending-refunds"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -35,6 +36,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in refund reconciliation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/reconcile-sessions/route.ts b/app/api/cleanup/reconcile-sessions/route.ts index 152db94c4..1716815a8 100644 --- a/app/api/cleanup/reconcile-sessions/route.ts +++ b/app/api/cleanup/reconcile-sessions/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcileOrphanedSessions } from "@/jobs/meetings/reconcile-orphaned-sessions"; import { getMaintenanceState } from "@/lib/maintenance"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function POST(req: NextRequest) { try { @@ -30,6 +31,11 @@ export async function POST(req: NextRequest) { return NextResponse.json(result); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Reconcile sessions API route failed:", error); return NextResponse.json( { diff --git a/app/api/cleanup/reconcile-slot-availability/route.ts b/app/api/cleanup/reconcile-slot-availability/route.ts index 7225f7309..b5ee97877 100644 --- a/app/api/cleanup/reconcile-slot-availability/route.ts +++ b/app/api/cleanup/reconcile-slot-availability/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { reconcileSlotAvailability } from "@/scripts/appointments/reconcile-slot-availability"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -38,6 +39,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in slot reconciliation:", error); return NextResponse.json( { diff --git a/app/api/cleanup/release-earnings/route.ts b/app/api/cleanup/release-earnings/route.ts index e1adf2866..ecf54ff37 100644 --- a/app/api/cleanup/release-earnings/route.ts +++ b/app/api/cleanup/release-earnings/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { releaseEarningsFromHold } from "@/scripts/earnings/release-earnings"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -33,6 +34,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in earnings release:", error); return NextResponse.json( { diff --git a/app/api/cleanup/sso-cert-expiry-alert/route.ts b/app/api/cleanup/sso-cert-expiry-alert/route.ts new file mode 100644 index 000000000..2ae651273 --- /dev/null +++ b/app/api/cleanup/sso-cert-expiry-alert/route.ts @@ -0,0 +1,63 @@ +/** + * POST /api/cleanup/sso-cert-expiry-alert + * + * HTTP companion to `jobs/cleanup/sso-cert-expiry-alert.ts`. Lets an + * operator run the scan on-demand after rotating a cert or onboarding + * a new SAML provider — useful for confirming the alert path without + * waiting for the 03:00 UTC slot. + * + * Auth: `CRON_SECRET` bearer. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { runSsoCertExpiryAlert } from "@/scripts/cleanup/sso-cert-expiry-alert"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + console.warn("Unauthorized sso-cert-expiry-alert attempt"); + return NextResponse.json( + { + error: "Unauthorized", + message: + "Please provide a valid authorization header with the CRON_SECRET", + }, + { status: 401 }, + ); + } + + try { + const result = await runSsoCertExpiryAlert(); + + console.log("✅ SSO cert expiry alert completed:", { + scanned: result.scanned, + alerted: result.alerted, + success: result.success, + }); + + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("[cleanup/sso-cert-expiry-alert] failed:", error); + return NextResponse.json( + { + error: "Alert scan failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +// Some external schedulers (e.g. legacy Vercel Cron) only emit GET. +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/stale-invitations/route.ts b/app/api/cleanup/stale-invitations/route.ts new file mode 100644 index 000000000..bad44e900 --- /dev/null +++ b/app/api/cleanup/stale-invitations/route.ts @@ -0,0 +1,62 @@ +/** + * POST /api/cleanup/stale-invitations + * + * HTTP companion to `jobs/cleanup/cleanup-stale-invitations.ts`. Lets + * an operator run the cleanup on-demand (e.g. after bulk-inviting a + * stale email list) without waiting for the scheduled 02:30 UTC slot. + * + * Gated by CRON_SECRET (or VERCEL_CRON_SECRET) — identical pattern to + * every other `/api/cleanup/*` route. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { cleanupStaleInvitations } from "@/scripts/cleanup/cleanup-stale-invitations"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function POST(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + console.warn("Unauthorized stale-invitations cleanup attempt"); + return NextResponse.json( + { + error: "Unauthorized", + message: + "Please provide a valid authorization header with the CRON_SECRET", + }, + { status: 401 }, + ); + } + + try { + const result = await cleanupStaleInvitations(); + + console.log("✅ Stale invitation cleanup completed:", { + expired: result.expired, + success: result.success, + }); + + return NextResponse.json(result, { status: result.success ? 200 : 500 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("[cleanup/stale-invitations] failed:", error); + return NextResponse.json( + { + error: "Cleanup failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +// Some external schedulers (e.g. legacy Vercel Cron) only emit GET. +export async function GET(req: NextRequest): Promise { + return POST(req); +} diff --git a/app/api/cleanup/stale-pending-consultations/route.ts b/app/api/cleanup/stale-pending-consultations/route.ts index f865cc10c..91ceda095 100644 --- a/app/api/cleanup/stale-pending-consultations/route.ts +++ b/app/api/cleanup/stale-pending-consultations/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { cleanupStalePendingConsultations } from "@/scripts/appointments/cleanup-stale-pending-consultations"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -33,6 +34,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in stale consultation cleanup:", error); return NextResponse.json( { diff --git a/app/api/cleanup/sweep-abandoned-overage-charges/route.ts b/app/api/cleanup/sweep-abandoned-overage-charges/route.ts new file mode 100644 index 000000000..c90ef4354 --- /dev/null +++ b/app/api/cleanup/sweep-abandoned-overage-charges/route.ts @@ -0,0 +1,45 @@ +/** + * Abandoned CHARGE_MEMBER overage-charge sweeper API endpoint (#785, task #25). + * CRON_SECRET-gated wrapper. FAILs never-paid PENDING side-charges to free the + * per-cycle circuit-breaker ceiling. Runs daily. + */ +import { NextRequest, NextResponse } from "next/server"; +import { sweepAbandonedOverageCharges } from "@/scripts/cleanup/sweep-abandoned-overage-charges"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function GET(req: NextRequest): Promise { + try { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + console.warn("Unauthorized abandoned overage-charge sweep attempt"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await sweepAbandonedOverageCharges(); + console.log("✅ Abandoned overage-charge sweep completed:", { + scanned: result.scanned, + failed: result.failed, + }); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) skips + // with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("Error in abandoned overage-charge sweep:", error); + return NextResponse.json( + { + error: "Failed to sweep abandoned overage charges", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function POST(req: NextRequest): Promise { + return GET(req); +} diff --git a/app/api/cleanup/sweep-orphaned-topup-captures/route.ts b/app/api/cleanup/sweep-orphaned-topup-captures/route.ts new file mode 100644 index 000000000..f2eb76656 --- /dev/null +++ b/app/api/cleanup/sweep-orphaned-topup-captures/route.ts @@ -0,0 +1,47 @@ +/** + * Captured-but-uncredited wallet top-up reconciler API endpoint (#785, task #23). + * Thin CRON_SECRET-gated wrapper around the reconciler. Runs every ~30 minutes. + */ +import { NextRequest, NextResponse } from "next/server"; +import { sweepOrphanedTopupCaptures } from "@/scripts/cleanup/sweep-orphaned-topup-captures"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function GET(req: NextRequest): Promise { + try { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + console.warn("Unauthorized captured-top-up sweep attempt"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await sweepOrphanedTopupCaptures(); + console.log("✅ Captured-top-up sweep completed:", { + scanned: result.scanned, + recredited: result.recredited, + stillFailing: result.stillFailing, + }); + + const status = result.stillFailing > 0 ? 207 : 200; + return NextResponse.json(result, { status }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("Error in captured-top-up sweep:", error); + return NextResponse.json( + { + error: "Failed to sweep captured top-ups", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function POST(req: NextRequest): Promise { + return GET(req); +} diff --git a/app/api/cleanup/sweep-stuck-webhook-events/route.ts b/app/api/cleanup/sweep-stuck-webhook-events/route.ts new file mode 100644 index 000000000..7bb728d7a --- /dev/null +++ b/app/api/cleanup/sweep-stuck-webhook-events/route.ts @@ -0,0 +1,54 @@ +/** + * B5 stuck-webhook sweeper API endpoint (#785, task #10). + * + * Thin wrapper around scripts/cleanup/sweep-stuck-webhook-events.ts. Re-drives + * WebhookEvent rows left processed=false after an after()-callback crash. + * + * Schedule: every ~10 minutes (CRON_SECRET-gated, like the other cleanup jobs). + */ +import { NextRequest, NextResponse } from "next/server"; +import { sweepStuckWebhookEvents } from "@/scripts/cleanup/sweep-stuck-webhook-events"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; + +export async function GET(req: NextRequest): Promise { + try { + const authHeader = req.headers.get("authorization"); + const cronSecret = + process.env.CRON_SECRET || process.env.VERCEL_CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + console.warn("Unauthorized stuck-webhook sweep attempt"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await sweepStuckWebhookEvents(); + + console.log("✅ Stuck-webhook sweep completed:", { + scanned: result.scanned, + recovered: result.recovered, + stillFailing: result.stillFailing, + }); + + // 207 when some events are still failing after a re-drive (needs attention). + const status = result.stillFailing > 0 ? 207 : 200; + return NextResponse.json(result, { status }); + } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("Error in stuck-webhook sweep:", error); + return NextResponse.json( + { + error: "Failed to sweep stuck webhook events", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function POST(req: NextRequest): Promise { + return GET(req); +} diff --git a/app/api/cleanup/sync-payment-earnings/route.ts b/app/api/cleanup/sync-payment-earnings/route.ts index 8dc94c7bf..078c4e7c7 100644 --- a/app/api/cleanup/sync-payment-earnings/route.ts +++ b/app/api/cleanup/sync-payment-earnings/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { syncPaymentEarnings } from "@/scripts/earnings/sync-payment-earnings"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -36,6 +37,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in payment-earning sync:", error); return NextResponse.json( { diff --git a/app/api/cleanup/tentative-slots/route.ts b/app/api/cleanup/tentative-slots/route.ts index dd73b8acf..45fc5c93c 100644 --- a/app/api/cleanup/tentative-slots/route.ts +++ b/app/api/cleanup/tentative-slots/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { cleanupTentativeSlots } from "@/scripts/appointments/cleanup-tentative-slots"; +import { CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -33,6 +34,11 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json(result, { status: result.success ? 200 : 500 }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } console.error("Error in tentative slot cleanup:", error); return NextResponse.json( { diff --git a/app/api/cleanup/transfer-expiring-recordings/route.ts b/app/api/cleanup/transfer-expiring-recordings/route.ts index 23bed729d..5950bda86 100644 --- a/app/api/cleanup/transfer-expiring-recordings/route.ts +++ b/app/api/cleanup/transfer-expiring-recordings/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from "next/server"; import { RecordingTransferService } from "@/lib/stream/recording-transfer-service"; import { streamLogger } from "@/lib/stream-logger"; +import { withCronLock, CronLockHeldError } from "@/lib/cron/with-cron-lock"; export async function GET(req: NextRequest): Promise { try { @@ -24,17 +25,25 @@ export async function GET(req: NextRequest): Promise { streamLogger.info("Starting transfer-expiring-recordings cron"); - // Phase 1: Auto-transfer SUPABASE_PERMANENT recordings - const transferResult = - await RecordingTransferService.processExpiringRecordings( - 5, // 5 days before expiry - 10, // batch size - "SUPABASE_PERMANENT", - ); + // #476 — both phases under one lock, same key as the GH Actions entry. + const { transferResult, expiringStreamOnly } = await withCronLock( + "transfer-expiring-recordings", + { failMode: "open" }, + async () => { + // Phase 1: Auto-transfer SUPABASE_PERMANENT recordings + const transferResult = + await RecordingTransferService.processExpiringRecordings( + 5, // 5 days before expiry + 10, // batch size + "SUPABASE_PERMANENT", + ); - // Phase 2: Find STREAM_ONLY recordings expiring soon (for notifications) - const expiringStreamOnly = - await RecordingTransferService.getExpiringStreamOnlyRecordings(3); + // Phase 2: Find STREAM_ONLY recordings expiring soon (for notifications) + const expiringStreamOnly = + await RecordingTransferService.getExpiringStreamOnlyRecordings(3); + return { transferResult, expiringStreamOnly }; + }, + ); // TODO: Send Novu notifications to consultants with expiring STREAM_ONLY recordings // Group by consultant and send one notification per consultant @@ -52,6 +61,11 @@ export async function GET(req: NextRequest): Promise { errors: transferResult.errors, }); } catch (error) { + // #476 — concurrent invocation (schedule overlap / manual re-run) + // skips with a 409 instead of double-running. + if (error instanceof CronLockHeldError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } streamLogger.error("Transfer expiring recordings cron failed", error); return NextResponse.json( { error: "Cron job failed" }, diff --git a/app/api/collaborations/class/[planId]/revenue-split/route.ts b/app/api/collaborations/class/[planId]/revenue-split/route.ts index 5aa959b72..64674ba22 100644 --- a/app/api/collaborations/class/[planId]/revenue-split/route.ts +++ b/app/api/collaborations/class/[planId]/revenue-split/route.ts @@ -28,7 +28,7 @@ export async function GET( const isOwner = session.user.consultantProfileId === plan.consultantProfileId; if (!isOwner) { - const collab = await prisma.classCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { classPlanId: planId, consultantProfileId: session.user.consultantProfileId ?? "__none__", diff --git a/app/api/collaborations/class/[planId]/route.ts b/app/api/collaborations/class/[planId]/route.ts index 16f7bd347..92f5f1b9b 100644 --- a/app/api/collaborations/class/[planId]/route.ts +++ b/app/api/collaborations/class/[planId]/route.ts @@ -90,7 +90,7 @@ export async function POST( ); } - const existingCollab = await prisma.classCollaborator.findFirst({ + const existingCollab = await prisma.collaborator.findFirst({ where: { classPlanId: planId, consultantProfileId, diff --git a/app/api/collaborations/webinar/[planId]/revenue-split/route.ts b/app/api/collaborations/webinar/[planId]/revenue-split/route.ts index e628a3e08..4460db10d 100644 --- a/app/api/collaborations/webinar/[planId]/revenue-split/route.ts +++ b/app/api/collaborations/webinar/[planId]/revenue-split/route.ts @@ -28,7 +28,7 @@ export async function GET( const isOwner = session.user.consultantProfileId === plan.consultantProfileId; if (!isOwner) { - const collab = await prisma.webinarCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { webinarPlanId: planId, consultantProfileId: session.user.consultantProfileId ?? "__none__", diff --git a/app/api/collaborations/webinar/[planId]/route.ts b/app/api/collaborations/webinar/[planId]/route.ts index bbab38985..d5ac7852d 100644 --- a/app/api/collaborations/webinar/[planId]/route.ts +++ b/app/api/collaborations/webinar/[planId]/route.ts @@ -91,7 +91,7 @@ export async function POST( ); } - const existingCollab = await prisma.webinarCollaborator.findFirst({ + const existingCollab = await prisma.collaborator.findFirst({ where: { webinarPlanId: planId, consultantProfileId, diff --git a/app/api/collaborators/[consultantProfileId]/availability/route.ts b/app/api/collaborators/[consultantProfileId]/availability/route.ts index 749598af1..ff05b0b58 100644 --- a/app/api/collaborators/[consultantProfileId]/availability/route.ts +++ b/app/api/collaborators/[consultantProfileId]/availability/route.ts @@ -24,25 +24,18 @@ export async function GET( if (!isPrivileged(session.user.role)) { const isOwner = session.user.consultantProfileId === consultantProfileId; if (!isOwner) { - const [webinarCollab, classCollab] = await Promise.all([ - prisma.webinarCollaborator.findFirst({ - where: { - consultantProfileId: - session.user.consultantProfileId ?? "__none__", - status: "ACCEPTED", - webinarPlan: { consultantProfileId }, - }, - }), - prisma.classCollaborator.findFirst({ - where: { - consultantProfileId: - session.user.consultantProfileId ?? "__none__", - status: "ACCEPTED", - classPlan: { consultantProfileId }, - }, - }), - ]); - if (!webinarCollab && !classCollab) { + // #784 — merged Collaborator model covers both plan types in one lookup + const collab = await prisma.collaborator.findFirst({ + where: { + consultantProfileId: session.user.consultantProfileId ?? "__none__", + status: "ACCEPTED", + OR: [ + { webinarPlan: { consultantProfileId } }, + { classPlan: { consultantProfileId } }, + ], + }, + }); + if (!collab) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } } diff --git a/app/api/consultant/payout-accounts/[id]/route.ts b/app/api/consultant/payout-accounts/[id]/route.ts index b316d2965..384022ea2 100644 --- a/app/api/consultant/payout-accounts/[id]/route.ts +++ b/app/api/consultant/payout-accounts/[id]/route.ts @@ -124,7 +124,7 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { } // Don't allow deletion if there are pending payouts using this account - const pendingPayouts = await prisma.payout.count({ + const pendingPayouts = await prisma.consultantPayout.count({ where: { consultantProfileId: consultantProfile.id, status: { in: ["PENDING", "APPROVED", "PROCESSING"] }, diff --git a/app/api/csp-report/route.ts b/app/api/csp-report/route.ts new file mode 100644 index 000000000..f88e7671b --- /dev/null +++ b/app/api/csp-report/route.ts @@ -0,0 +1,52 @@ +/** + * POST /api/csp-report + * + * Sink for `Content-Security-Policy-Report-Only` violation reports. + * Browsers send this with `Content-Type: application/csp-report`; + * we accept either that or plain JSON since the spec is in flux. + * + * The route does NOT require auth — anyone can post a CSP report (the + * browser is the originator, not the user). We rate-limit via the + * existing `spamLimiter` keyed on IP to keep a hostile receiver from + * flooding our logs. + * + * The report is logged as a structured event (`event: "csp_violation"`) + * so an operator scanning `console` output during the report-only + * rollout window can see which directives drift in production. When + * we flip to enforce mode (`ENABLE_CSP_ENFORCE=true`), the same reports + * still arrive — just attached to a blocked request rather than an + * allowed-but-flagged one. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { applyRateLimit, spamLimiter } from "@/lib/rate-limit"; + +export async function POST(req: NextRequest) { + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + const rl = await applyRateLimit(spamLimiter, `csp:${ip}`); + if (rl) return rl; + + const body = await req.json().catch(() => null); + if (!body) { + return NextResponse.json( + { error: "Invalid CSP report payload" }, + { status: 400 }, + ); + } + + // Structured log — `event: "csp_violation"` is the canonical search + // key for the operator dashboard / log aggregation pipeline. + console.warn( + JSON.stringify({ + event: "csp_violation", + ip, + ua: req.headers.get("user-agent"), + report: body, + }), + ); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/dashboard/consultant/[consultantId]/documents/route.ts b/app/api/dashboard/consultant/[consultantId]/documents/route.ts index 4a6e8d4c1..7f9af198c 100644 --- a/app/api/dashboard/consultant/[consultantId]/documents/route.ts +++ b/app/api/dashboard/consultant/[consultantId]/documents/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { Prisma, DocumentReviewStatus } from "@prisma/client"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; import { getSession } from "@/lib/auth-server"; // GET - Get all documents for review by consultant @@ -140,6 +141,35 @@ export async function GET( ); } + // B1-personal-retrofit: parse + authorize ?orgScope=. Filter applies + // to the parent Appointment.organizationId. + const callerMembershipsForScope = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const docScopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMembershipsForScope, + userRole: session.user.role, + // Self-scoped consultant endpoint. + allowAllForOwner: true, + }); + if (!docScopeResolution.ok) { + return NextResponse.json( + { + error: docScopeResolution.message, + code: docScopeResolution.code, + }, + { status: docScopeResolution.status }, + ); + } + const docOrgFilter: Partial = + docScopeResolution.scope.kind === "personal" + ? { organizationId: null } + : docScopeResolution.scope.kind === "org" + ? { organizationId: docScopeResolution.scope.orgId } + : {}; + // Build where clause const where: Prisma.AppointmentDocumentWhereInput = { appointment: { @@ -161,6 +191,7 @@ export async function GET( }, }, ], + ...docOrgFilter, }, }; diff --git a/app/api/dashboard/consultant/[consultantId]/planner/route.ts b/app/api/dashboard/consultant/[consultantId]/planner/route.ts index d566f3eaf..309446b54 100644 --- a/app/api/dashboard/consultant/[consultantId]/planner/route.ts +++ b/app/api/dashboard/consultant/[consultantId]/planner/route.ts @@ -7,6 +7,7 @@ import { isPrivileged, forbiddenResponse, } from "@/lib/auth-helpers"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; // ============================================================================= // Prisma Query Types - Derived from actual query shape for type safety @@ -46,11 +47,18 @@ const classInclude = { appointments: true, } satisfies Prisma.ClassInclude; -// Derive types from the include objects -type PlannerWebinar = Prisma.WebinarGetPayload<{ - include: typeof webinarInclude; -}>; -type PlannerClass = Prisma.ClassGetPayload<{ include: typeof classInclude }>; +// Derive types from the include objects via the extended client — raw +// GetPayload would re-introduce bigint money fields (#780). +type PlannerWebinar = Prisma.Result< + typeof prisma.webinar, + { include: typeof webinarInclude }, + "findFirstOrThrow" +>; +type PlannerClass = Prisma.Result< + typeof prisma.class, + { include: typeof classInclude }, + "findFirstOrThrow" +>; // Response types with discriminators and role annotations type WebinarEvent = PlannerWebinar & { @@ -170,9 +178,6 @@ export async function GET( request: Request, { params }: { params: Promise<{ consultantId: string }> }, ) { - // Note: request parameter kept for Next.js API route signature compatibility - void request; - const authResult = await requireApiAuth(); if (authResult.error) return authResult.error; const { session } = authResult; @@ -194,22 +199,96 @@ export async function GET( ); } + // B1-personal-retrofit: parse + authorize ?orgScope=. Filter applies + // to the appointment.organizationId attached to each Webinar/Class. + // Plans without bookings yet are NOT filtered (the planner shows + // owned + collaborated plans regardless of whether anyone has + // booked them). + const url = new URL(request.url); + const consultantUser = await prisma.consultantProfile.findUnique({ + where: { id: consultantId }, + select: { userId: true }, + }); + const callerMemberships = consultantUser + ? await prisma.membership.findMany({ + where: { userId: consultantUser.userId, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }) + : []; + const scopeResolution = resolveOrgScope({ + raw: url.searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Self-scoped consultant endpoint. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + // For Webinar (1:1 appointment) — `appointment.is.organizationId`. + // For Class (1:many appointments) — `appointments.some.organizationId`. + // + // Personal scope: include events that have NO appointment yet (unbooked) + // OR have an appointment with organizationId=null. Using only + // `{ appointment: { is: { organizationId: null } } }` would exclude + // freshly created unbooked events, hiding them from the consultant's + // own inventory view. Issue: #732 (planner inventory vs booking-history + // semantics — flagged in the May 2026 readiness audit). + const webinarApptOrg: Prisma.WebinarWhereInput | undefined = + scopeResolution.scope.kind === "personal" + ? { + OR: [ + { appointment: { is: null } }, + { appointment: { is: { organizationId: null } } }, + ], + } + : scopeResolution.scope.kind === "org" + ? { + appointment: { + is: { organizationId: scopeResolution.scope.orgId }, + }, + } + : undefined; + const classApptOrg: Prisma.ClassWhereInput | undefined = + scopeResolution.scope.kind === "personal" + ? { + OR: [ + { appointments: { none: {} } }, + { appointments: { some: { organizationId: null } } }, + ], + } + : scopeResolution.scope.kind === "org" + ? { + appointments: { + some: { organizationId: scopeResolution.scope.orgId }, + }, + } + : undefined; + // Fetch owned plans, collaborated plans, and collaborator roles in parallel const [ ownedWebinarsRaw, ownedClassesRaw, collabWebinarsRaw, collabClassesRaw, - webinarCollabRoles, - classCollabRoles, + collabRoles, ] = await Promise.all([ // Owned plans prisma.webinar.findMany({ - where: { webinarPlan: { consultantProfileId: consultantId } }, + where: { + webinarPlan: { consultantProfileId: consultantId }, + ...(webinarApptOrg ?? {}), + }, include: webinarInclude, }), prisma.class.findMany({ - where: { classPlan: { consultantProfileId: consultantId } }, + where: { + classPlan: { consultantProfileId: consultantId }, + ...(classApptOrg ?? {}), + }, include: classInclude, }), // Collaborated plans (only ACCEPTED) @@ -220,6 +299,7 @@ export async function GET( some: { consultantProfileId: consultantId, status: "ACCEPTED" }, }, }, + ...(webinarApptOrg ?? {}), }, include: webinarInclude, }), @@ -230,27 +310,24 @@ export async function GET( some: { consultantProfileId: consultantId, status: "ACCEPTED" }, }, }, + ...(classApptOrg ?? {}), }, include: classInclude, }), - // Collaborator role lookups - prisma.webinarCollaborator.findMany({ - where: { consultantProfileId: consultantId, status: "ACCEPTED" }, - select: { webinarPlanId: true, role: true }, - }), - prisma.classCollaborator.findMany({ + // Collaborator role lookups (#784 — one merged model for both plan types) + prisma.collaborator.findMany({ where: { consultantProfileId: consultantId, status: "ACCEPTED" }, - select: { classPlanId: true, role: true }, + select: { webinarPlanId: true, classPlanId: true, role: true }, }), ]); - // Build role lookup maps - const webinarRoleMap = Object.fromEntries( - webinarCollabRoles.map((c) => [c.webinarPlanId, c.role]), - ); - const classRoleMap = Object.fromEntries( - classCollabRoles.map((c) => [c.classPlanId, c.role]), - ); + // Build role lookup maps — exactly one plan FK is set per record (#784) + const webinarRoleMap: Record = {}; + const classRoleMap: Record = {}; + for (const c of collabRoles) { + if (c.webinarPlanId) webinarRoleMap[c.webinarPlanId] = c.role; + else if (c.classPlanId) classRoleMap[c.classPlanId] = c.role; + } // Collect owned IDs for deduplication const ownedWebinarIds = new Set(ownedWebinarsRaw.map((w) => w.id)); diff --git a/app/api/dashboard/consultant/[consultantId]/requests/route.ts b/app/api/dashboard/consultant/[consultantId]/requests/route.ts index 17bb192ae..00df19ae9 100644 --- a/app/api/dashboard/consultant/[consultantId]/requests/route.ts +++ b/app/api/dashboard/consultant/[consultantId]/requests/route.ts @@ -6,6 +6,7 @@ import { isPrivileged, forbiddenResponse, } from "@/lib/auth-helpers"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; // ============================================================================= // Prisma Query Types - Derived from actual query shape for type safety @@ -227,25 +228,38 @@ const consultantInclude = { classPlans: true, } satisfies Prisma.ConsultantProfileInclude; -// Derive types from the include objects -type RequestsConsultation = Prisma.ConsultationGetPayload<{ - include: typeof consultationInclude; -}>; -type RequestsSubscription = Prisma.SubscriptionGetPayload<{ - include: typeof subscriptionInclude; -}>; -type RequestsWeeklyAvailability = Prisma.SlotOfAvailabilityWeeklyGetPayload<{ - include: typeof weeklyAvailabilityInclude; -}>; -type RequestsCustomAvailability = Prisma.SlotOfAvailabilityCustomGetPayload<{ - include: typeof customAvailabilityInclude; -}>; -type RequestsAppointment = Prisma.AppointmentGetPayload<{ - include: typeof appointmentInclude; -}>; -type RequestsConsultant = Prisma.ConsultantProfileGetPayload<{ - include: typeof consultantInclude; -}>; +// Derive types from the include objects via the extended client — raw +// GetPayload would re-introduce bigint money fields (#780). +type RequestsConsultation = Prisma.Result< + typeof prisma.consultation, + { include: typeof consultationInclude }, + "findFirstOrThrow" +>; +type RequestsSubscription = Prisma.Result< + typeof prisma.subscription, + { include: typeof subscriptionInclude }, + "findFirstOrThrow" +>; +type RequestsWeeklyAvailability = Prisma.Result< + typeof prisma.slotOfAvailabilityWeekly, + { include: typeof weeklyAvailabilityInclude }, + "findFirstOrThrow" +>; +type RequestsCustomAvailability = Prisma.Result< + typeof prisma.slotOfAvailabilityCustom, + { include: typeof customAvailabilityInclude }, + "findFirstOrThrow" +>; +type RequestsAppointment = Prisma.Result< + typeof prisma.appointment, + { include: typeof appointmentInclude }, + "findFirstOrThrow" +>; +type RequestsConsultant = Prisma.Result< + typeof prisma.consultantProfile, + { include: typeof consultantInclude }, + "findFirstOrThrow" +>; // Response data interface interface RequestsData { @@ -290,6 +304,42 @@ export async function GET( ); } + // B1-personal-retrofit: parse + authorize ?orgScope=. Default + // `personal` keeps existing callers stable. The scope filters the + // approved-appointments query below; pending consultations / + // subscriptions are NOT filtered because they don't have an + // appointment yet (org context is set at checkout, not at request). + const url = new URL(request.url); + const consultantUser = await prisma.consultantProfile.findUnique({ + where: { id: consultantProfileId }, + select: { userId: true }, + }); + const callerMemberships = consultantUser + ? await prisma.membership.findMany({ + where: { userId: consultantUser.userId, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }) + : []; + const scopeResolution = resolveOrgScope({ + raw: url.searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Self-scoped consultant endpoint. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + const apptOrgWhere: Prisma.AppointmentWhereInput | undefined = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : undefined; + // PERFORMANCE FIX #364: Use direct Prisma queries instead of internal HTTP fetches // This eliminates network overhead and reduces response time significantly const [ @@ -375,6 +425,8 @@ export async function GET( }, }, ], + // B1-personal-retrofit: scope filter applied here. + ...(apptOrgWhere ?? {}), }, include: appointmentInclude, }), diff --git a/app/api/dashboard/consultant/[consultantId]/route.ts b/app/api/dashboard/consultant/[consultantId]/route.ts index a88b03389..156e7be30 100644 --- a/app/api/dashboard/consultant/[consultantId]/route.ts +++ b/app/api/dashboard/consultant/[consultantId]/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; import { getSession } from "@/lib/auth-server"; import { PAYOUT_CONSTANTS } from "@/lib/payments/payouts/constants"; +import { sumPaise } from "@/lib/payments/utils/money"; // ============================================================================= // Prisma Query Types - Derived from actual query shape for type safety @@ -213,16 +214,23 @@ const subscriptionInclude = { }, } satisfies Prisma.SubscriptionInclude; -// Derive types from the include objects -type DashboardAppointment = Prisma.AppointmentGetPayload<{ - include: typeof appointmentInclude; -}>; -type DashboardConsultation = Prisma.ConsultationGetPayload<{ - include: typeof consultationInclude; -}>; -type DashboardSubscription = Prisma.SubscriptionGetPayload<{ - include: typeof subscriptionInclude; -}>; +// Derive types from the include objects via the extended client — raw +// GetPayload would re-introduce bigint money fields (#780). +type DashboardAppointment = Prisma.Result< + typeof prisma.appointment, + { include: typeof appointmentInclude }, + "findFirstOrThrow" +>; +type DashboardConsultation = Prisma.Result< + typeof prisma.consultation, + { include: typeof consultationInclude }, + "findFirstOrThrow" +>; +type DashboardSubscription = Prisma.Result< + typeof prisma.subscription, + { include: typeof subscriptionInclude }, + "findFirstOrThrow" +>; // ============================================================================= // Helper Functions @@ -436,7 +444,7 @@ export async function GET( // --- Performance Snapshot Queries --- // 1a. Earnings this month (consultant share from ConsultantEarnings, excluding refunded, minus partial refunds) prisma.consultantEarnings.aggregate({ - _sum: { consultantShare: true, refundedShareAmount: true }, + _sum: { consultantSharePaise: true, refundedShareAmount: true }, where: { consultantProfileId, status: { not: "REFUNDED" }, @@ -445,7 +453,7 @@ export async function GET( }), // 1b. Earnings last month prisma.consultantEarnings.aggregate({ - _sum: { consultantShare: true, refundedShareAmount: true }, + _sum: { consultantSharePaise: true, refundedShareAmount: true }, where: { consultantProfileId, status: { not: "REFUNDED" }, @@ -483,7 +491,7 @@ export async function GET( // --- Financial Summary Queries --- // 5. Net earnings (all-time, excluding refunded, minus partial refunds) prisma.consultantEarnings.aggregate({ - _sum: { consultantShare: true, refundedShareAmount: true }, + _sum: { consultantSharePaise: true, refundedShareAmount: true }, where: { consultantProfileId, status: { not: "REFUNDED" }, @@ -491,7 +499,7 @@ export async function GET( }), // 6. Ready earnings (eligible for next payout — not yet assigned to a payout) prisma.consultantEarnings.aggregate({ - _sum: { consultantShare: true, refundedShareAmount: true }, + _sum: { consultantSharePaise: true, refundedShareAmount: true }, where: { consultantProfileId, status: "READY", @@ -517,6 +525,10 @@ export async function GET( (appointment: DashboardAppointment) => ({ id: appointment.id, appointmentType: appointment.appointmentType, + // Org-funding marker — drives the "Sponsored · " badge on + // the consultant Home + Appointments surfaces. Previously dropped + // by this manual field-mapping transform. + organizationId: appointment.organizationId, slotsOfAppointment: appointment.slotsOfAppointment.map((slot) => ({ id: slot.id, startsAt: slot.startsAt, @@ -659,12 +671,13 @@ export async function GET( })); // --- Compute Performance Snapshot derived values --- + // #780 — _sum bypasses the result extension: bigint until sumPaise'd. const earningsThisMonthVal = - (earningsThisMonth._sum.consultantShare ?? 0) - - (earningsThisMonth._sum.refundedShareAmount ?? 0); + sumPaise(earningsThisMonth._sum.consultantSharePaise) - + sumPaise(earningsThisMonth._sum.refundedShareAmount); const earningsLastMonthVal = - (earningsLastMonth._sum.consultantShare ?? 0) - - (earningsLastMonth._sum.refundedShareAmount ?? 0); + sumPaise(earningsLastMonth._sum.consultantSharePaise) - + sumPaise(earningsLastMonth._sum.refundedShareAmount); // Earnings trend: percentage change (guard against division by zero) const earningsTrend = @@ -692,24 +705,20 @@ export async function GET( : null; // Trial conversion rate - const trialCountMap = new Map( - trialCounts.map((t) => [t.status, t._count]), - ); + const trialCountMap = new Map(trialCounts.map((t) => [t.status, t._count])); const completedTrials = trialCountMap.get("COMPLETED") ?? 0; const convertedTrials = trialCountMap.get("CONVERTED") ?? 0; const trialDenom = completedTrials + convertedTrials; const trialConversionRate = - trialDenom > 0 - ? Math.round((convertedTrials / trialDenom) * 100) - : null; + trialDenom > 0 ? Math.round((convertedTrials / trialDenom) * 100) : null; // --- Financial Summary derived values --- const netEarningsVal = - (netEarningsAgg._sum.consultantShare ?? 0) - - (netEarningsAgg._sum.refundedShareAmount ?? 0); + sumPaise(netEarningsAgg._sum.consultantSharePaise) - + sumPaise(netEarningsAgg._sum.refundedShareAmount); const readyEarningsVal = - (readyEarningsAgg._sum.consultantShare ?? 0) - - (readyEarningsAgg._sum.refundedShareAmount ?? 0); + sumPaise(readyEarningsAgg._sum.consultantSharePaise) - + sumPaise(readyEarningsAgg._sum.refundedShareAmount); const payoutMinimum = PAYOUT_CONSTANTS.MINIMUM_PAYOUT_AMOUNT; const payoutEligible = readyEarningsVal >= payoutMinimum; @@ -719,12 +728,13 @@ export async function GET( const activeClassIds = new Set(); for (const apt of sortedAppointments) { const isCompleted = apt.slotsOfAppointment.every( - (s) => s.completionStatus === "COMPLETED" || s.completionStatus === "CANCELLED", + (s) => + s.completionStatus === "COMPLETED" || + s.completionStatus === "CANCELLED", ); if (isCompleted) continue; const consulteeId = - apt.consultation?.requestedBy?.id ?? - apt.subscription?.requestedBy?.id; + apt.consultation?.requestedBy?.id ?? apt.subscription?.requestedBy?.id; if (consulteeId) activeClientIds.add(consulteeId); if (apt.subscription?.id) activeSubIds.add(apt.subscription.id); if (apt.class?.id) activeClassIds.add(apt.class.id); diff --git a/app/api/dashboard/consultee/[consulteeId]/events/route.ts b/app/api/dashboard/consultee/[consulteeId]/events/route.ts index 089777b11..7cd635712 100644 --- a/app/api/dashboard/consultee/[consulteeId]/events/route.ts +++ b/app/api/dashboard/consultee/[consulteeId]/events/route.ts @@ -1,12 +1,33 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { WaitlistStatus } from "@prisma/client"; +import { WaitlistStatus, type Prisma } from "@prisma/client"; import { requireApiAuth, isPrivileged, forbiddenResponse, } from "@/lib/auth-helpers"; +import { resolveOrgScope, type Scope } from "@/lib/api/scope/parse"; +/** + * Personal "all my bookings" widget endpoint. Returns the union of 5 + * booking types (consultations, subscriptions, webinars, classes, + * trials) flattened for the consultee dashboard. Pre-existing surface + * — keep using this for the consultee's primary appointments view. + * + * NOT to be confused with `/api/appointments` (#674 / B1-hybrid), + * which is the paginated scope-aware list used by the new org + * dashboards. See `prompts/enterprise-test-prompt.md` §10 for the + * tradeoff matrix. + * + * `?orgScope=` — added in B1-personal-retrofit so + * the consultee dashboard can toggle between personal-only / a + * specific org's bookings / everything (admin-only). Default is + * `personal` for backwards compatibility (previous callers omit the + * param and get the same data they always have, since pre-B1 every + * row was implicitly personal). Cross-tenant scope guard fires here + * just like in `/api/appointments` — the caller must be a member of + * the requested org or hold ADMIN/STAFF. + */ export async function GET( request: Request, { params }: { params: Promise<{ consulteeId: string }> }, @@ -47,12 +68,62 @@ export async function GET( const userId = consulteeProfile.userId; + // B1-personal-retrofit: parse + authorize ?orgScope=. Default + // `personal` keeps every existing caller working without changes. + const url = new URL(request.url); + const callerMemberships = await prisma.membership.findMany({ + where: { userId, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: url.searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Self-scoped: route already rejects requests for someone else's + // consulteeProfileId, so `?orgScope=all` here means "my personal + // + every org I belong to" — safe for any role. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + const scope: Scope = scopeResolution.scope; + + // Build per-resource org filters. The 5 booking models attach to + // org context differently: + // - Consultation / Webinar (1:1 appointment): filter via + // `appointment.is.organizationId` + // - Subscription / Class (1:many appointments): filter via + // `appointments.some.organizationId` so the parent surfaces if + // ANY child appointment matches the scope + // - TrialSession: filter directly via `organizationId` + const oneApptOrgWhere: Prisma.AppointmentWhereInput | undefined = + scope.kind === "personal" + ? { organizationId: null } + : scope.kind === "org" + ? { organizationId: scope.orgId } + : undefined; + const manyApptOrgWhere: Prisma.AppointmentWhereInput | undefined = + oneApptOrgWhere; + const trialOrgWhere: Prisma.TrialSessionWhereInput | undefined = + scope.kind === "personal" + ? { organizationId: null } + : scope.kind === "org" + ? { organizationId: scope.orgId } + : undefined; + // PERFORMANCE FIX: Use direct Prisma queries instead of internal HTTP fetches // This avoids network overhead and reduces response time from 11+ seconds to <1 second const [consultations, subscriptions, webinars, classes, trials] = await Promise.all([ prisma.consultation.findMany({ - where: { requestedById: consulteeId }, + where: { + requestedById: consulteeId, + ...(oneApptOrgWhere && { appointment: { is: oneApptOrgWhere } }), + }, include: { consultationPlan: { include: { @@ -87,7 +158,12 @@ export async function GET( orderBy: { requestedAt: "desc" }, }), prisma.subscription.findMany({ - where: { requestedById: consulteeId }, + where: { + requestedById: consulteeId, + ...(manyApptOrgWhere && { + appointments: { some: manyApptOrgWhere }, + }), + }, include: { subscriptionPlan: { include: { @@ -130,6 +206,7 @@ export async function GET( slotsOfAppointment: { some: { user: { some: { id: userId } } }, }, + ...(oneApptOrgWhere ?? {}), }, }, { @@ -204,6 +281,7 @@ export async function GET( slotsOfAppointment: { some: { user: { some: { id: userId } } }, }, + ...(manyApptOrgWhere ?? {}), }, }, }, @@ -271,7 +349,10 @@ export async function GET( }), // Trial sessions: Free trials requested by the consultee prisma.trialSession.findMany({ - where: { consulteeProfileId: consulteeId }, + where: { + consulteeProfileId: consulteeId, + ...(trialOrgWhere ?? {}), + }, include: { subscriptionPlan: { include: { diff --git a/app/api/dashboard/consultee/[consulteeId]/payments/route.ts b/app/api/dashboard/consultee/[consulteeId]/payments/route.ts index 5cf2f0e36..411865d3a 100644 --- a/app/api/dashboard/consultee/[consulteeId]/payments/route.ts +++ b/app/api/dashboard/consultee/[consulteeId]/payments/route.ts @@ -5,6 +5,7 @@ import { isPrivileged, forbiddenResponse, } from "@/lib/auth-helpers"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; export async function GET( request: Request, @@ -16,6 +17,7 @@ export async function GET( try { const { consulteeId } = await params; + const { searchParams } = new URL(request.url); if ( !isPrivileged(session.user.role) && @@ -45,10 +47,37 @@ export async function GET( const userId = consulteeProfile.userId; + // #674 org-scope filter. Payment.organizationId is populated by the + // backfill so an Acme + Zeta consultee's payment history correctly + // splits per org context. Personal scope = pre-org-tagging history. + const callerMemberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Self-scoped consultee endpoint. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + const orgFilter = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : {}; + const [payments, invoices, credits, creditUsages] = await Promise.all([ - // All payments for this user + // All payments for this user, scoped to the selected org context prisma.payment.findMany({ - where: { userId }, + where: { userId, ...orgFilter }, include: { appointment: { select: { @@ -86,23 +115,12 @@ export async function GET( orderBy: { createdAt: "desc" }, }), - // Invoices for this user's payments - prisma.invoice.findMany({ - where: { - payment: { userId }, - }, - include: { - payment: { - select: { - id: true, - amount: true, - currency: true, - paymentStatus: true, - }, - }, - }, - orderBy: { createdAt: "desc" }, - }), + // Personal-consultee per-Payment invoice surface removed in v0 + // lockdown (#768). UI now shows OrganizationInvoice rows for + // org-funded paths only; PERSONAL consultees can request a + // receipt from support until v1.1 re-introduces a per-Payment + // invoice flow. Empty array keeps the response shape stable. + Promise.resolve([] as const), // Referral credits prisma.referralCredit.findMany({ @@ -149,6 +167,10 @@ export async function GET( paymentGateway: p.paymentGateway, appointmentType: apt?.appointmentType || null, planTitle, + // Org-funding marker — drives the "Sponsored · " badge on + // the consultee payments table row. Same convention as the + // appointments + home surfaces. Populated by the #674 backfill. + organizationId: p.organizationId, discount: p.discountCode ? { code: p.discountCode.code, diff --git a/app/api/dashboard/consultee/[consulteeId]/resources/route.ts b/app/api/dashboard/consultee/[consulteeId]/resources/route.ts index a62b93e95..7f92b62b4 100644 --- a/app/api/dashboard/consultee/[consulteeId]/resources/route.ts +++ b/app/api/dashboard/consultee/[consulteeId]/resources/route.ts @@ -112,23 +112,35 @@ const classInclude = { }, } satisfies Prisma.ClassInclude; -type ConsultationWithResources = Prisma.ConsultationGetPayload<{ - include: typeof consultationInclude; -}>; -type SubscriptionWithResources = Prisma.SubscriptionGetPayload<{ - include: typeof subscriptionInclude; -}>; -type WebinarWithResources = Prisma.WebinarGetPayload<{ - include: typeof webinarInclude; -}>; -type ClassWithResources = Prisma.ClassGetPayload<{ - include: typeof classInclude; -}>; +// Derived via the extended client — raw GetPayload would re-introduce +// bigint money/fileSize fields (#780). +type ConsultationWithResources = Prisma.Result< + typeof prisma.consultation, + { include: typeof consultationInclude }, + "findFirstOrThrow" +>; +type SubscriptionWithResources = Prisma.Result< + typeof prisma.subscription, + { include: typeof subscriptionInclude }, + "findFirstOrThrow" +>; +type WebinarWithResources = Prisma.Result< + typeof prisma.webinar, + { include: typeof webinarInclude }, + "findFirstOrThrow" +>; +type ClassWithResources = Prisma.Result< + typeof prisma.class, + { include: typeof classInclude }, + "findFirstOrThrow" +>; // Appointment type that has slotsOfAppointment with meetingSession recordings -type AppointmentWithSlots = Prisma.AppointmentGetPayload<{ - include: typeof slotsWithRecordings; -}>; +type AppointmentWithSlots = Prisma.Result< + typeof prisma.appointment, + { include: typeof slotsWithRecordings }, + "findFirstOrThrow" +>; export async function GET( request: Request, @@ -302,8 +314,7 @@ export async function GET( consultantImage: c.consultationPlan.consultantProfile.user.image, status: c.requestStatus, date: - c.appointment?.slotsOfAppointment?.[0]?.startsAt || - c.requestedAt, + c.appointment?.slotsOfAppointment?.[0]?.startsAt || c.requestedAt, materials: c.consultationPlan.materials, recordings: await extractRecordings( c.appointment ? [c.appointment] : [], @@ -349,8 +360,7 @@ export async function GET( id: cl.id, planTitle: cl.classPlan.title, consultantName: cl.classPlan.consultantProfile?.user.name ?? null, - consultantImage: - cl.classPlan.consultantProfile?.user.image ?? null, + consultantImage: cl.classPlan.consultantProfile?.user.image ?? null, status: cl.status, date: cl.schedulingPeriodStartsAt || diff --git a/app/api/dev/mock-webhook/route.ts b/app/api/dev/mock-webhook/route.ts index d65755148..5b0f83316 100644 --- a/app/api/dev/mock-webhook/route.ts +++ b/app/api/dev/mock-webhook/route.ts @@ -237,7 +237,7 @@ async function handleMockPayoutProcessed( } // Find payout by ID or providerPayoutId - const payout = await prisma.payout.findFirst({ + const payout = await prisma.consultantPayout.findFirst({ where: { OR: [{ id: payoutId }, { providerPayoutId: payoutId }], }, @@ -256,7 +256,7 @@ async function handleMockPayoutProcessed( // Update the payout with the mock provider ID if not set if (!payout.providerPayoutId) { - await prisma.payout.update({ + await prisma.consultantPayout.update({ where: { id: payout.id }, data: { providerPayoutId }, }); @@ -290,7 +290,7 @@ async function handleMockPayoutRejected( }; } - const payout = await prisma.payout.findFirst({ + const payout = await prisma.consultantPayout.findFirst({ where: { OR: [{ id: payoutId }, { providerPayoutId: payoutId }], }, @@ -308,7 +308,7 @@ async function handleMockPayoutRejected( // Update the payout with the mock provider ID if not set if (!payout.providerPayoutId) { - await prisma.payout.update({ + await prisma.consultantPayout.update({ where: { id: payout.id }, data: { providerPayoutId }, }); diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts new file mode 100644 index 000000000..db03d71b2 --- /dev/null +++ b/app/api/documents/route.ts @@ -0,0 +1,71 @@ +/** + * GET /api/documents + * + * Personal AppointmentDocument list with optional `?orgScope=` filter + * (#674 / B1-hybrid). Org-scoped sibling at + * `/api/organizations/[orgId]/documents`. + * + * NOT TO BE CONFUSED WITH + * `/api/dashboard/consultant/[consultantId]/documents` which is the + * pre-existing consultant-side review queue (different shape, accepts + * its own `?orgScope=` since the B1-personal-retrofit). This endpoint + * is the unified scope-aware list used by org dashboards. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { listDocumentsScoped } from "@/lib/api/scope/list-documents"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + reviewStatus: z + .enum(["PENDING", "APPROVED", "REJECTED", "IN_REVIEW", "NEEDS_REVISION"]) + .optional(), +}); + +export async function GET(req: NextRequest) { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + const session = auth.session; + + const memberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + + const url = new URL(req.url); + const scopeResolution = resolveOrgScope({ + raw: url.searchParams.get("orgScope"), + memberships, + userRole: (session.user as { role?: string }).role, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + + const filters = QuerySchema.safeParse({ + reviewStatus: url.searchParams.get("reviewStatus") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listDocumentsScoped({ + scope: scopeResolution.scope, + userId: session.user.id, + reviewStatus: filters.data.reviewStatus, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/events/classes/route.ts b/app/api/events/classes/route.ts index 5d53c31e3..5b25ed1de 100644 --- a/app/api/events/classes/route.ts +++ b/app/api/events/classes/route.ts @@ -1,11 +1,13 @@ import prisma from "@/lib/prisma"; import { NextRequest, NextResponse } from "next/server"; +import type { Prisma } from "@prisma/client"; import { transformNestedPlanTopics } from "@/lib/topics"; import { requireApiAuth, isPrivileged, forbiddenResponse, } from "@/lib/auth-helpers"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; export async function GET(request: NextRequest) { // Require authentication (middleware already enforces cookie presence for /api/events/) @@ -50,6 +52,37 @@ export async function GET(request: NextRequest) { const startDateStr = searchParams.get("startDate"); const endDateStr = searchParams.get("endDate"); + // Org-scope filter — Class rows don't carry organizationId directly; + // attribution lives on the parent ClassPlan (per + // `docs/enterprise/30-programs-and-lifecycle/05-public-pages-and-discovery.md` + // — plans with `organizationId` set are the org's catalog). So we + // filter via the `classPlan.organizationId` relation. + const callerMemberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Self-scoped: non-admin callers are already locked to their own + // consultant/consulteeProfileId above, so `?orgScope=all` means + // "all of MY classes" — safe for any role. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + const classPlanOrgWhere: Prisma.ClassPlanWhereInput | null = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : null; // "all" → no filter + let classes; const dateFilter = @@ -66,6 +99,7 @@ export async function GET(request: NextRequest) { if (consulteeProfileId) { classes = await prisma.class.findMany({ where: { + ...(classPlanOrgWhere && { classPlan: classPlanOrgWhere }), OR: [ // Get classes where consultee is registered through appointments { @@ -168,6 +202,7 @@ export async function GET(request: NextRequest) { where: { classPlan: { consultantProfileId, + ...(classPlanOrgWhere ?? {}), }, ...dateFilter, }, @@ -188,7 +223,10 @@ export async function GET(request: NextRequest) { }); } else { classes = await prisma.class.findMany({ - where: { ...dateFilter }, + where: { + ...(classPlanOrgWhere && { classPlan: classPlanOrgWhere }), + ...dateFilter, + }, include: { classPlan: { include: { diff --git a/app/api/events/consultations/[consultationId]/route.ts b/app/api/events/consultations/[consultationId]/route.ts index 704697cbd..01bd1f30c 100644 --- a/app/api/events/consultations/[consultationId]/route.ts +++ b/app/api/events/consultations/[consultationId]/route.ts @@ -1,4 +1,4 @@ -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { AppointmentsType, PaymentGateway, @@ -26,35 +26,41 @@ import { createDirectMessageChannel } from "@/actions/stream/chat/channel.action import { streamLogger } from "@/lib/stream-logger"; /** - * Type for consultation with all related details needed for payment processing + * Type for consultation with all related details needed for payment processing. + * Derived via the extended client — raw GetPayload would re-introduce bigint + * money fields (#780). */ -type ConsultationWithDetails = Prisma.ConsultationGetPayload<{ - include: { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: true; +type ConsultationWithDetails = Prisma.Result< + typeof prisma.consultation, + { + include: { + consultationPlan: { + include: { + consultantProfile: { + include: { + user: true; + }; }; }; }; - }; - requestedBy: { - include: { - user: true; + requestedBy: { + include: { + user: true; + }; }; - }; - appointment: { - include: { - slotsOfAppointment: { - include: { - user: true; + appointment: { + include: { + slotsOfAppointment: { + include: { + user: true; + }; }; }; }; }; - }; -}>; + }, + "findFirstOrThrow" +>; export async function GET( request: Request, @@ -728,30 +734,34 @@ export async function PATCH( } // --- Stream channel creation (fire-and-forget, only on approval) --- - if (!result.duplicate && status === RequestStatus.APPROVED) try { - const consultationData = result.data; - const consultantUserId = - consultationData.consultationPlan?.consultantProfile?.userId; - const consulteeUserId = consultationData.requestedBy?.userId; - - if (consultantUserId && consulteeUserId) { - await addUserToEventChannel( - "consultation", - consultationId, - consulteeUserId, + if (!result.duplicate && status === RequestStatus.APPROVED) + try { + const consultationData = result.data; + const consultantUserId = + consultationData.consultationPlan?.consultantProfile?.userId; + const consulteeUserId = consultationData.requestedBy?.userId; + + if (consultantUserId && consulteeUserId) { + await addUserToEventChannel( + "consultation", + consultationId, + consulteeUserId, + ); + await createDirectMessageChannel(consultantUserId, consulteeUserId); + streamLogger.info( + "Stream channel created on consultation approval", + { + consultationId, + }, + ); + } + } catch (channelError) { + streamLogger.error( + "Auto-channel creation failed on consultation approval", + channelError, + { consultationId }, ); - await createDirectMessageChannel(consultantUserId, consulteeUserId); - streamLogger.info("Stream channel created on consultation approval", { - consultationId, - }); } - } catch (channelError) { - streamLogger.error( - "Auto-channel creation failed on consultation approval", - channelError, - { consultationId }, - ); - } // Return success response (exclude emailData from response) const { emailData: _emailData, ...responseData } = @@ -786,7 +796,7 @@ export async function PATCH( * Uses transaction client to maintain serializable isolation */ async function checkConsultationPayment( - tx: Prisma.TransactionClient, + tx: Tx, consultationId: string, ): Promise { const consultation = await tx.consultation.findUnique({ diff --git a/app/api/events/subscriptions/[subscriptionId]/route.ts b/app/api/events/subscriptions/[subscriptionId]/route.ts index d1f50ac03..7128059c4 100644 --- a/app/api/events/subscriptions/[subscriptionId]/route.ts +++ b/app/api/events/subscriptions/[subscriptionId]/route.ts @@ -1,4 +1,4 @@ -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { PaymentGateway, PaymentStatus, @@ -33,35 +33,41 @@ import { createDirectMessageChannel } from "@/actions/stream/chat/channel.action import { streamLogger } from "@/lib/stream-logger"; /** - * Type for subscription with all related details needed for payment processing + * Type for subscription with all related details needed for payment processing. + * Derived via the extended client — raw GetPayload would re-introduce bigint + * money fields (#780). */ -type SubscriptionWithDetails = Prisma.SubscriptionGetPayload<{ - include: { - subscriptionPlan: { - include: { - consultantProfile: { - include: { - user: true; +type SubscriptionWithDetails = Prisma.Result< + typeof prisma.subscription, + { + include: { + subscriptionPlan: { + include: { + consultantProfile: { + include: { + user: true; + }; }; }; }; - }; - requestedBy: { - include: { - user: true; + requestedBy: { + include: { + user: true; + }; }; - }; - appointments: { - include: { - slotsOfAppointment: { - include: { - user: true; + appointments: { + include: { + slotsOfAppointment: { + include: { + user: true; + }; }; }; }; }; - }; -}>; + }, + "findFirstOrThrow" +>; export async function GET( _request: NextRequest, @@ -743,9 +749,7 @@ export async function PATCH( image: session.user.image, }, subData.subscriptionPlan?.title || "Subscription", - session.user.id === consultantUserId - ? "consultant" - : "consultee", + session.user.id === consultantUserId ? "consultant" : "consultee", ); } } @@ -818,7 +822,7 @@ export async function PATCH( * Uses transaction client to maintain serializable isolation */ async function checkSubscriptionPayment( - tx: Prisma.TransactionClient, + tx: Tx, subscriptionId: string, ): Promise { const subscription = await tx.subscription.findUnique({ diff --git a/app/api/events/webinars/route.ts b/app/api/events/webinars/route.ts index 7af117fd9..ba4a76e0d 100644 --- a/app/api/events/webinars/route.ts +++ b/app/api/events/webinars/route.ts @@ -7,6 +7,7 @@ import { isPrivileged, forbiddenResponse, } from "@/lib/auth-helpers"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; export async function GET(request: NextRequest) { // Require authentication (middleware already enforces cookie presence for /api/events/) @@ -53,6 +54,37 @@ export async function GET(request: NextRequest) { const startDateStr = searchParams.get("startDate"); const endDateStr = searchParams.get("endDate"); + // Org-scope filter — Webinar rows don't carry organizationId directly; + // attribution lives on the parent WebinarPlan (per + // `docs/enterprise/30-programs-and-lifecycle/05-public-pages-and-discovery.md` + // — plans with `organizationId` set are the org's catalog). So we + // filter via the `webinarPlan.organizationId` relation. + const callerMemberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Self-scoped: non-admin callers are already locked to their own + // consultant/consulteeProfileId above, so `?orgScope=all` means + // "all of MY webinars" — safe for any role. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + const webinarPlanOrgWhere: Prisma.WebinarPlanWhereInput | null = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : null; // "all" → no filter + let webinars; const dateFilter = @@ -74,6 +106,7 @@ export async function GET(request: NextRequest) { if (consulteeProfileId) { webinars = await prisma.webinar.findMany({ where: { + ...(webinarPlanOrgWhere && { webinarPlan: webinarPlanOrgWhere }), OR: [ // Get webinars where consultee is registered through appointments { @@ -142,7 +175,6 @@ export async function GET(request: NextRequest) { payment: true, }, }, - // meetingRoom: true, waitlist: { where: { user: { @@ -169,6 +201,7 @@ export async function GET(request: NextRequest) { const whereClause: Prisma.WebinarWhereInput = { webinarPlan: { consultantProfileId, + ...(webinarPlanOrgWhere ?? {}), }, }; @@ -203,13 +236,15 @@ export async function GET(request: NextRequest) { }, }, }, - // meetingRoom: true, waitlist: true, }, }); } else { webinars = await prisma.webinar.findMany({ - where: { ...dateFilter }, + where: { + ...(webinarPlanOrgWhere && { webinarPlan: webinarPlanOrgWhere }), + ...dateFilter, + }, include: { webinarPlan: { include: { @@ -226,7 +261,6 @@ export async function GET(request: NextRequest) { }, }, }, - // meetingRoom: true, waitlist: true, }, }); @@ -305,7 +339,6 @@ export async function POST(request: Request) { }, }, }, - // meetingRoom: true, waitlist: true, }, }); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index e36b25c20..1c59d5b30 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -73,7 +73,11 @@ export async function GET(request: Request) { try { let timeoutId: ReturnType; await Promise.race([ - prisma.$queryRaw`SELECT 1`.finally(() => clearTimeout(timeoutId)), + // ORM connectivity probe (no raw SQL) — a cheap LIMIT 1 read proves the + // connection is alive; null (empty table) still means "connected". + prisma.user + .findFirst({ select: { id: true } }) + .finally(() => clearTimeout(timeoutId)), new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error("DB timeout")), 5000); }), diff --git a/app/api/invitations/preview/route.ts b/app/api/invitations/preview/route.ts new file mode 100644 index 000000000..b392e89b8 --- /dev/null +++ b/app/api/invitations/preview/route.ts @@ -0,0 +1,74 @@ +/** + * Public invitation preview — no authentication required. + * + * GET ?token= + * + * Returns enough context for the invite landing page to show a meaningful + * "You've been invited to join Acme Corp as a Learner" message before the + * visitor signs in. The token is 64-char random hex (256-bit entropy) so + * exposing org name + role to the token-holder is safe. + * + * Deliberately returns the minimum: orgName, orgLogo, role, expiresAt. + * Does NOT expose inviter identity, member counts, or billing info. + * + * Security — uniform error response for all invalid states: + * Whether the token never existed, has expired, or was already accepted, + * the response is identical (same HTTP status, same message). This prevents + * state enumeration: a caller cannot distinguish "never issued" from "already + * used" from "expired", limiting information leakage to the happy path only. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +// Factory — a Response body is a one-shot stream; never reuse a Response instance +// across multiple requests. Also sets Cache-Control: no-store so browsers and +// CDNs don't cache the 410 and serve stale error messages on subsequent navigations. +function invalidResponse() { + return NextResponse.json( + { error: "This invitation link is no longer valid. Please ask your organization admin for a new invite." }, + { status: 410, headers: { "Cache-Control": "no-store" } }, + ); +} + +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get("token"); + if (!token || token.length < 16) { + return invalidResponse(); + } + + const invitation = await prisma.invitation.findUnique({ + where: { id: token }, + select: { + status: true, + expiresAt: true, + role: true, + organization: { + select: { + name: true, + brandingProfile: { select: { logo: true } }, + }, + }, + }, + }); + + // Uniform 410 for all invalid states — do not distinguish between + // not-found, expired, accepted, or cancelled to prevent state enumeration. + if ( + !invitation || + invitation.status !== "pending" || + invitation.expiresAt < new Date() + ) { + return invalidResponse(); + } + + return NextResponse.json( + { + orgName: invitation.organization.name, + orgLogo: invitation.organization.brandingProfile?.logo ?? null, + role: invitation.role, + expiresAt: invitation.expiresAt.toISOString(), + }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts deleted file mode 100644 index 474ae8833..000000000 --- a/app/api/invoices/[id]/pdf/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Invoice PDF Download API - * GET /api/invoices/[id]/pdf - * Generates and returns a GST-compliant PDF invoice - * Caches generated PDFs in Supabase Storage for repeat downloads - */ - -import { NextRequest, NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; -import { getSession } from "@/lib/auth-server"; -import { - generateInvoicePdf, - getInvoicePdfData, -} from "@/lib/payments/payouts/invoice-pdf"; - -interface RouteParams { - params: Promise<{ id: string }>; -} - -export async function GET(req: NextRequest, { params }: RouteParams) { - try { - const session = await getSession(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - - // Fetch invoice with payment + user data - const invoice = await prisma.invoice.findFirst({ - where: { - OR: [{ id }, { invoiceNumber: id }], - }, - include: { - payment: { - include: { - user: { select: { name: true, email: true } }, - discountCode: { - select: { - code: true, - discountType: true, - discountValue: true, - }, - }, - creditUsages: { select: { amount: true, originalAmount: true } }, - }, - }, - }, - }); - - if (!invoice) { - return NextResponse.json( - { error: "Invoice not found" }, - { status: 404 }, - ); - } - - // Authorization: user must own the payment, or be admin/staff - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { role: true, email: true }, - }); - - const isOwner = invoice.payment?.user?.email === user?.email; - const isAdmin = user?.role === "ADMIN" || user?.role === "STAFF"; - - if (!isOwner && !isAdmin) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - // If we already have a cached PDF URL, redirect to it - if (invoice.pdfUrl) { - return NextResponse.redirect(invoice.pdfUrl); - } - - // Build PDF data from invoice record - const pdfData = await getInvoicePdfData(invoice); - - // Generate PDF buffer - const pdfBuffer = await generateInvoicePdf(pdfData); - - // Try to upload to Supabase Storage for caching (fire-and-forget) - uploadToStorage(invoice.id, invoice.invoiceNumber, pdfBuffer).catch( - (err) => { - console.error( - `Failed to cache PDF for invoice ${invoice.id}:`, - err, - ); - }, - ); - - // Return PDF response - return new Response(new Uint8Array(pdfBuffer), { - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="${invoice.invoiceNumber}.pdf"`, - "Cache-Control": "private, max-age=3600", - }, - }); - } catch (error) { - console.error("Error generating invoice PDF:", error); - return NextResponse.json( - { error: "Failed to generate invoice PDF" }, - { status: 500 }, - ); - } -} - -/** - * Upload generated PDF to Supabase Storage and update Invoice.pdfUrl - * Best-effort — failures are logged but don't block the response - */ -async function uploadToStorage( - invoiceId: string, - invoiceNumber: string, - pdfBuffer: Buffer, -): Promise { - // Dynamic import to avoid breaking if supabase isn't configured - let supabase; - try { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl || !supabaseServiceKey) { - console.warn( - "[invoice-pdf] Supabase credentials not configured, skipping PDF caching", - ); - return; - } - - const { createClient } = await import("@supabase/supabase-js"); - supabase = createClient(supabaseUrl, supabaseServiceKey); - } catch { - return; // Supabase not available - } - - const bucketName = "invoices"; - const filePath = `${invoiceId}/${invoiceNumber}.pdf`; - - // Try upload directly; create bucket only if it doesn't exist - let { error: uploadError } = await supabase.storage - .from(bucketName) - .upload(filePath, pdfBuffer, { - contentType: "application/pdf", - upsert: true, - }); - - if (uploadError?.message?.includes("Bucket not found")) { - await supabase.storage.createBucket(bucketName, { public: false }); - ({ error: uploadError } = await supabase.storage - .from(bucketName) - .upload(filePath, pdfBuffer, { - contentType: "application/pdf", - upsert: true, - })); - } - - if (uploadError) { - console.error(`[invoice-pdf] Upload failed: ${uploadError.message}`); - return; - } - - // Get signed URL (1 year expiry) - const { data: urlData } = await supabase.storage - .from(bucketName) - .createSignedUrl(filePath, 60 * 60 * 24 * 365); - - if (urlData?.signedUrl) { - await prisma.invoice.update({ - where: { id: invoiceId }, - data: { pdfUrl: urlData.signedUrl }, - }); - } -} diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts deleted file mode 100644 index 597468acd..000000000 --- a/app/api/invoices/[id]/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Invoice Details API - * Get specific invoice by ID or invoice number - */ - -import { NextRequest, NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; -import { getInvoiceById, getInvoiceByNumber } from "@/lib/payments/payouts"; - -import { getSession } from "@/lib/auth-server"; -interface RouteParams { - params: Promise<{ id: string }>; -} - -/** - * GET /api/invoices/[id] - * Get invoice by ID or invoice number - */ -export async function GET(req: NextRequest, { params }: RouteParams) { - try { - const session = await getSession(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - - // Try to find by ID first, then by invoice number - let invoice = await getInvoiceById(id); - if (!invoice) { - invoice = await getInvoiceByNumber(id); - } - - if (!invoice) { - return NextResponse.json({ error: "Invoice not found" }, { status: 404 }); - } - - // Check authorization - user can only view their own invoices - // unless they're an admin - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, - }); - - if ( - invoice.payment?.user?.email !== session.user.email && - user?.role !== "ADMIN" - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - // Parse items from JSON - const invoiceData = { - ...invoice, - items: invoice.items as Array<{ - description: string; - quantity: number; - unitPrice: number; - amount: number; - hsnCode?: string; - }>, - }; - - return NextResponse.json({ invoice: invoiceData }); - } catch (error) { - console.error("Error fetching invoice:", error); - return NextResponse.json( - { error: "Failed to fetch invoice" }, - { status: 500 }, - ); - } -} diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts deleted file mode 100644 index d92862a00..000000000 --- a/app/api/invoices/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Invoice API - * Get user's invoices - */ - -import { NextRequest, NextResponse } from "next/server"; -import { PaymentStatus } from "@prisma/client"; -import { getUserInvoices } from "@/lib/payments/payouts"; - -import { getSession } from "@/lib/auth-server"; -/** - * GET /api/invoices - * Get current user's invoices - */ -export async function GET(req: NextRequest) { - try { - const session = await getSession(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Parse query parameters - const { searchParams } = new URL(req.url); - const status = searchParams.get("status") as PaymentStatus | null; - const limit = parseInt(searchParams.get("limit") || "20"); - const offset = parseInt(searchParams.get("offset") || "0"); - - const { invoices, total, hasMore } = await getUserInvoices( - session.user.id, - { - status: status || undefined, - limit, - offset, - }, - ); - - return NextResponse.json({ - invoices, - pagination: { - total, - limit, - offset, - hasMore, - }, - }); - } catch (error) { - console.error("Error fetching invoices:", error); - return NextResponse.json( - { error: "Failed to fetch invoices" }, - { status: 500 }, - ); - } -} diff --git a/app/api/meetings/[meetingId]/validate-access/route.ts b/app/api/meetings/[meetingId]/validate-access/route.ts index 98aa7aaf1..b314e919a 100644 --- a/app/api/meetings/[meetingId]/validate-access/route.ts +++ b/app/api/meetings/[meetingId]/validate-access/route.ts @@ -139,7 +139,7 @@ export async function GET( const classPlanId = appointment.class?.classPlan?.id; if (webinarPlanId) { - const collab = await prisma.webinarCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { webinarPlanId, consultantProfileId: userProfile.consultantProfileId, @@ -156,7 +156,7 @@ export async function GET( } if (classPlanId) { - const collab = await prisma.classCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { classPlanId, consultantProfileId: userProfile.consultantProfileId, diff --git a/app/api/org-workspace/[orgWorkspaceId]/activity/route.ts b/app/api/org-workspace/[orgWorkspaceId]/activity/route.ts new file mode 100644 index 000000000..9effc67c3 --- /dev/null +++ b/app/api/org-workspace/[orgWorkspaceId]/activity/route.ts @@ -0,0 +1,139 @@ +/** + * GET /api/org-workspace/[orgWorkspaceId]/activity + * + * Cross-org audit feed for an OrgWorkspace. Aggregates `OrgAuditLog` rows + * from every org where the caller has an ACTIVE OWNER membership and + * stitches them into a single timeline. Distinct from the per-org + * /audit endpoint which scopes to one org and supports rich filters. + * This one is intentionally simpler: latest-first, no filtering, used + * to drive a "what changed across my portfolio in the last week" + * widget. + * + * Pagination: cursor on `(createdAt DESC, id DESC)`. The + * `@@index([organizationId, createdAt])` covers the per-org slice; + * Postgres composes them via the `IN` clause without a new index. + * + * Auth: same IDOR posture as /billing — orgWorkspaceId in URL must match + * the caller's `orgWorkspaceProfileId`. + * + * Returns enriched rows: each row carries the orgName + actor's display + * name so the UI doesn't need a follow-up join. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; + +const QuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.string().optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgWorkspaceId: string }> }, +) { + const { orgWorkspaceId } = await params; + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + // `orgWorkspaceProfileId` is part of the customSession-augmented user + // type (lib/auth.ts:522) — direct access is type-safe. + if (auth.session.user.orgWorkspaceProfileId !== orgWorkspaceId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const q = parsed.data; + + const ownedOrgs = await prisma.membership.findMany({ + where: { + userId: auth.session.user.id, + role: "OWNER", + status: "ACTIVE", + }, + select: { + organizationId: true, + organization: { select: { id: true, name: true, slug: true } }, + }, + }); + const orgIds = ownedOrgs.map((o) => o.organizationId); + const orgById = new Map(ownedOrgs.map((o) => [o.organizationId, o.organization])); + + if (orgIds.length === 0) { + return NextResponse.json({ + data: [], + pagination: { hasMore: false, nextCursor: null, limit: q.limit }, + }); + } + + const rows = await prisma.orgAuditLog.findMany({ + where: { organizationId: { in: orgIds } }, + orderBy: { createdAt: "desc" }, + take: q.limit + 1, + ...(q.cursor && { cursor: { id: q.cursor }, skip: 1 }), + select: { + id: true, + organizationId: true, + actorMembershipId: true, + category: true, + action: true, + description: true, + createdAt: true, + }, + }); + + const hasMore = rows.length > q.limit; + const slice = hasMore ? rows.slice(0, q.limit) : rows; + const nextCursor = hasMore ? slice[slice.length - 1]?.id ?? null : null; + + // Actor names — OrgAuditLog has no Prisma relation back to Membership + // (the FK column is bare), so a single batched lookup keyed on the + // distinct actorMembershipIds beats N+1 round-trips. + const actorIds = Array.from( + new Set(slice.map((r) => r.actorMembershipId).filter((v): v is string => !!v)), + ); + const actors = actorIds.length + ? await prisma.membership.findMany({ + where: { id: { in: actorIds } }, + select: { + id: true, + user: { select: { name: true, email: true } }, + }, + }) + : []; + const actorById = new Map(actors.map((a) => [a.id, a])); + + const data = slice.map((r) => { + const org = orgById.get(r.organizationId); + const actor = r.actorMembershipId + ? actorById.get(r.actorMembershipId) + : undefined; + return { + id: r.id, + organizationId: r.organizationId, + organizationName: org?.name ?? null, + organizationSlug: org?.slug ?? null, + category: r.category, + action: r.action, + description: r.description, + createdAt: r.createdAt, + actorName: actor?.user?.name ?? actor?.user?.email ?? null, + }; + }); + + return NextResponse.json({ + data, + pagination: { hasMore, nextCursor, limit: q.limit }, + }); +} diff --git a/app/api/org-workspace/[orgWorkspaceId]/billing/route.ts b/app/api/org-workspace/[orgWorkspaceId]/billing/route.ts new file mode 100644 index 000000000..8160bfef7 --- /dev/null +++ b/app/api/org-workspace/[orgWorkspaceId]/billing/route.ts @@ -0,0 +1,146 @@ +/** + * GET /api/org-workspace/[orgWorkspaceId]/billing + * + * Cross-org billing roll-up for an OrgWorkspace operator. Aggregates: + * - Outstanding invoice paise across all orgs the caller OWNS + * (status ∈ {ISSUED, OVERDUE} — i.e. issued but not paid) + * - Wallet balance across all orgs (BillingAccount.walletBalance) + * - Active member count across all orgs (informational, drives the + * home stats row) + * - Per-org breakdown so the dashboard table can list each org's + * numbers without a second round-trip + * + * Auth: the URL's orgWorkspaceId must match the caller's + * `orgWorkspaceProfileId`. We never let one operator browse another + * operator's portfolio — same posture as the dashboard layout's + * IDOR guard. + * + * Returns 200 even when the operator has zero owned orgs (just + * zeroed-out summary + empty per-org array). Avoids forcing the UI + * to handle a "no profile yet" branch separately. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { sumPaise } from "@/lib/payments/utils/money"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgWorkspaceId: string }> }, +) { + const { orgWorkspaceId } = await params; + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + // `orgWorkspaceProfileId` is part of the customSession-augmented user + // type (lib/auth.ts:522) — direct access is type-safe. + if (auth.session.user.orgWorkspaceProfileId !== orgWorkspaceId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + // Owned orgs (any status — we count DEACTIVATED orgs too because + // they may carry residual outstanding invoices the operator still + // has to settle). + const ownedMemberships = await prisma.membership.findMany({ + where: { + userId: auth.session.user.id, + role: "OWNER", + status: "ACTIVE", + }, + select: { + organizationId: true, + organization: { + select: { + id: true, + name: true, + slug: true, + status: true, + billingAccount: { + select: { + id: true, + walletBalance: true, + currency: true, + fundingSource: true, + }, + }, + }, + }, + }, + }); + + const orgIds = ownedMemberships.map((m) => m.organizationId); + + if (orgIds.length === 0) { + return NextResponse.json({ + summary: { + orgsOwned: 0, + totalActiveMembers: 0, + totalOutstandingPaise: 0, + totalWalletPaise: 0, + }, + perOrg: [], + }); + } + + // Parallel: invoice aggregates per org + member counts per org. + const [invoiceAgg, memberAgg] = await Promise.all([ + prisma.organizationInvoice.groupBy({ + by: ["billingAccountId"], + where: { + billingAccount: { ownerOrgId: { in: orgIds } }, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + _sum: { totalPaise: true }, + _count: { _all: true }, + }), + prisma.membership.groupBy({ + by: ["organizationId"], + where: { organizationId: { in: orgIds }, status: "ACTIVE" }, + _count: { _all: true }, + }), + ]); + + // Index by joinable key for an O(orgs) merge. + const invoiceByBA = new Map( + invoiceAgg.map((row) => [ + row.billingAccountId, + { + outstandingCount: row._count._all, + outstandingPaise: sumPaise(row._sum.totalPaise), + }, + ]), + ); + const membersByOrg = new Map( + memberAgg.map((row) => [row.organizationId, row._count._all]), + ); + + const perOrg = ownedMemberships.map((m) => { + const ba = m.organization.billingAccount; + const inv = ba ? invoiceByBA.get(ba.id) : undefined; + return { + organizationId: m.organization.id, + organizationName: m.organization.name, + organizationSlug: m.organization.slug, + organizationStatus: m.organization.status, + fundingSource: ba?.fundingSource ?? null, + currency: ba?.currency ?? "INR", + walletBalancePaise: ba?.walletBalance ?? 0, + outstandingCount: inv?.outstandingCount ?? 0, + outstandingPaise: inv?.outstandingPaise ?? 0, + activeMembers: membersByOrg.get(m.organization.id) ?? 0, + }; + }); + + const summary = { + orgsOwned: ownedMemberships.length, + totalActiveMembers: perOrg.reduce((sum, o) => sum + o.activeMembers, 0), + totalOutstandingPaise: perOrg.reduce( + (sum, o) => sum + o.outstandingPaise, + 0, + ), + totalWalletPaise: perOrg.reduce((sum, o) => sum + o.walletBalancePaise, 0), + }; + + return NextResponse.json({ summary, perOrg }); +} diff --git a/app/api/org-workspace/[orgWorkspaceId]/settings/route.ts b/app/api/org-workspace/[orgWorkspaceId]/settings/route.ts new file mode 100644 index 000000000..79e8fae1f --- /dev/null +++ b/app/api/org-workspace/[orgWorkspaceId]/settings/route.ts @@ -0,0 +1,202 @@ +/** + * GET /api/org-workspace/[orgWorkspaceId]/settings + * PATCH /api/org-workspace/[orgWorkspaceId]/settings + * + * Operator-level preferences for the cross-org workspace dashboard. + * Settings live on `OrgWorkspaceProfile`: + * + * - `defaultLandingOrganizationId` — the org to open when the operator + * hits the workspace shell without a specific orgId. Validated on + * PATCH against the caller's ACTIVE OWNER memberships so we can't + * pin an org the operator doesn't actually own. + * - `notificationRoutingMode` — BELL_AND_EMAIL | BELL_ONLY | EMAIL_ONLY + * | NEITHER. Read by Novu dispatchers when an org-lifecycle event + * fires for an operator who owns multiple orgs. + * - `locale` — BCP 47 string used for cross-org workspace formatting. + * Per-org pages still use the org's own region defaults. + * - `currencyDisplayCode` — ISO 4217 used for the workspace overview + * rollup numbers (per-org pages render in the org's billing + * currency). + * + * Auth: the URL's `orgWorkspaceId` must match the caller's + * `orgWorkspaceProfileId`. Mirrors the IDOR posture in + * `app/api/org-workspace/[orgWorkspaceId]/billing/route.ts`. + * + * GET on a brand-new profile returns the column defaults — the row is + * always present (created during first-org-creation in + * `app/api/organizations/route.ts`). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { NotificationRoutingMode } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; + +const PatchBodySchema = z + .object({ + // `defaultLandingOrganizationId: null` clears the preference. The + // distinction between "key absent" (don't touch) and "key=null" + // (clear) is preserved via Zod's optional+nullable composition. + defaultLandingOrganizationId: z.string().min(1).nullable().optional(), + notificationRoutingMode: z + .nativeEnum(NotificationRoutingMode) + .optional(), + // Light validation only — Intl.NumberFormat will tolerate most BCP-47 + // strings. We reject obvious garbage but don't enumerate every locale. + locale: z + .string() + .min(2) + .max(35) + .regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, { + message: "Locale must be a BCP-47 tag (e.g. en-IN, en-US)", + }) + .nullable() + .optional(), + currencyDisplayCode: z + .string() + .regex(/^[A-Z]{3}$/, { + message: "Currency code must be a 3-letter ISO 4217 string", + }) + .nullable() + .optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgWorkspaceId: string }> }, +) { + const { orgWorkspaceId } = await params; + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + if (auth.session.user.orgWorkspaceProfileId !== orgWorkspaceId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const profile = await prisma.orgWorkspaceProfile.findUnique({ + where: { id: orgWorkspaceId }, + select: { + id: true, + defaultLandingOrganizationId: true, + notificationRoutingMode: true, + locale: true, + currencyDisplayCode: true, + updatedAt: true, + }, + }); + + if (!profile) { + return NextResponse.json( + { error: "OrgWorkspaceProfile not found" }, + { status: 404 }, + ); + } + + // List the caller's ACTIVE OWNER memberships so the UI can render a + // pick-list for `defaultLandingOrganizationId`. Keeping this on the + // settings GET (rather than asking the UI to call /organizations + // separately) keeps the page first-paint cheap. + const ownedOrgs = await prisma.membership.findMany({ + where: { + userId: auth.session.user.id, + role: "OWNER", + status: "ACTIVE", + }, + select: { + organization: { + select: { id: true, name: true, status: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + // Filter DEACTIVATED orgs out of the picklist — the operator + // shouldn't pin a tombstoned org as their landing target. + const candidateOrgs = ownedOrgs + .map((m) => m.organization) + .filter((o) => o.status !== "DEACTIVATED"); + + return NextResponse.json({ profile, candidateOrgs }); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgWorkspaceId: string }> }, +) { + const { orgWorkspaceId } = await params; + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + if (auth.session.user.orgWorkspaceProfileId !== orgWorkspaceId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Verify `defaultLandingOrganizationId` (when provided + non-null) + // resolves to an org the caller actually OWNS and is ACTIVE. Without + // this check an operator could pin an arbitrary org id and the + // redirect target would surface a 404 they don't recognise. + if ( + body.defaultLandingOrganizationId !== undefined && + body.defaultLandingOrganizationId !== null + ) { + const ownership = await prisma.membership.findFirst({ + where: { + userId: auth.session.user.id, + organizationId: body.defaultLandingOrganizationId, + role: "OWNER", + status: "ACTIVE", + }, + select: { id: true }, + }); + if (!ownership) { + return NextResponse.json( + { + error: + "Cannot set default landing org you don't own (or org is inactive).", + code: "DEFAULT_LANDING_ORG_INVALID", + }, + { status: 400 }, + ); + } + } + + const updated = await prisma.orgWorkspaceProfile.update({ + where: { id: orgWorkspaceId }, + data: { + ...(body.defaultLandingOrganizationId !== undefined && { + defaultLandingOrganizationId: body.defaultLandingOrganizationId, + }), + ...(body.notificationRoutingMode !== undefined && { + notificationRoutingMode: body.notificationRoutingMode, + }), + ...(body.locale !== undefined && { locale: body.locale }), + ...(body.currencyDisplayCode !== undefined && { + currencyDisplayCode: body.currencyDisplayCode, + }), + }, + select: { + id: true, + defaultLandingOrganizationId: true, + notificationRoutingMode: true, + locale: true, + currencyDisplayCode: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ profile: updated }); +} diff --git a/app/api/organizations/[orgId]/activity/route.ts b/app/api/organizations/[orgId]/activity/route.ts new file mode 100644 index 000000000..5ccea4ee6 --- /dev/null +++ b/app/api/organizations/[orgId]/activity/route.ts @@ -0,0 +1,99 @@ +/** + * GET /api/organizations/[orgId]/activity + * + * Read-only view over `OrgAuditLog` for a single organization. The audit + * log is the central event feed for every mutation against the org — it + * gets written inside the same transaction as the mutation it records + * (see the 9a-9g routes), so this endpoint is the canonical "what + * happened" surface for admins. + * + * Filters: + * category=MEMBER|CONTRACT|PROGRAM|WALLET|INVOICE|PAYOUT|SETTINGS|CONSENT|SYSTEM + * action= + * actorMembershipId= + * from=ISO-8601 (inclusive lower bound) + * to=ISO-8601 (exclusive upper bound) + * limit=1..200 (default 50) + * cursor= (for reverse-chronological pagination) + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +const CategorySchema = z.enum([ + "MEMBER", + "CONTRACT", + "PROGRAM", + "WALLET", + "INVOICE", + "PAYOUT", + "SETTINGS", + "CONSENT", + "SYSTEM", +]); + +const QuerySchema = z.object({ + category: CategorySchema.optional(), + action: z.string().min(1).max(128).optional(), + actorMembershipId: z.string().uuid().optional(), + targetMembershipId: z.string().uuid().optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + cursor: z.string().optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsedQuery = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsedQuery.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsedQuery.error.flatten() }, + { status: 400 }, + ); + } + const q = parsedQuery.data; + + const rows = await prisma.orgAuditLog.findMany({ + where: { + organizationId: orgId, + ...(q.category && { category: q.category }), + ...(q.action && { action: q.action }), + ...(q.actorMembershipId && { actorMembershipId: q.actorMembershipId }), + ...(q.targetMembershipId && { + targetMembershipId: q.targetMembershipId, + }), + ...(q.from || q.to + ? { + createdAt: { + ...(q.from && { gte: q.from }), + ...(q.to && { lt: q.to }), + }, + } + : {}), + }, + orderBy: { createdAt: "desc" }, + take: q.limit + 1, + ...(q.cursor && { cursor: { id: q.cursor }, skip: 1 }), + }); + + const hasMore = rows.length > q.limit; + const data = hasMore ? rows.slice(0, q.limit) : rows; + const nextCursor = hasMore ? data[data.length - 1]?.id ?? null : null; + + return NextResponse.json({ + data, + pagination: { hasMore, nextCursor, limit: q.limit }, + }); +} diff --git a/app/api/organizations/[orgId]/analytics/route.ts b/app/api/organizations/[orgId]/analytics/route.ts new file mode 100644 index 000000000..551604ae2 --- /dev/null +++ b/app/api/organizations/[orgId]/analytics/route.ts @@ -0,0 +1,293 @@ +/** + * GET /api/organizations/[orgId]/analytics + * + * Dashboard aggregate endpoint — the one call the org-home page makes to + * render every stat tile. Returns an object keyed by section so the client + * can lay out cards without extra round-trips: + * + * capabilities { canSponsor, canHost, fundingSource, walletBalance } + * members { total, active, byRole } + * programs { total, active, activeAssignments } + * wallet { balancePaise, recentTopUps, recentDebits } (WALLET only) + * invoices { outstanding, pastDue, paidLast30d } (INVOICE only) + * reimbursements{ last30dCount, last30dPaise } (PERSONAL only — #714) + * earnings { pending, ready, paid, refunded } (canHost only) + * + * All aggregates are `count` / `_sum` queries — no per-row enumeration — + * so the response stays cheap even for orgs with tens of thousands of rows. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ledgerAccountId } from "@/lib/payments/ledger/post"; +import { sumPaise } from "@/lib/payments/utils/money"; +import { resolveActivationSignals } from "@/lib/enterprise/org-activation-signals"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { + status: true, + canSponsor: true, + canHost: true, + billingAccount: { + select: { fundingSource: true, walletBalance: true, currency: true }, + }, + }, + }); + if (!org) { + return NextResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const thirtyDaysAgo = new Date(Date.now() - THIRTY_DAYS_MS); + const baId = await prisma.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + select: { id: true }, + }); + + const [ + memberAggregate, + memberByRole, + programTotal, + activeAssignments, + recentWallet, + outstandingInvoiceAgg, + paidInvoiceAgg, + pastDueInvoiceCount, + earningsAggregate, + licenseSubscription, + reimbursementAgg, + activationSignals, + ] = await Promise.all([ + prisma.membership.groupBy({ + by: ["status"], + where: { organizationId: orgId }, + _count: { _all: true }, + }), + prisma.membership.groupBy({ + by: ["role"], + where: { organizationId: orgId, status: "ACTIVE" }, + _count: { _all: true }, + }), + prisma.program.groupBy({ + by: ["status"], + where: { contract: { organizationId: orgId } }, + _count: { _all: true }, + }), + prisma.programAssignment.count({ + where: { + program: { contract: { organizationId: orgId } }, + periodEnd: { gte: new Date() }, + }, + }), + // #772 B3 — wallet activity derives from the double-entry journal: group the + // org's WALLET-account entries (last 30d) by originating txn kind and sum the + // signed delta (CREDIT = +, DEBIT = −), preserving the {reason,count,deltaPaise} + // shape. ORM read + JS aggregation (no raw SQL): the 30-day window per org is + // bounded, so pulling the entries and folding them in app code is fine. + org.billingAccount?.fundingSource === "WALLET" && + baId && + org.billingAccount.currency + ? (async (): Promise< + Array<{ reason: string; count: number; deltaPaise: number | null }> + > => { + const entries = await prisma.ledgerEntry.findMany({ + where: { + accountId: ledgerAccountId({ + kind: "WALLET", + organizationId: orgId, + currency: org.billingAccount!.currency, + }), + createdAt: { gte: thirtyDaysAgo }, + }, + select: { + direction: true, + amountPaise: true, + transaction: { select: { kind: true } }, + }, + }); + // #780 — extended-client reads surface amountPaise as number. + const byKind = new Map(); + for (const e of entries) { + const kind = e.transaction.kind; + const cur = byKind.get(kind) ?? { count: 0, delta: 0 }; + cur.count += 1; + cur.delta += + e.direction === "CREDIT" ? e.amountPaise : -e.amountPaise; + byKind.set(kind, cur); + } + return Array.from(byKind.entries()).map(([reason, v]) => ({ + reason, + count: v.count, + deltaPaise: v.delta, + })); + })() + : Promise.resolve( + [] as Array<{ + reason: string; + count: number; + deltaPaise: number | null; + }>, + ), + org.billingAccount?.fundingSource === "INVOICE" && baId + ? prisma.organizationInvoice.aggregate({ + where: { + billingAccountId: baId.id, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + _sum: { totalPaise: true }, + _count: { _all: true }, + }) + : Promise.resolve(null), + org.billingAccount?.fundingSource === "INVOICE" && baId + ? prisma.organizationInvoice.aggregate({ + where: { + billingAccountId: baId.id, + status: "PAID", + paidAt: { gte: thirtyDaysAgo }, + }, + _sum: { totalPaise: true }, + _count: { _all: true }, + }) + : Promise.resolve(null), + org.billingAccount?.fundingSource === "INVOICE" && baId + ? prisma.organizationInvoice.count({ + where: { + billingAccountId: baId.id, + status: "OVERDUE", + }, + }) + : Promise.resolve(0), + org.canHost + ? prisma.organizationEarnings.groupBy({ + by: ["status"], + where: { organizationId: orgId }, + _sum: { orgSharePaise: true, refundedAmountPaise: true }, + _count: { _all: true }, + }) + : Promise.resolve([]), + // BillingSubscription is set up by the LICENSE contract create flow + // (see app/api/organizations/[orgId]/contracts/route.ts). For LICENSE + // orgs we surface it so the home Get-Started checklist can mark + // "Configure billing settings" done once a fee has been captured. + org.billingAccount?.fundingSource === "LICENSE" && baId + ? prisma.billingSubscription.findUnique({ + where: { billingAccountId: baId.id }, + select: { id: true, model: true, cycle: true, flatFeePaise: true }, + }) + : Promise.resolve(null), + // PERSONAL-funded orgs: org-tagged SUCCEEDED payments in the last + // 30d (members paid out of pocket). Drives the /home reimbursement + // summary card — full report lives at /reimbursements. (#714) + org.billingAccount?.fundingSource === "PERSONAL" + ? prisma.payment.aggregate({ + where: { + organizationId: orgId, + paymentStatus: "SUCCEEDED", + createdAt: { gte: thirtyDaysAgo }, + }, + _sum: { amount: true }, + _count: { _all: true }, + }) + : Promise.resolve(null), + // #777 §A / #779 §F — the extra signals (contract / KYB / contract-expiring / + // pending-overage / stuck-payout / credit cap-near) the home action-center + // needs but the tiles above don't already carry. + resolveActivationSignals(orgId), + ]); + + const memberTotal = memberAggregate.reduce( + (acc, s) => acc + s._count._all, + 0, + ); + const memberActive = + memberAggregate.find((s) => s.status === "ACTIVE")?._count._all ?? 0; + + const programActive = + programTotal.find((p) => p.status === "ACTIVE")?._count._all ?? 0; + const programTotalCount = programTotal.reduce( + (acc, s) => acc + s._count._all, + 0, + ); + + return NextResponse.json({ + status: org.status, + capabilities: { + canSponsor: org.canSponsor, + canHost: org.canHost, + fundingSource: org.billingAccount?.fundingSource ?? null, + walletBalance: org.billingAccount?.walletBalance ?? null, + currency: org.billingAccount?.currency ?? null, + }, + activation: activationSignals, + members: { + total: memberTotal, + active: memberActive, + byRole: memberByRole.map((r) => ({ + role: r.role, + count: r._count._all, + })), + }, + programs: { + total: programTotalCount, + active: programActive, + activeAssignments, + }, + wallet: + org.billingAccount?.fundingSource === "WALLET" + ? { + balancePaise: org.billingAccount.walletBalance ?? 0, + recent: recentWallet.map((r) => ({ + reason: r.reason, + count: Number(r.count), + deltaPaise: Number(r.deltaPaise ?? 0), + })), + } + : null, + invoices: + org.billingAccount?.fundingSource === "INVOICE" + ? { + outstandingCount: outstandingInvoiceAgg?._count._all ?? 0, + // #780 — _sum bypasses the result extension: bigint until sumPaise'd. + outstandingPaise: sumPaise(outstandingInvoiceAgg?._sum.totalPaise), + pastDueCount: pastDueInvoiceCount, + paidLast30dCount: paidInvoiceAgg?._count._all ?? 0, + paidLast30dPaise: sumPaise(paidInvoiceAgg?._sum.totalPaise), + } + : null, + subscription: licenseSubscription + ? { + model: licenseSubscription.model, + cycle: licenseSubscription.cycle, + flatFeePaise: licenseSubscription.flatFeePaise, + } + : null, + reimbursements: reimbursementAgg + ? { + last30dCount: reimbursementAgg._count._all, + last30dPaise: sumPaise(reimbursementAgg._sum.amount), + } + : null, + earnings: org.canHost + ? earningsAggregate.map((e) => ({ + status: e.status, + count: e._count._all, + orgSharePaise: sumPaise(e._sum.orgSharePaise), + refundedPaise: sumPaise(e._sum.refundedAmountPaise), + })) + : null, + }); +} diff --git a/app/api/organizations/[orgId]/appointments/route.ts b/app/api/organizations/[orgId]/appointments/route.ts new file mode 100644 index 000000000..894fbc103 --- /dev/null +++ b/app/api/organizations/[orgId]/appointments/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/organizations/[orgId]/appointments + * + * Org-scoped appointments list (#674 / B1-hybrid). MANAGER+ at the org. + * Forces `scope = org:`; calls the same shared service that the + * personal `/api/appointments?orgScope=...` route uses, so the response + * shape is identical. + * + * Query params: `appointmentType`, `page`, `perPage`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { listAppointmentsScoped } from "@/lib/api/scope/list-appointments"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + appointmentType: z + .enum(["CONSULTATION", "SUBSCRIPTION", "WEBINAR", "CLASS", "TRIAL"]) + .optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + appointmentType: url.searchParams.get("appointmentType") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listAppointmentsScoped({ + scope: { kind: "org", orgId }, + userId: access.session.user.id, + appointmentType: filters.data.appointmentType, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/organizations/[orgId]/audit/export/route.ts b/app/api/organizations/[orgId]/audit/export/route.ts new file mode 100644 index 000000000..1d419263c --- /dev/null +++ b/app/api/organizations/[orgId]/audit/export/route.ts @@ -0,0 +1,287 @@ +/** + * GET /api/organizations/[orgId]/audit/export + * + * CSV export for the audit-log viewer. Reuses the same filter semantics + * as `GET /api/organizations/[orgId]/audit` but streams the full filtered + * set rather than a paginated page. Required for compliance reviews + * where the reviewer wants the whole trail for a date range. + * + * Self-auditing: the export action itself emits an + * `AUDIT_LOG_EXPORTED` audit row before streaming the body. A + * compliance review asking "who pulled our audit trail in Q2?" + * needs that row to exist. + * + * Streaming is chunked via a ReadableStream so orgs with 10k+ rows + * don't OOM the function. Each chunk = 500 rows. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + sanitizeAuditDescription, + sanitizeAuditDetails, +} from "@/lib/enterprise/audit-sanitize"; + +type AuditExportRow = { + id: string; + category: string; + action: string; + description: string; + details: Prisma.JsonValue | null; + actorMembershipId: string | null; + targetMembershipId: string | null; + createdAt: Date; +}; + +const CategorySchema = z.enum([ + "MEMBER", + "CONTRACT", + "PROGRAM", + "WALLET", + "INVOICE", + "PAYOUT", + "SETTINGS", + "CONSENT", + "CATALOG", + "SYSTEM", +]); + +const QuerySchema = z.object({ + categories: z + .string() + .optional() + .transform((v) => + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], + ) + .pipe(z.array(CategorySchema)), + actions: z + .string() + .optional() + .transform((v) => + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], + ), + actorMembershipId: z.string().optional(), + q: z.string().max(200).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), +}); + +const CSV_CHUNK_SIZE = 500; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const q = parsed.data; + + const where: Prisma.OrgAuditLogWhereInput = { + organizationId: orgId, + ...(q.categories.length > 0 ? { category: { in: q.categories } } : {}), + ...(q.actions.length > 0 ? { action: { in: q.actions } } : {}), + ...(q.actorMembershipId + ? { actorMembershipId: q.actorMembershipId } + : {}), + ...(q.from || q.to + ? { + createdAt: { + ...(q.from ? { gte: q.from } : {}), + ...(q.to ? { lte: q.to } : {}), + }, + } + : {}), + ...(q.q + ? { description: { contains: q.q, mode: "insensitive" as const } } + : {}), + }; + + // Emit the export-action audit row up-front (before the stream starts) + // so the record lands even if the download is cancelled mid-flight. + const totalCount = await prisma.orgAuditLog.count({ where }); + await prisma.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.AUDIT_LOG_EXPORTED, + description: `Audit log exported (${totalCount} rows)`, + details: { + filters: { + categories: q.categories, + actions: q.actions, + actorMembershipId: q.actorMembershipId ?? null, + q: q.q ?? null, + from: q.from?.toISOString() ?? null, + to: q.to?.toISOString() ?? null, + }, + rowCount: totalCount, + }, + }, + }); + + // Stream rows to the client. Each chunk fetches CSV_CHUNK_SIZE rows + // via cursor pagination so memory stays bounded regardless of total + // size. Using a ReadableStream lets the browser start downloading + // before we've assembled the full body. + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + try { + // Header row + controller.enqueue( + encoder.encode( + "createdAt,category,action,actor_email,actor_role,target_email,description,details_json\n", + ), + ); + + let cursor: { createdAt: Date; id: string } | null = null; + // Upper bound on iterations prevents a runaway loop if the DB + // ever returns inconsistent cursor progress. 400 * 500 = 200k + // rows — well above any realistic audit-log query in this + // product's lifetime. + const MAX_ITERATIONS = 400; + + for (let i = 0; i < MAX_ITERATIONS; i++) { + const rows: AuditExportRow[] = await prisma.orgAuditLog.findMany({ + where: cursor + ? { + ...where, + OR: [ + { createdAt: { lt: cursor.createdAt } }, + { + createdAt: cursor.createdAt, + id: { lt: cursor.id }, + }, + ], + } + : where, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: CSV_CHUNK_SIZE, + select: { + id: true, + category: true, + action: true, + description: true, + details: true, + actorMembershipId: true, + targetMembershipId: true, + createdAt: true, + }, + }); + if (rows.length === 0) break; + + // Resolve membership → email/role in-batch + const mids: string[] = Array.from( + new Set( + [ + ...rows + .map((r: AuditExportRow) => r.actorMembershipId) + .filter((v: string | null): v is string => !!v), + ...rows + .map((r: AuditExportRow) => r.targetMembershipId) + .filter((v: string | null): v is string => !!v), + ], + ), + ); + const members = mids.length + ? await prisma.membership.findMany({ + where: { id: { in: mids } }, + select: { + id: true, + role: true, + user: { select: { email: true } }, + }, + }) + : []; + const mmap = new Map( + members.map((m) => [m.id, { role: m.role, email: m.user.email }]), + ); + + for (const row of rows) { + const actor = row.actorMembershipId + ? mmap.get(row.actorMembershipId) + : null; + const target = row.targetMembershipId + ? mmap.get(row.targetMembershipId) + : null; + const line = [ + row.createdAt.toISOString(), + row.category, + row.action, + csvEscape(actor?.email ?? ""), + actor?.role ?? "", + csvEscape(target?.email ?? ""), + csvEscape(sanitizeAuditDescription(row.description)), + csvEscape(JSON.stringify(sanitizeAuditDetails(row.details) ?? {})), + ].join(","); + controller.enqueue(encoder.encode(line + "\n")); + } + + const last: AuditExportRow = rows[rows.length - 1]; + cursor = { createdAt: last.createdAt, id: last.id }; + if (rows.length < CSV_CHUNK_SIZE) break; + } + + controller.close(); + } catch (err) { + console.error( + JSON.stringify({ + event: "audit_export_stream_failed", + orgId, + reason: err instanceof Error ? err.message : String(err), + }), + ); + controller.error(err); + } + }, + }); + + const now = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + return new NextResponse(stream, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="audit-${orgId}-${now}.csv"`, + "Cache-Control": "no-store", + }, + }); +} + +/** + * CSV-escape a field: wrap in double quotes if it contains comma / + * quote / newline; double-up any embedded quotes. + */ +function csvEscape(v: string): string { + if (v === "" || v === null || v === undefined) return ""; + const needsQuoting = /[",\n\r]/.test(v); + if (!needsQuoting) return v; + return `"${v.replace(/"/g, '""')}"`; +} diff --git a/app/api/organizations/[orgId]/audit/route.ts b/app/api/organizations/[orgId]/audit/route.ts new file mode 100644 index 000000000..b80c9f831 --- /dev/null +++ b/app/api/organizations/[orgId]/audit/route.ts @@ -0,0 +1,231 @@ +/** + * GET /api/organizations/[orgId]/audit + * + * Cursor-paginated audit log browser for MAINTAINER+ roles. Supports + * category / action / actor / freetext / date-range filters. Used by + * the audit viewer page at `/dashboard/organization/[orgId]/audit`. + * + * Pagination is cursor-based on `(createdAt DESC, id DESC)` — the + * existing `@@index([organizationId, createdAt])` and + * `@@index([organizationId, category, createdAt])` cover the hot + * filters without a new index. + * + * Freetext search (`q=` param) hits `description` via ILIKE. The + * `details` JSON isn't indexed and would require a GIN index to + * search efficiently — out of scope; `q` is description-only. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { isAtLeastRole } from "@/lib/auth/role-ranks"; +import { + sanitizeAuditDescription, + sanitizeAuditDetails, +} from "@/lib/enterprise/audit-sanitize"; + +const CategorySchema = z.enum([ + "MEMBER", + "CONTRACT", + "PROGRAM", + "WALLET", + "INVOICE", + "PAYOUT", + "SETTINGS", + "CONSENT", + "CATALOG", + "SYSTEM", +]); + +const QuerySchema = z.object({ + // Multi-select filters are supplied as comma-separated lists so the + // URL stays readable + bookmarkable by support. Empty = no filter. + categories: z + .string() + .optional() + .transform((v) => + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], + ) + .pipe(z.array(CategorySchema)), + actions: z + .string() + .optional() + .transform((v) => + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], + ), + actorMembershipId: z.string().optional(), + q: z.string().max(200).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + // Cursor = base64 of `|` from the last row returned. + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // #777 — SUPPORT (rank 30) gets read-only audit access for L1/L2 ticket + // investigation, alongside MAINTAINER+. Mirrors the sidebar + page gate. + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + if ( + access.member.role !== "SUPPORT" && + !isAtLeastRole(access.member.role, "MAINTAINER") + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const q = parsed.data; + + // Decode cursor (iso|id). Invalid cursors are silently ignored — + // caller gets page 1 instead of a 400, which is friendlier for + // shared/bookmarked URLs where the cursor may be stale. + let cursorAt: Date | null = null; + let cursorId: string | null = null; + if (q.cursor) { + try { + const decoded = Buffer.from(q.cursor, "base64").toString("utf-8"); + const [iso, id] = decoded.split("|"); + if (iso && id) { + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) { + cursorAt = d; + cursorId = id; + } + } + } catch { + // ignore — fall through to no-cursor + } + } + + const where: Prisma.OrgAuditLogWhereInput = { + organizationId: orgId, + ...(q.categories.length > 0 ? { category: { in: q.categories } } : {}), + ...(q.actions.length > 0 ? { action: { in: q.actions } } : {}), + ...(q.actorMembershipId + ? { actorMembershipId: q.actorMembershipId } + : {}), + ...(q.from || q.to + ? { + createdAt: { + ...(q.from ? { gte: q.from } : {}), + ...(q.to ? { lte: q.to } : {}), + }, + } + : {}), + ...(q.q + ? { description: { contains: q.q, mode: "insensitive" as const } } + : {}), + ...(cursorAt && cursorId + ? { + OR: [ + { createdAt: { lt: cursorAt } }, + { + createdAt: cursorAt, + id: { lt: cursorId }, + }, + ], + } + : {}), + }; + + const rows = await prisma.orgAuditLog.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: q.limit + 1, // peek one extra to detect if more exist + select: { + id: true, + category: true, + action: true, + description: true, + details: true, + actorMembershipId: true, + targetMembershipId: true, + createdAt: true, + }, + }); + + const hasMore = rows.length > q.limit; + const pageRows = hasMore ? rows.slice(0, q.limit) : rows; + + const nextCursor = + hasMore && pageRows.length > 0 + ? Buffer.from( + `${pageRows[pageRows.length - 1].createdAt.toISOString()}|${pageRows[pageRows.length - 1].id}`, + "utf-8", + ).toString("base64") + : null; + + // Resolve actor + target membership labels in a single query so the + // viewer can render human-readable names without N+1. + const membershipIds = Array.from( + new Set([ + ...pageRows.map((r) => r.actorMembershipId).filter((v): v is string => !!v), + ...pageRows.map((r) => r.targetMembershipId).filter((v): v is string => !!v), + ]), + ); + + const members = membershipIds.length + ? await prisma.membership.findMany({ + where: { id: { in: membershipIds } }, + select: { + id: true, + role: true, + user: { select: { name: true, email: true } }, + }, + }) + : []; + + const memberMap = new Map( + members.map((m) => [m.id, { role: m.role, user: m.user }]), + ); + + // Read-side scrub — strip engineering-noise patterns from the + // description column AND drop sensitive keys from `details` before + // the row ships to org-visible surfaces. This catches legacy rows + // written before the call-site discipline landed, plus any future + // regression. The raw payload remains in the DB for compliance; + // only the projection is sanitized. See lib/enterprise/audit-sanitize.ts. + return NextResponse.json({ + rows: pageRows.map((r) => ({ + id: r.id, + category: r.category, + action: r.action, + description: sanitizeAuditDescription(r.description), + details: sanitizeAuditDetails(r.details), + createdAt: r.createdAt.toISOString(), + actor: r.actorMembershipId + ? memberMap.get(r.actorMembershipId) ?? null + : null, + target: r.targetMembershipId + ? memberMap.get(r.targetMembershipId) ?? null + : null, + })), + nextCursor, + }); +} diff --git a/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pay/route.ts b/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pay/route.ts new file mode 100644 index 000000000..135a6979c --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pay/route.ts @@ -0,0 +1,181 @@ +/** + * POST /api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pay + * + * Mints a real Razorpay order for an issued invoice and returns the + * order id + publishable key + amount so the dashboard can open + * Razorpay's hosted checkout. The order's `notes.type = + * "invoice_payment"` routes the webhook handler at + * /api/webhooks/razorpay (see app/api/webhooks/utils.ts#handleOrgPaymentSuccess) + * to transition the invoice ISSUED → PAID atomically. + * + * This endpoint never settles the invoice itself — doing so client- + * side would leave a race where the client thinks it's paid before + * Razorpay confirms. The webhook is the only path that mutates + * invoice.status to PAID. + * + * If RAZORPAY_KEY_ID/SECRET are missing (preview / CI / dev), the + * route returns 503 so the client surfaces a "payment gateway not + * configured" error instead of opening a popup that can never + * resolve. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: paying an invoice mints a Razorpay order — finance-team action that +// BILLING_ADMIN should be able to perform without escalating to OWNER. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { createRazorpayOrder } from "@/lib/payments/core/razorpay"; +import { PaymentError } from "@/lib/payments/core/types"; + +export async function POST( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; invoiceId: string }>; + }, +) { + const { orgId, invoiceId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { + canSponsor: true, + requireActive: true, + }); + if (access.error) return access.error; + + const invoice = await prisma.organizationInvoice.findFirst({ + where: { id: invoiceId, organizationId: orgId }, + select: { + id: true, + status: true, + invoiceNumber: true, + totalPaise: true, + displayCurrency: true, + paidAt: true, + providerPaymentOrderId: true, + }, + }); + if (!invoice) { + return NextResponse.json({ error: "Invoice not found" }, { status: 404 }); + } + if (invoice.status === "PAID") { + return NextResponse.json( + { error: "Invoice already paid", paidAt: invoice.paidAt }, + { status: 409 }, + ); + } + if (!["ISSUED", "OVERDUE"].includes(invoice.status)) { + return NextResponse.json( + { + error: `Cannot pay an invoice in ${invoice.status} state`, + invoiceStatus: invoice.status, + }, + { status: 409 }, + ); + } + + let razorpayOrderId: string; + // Idempotent Pay: if we already minted a Razorpay order for this + // invoice (persisted on OrganizationInvoice.providerPaymentOrderId), + // reuse it instead of creating another. Without this, every retry of + // the client popup leaks a fresh order into the gateway. + if (invoice.providerPaymentOrderId) { + razorpayOrderId = invoice.providerPaymentOrderId; + console.log( + `[invoice/pay] reusing existing Razorpay order ${razorpayOrderId} for invoice ${invoiceId}`, + ); + } else { + try { + const order = await createRazorpayOrder({ + amount: invoice.totalPaise, + currency: invoice.displayCurrency, + paymentGateway: "RAZORPAY", + // PaymentIntentParams.metadata insists on appointmentId/Type for + // booking flows; invoice payments don't have an appointment so + // we pass empty strings. The webhook routes purely off + // `notes.type === "invoice_payment"` + `notes.invoiceId`. + metadata: { + appointmentId: "", + appointmentType: "", + type: "invoice_payment", + invoiceId, + organizationId: orgId, + invoiceNumber: invoice.invoiceNumber, + }, + }); + razorpayOrderId = order.id; + } catch (err) { + if (err instanceof PaymentError && err.code === "RAZORPAY_NOT_INITIALIZED") { + return NextResponse.json( + { + error: + "Payment gateway not configured. Set RAZORPAY_KEY_ID and RAZORPAY_SECRET to enable invoice payments.", + errorType: err.code, + }, + { status: 503 }, + ); + } + console.error("[invoice/pay] createRazorpayOrder failed:", err); + return NextResponse.json( + { + error: + err instanceof Error + ? err.message + : "Failed to initiate Razorpay order", + errorType: + err instanceof PaymentError ? err.code : "RAZORPAY_ORDER_FAILED", + }, + { status: 502 }, + ); + } + + // Persist the order id + audit log atomically so the next retry + // sees the order id (and audit writes tie to the gateway side-effect). + try { + await prisma.$transaction(async (tx) => { + await tx.organizationInvoice.update({ + where: { id: invoiceId }, + data: { providerPaymentOrderId: razorpayOrderId }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_PAYMENT_INITIATED, + description: `Payment initiated for invoice ${invoice.invoiceNumber}`, + details: { + invoiceId, + razorpayOrderId, + totalPaise: invoice.totalPaise, + }, + }, + }); + }); + } catch (err) { + // Gateway order already live — log but return it so the client + // can proceed. The webhook still honours the order on capture. + console.error( + "[invoice/pay] failed to persist providerPaymentOrderId/audit log:", + err, + ); + } + } + + return NextResponse.json({ + // `razorpayOrderId` (`order_<…>`) drives Razorpay checkout + // (`new Razorpay({ order_id })`); Razorpay echoes the order's + // `notes` back on capture so the webhook can route the + // confirmation without the client forwarding anything itself. + razorpayOrderId, + keyId: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID, + amountPaise: invoice.totalPaise, + currency: invoice.displayCurrency, + invoice: { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + }, + }); +} diff --git a/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf/route.ts b/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf/route.ts new file mode 100644 index 000000000..e11708129 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf/route.ts @@ -0,0 +1,184 @@ +/** + * GET /api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf + * + * Lazy invoice-PDF endpoint. On the first request, renders the invoice + * via `@react-pdf/renderer`, uploads to Supabase Storage, caches the + * path + timestamp on `OrganizationInvoice.pdfStoragePath` / + * `pdfGeneratedAt`, and redirects the caller to a 24h signed URL. + * Subsequent requests reuse the cache. + * + * Cache invalidation: when the invoice transitions to REFUNDED / VOID / + * CANCELLED, the status-change route clears both cache columns so the + * next download regenerates against the new state. We also treat a + * cached PDF older than the signed-URL TTL (24h) as stale and force + * regeneration so the signed URL handed back is always fresh. + * + * Auth: MAINTAINER+ per the billing-account pattern used elsewhere in + * the org routes — any member with billing visibility can download. + * OWNER-only isn't required because the PDF doesn't expose new data — + * the contents are the same invoice row already readable via the + * existing /invoices list endpoint. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { + renderOrgInvoicePdf, + type OrgInvoicePdfData, + type OrgInvoiceLineItem, +} from "@/lib/pdf/invoice-renderer"; +import { + pdfStoragePathFor, + uploadInvoicePdf, + createInvoicePdfSignedUrl, +} from "@/lib/pdf/storage"; + +const PDF_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h — matches signed-URL TTL + +// Platform-side supplier info for the invoice PDF. Kept here (not in +// .env) so it's diffable in the repo; swap for an env-driven lookup +// when we need per-region suppliers (RTN INDIA vs platform entity in +// a future GCC expansion). +const SUPPLIER = { + name: "Familiarise Technologies Private Limited", + gstin: process.env.PLATFORM_GSTIN ?? "29AAFCF1234Q1ZN", + address: + "Koramangala 1st Block, Bangalore, Karnataka 560034, India", + email: "billing@familiarise.com", +} as const; + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; invoiceId: string }>; + }, +) { + const { orgId, invoiceId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const invoice = await prisma.organizationInvoice.findFirst({ + where: { id: invoiceId, organizationId: orgId }, + include: { + organization: { + select: { + name: true, + billingEmail: true, + taxInfo: { select: { gstin: true } }, + }, + }, + lineItems: { orderBy: { position: "asc" } }, + }, + }); + if (!invoice) { + return NextResponse.json( + { error: "Invoice not found" }, + { status: 404 }, + ); + } + + // DRAFT invoices aren't legally issued — refuse the PDF until ISSUED + // or later. Otherwise a finance team could circulate an unissued PDF + // that we'd later have to reconcile. + if (invoice.status === "DRAFT") { + return NextResponse.json( + { + error: + "Invoice is still in DRAFT. Issue the invoice before generating a PDF.", + code: "INVOICE_NOT_ISSUED", + }, + { status: 409 }, + ); + } + + const now = Date.now(); + const cachedIsFresh = + invoice.pdfStoragePath && + invoice.pdfGeneratedAt && + now - invoice.pdfGeneratedAt.getTime() < PDF_CACHE_TTL_MS; + + try { + if (cachedIsFresh && invoice.pdfStoragePath) { + // Re-sign the URL without re-rendering. `createSignedUrl` is cheap + // (single Supabase API call, no storage write). + const url = await createInvoicePdfSignedUrl(invoice.pdfStoragePath); + return NextResponse.redirect(url, { status: 302 }); + } + + // Render path — assemble data, render, upload, cache, redirect. + const items: OrgInvoiceLineItem[] = invoice.lineItems.map((row) => ({ + description: row.description, + quantity: row.quantity, + unitPrice: row.unitPricePaise, + paymentId: row.paymentId, + hsnCode: row.hsnCode, + })); + const data: OrgInvoicePdfData = { + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + displayCurrency: invoice.displayCurrency, + subtotalPaise: invoice.subtotalPaise, + igstPaise: invoice.igstPaise, + cgstPaise: invoice.cgstPaise, + sgstPaise: invoice.sgstPaise, + totalPaise: invoice.totalPaise, + hsnCode: invoice.hsnCode, + placeOfSupply: invoice.placeOfSupply, + gstin: invoice.gstin, + reverseCharge: invoice.reverseCharge, + issuedAt: invoice.issuedAt, + dueDate: invoice.dueDate, + paidAt: invoice.paidAt, + billingCycleStart: invoice.billingCycleStart, + billingCycleEnd: invoice.billingCycleEnd, + items, + org: { + name: invoice.organization.name, + gstin: invoice.organization.taxInfo?.gstin ?? null, + billingEmail: invoice.organization.billingEmail, + }, + supplier: SUPPLIER, + irn: { + value: invoice.irn, + ackNumber: invoice.ackNumber, + ackDate: invoice.ackDate, + irpStatus: invoice.irpStatus, + }, + }; + + const buffer = await renderOrgInvoicePdf(data); + const storagePath = pdfStoragePathFor(orgId, invoiceId); + await uploadInvoicePdf({ storagePath, buffer }); + + await prisma.organizationInvoice.update({ + where: { id: invoiceId }, + data: { + pdfStoragePath: storagePath, + pdfGeneratedAt: new Date(), + }, + }); + + const url = await createInvoicePdfSignedUrl(storagePath); + return NextResponse.redirect(url, { status: 302 }); + } catch (err) { + console.error( + JSON.stringify({ + event: "invoice_pdf_render_failed", + invoiceId, + orgId, + reason: err instanceof Error ? err.message : String(err), + }), + ); + return NextResponse.json( + { + error: "Failed to generate invoice PDF", + details: err instanceof Error ? err.message : undefined, + }, + { status: 500 }, + ); + } +} + diff --git a/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/route.ts b/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/route.ts new file mode 100644 index 000000000..8ba59156b --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/route.ts @@ -0,0 +1,224 @@ +/** + * GET /api/organizations/[orgId]/billing-account/invoices/[invoiceId] + * PATCH /api/organizations/[orgId]/billing-account/invoices/[invoiceId] + * + * Invoice status transitions are narrow: + * DRAFT → ISSUED (manual issue; also done by issueImmediately on POST) + * DRAFT → CANCELLED (safe to cancel before send) + * ISSUED → PAID (webhook path — see /pay) + * ISSUED → OVERDUE (cron path; not exposed as an API mutation) + * ISSUED → VOID (credit-note equivalent; refund-worthy) + * + * The /pay sub-route handles the webhook → PAID transition. This + * PATCH only covers manual admin actions that don't need payment + * gateway integration. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: invoice PATCH covers status transitions (DRAFT → ISSUED, ISSUED → VOID) +// which are finance-team mutations; allow BILLING_ADMIN alongside OWNER. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { transitionOrgInvoice } from "@/lib/enterprise/transitions"; + +const PatchStatusSchema = z.enum(["ISSUED", "CANCELLED", "VOID"]); + +const PatchBodySchema = z + .object({ + status: PatchStatusSchema.optional(), + dueDate: z.coerce.date().optional(), + pdfUrl: z.string().url().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; invoiceId: string }>; + }, +) { + const { orgId, invoiceId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const invoice = await prisma.organizationInvoice.findFirst({ + where: { id: invoiceId, organizationId: orgId }, + include: { + purchaseOrder: true, + contract: { select: { id: true, status: true } }, + billedPayments: { + select: { id: true, amount: true, currency: true, createdAt: true }, + }, + payment: true, + }, + }); + if (!invoice) { + return NextResponse.json({ error: "Invoice not found" }, { status: 404 }); + } + return NextResponse.json({ invoice }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; invoiceId: string }>; + }, +) { + const { orgId, invoiceId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canSponsor: true }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.organizationInvoice.findFirst({ + where: { id: invoiceId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Invoice not found"), { + httpStatus: 404, + }); + } + + // Route policy — narrower than the global INVOICE_ALLOWED_FROM on + // purpose: PAID/OVERDUE/REFUNDED are webhook- and cron-owned moves, not + // manual ones. This pre-check is the friendly error; the CAS below is + // the race-safe enforcement. + if (body.status) { + const allowed: Record = { + DRAFT: ["ISSUED", "CANCELLED"], + ISSUED: ["VOID"], + OVERDUE: ["VOID"], + PAID: [], + VOID: [], + CANCELLED: [], + }; + const allowedFromCurrent = allowed[current.status] ?? []; + if (!allowedFromCurrent.includes(body.status)) { + throw Object.assign( + new Error( + `Cannot transition invoice from ${current.status} to ${body.status}`, + ), + { httpStatus: 409 }, + ); + } + } + + // Invalidate the cached PDF when the invoice transitions out of a + // sendable state (CANCELLED / VOID) — the next GET …/pdf must + // regenerate so the watermark + status reflect the change. The + // refunded path lives on Payment, not OrganizationInvoice, so we + // don't branch on REFUNDED here. + const invalidatePdfCache = + body.status !== undefined && + (body.status === "CANCELLED" || body.status === "VOID"); + + const restorePoBalance = + body.status && + body.status !== current.status && + (body.status === "VOID" || body.status === "CANCELLED") && + current.purchaseOrderId !== null; + + if (body.status) { + // CAS — a concurrent transition (e.g. the dunning cron flipping + // ISSUED → OVERDUE, or the payment webhook landing PAID) between the + // pre-check read and this write matches zero rows and 409s instead + // of voiding a paid invoice. + await transitionOrgInvoice(tx, { + where: { id: invoiceId, organizationId: orgId }, + to: body.status, + data: { + ...(body.status === "ISSUED" ? { issuedAt: new Date() } : {}), + ...(body.dueDate !== undefined && { dueDate: body.dueDate }), + ...(body.pdfUrl !== undefined && { pdfUrl: body.pdfUrl }), + ...(invalidatePdfCache && { + pdfStoragePath: null, + pdfGeneratedAt: null, + }), + }, + audit: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "INVOICE", + action: + body.status === "ISSUED" + ? AUDIT_ACTIONS.INVOICE.INVOICE_ISSUED + : body.status === "CANCELLED" + ? AUDIT_ACTIONS.INVOICE.INVOICE_CANCELLED + : AUDIT_ACTIONS.INVOICE.INVOICE_VOIDED, + description: `Invoice ${current.invoiceNumber}: ${current.status} → ${body.status}`, + details: { + invoiceId, + from: current.status, + to: body.status, + ...(restorePoBalance && { + purchaseOrderId: current.purchaseOrderId, + restoredPaise: current.totalPaise, + }), + }, + }, + }); + + // PO balance restoration on VOID / CANCELLED. The invoice POST + // route atomically decremented `PurchaseOrder.remainingAmountPaise` + // at issue time; when the invoice is now being voided or cancelled, + // atomically increment the PO balance back so the consumed budget + // is released. Unbounded increment is safe — we can never overshoot + // `totalAmountPaise` because we only restore amounts we previously + // took, and the CAS above guarantees this runs at most once per + // invoice (VOID/CANCELLED are terminal). + if (restorePoBalance && current.purchaseOrderId) { + await tx.purchaseOrder.update({ + where: { id: current.purchaseOrderId }, + data: { + remainingAmountPaise: { increment: current.totalPaise }, + }, + }); + } + } else { + const scalarData = { + ...(body.dueDate !== undefined && { dueDate: body.dueDate }), + ...(body.pdfUrl !== undefined && { pdfUrl: body.pdfUrl }), + }; + if (Object.keys(scalarData).length > 0) { + await tx.organizationInvoice.update({ + where: { id: invoiceId }, + data: scalarData, + }); + } + } + + // updateMany returns no row — re-read in-tx for the response body. + return tx.organizationInvoice.findUniqueOrThrow({ + where: { id: invoiceId }, + }); + }); + + return NextResponse.json({ invoice: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/billing-account/invoices/route.ts b/app/api/organizations/[orgId]/billing-account/invoices/route.ts new file mode 100644 index 000000000..dabbe6d4a --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/invoices/route.ts @@ -0,0 +1,347 @@ +/** + * GET /api/organizations/[orgId]/billing-account/invoices + * POST /api/organizations/[orgId]/billing-account/invoices + * + * POST manually generates an invoice (vs the daily cron at + * jobs/billing/generate-subscription-invoices.ts which auto-generates + * from BillingSubscription.nextInvoiceDate). Useful for one-off line + * items or for re-issuing a voided invoice. + * + * Every invoice carries its GST breakdown + IRN placeholder. The IRN + * stays PENDING until the IRP uploader cron (stubbed) populates it. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: invoice creation is the canonical finance-team mutation; downgrade +// from OWNER-only so BILLING_ADMIN can issue invoices without escalation. +// MAINTAINER is intentionally excluded — see `lib/auth/billing-admin-gate.ts`. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { deriveGstBreakdown } from "@/lib/compliance/gst"; +import { generateOrgInvoiceNumber } from "@/lib/payments/billing/invoice-numbering"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { dispatchWebhookEvent } from "@/lib/enterprise/outbound-webhooks/dispatch"; +import { notifyOrgInvoiceIssued } from "@/lib/novu/org-workflows"; + +const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); + +const InvoiceStatusSchema = z.enum([ + "DRAFT", + "ISSUED", + "PAID", + "OVERDUE", + "VOID", + "CANCELLED", +]); + +const LineItemSchema = z.object({ + description: z.string().min(1).max(500), + quantity: z.coerce.number().int().min(1), + unitPrice: z.coerce.number().int().min(0), + paymentId: z.string().optional(), +}); + +const CreateBodySchema = z.object({ + purchaseOrderId: z.string().min(1).nullable().optional(), + contractId: z.string().min(1).nullable().optional(), + displayCurrency: CurrencySchema.default("INR"), + items: z.array(LineItemSchema).min(1), + // Due date is caller-provided so the /billing page can render NET-60 + // or NET-30 depending on contract terms. Server uses it verbatim. + dueDate: z.coerce.date(), + billingCycleStart: z.coerce.date().nullable().optional(), + billingCycleEnd: z.coerce.date().nullable().optional(), + // Issued-vs-draft is explicit: a DRAFT invoice isn't billed, an + // ISSUED one is. We don't auto-transition on POST because some + // callers want to review before sending. + issueImmediately: z.coerce.boolean().default(false), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const url = new URL(req.url); + const rawStatus = url.searchParams.get("status"); + const status = rawStatus ? InvoiceStatusSchema.safeParse(rawStatus) : null; + const page = Math.max(1, Number(url.searchParams.get("page") ?? 1)); + const perPage = Math.min( + 100, + Math.max(1, Number(url.searchParams.get("perPage") ?? 20)), + ); + + const where = { + organizationId: orgId, + ...(status?.success ? { status: status.data } : {}), + }; + + const [total, invoices] = await prisma.$transaction([ + prisma.organizationInvoice.count({ where }), + prisma.organizationInvoice.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * perPage, + take: perPage, + include: { + purchaseOrder: { select: { id: true, poNumber: true } }, + }, + }), + ]); + + return NextResponse.json({ data: invoices, meta: { total, page, perPage } }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canSponsor: true }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { + id: true, + name: true, + slug: true, + taxInfo: { select: { gstStateCode: true, gstin: true, hsnDefault: true } }, + dataResidencyRegion: true, + billingAccountId: true, + invoiceNumberPrefix: true, + }, + }); + if (!org?.billingAccountId) { + return NextResponse.json( + { error: "Organization does not have a BillingAccount" }, + { status: 404 }, + ); + } + + if (body.purchaseOrderId) { + const po = await prisma.purchaseOrder.findUnique({ + where: { id: body.purchaseOrderId }, + select: { organizationId: true, status: true }, + }); + if (!po || po.organizationId !== orgId) { + return NextResponse.json( + { error: "PurchaseOrder does not belong to this organization" }, + { status: 400 }, + ); + } + if (po.status !== "ACTIVE") { + return NextResponse.json( + { error: `PurchaseOrder is ${po.status}; only ACTIVE POs can be invoiced against` }, + { status: 409 }, + ); + } + } + if (body.contractId) { + const contract = await prisma.contract.findUnique({ + where: { id: body.contractId }, + select: { organizationId: true }, + }); + if (!contract || contract.organizationId !== orgId) { + return NextResponse.json( + { error: "Contract does not belong to this organization" }, + { status: 400 }, + ); + } + } + + const subtotal = body.items.reduce( + (sum, item) => sum + item.quantity * item.unitPrice, + 0, + ); + + // GST breakdown delegates to the compliance stub. In production the + // stub is replaced with the live resolver (place-of-supply + IGST + // vs CGST+SGST split); either way we store the numbers at invoice + // creation so retroactive tax-rule changes don't rewrite history. + const gst = deriveGstBreakdown({ + subtotalPaise: subtotal, + // Env-overridable; see jobs/billing/generate-subscription-invoices.ts + // for the same pattern. Falls back to KA when unset. + supplierStateCode: process.env.SUPPLIER_STATE_CODE ?? "KA", + buyerStateCode: org.taxInfo?.gstStateCode ?? null, + buyerCountry: org.dataResidencyRegion === "IN" ? "IN" : "US", + hsnCode: org.taxInfo?.hsnDefault, + }); + + const issuedAt = body.issueImmediately ? new Date() : new Date(); + + let invoice; + try { + invoice = await prisma.$transaction(async (tx) => { + // Per-org sequential numbering: counter row atomically reserves the + // next seq under (org, fiscal-year) so two concurrent POSTs can't + // collide on the @@unique([organizationId, invoiceNumber]) constraint. + const { invoiceNumber, fiscalYear } = await generateOrgInvoiceNumber( + tx, + { id: org.id, slug: org.slug, invoiceNumberPrefix: org.invoiceNumberPrefix }, + issuedAt, + ); + + // PO balance enforcement (race-safe). When the invoice is linked to + // a PO, atomically decrement `remainingAmountPaise` and fail closed + // (409 `PO_BALANCE_EXCEEDED`) if the PO is no longer ACTIVE or has + // insufficient remaining budget. Mirrors the wallet-debit pattern + // in `lib/api/organizations/wallet.ts`. Restoration on VOID / + // CANCELLED is handled in the PATCH route at + // `[invoiceId]/route.ts`. + if (body.purchaseOrderId) { + const claim = await tx.purchaseOrder.updateMany({ + where: { + id: body.purchaseOrderId, + organizationId: orgId, + status: "ACTIVE", + remainingAmountPaise: { gte: gst.totalPaise }, + }, + data: { remainingAmountPaise: { decrement: gst.totalPaise } }, + }); + if (claim.count !== 1) { + const err = new Error( + "PurchaseOrder balance insufficient or no longer ACTIVE", + ); + Object.assign(err, { + httpStatus: 409, + code: "PO_BALANCE_EXCEEDED", + }); + throw err; + } + } + + const created = await tx.organizationInvoice.create({ + data: { + billingAccountId: org.billingAccountId!, + organizationId: orgId, + purchaseOrderId: body.purchaseOrderId ?? null, + contractId: body.contractId ?? null, + invoiceNumber, + fiscalYear, + status: body.issueImmediately ? "ISSUED" : "DRAFT", + displayCurrency: body.displayCurrency, + inrEquivalentPaise: gst.totalPaise, + subtotalPaise: gst.subtotalPaise, + igstPaise: gst.igstPaise, + cgstPaise: gst.cgstPaise, + sgstPaise: gst.sgstPaise, + totalPaise: gst.totalPaise, + taxRate: gst.igstPaise + gst.cgstPaise + gst.sgstPaise > 0 ? 0.18 : 0, + hsnCode: gst.hsnCode, + placeOfSupply: gst.placeOfSupply, + reverseCharge: gst.reverseCharge, + gstin: org.taxInfo?.gstin ?? null, + irpStatus: "PENDING", + autoGenerated: false, + issuedAt: body.issueImmediately ? new Date() : null, + dueDate: body.dueDate, + billingCycleStart: body.billingCycleStart ?? null, + billingCycleEnd: body.billingCycleEnd ?? null, + // #768 — line items as typed children (createMany inside the + // same transaction so a failed write rolls back atomically). + lineItems: { + create: body.items.map((item, idx) => ({ + position: idx, + description: item.description, + quantity: item.quantity, + unitPricePaise: item.unitPrice, + paymentId: item.paymentId ?? null, + })), + }, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_GENERATED, + description: `${body.issueImmediately ? "Issued" : "Drafted"} invoice ${invoiceNumber}`, + details: { + invoiceId: created.id, + invoiceNumber, + totalPaise: created.totalPaise, + status: created.status, + placeOfSupply: created.placeOfSupply, + }, + }, + }); + + // Outbound webhook only on ISSUED transitions; a DRAFT invoice + // hasn't been "sent" yet — integrators should only see invoices + // they need to act on (booking entries, AP queues). Resending on + // a later DRAFT→ISSUED PATCH happens in the [invoiceId] route. + if (body.issueImmediately) { + await dispatchWebhookEvent({ + prisma: tx, + organizationId: orgId, + eventType: "invoice.issued", + payload: { + invoiceId: created.id, + invoiceNumber: created.invoiceNumber, + totalPaise: created.totalPaise, + displayCurrency: created.displayCurrency, + dueDate: created.dueDate, + purchaseOrderId: created.purchaseOrderId, + contractId: created.contractId, + }, + }); + } + + return created; + }); + } catch (err) { + const httpStatus = + err && typeof err === "object" && "httpStatus" in err + ? (err as { httpStatus: number }).httpStatus + : null; + const code = + err && typeof err === "object" && "code" in err + ? (err as { code: string }).code + : null; + if (httpStatus && code) { + return NextResponse.json( + { error: (err as Error).message, code }, + { status: httpStatus }, + ); + } + throw err; + } + + // Side-effect: if the invoice was issued on creation, fire the Novu + // bell workflow so OWNERs see it immediately (email delivery is via + // the `billingEmail` channel configured on the workflow in Novu). + if (body.issueImmediately) { + const origin = new URL(req.url).origin; + notifyOrgInvoiceIssued(orgId, { + invoiceNumber: invoice.invoiceNumber, + orgName: org.name, + totalPaise: invoice.totalPaise, + currency: body.displayCurrency, + dueDate: body.dueDate.toISOString(), + dashboardUrl: `${origin}/dashboard/organization/${orgId}/billing`, + }).catch((err) => + console.error("[notifyOrgInvoiceIssued] failed:", err), + ); + } + + return NextResponse.json({ invoice }, { status: 201 }); +} diff --git a/app/api/organizations/[orgId]/billing-account/purchase-orders/[poId]/route.ts b/app/api/organizations/[orgId]/billing-account/purchase-orders/[poId]/route.ts new file mode 100644 index 000000000..87e26bfb8 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/purchase-orders/[poId]/route.ts @@ -0,0 +1,217 @@ +/** + * GET /api/organizations/[orgId]/billing-account/purchase-orders/[poId] + * PATCH /api/organizations/[orgId]/billing-account/purchase-orders/[poId] + * DELETE /api/organizations/[orgId]/billing-account/purchase-orders/[poId] + * + * DELETE is narrow: only POs with no contracts and no invoices can be + * hard-deleted. Otherwise mark CANCELLED via PATCH. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: PATCH and DELETE on a PO are finance-team mutations; allow +// BILLING_ADMIN alongside OWNER while still excluding MAINTAINER. See +// `lib/auth/billing-admin-gate.ts`. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { transitionPurchaseOrder } from "@/lib/enterprise/transitions"; + +const PoStatusSchema = z.enum(["ACTIVE", "CLOSED", "CANCELLED"]); + +const PatchBodySchema = z + .object({ + status: PoStatusSchema.optional(), + poDate: z.coerce.date().optional(), + validUntil: z.coerce.date().nullable().optional(), + totalAmountPaise: z.coerce.number().int().min(0).optional(), + remainingAmountPaise: z.coerce.number().int().min(0).optional(), + uploadedDocUrl: z.string().url().nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; poId: string }>; + }, +) { + const { orgId, poId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const po = await prisma.purchaseOrder.findFirst({ + where: { id: poId, organizationId: orgId }, + include: { + contracts: { + select: { id: true, status: true, effectiveFrom: true, effectiveTo: true }, + }, + invoices: { + select: { id: true, invoiceNumber: true, status: true, totalPaise: true }, + }, + }, + }); + if (!po) { + return NextResponse.json({ error: "PurchaseOrder not found" }, { status: 404 }); + } + return NextResponse.json({ purchaseOrder: po }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; poId: string }>; + }, +) { + const { orgId, poId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canSponsor: true }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.purchaseOrder.findFirst({ + where: { id: poId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("PurchaseOrder not found"), { + httpStatus: 404, + }); + } + // Money/term fields are only meaningful on an ACTIVE PO — editing them + // on a CLOSED/CANCELLED row is the same class of bug as terminal-state + // re-entry, so they share the CAS guard. The doc attachment is plain + // paperwork and stays editable in any state. + const moneyOrTermData = { + ...(body.poDate !== undefined && { poDate: body.poDate }), + ...(body.validUntil !== undefined && { validUntil: body.validUntil }), + ...(body.totalAmountPaise !== undefined && { + totalAmountPaise: body.totalAmountPaise, + }), + ...(body.remainingAmountPaise !== undefined && { + remainingAmountPaise: body.remainingAmountPaise, + }), + }; + const docData = { + ...(body.uploadedDocUrl !== undefined && { + uploadedDocUrl: body.uploadedDocUrl, + }), + }; + + if (body.status !== undefined && body.status !== current.status) { + await transitionPurchaseOrder(tx, { + where: { id: poId, organizationId: orgId }, + to: body.status, + data: { ...moneyOrTermData, ...docData }, + audit: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "INVOICE", // PO lifecycle rides the INVOICE category (see OrgAuditCategory) + action: + body.status === "CLOSED" + ? AUDIT_ACTIONS.INVOICE.PURCHASE_ORDER_CLOSED + : AUDIT_ACTIONS.INVOICE.PURCHASE_ORDER_CANCELLED, + description: `PurchaseOrder ${poId}: ${current.status} → ${body.status}`, + details: { poId, from: current.status, to: body.status }, + }, + }); + } else { + if (Object.keys(moneyOrTermData).length > 0) { + const res = await tx.purchaseOrder.updateMany({ + where: { id: poId, organizationId: orgId, status: "ACTIVE" }, + data: moneyOrTermData, + }); + if (res.count === 0) { + throw Object.assign( + new Error( + "PO amounts and dates are immutable once the PO is closed or cancelled", + ), + { httpStatus: 409, code: "PO_NOT_ACTIVE" }, + ); + } + } + if (Object.keys(docData).length > 0) { + await tx.purchaseOrder.update({ where: { id: poId }, data: docData }); + } + } + + // updateMany returns no row — re-read in-tx for the response body. + return tx.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); + }); + return NextResponse.json({ purchaseOrder: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + const code = + "code" in err && typeof err.code === "string" ? err.code : undefined; + return NextResponse.json( + { error: err.message, ...(code && { code }) }, + { status }, + ); + } + throw err; + } +} + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; poId: string }>; + }, +) { + const { orgId, poId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canSponsor: true }); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.purchaseOrder.findFirst({ + where: { id: poId, organizationId: orgId }, + include: { + _count: { select: { contracts: true, invoices: true } }, + }, + }); + if (!current) { + throw Object.assign(new Error("PurchaseOrder not found"), { + httpStatus: 404, + }); + } + if (current._count.contracts > 0 || current._count.invoices > 0) { + throw Object.assign( + new Error( + "Cannot delete a PO referenced by contracts or invoices. Mark CANCELLED via PATCH instead.", + ), + { httpStatus: 409 }, + ); + } + await tx.purchaseOrder.delete({ where: { id: poId } }); + }); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts b/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts new file mode 100644 index 000000000..5be0ee119 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts @@ -0,0 +1,147 @@ +/** + * GET /api/organizations/[orgId]/billing-account/purchase-orders + * POST /api/organizations/[orgId]/billing-account/purchase-orders + * + * First-class PurchaseOrder surface for India AP 3-way-match workflows + * (Org.requiresPO=true forces every Contract and Invoice to reference a + * live PO). Orgs without `requiresPO=true` can still create POs for + * tracking, but the 3-way match isn't enforced. + * + * PO numbers are caller-provided (`poNumber`) because enterprise + * finance teams issue them from their own AP systems and expect the + * same number to appear on the invoice. We enforce uniqueness per org. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: PO creation is a finance-team mutation that BILLING_ADMIN should +// be able to perform without escalating to OWNER. The disjunction is +// enforced by `requireOrgBillingAdminOrOwner`; MAINTAINER is intentionally +// excluded — see `lib/auth/billing-admin-gate.ts` for the rationale. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); +const PoStatusSchema = z.enum(["ACTIVE", "CLOSED", "CANCELLED"]); + +const CreateBodySchema = z.object({ + poNumber: z.string().min(1).max(64), + poDate: z.coerce.date(), + validUntil: z.coerce.date().nullable().optional(), + totalAmountPaise: z.coerce.number().int().min(0), + currency: CurrencySchema.default("INR"), + uploadedDocUrl: z.string().url().nullable().optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const url = new URL(req.url); + const rawStatus = url.searchParams.get("status"); + const status = rawStatus ? PoStatusSchema.safeParse(rawStatus) : null; + + const purchaseOrders = await prisma.purchaseOrder.findMany({ + where: { + organizationId: orgId, + ...(status?.success ? { status: status.data } : {}), + }, + orderBy: { createdAt: "desc" }, + include: { + _count: { + select: { invoices: true, contracts: true }, + }, + }, + }); + + return NextResponse.json({ data: purchaseOrders }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { + canSponsor: true, + requireActive: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Rely on the `(organizationId, poNumber)` unique index to guarantee no + // duplicates under concurrency. A read-before-write pre-check opens a + // race window (two simultaneous POSTs can both pass the findFirst and + // both reach `create`); the DB catches the second one as P2002 but the + // handler needs to translate that to a 409 rather than letting it + // surface as a 500. + try { + const po = await prisma.$transaction(async (tx) => { + const created = await tx.purchaseOrder.create({ + data: { + organizationId: orgId, + poNumber: body.poNumber, + poDate: body.poDate, + validUntil: body.validUntil ?? null, + totalAmountPaise: body.totalAmountPaise, + // remainingAmountPaise mirrors totalAmountPaise at creation. + // Invoice issuance doesn't auto-decrement today — we leave + // that to a follow-up reconciliation pass so the 3-way match + // can be done strictly or leniently per org policy. + remainingAmountPaise: body.totalAmountPaise, + currency: body.currency, + uploadedDocUrl: body.uploadedDocUrl ?? null, + status: "ACTIVE", + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.PURCHASE_ORDER_CREATED, + description: `PO ${body.poNumber} created (${body.currency} ${( + body.totalAmountPaise / 100 + ).toLocaleString()})`, + details: { + purchaseOrderId: created.id, + poNumber: body.poNumber, + totalAmountPaise: body.totalAmountPaise, + }, + }, + }); + + return created; + }); + + return NextResponse.json({ purchaseOrder: po }, { status: 201 }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return NextResponse.json( + { error: `PO number ${body.poNumber} already exists for this org` }, + { status: 409 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/billing-account/route.ts b/app/api/organizations/[orgId]/billing-account/route.ts new file mode 100644 index 000000000..5c51ee979 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/route.ts @@ -0,0 +1,282 @@ +/** + * GET /api/organizations/[orgId]/billing-account + * PATCH /api/organizations/[orgId]/billing-account + * + * One BillingAccount per sponsoring org. This endpoint manages the + * top-level account record — funding source, billing email, credit + * limit. The wallet ledger lives under /wallet, invoices under + * /invoices, purchase orders under /purchase-orders. + * + * A funding-source change is a serious lifecycle event — it switches + * which downstream flow runs at checkout (WALLET→wallet debit, + * INVOICE→accrual, LICENSE→no charge). Only OWNERs can change it, and + * we only allow the change when there are no outstanding invoices or + * non-zero wallet balance that would be orphaned. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: PATCH on the billing account (funding-source / credit-limit edits) +// is a finance-team action, not an org-admin one. The shared +// `requireOrgBillingAdminOrOwner` helper allows OWNER and BILLING_ADMIN +// only — explicitly NOT MAINTAINER — so the gate matches the role +// description in `lib/labels/org-labels.ts`. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +// PROJECT is reserved in the Prisma enum for v2 project-billing; the +// API layer rejects it so callers can't quietly land a BillingAccount +// shape checkout can't honour. See note in app/api/organizations/route.ts. +const FundingSourceSchema = z.enum([ + "PERSONAL", + "LICENSE", + "WALLET", + "INVOICE", +]); + +const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); + +// #777 §C — wallet minimum-balance + auto-top-up config. NOTIFY-ONLY floor for +// now (cron emails finance below the minimum); the mandate charge lands later. +const PatchBodySchema = z + .object({ + billingEmail: z.string().email().optional(), + currency: CurrencySchema.optional(), + fundingSource: FundingSourceSchema.optional(), + creditLimit: z.coerce.number().int().min(0).nullable().optional(), + minBalancePaise: z.coerce.number().int().min(0).nullable().optional(), + autoTopUpEnabled: z.boolean().optional(), + autoTopUpAmountPaise: z.coerce.number().int().positive().nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +// #777 §C — the three wallet-alert fields are only meaningful for WALLET +// funding; presence of any lets the handler reject non-WALLET accounts. +const WALLET_ALERT_FIELDS = [ + "minBalancePaise", + "autoTopUpEnabled", + "autoTopUpAmountPaise", +] as const; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { + billingAccount: { + include: { + subscription: true, + _count: { + select: { + invoices: true, + contracts: true, + walletTopUps: true, + }, + }, + }, + }, + }, + }); + if (!org?.billingAccount) { + return NextResponse.json( + { error: "Organization does not have a BillingAccount (canSponsor=false)" }, + { status: 404 }, + ); + } + return NextResponse.json({ billingAccount: org.billingAccount }); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canSponsor: true }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const ba = await tx.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + }); + if (!ba) { + throw Object.assign( + new Error( + "Organization does not have a BillingAccount. Enable canSponsor first.", + ), + { httpStatus: 404 }, + ); + } + + // #777 §C — wallet-alert config guards. The effective funding source + // is the incoming one if being changed, else the stored one. These + // fields only apply to WALLET funding, and enabling auto-top-up needs + // both the floor and the charge amount set so the cron has a target. + const walletAlertTouched = WALLET_ALERT_FIELDS.some( + (f) => body[f] !== undefined, + ); + if (walletAlertTouched) { + const effectiveFunding = body.fundingSource ?? ba.fundingSource; + if (effectiveFunding !== "WALLET") { + throw Object.assign( + new Error( + "Balance alerts and auto-top-up only apply to WALLET-funded accounts.", + ), + { httpStatus: 400, code: "WALLET_ONLY" }, + ); + } + const nextEnabled = body.autoTopUpEnabled ?? ba.autoTopUpEnabled; + const nextMin = + body.minBalancePaise !== undefined + ? body.minBalancePaise + : ba.minBalancePaise; + const nextAmount = + body.autoTopUpAmountPaise !== undefined + ? body.autoTopUpAmountPaise + : ba.autoTopUpAmountPaise; + if (nextEnabled && (nextMin == null || nextAmount == null)) { + throw Object.assign( + new Error( + "Enabling auto-top-up requires both a minimum balance and a top-up amount.", + ), + { httpStatus: 400 }, + ); + } + } + + // Funding-source change guards. We only refuse the change when + // moving away from WALLET with a non-zero balance or away from + // INVOICE with outstanding invoices — either would orphan money + // in the old mode. The reverse transitions (INTO WALLET/INVOICE) + // are always fine because we're starting fresh in the new mode. + if ( + body.fundingSource && + body.fundingSource !== ba.fundingSource + ) { + if ( + ba.fundingSource === "WALLET" && + (ba.walletBalance ?? 0) > 0 + ) { + throw Object.assign( + new Error( + "Cannot switch funding source with a non-zero wallet balance. Drain or refund the wallet first.", + ), + { httpStatus: 409 }, + ); + } + if (ba.fundingSource === "INVOICE") { + const outstanding = await tx.organizationInvoice.count({ + where: { + billingAccountId: ba.id, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + }); + if (outstanding > 0) { + throw Object.assign( + new Error( + `Cannot switch funding source with ${outstanding} outstanding invoice(s). Settle or void them first.`, + ), + { httpStatus: 409 }, + ); + } + } + } + + const next = await tx.billingAccount.update({ + where: { id: ba.id }, + data: { + ...(body.billingEmail !== undefined && { + billingEmail: body.billingEmail, + }), + ...(body.currency !== undefined && { currency: body.currency }), + ...(body.fundingSource !== undefined && { + fundingSource: body.fundingSource, + // Initialize walletBalance when moving TO wallet, clear + // when moving AWAY. Keeps the column NULL for non-wallet + // accounts so callers can't accidentally read it. + walletBalance: + body.fundingSource === "WALLET" + ? ba.walletBalance ?? 0 + : null, + }), + ...(body.creditLimit !== undefined && { + creditLimit: body.creditLimit, + }), + ...(body.minBalancePaise !== undefined && { + minBalancePaise: body.minBalancePaise, + }), + ...(body.autoTopUpEnabled !== undefined && { + autoTopUpEnabled: body.autoTopUpEnabled, + }), + ...(body.autoTopUpAmountPaise !== undefined && { + autoTopUpAmountPaise: body.autoTopUpAmountPaise, + }), + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: "BillingAccount updated", + details: { + from: { + fundingSource: ba.fundingSource, + currency: ba.currency, + creditLimit: ba.creditLimit, + minBalancePaise: ba.minBalancePaise, + autoTopUpEnabled: ba.autoTopUpEnabled, + autoTopUpAmountPaise: ba.autoTopUpAmountPaise, + }, + to: { + fundingSource: next.fundingSource, + currency: next.currency, + creditLimit: next.creditLimit, + minBalancePaise: next.minBalancePaise, + autoTopUpEnabled: next.autoTopUpEnabled, + autoTopUpAmountPaise: next.autoTopUpAmountPaise, + }, + }, + }, + }); + + return next; + }); + + return NextResponse.json({ billingAccount: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + const code = + "code" in err && typeof err.code === "string" ? err.code : undefined; + return NextResponse.json( + { error: err.message, ...(code && { code }) }, + { status }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/billing-account/wallet/route.ts b/app/api/organizations/[orgId]/billing-account/wallet/route.ts new file mode 100644 index 000000000..5c1eade71 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/wallet/route.ts @@ -0,0 +1,121 @@ +/** + * GET /api/organizations/[orgId]/billing-account/wallet + * + * Read-only wallet snapshot: current balance + paginated ledger. Only + * returns data when the BillingAccount is in WALLET funding mode — for + * INVOICE or LICENSE orgs the wallet isn't meaningful, so callers get + * a 404 rather than a zero-balance ghost response. + * + * Top-ups go through the sibling /top-ups route. Refunds come from the + * /api/payments/refunds webhook path and end up as WalletEntry rows + * with reason=REFUND. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ledgerAccountId } from "@/lib/payments/ledger/post"; + +const LedgerQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + perPage: z.coerce.number().int().min(1).max(100).default(50), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const ba = await prisma.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + select: { + id: true, + fundingSource: true, + currency: true, + walletBalance: true, + // #777 §C — balance-alert config surfaced so the wallet tab can render + // its config section off the same fetch. + minBalancePaise: true, + autoTopUpEnabled: true, + autoTopUpAmountPaise: true, + }, + }); + if (!ba) { + return NextResponse.json( + { error: "Organization does not have a BillingAccount" }, + { status: 404 }, + ); + } + if (ba.fundingSource !== "WALLET") { + return NextResponse.json( + { + error: "Wallet is only available for WALLET-funded accounts", + currentFundingSource: ba.fundingSource, + }, + { status: 409 }, + ); + } + + const url = new URL(req.url); + const parsed = LedgerQuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid pagination", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { page, perPage } = parsed.data; + + // #772 B3 — wallet history is now the double-entry journal: the org's + // WALLET LedgerAccount holds one entry per cash movement (CREDIT = top-up + // / refund-in, DEBIT = booking spend / top-up refund-out). We surface a + // WalletEntry-compatible shape (signed deltaPaise + reason) so the client + // ledger view is unchanged. + const walletAccountId = ledgerAccountId({ + kind: "WALLET", + organizationId: orgId, + currency: ba.currency, + }); + const [total, entries] = await prisma.$transaction([ + prisma.ledgerEntry.count({ where: { accountId: walletAccountId } }), + prisma.ledgerEntry.findMany({ + where: { accountId: walletAccountId }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * perPage, + take: perPage, + include: { + transaction: { + select: { kind: true, paymentId: true, description: true }, + }, + }, + }), + ]); + const ledger = entries.map((e) => ({ + id: e.id, + deltaPaise: + e.direction === "CREDIT" ? Number(e.amountPaise) : -Number(e.amountPaise), + reason: e.transaction.kind, + paymentId: e.transaction.paymentId, + notes: e.transaction.description, + createdAt: e.createdAt, + })); + + return NextResponse.json({ + billingAccount: { + id: ba.id, + currency: ba.currency, + walletBalance: ba.walletBalance ?? 0, + minBalancePaise: ba.minBalancePaise, + autoTopUpEnabled: ba.autoTopUpEnabled, + autoTopUpAmountPaise: ba.autoTopUpAmountPaise, + }, + ledger, + meta: { total, page, perPage }, + }); +} diff --git a/app/api/organizations/[orgId]/billing-account/wallet/top-ups/[topUpId]/route.ts b/app/api/organizations/[orgId]/billing-account/wallet/top-ups/[topUpId]/route.ts new file mode 100644 index 000000000..746914b97 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/wallet/top-ups/[topUpId]/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/organizations/[orgId]/billing-account/wallet/top-ups/[topUpId] + * + * `topUpId` is the top-up idempotency key (`we_`) returned by + * `POST /top-ups` as `topUpId` and persisted as + * `WalletTopUp.providerOrderId @unique` — NOT the Razorpay order id + * (`order_<…>`). The two ids are minted in the same POST and share + * `notes.walletEntryOrderId` on the gateway side, but only the top-up + * id is safe to expose in URLs (the Razorpay id is gateway state). + * + * Used by the client post-checkout to poll for "did the webhook + * confirm my top-up yet?" without exposing the whole ledger. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; topUpId: string }>; + }, +) { + const { orgId, topUpId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + // `topUpId` is stored as WalletTopUp.providerOrderId (see the file + // header). Scope the lookup by billing-account ownership so a stolen + // id from another tenant can't leak state. + const topUp = await prisma.walletTopUp.findFirst({ + where: { + providerOrderId: topUpId, + billingAccount: { ownerOrgId: orgId }, + }, + }); + if (!topUp) { + return NextResponse.json({ error: "Top-up not found" }, { status: 404 }); + } + + // WalletTopUp.status carries the lifecycle directly: PENDING until the + // webhook confirms, then CONFIRMED; FAILED if the gateway rejected. + const status = + topUp.status === "CONFIRMED" + ? "confirmed" + : topUp.status === "FAILED" + ? "failed" + : "pending"; + return NextResponse.json({ + topUp: { + topUpId: topUp.providerOrderId, + providerPaymentId: topUp.providerPaymentId, + status, + amountPaise: topUp.amountPaise, + createdAt: topUp.createdAt, + }, + }); +} diff --git a/app/api/organizations/[orgId]/billing-account/wallet/top-ups/route.ts b/app/api/organizations/[orgId]/billing-account/wallet/top-ups/route.ts new file mode 100644 index 000000000..d5bc20c61 --- /dev/null +++ b/app/api/organizations/[orgId]/billing-account/wallet/top-ups/route.ts @@ -0,0 +1,320 @@ +/** + * GET /api/organizations/[orgId]/billing-account/wallet/top-ups + * POST /api/organizations/[orgId]/billing-account/wallet/top-ups + * + * POST mints two correlated ids: + * 1. `topUpId` (`we_`) — our wallet-entry idempotency key, + * stored as `WalletEntry.providerOrderId @unique` and used as the + * URL parameter for `GET /top-ups/{topUpId}` polling. + * 2. `razorpayOrderId` (`order_<…>`) — the gateway-side order minted + * by `createRazorpayOrder`. The dashboard opens Razorpay checkout + * with this id; Razorpay echoes the order's `notes` back on + * capture, so the webhook handler at /api/webhooks/razorpay (see + * `handleOrgPaymentSuccess`) reads `notes.type === + * "credit_purchase"` + `notes.walletEntryOrderId` and calls + * `confirmTopUp` to settle the entry into a real balance increase. + * + * Idempotency: WalletEntry.providerOrderId @unique guarantees two + * concurrent POSTs can't both mint an entry for the same client token, + * and webhook redelivery can't double-credit the wallet. + * + * When RAZORPAY_KEY_ID/SECRET are missing (preview / CI / dev without + * gateway), the route returns 503 so the client can surface a + * "payment gateway not configured" message instead of pretending + * money was charged. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { randomUUID } from "node:crypto"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: wallet top-ups mint external Razorpay/Stripe orders — finance-team +// action. BILLING_ADMIN should be able to fund the wallet without +// escalating to OWNER. The existing pre-Arch-4 comment above said +// "with the person who pays the bill", which is now BILLING_ADMIN. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { initiateTopUp } from "@/lib/api/organizations/wallet"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { createRazorpayOrder } from "@/lib/payments/core/razorpay"; +import { PaymentError } from "@/lib/payments/core/types"; + +const TopUpBodySchema = z.object({ + // Minimum top-up of ₹100 (10000 paise) so gateway fees don't dwarf + // the credit. No hard maximum — enterprise orgs routinely top up + // in lakhs; we rely on the admin-role gate to authorize. + amountPaise: z.coerce.number().int().min(10_000), + // Optional idempotency key from the client. If supplied, reuse a + // pending WalletEntry instead of minting a new Razorpay order on + // a double-click. + clientIdempotencyKey: z.string().min(8).max(128).optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canSponsor: true }); + if (access.error) return access.error; + + const ba = await prisma.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + select: { id: true, fundingSource: true }, + }); + if (!ba || ba.fundingSource !== "WALLET") { + return NextResponse.json( + { error: "Wallet top-ups require WALLET funding" }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const page = Math.max(1, Number(url.searchParams.get("page") ?? 1)); + const perPage = Math.min( + 100, + Math.max(1, Number(url.searchParams.get("perPage") ?? 20)), + ); + + const where = { billingAccountId: ba.id }; + const [total, topUps] = await prisma.$transaction([ + prisma.walletTopUp.count({ where }), + prisma.walletTopUp.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * perPage, + take: perPage, + }), + ]); + + return NextResponse.json({ + data: topUps, + meta: { total, page, perPage }, + }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // OWNER-only: top-up moves real money. A MAINTAINER can queue + // invites and edit programs, but spinning up an external Razorpay + // charge should live with the person who pays the bill. + // Also gated on org status=ACTIVE — a pre-verification org cannot + // charge a card, so we reject before minting a Razorpay order. + const access = await requireOrgBillingAdminOrOwner(orgId, { + canSponsor: true, + requireActive: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = TopUpBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { amountPaise, clientIdempotencyKey } = parsed.data; + + const ba = await prisma.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + }); + if (!ba) { + return NextResponse.json( + { error: "Organization does not have a BillingAccount" }, + { status: 404 }, + ); + } + if (ba.fundingSource !== "WALLET") { + return NextResponse.json( + { error: "Top-ups are only allowed on WALLET funding" }, + { status: 409 }, + ); + } + + // Idempotent by client key: reuse an open pending entry instead of + // minting a second Razorpay order on a duplicate POST. The + // WalletEntry row itself is keyed by providerOrderId (@unique), so + // even without the client key a retry of the same physical request + // can't create a duplicate. If we already minted a Razorpay order + // for this entry, persist the gateway order id alongside the entry + // (in notes/`razorpayOrderId`) so the client can resume checkout + // without us minting a fresh order on the gateway side. + if (clientIdempotencyKey) { + const existing = await prisma.walletTopUp.findUnique({ + where: { providerOrderId: clientIdempotencyKey }, + }); + if (existing) { + // We can't retrieve the original Razorpay order id here without + // a dedicated column, so a "resume" path requires a fresh order. + // To stay strictly idempotent, surface the existing pending entry + // and instruct the client to retry without the same key. + return NextResponse.json( + { + topUpId: existing.providerOrderId, + amountPaise, + status: "pending", + reused: true, + error: + "A top-up with this idempotency key already exists. Retry without the key to launch a new gateway order.", + }, + { status: 200 }, + ); + } + } + + const walletEntryOrderId = + clientIdempotencyKey ?? `we_${randomUUID().replace(/-/g, "")}`; + + // Order of operations (fixes "orphaned gateway order" leak): + // (1) Persist the pending WalletEntry placeholder FIRST — if + // something later fails, we know this DB row exists and the + // abandoned-top-ups cleanup cron can reap it. + // (2) Mint the Razorpay order SECOND. If this fails, delete the + // placeholder (no gateway side-effect to compensate). + // (3) Append the `razorpay_order=` to notes so operators can + // trace the placeholder back to the gateway order. + // + // The previous order (create Razorpay order → persist WalletEntry) + // leaked orders into Razorpay whenever the DB write failed, and the + // gateway order would linger until its 24h TTL with no DB trace. + try { + await prisma.$transaction(async (tx) => { + await initiateTopUp(tx, { + billingAccountId: ba.id, + amountPaise, + providerOrderId: walletEntryOrderId, + notes: `Top-up initiated by membership ${access.member.id}; razorpay_order=pending`, + }); + }); + } catch (err) { + console.error( + "[wallet/top-ups] placeholder WalletEntry persistence failed:", + err, + ); + return NextResponse.json( + { + error: + err instanceof Error + ? err.message + : "Failed to record pending top-up", + }, + { status: 500 }, + ); + } + + let razorpayOrderId: string; + try { + const order = await createRazorpayOrder({ + amount: amountPaise, + currency: ba.currency, + paymentGateway: "RAZORPAY", + // PaymentIntentParams.metadata insists on appointmentId/Type for + // booking flows; org-level payments don't have an appointment so + // we pass empty strings. The webhook routes purely off + // `notes.type === "credit_purchase"`, never on appointment fields. + metadata: { + appointmentId: "", + appointmentType: "", + type: "credit_purchase", + walletEntryOrderId, + organizationId: orgId, + billingAccountId: ba.id, + // amountPaise duplicated in notes so the webhook can pass it + // to confirmTopUp without a separate DB lookup. + amountPaise: String(amountPaise), + }, + }); + razorpayOrderId = order.id; + } catch (err) { + // Razorpay refused — reap the placeholder so abandoned-cleanup + // doesn't have to. If this delete fails too, the cron will eventually + // pick it up; the user sees a clean error either way. + await prisma.walletTopUp + .delete({ where: { providerOrderId: walletEntryOrderId } }) + .catch((cleanupErr) => + console.error( + "[wallet/top-ups] failed to reap orphan WalletTopUp:", + cleanupErr, + ), + ); + if (err instanceof PaymentError && err.code === "RAZORPAY_NOT_INITIALIZED") { + return NextResponse.json( + { + error: + "Payment gateway not configured. Set RAZORPAY_KEY_ID and RAZORPAY_SECRET to enable top-ups.", + errorType: err.code, + }, + { status: 503 }, + ); + } + console.error("[wallet/top-ups] createRazorpayOrder failed:", err); + return NextResponse.json( + { + error: + err instanceof Error + ? err.message + : "Failed to initiate Razorpay order", + errorType: + err instanceof PaymentError ? err.code : "RAZORPAY_ORDER_FAILED", + }, + { status: 502 }, + ); + } + + try { + await prisma.$transaction(async (tx) => { + await tx.walletTopUp.update({ + where: { providerOrderId: walletEntryOrderId }, + data: { + notes: `Top-up initiated by membership ${access.member.id}; razorpay_order=${razorpayOrderId}`, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "WALLET", + action: AUDIT_ACTIONS.WALLET.WALLET_TOPUP, + description: `Top-up initiated: ₹${(amountPaise / 100).toLocaleString("en-IN")}`, + details: { + walletEntryOrderId, + razorpayOrderId, + amountPaise, + }, + }, + }); + }); + } catch (err) { + // Notes/audit-log write failed, but the WalletEntry already exists + // and the Razorpay order is live — the top-up will still settle on + // webhook capture. Return 201 and log for operators. + console.error( + "[wallet/top-ups] notes/audit-log write failed (top-up still valid):", + err, + ); + } + + return NextResponse.json( + { + // `topUpId` is our wallet-entry idempotency key (`we_`) — + // the same value used as `WalletEntry.providerOrderId` and as the + // URL parameter for `GET /top-ups/{topUpId}` polling. + topUpId: walletEntryOrderId, + // `razorpayOrderId` (`order_<…>`) drives Razorpay checkout + // (`new Razorpay({ order_id })`); Razorpay echoes the order's + // `notes` back on capture so the webhook can route the + // confirmation without the client forwarding anything itself. + razorpayOrderId, + keyId: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID, + amountPaise, + currency: ba.currency, + status: "pending", + reused: false, + }, + { status: 201 }, + ); +} diff --git a/app/api/organizations/[orgId]/billing/route.ts b/app/api/organizations/[orgId]/billing/route.ts new file mode 100644 index 000000000..75e0e2b84 --- /dev/null +++ b/app/api/organizations/[orgId]/billing/route.ts @@ -0,0 +1,162 @@ +/** + * GET /api/organizations/[orgId]/billing + * + * Aggregated billing snapshot for the unified Billing dashboard. All + * sums are computed via DB-side `aggregate({ _sum })` so the response + * stays O(1) regardless of org volume — no in-memory `.reduce` loops + * over invoice/payment lists. + * + * Shape (consumed by `BillingPageClient.fetchBilling`): + * { + * fundingSource: FundingSource | null, + * monthToDate: { gross: number, paymentCount: number }, + * outstanding: { amount: number, invoiceCount: number }, + * pendingCharges: { amount: number, paymentCount: number } | null, + * paymentTermsDays: number, + * } + * + * `pendingCharges` is non-null only for INVOICE-funded orgs (where + * Payment rows accrue with `billableToOrgInvoiceId = null` until the + * monthly cron rolls them into an OrganizationInvoice). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { sumPaise } from "@/lib/payments/utils/money"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MANAGER", + canSponsor: true, + }); + if (access.error) return access.error; + + const billingAccount = await prisma.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + // #777 §B — creditLimit drives the INVOICE credit-limit visibility line. + select: { id: true, fundingSource: true, creditLimit: true }, + }); + + const startOfMonth = new Date(); + startOfMonth.setUTCDate(1); + startOfMonth.setUTCHours(0, 0, 0, 0); + + const [monthAgg, outstandingAgg, pendingAgg, licenseContract] = + await Promise.all([ + prisma.payment.aggregate({ + where: { + organizationId: orgId, + paymentStatus: "SUCCEEDED", + createdAt: { gte: startOfMonth }, + }, + _sum: { amount: true }, + _count: { _all: true }, + }), + prisma.organizationInvoice.aggregate({ + where: { + organizationId: orgId, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + _sum: { totalPaise: true }, + _count: { _all: true }, + }), + billingAccount?.fundingSource === "INVOICE" + ? prisma.payment.aggregate({ + where: { + organizationId: orgId, + billingAccountId: billingAccount.id, + billableToOrgInvoiceId: null, + paymentStatus: "SUCCEEDED", + }, + _sum: { amount: true }, + _count: { _all: true }, + }) + : Promise.resolve(null), + // Surface the active LICENSE contract + its BillingSubscription for + // the Annual License panel on /billing. T5 (#756 GS-1) wires the + // contract create flow to atomically insert a BillingSubscription; + // this is the read side that displays it. We explicitly filter to + // contracts that HAVE a subscription — an org with multiple ACTIVE + // LICENSE contracts (e.g. older fee-less ones alongside a newer one + // that captured the fee) should show the one with the actual + // commercial value, not whichever has the most recent effectiveFrom. + billingAccount?.fundingSource === "LICENSE" + ? prisma.contract.findFirst({ + where: { + organizationId: orgId, + status: "ACTIVE", + subscription: { isNot: null }, + }, + orderBy: { effectiveFrom: "desc" }, + select: { + id: true, + effectiveFrom: true, + effectiveTo: true, + autoRenew: true, + subscription: { + select: { + model: true, + cycle: true, + flatFeePaise: true, + currentCycleStart: true, + currentCycleEnd: true, + nextInvoiceDate: true, + }, + }, + }, + }) + : Promise.resolve(null), + ]); + + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { paymentTermsDays: true }, + }); + + return NextResponse.json({ + fundingSource: billingAccount?.fundingSource ?? null, + // null = unlimited (#777 §B credit-limit visibility). + creditLimitPaise: billingAccount?.creditLimit ?? null, + monthToDate: { + gross: sumPaise(monthAgg._sum.amount), + paymentCount: monthAgg._count._all, + }, + outstanding: { + amount: sumPaise(outstandingAgg._sum.totalPaise), + invoiceCount: outstandingAgg._count._all, + }, + pendingCharges: pendingAgg + ? { + amount: sumPaise(pendingAgg._sum.amount), + paymentCount: pendingAgg._count._all, + } + : null, + paymentTermsDays: org?.paymentTermsDays ?? 60, + licenseContract: licenseContract + ? { + id: licenseContract.id, + effectiveFrom: licenseContract.effectiveFrom.toISOString(), + effectiveTo: licenseContract.effectiveTo?.toISOString() ?? null, + autoRenew: licenseContract.autoRenew, + subscription: licenseContract.subscription + ? { + model: licenseContract.subscription.model, + cycle: licenseContract.subscription.cycle, + flatFeePaise: licenseContract.subscription.flatFeePaise, + currentCycleStart: + licenseContract.subscription.currentCycleStart.toISOString(), + currentCycleEnd: + licenseContract.subscription.currentCycleEnd.toISOString(), + nextInvoiceDate: + licenseContract.subscription.nextInvoiceDate.toISOString(), + } + : null, + } + : null, + }); +} diff --git a/app/api/organizations/[orgId]/branding/[asset]/route.ts b/app/api/organizations/[orgId]/branding/[asset]/route.ts new file mode 100644 index 000000000..ba51d6069 --- /dev/null +++ b/app/api/organizations/[orgId]/branding/[asset]/route.ts @@ -0,0 +1,231 @@ +/** + * POST /api/organizations/[orgId]/branding/[asset] + * DELETE /api/organizations/[orgId]/branding/[asset] + * + * One file, two assets: `[asset]` is `"logo"` or `"banner"`. POST takes a + * multipart `file`, uploads to the `organization-images` bucket via + * `lib/supabase.ts` helpers, then writes the resulting public URL to + * `Organization.logo` or `Organization.bannerImage` inside a Prisma + * transaction that also emits an `OrgAuditLog` row (category `SETTINGS`, + * action `SETTINGS_CHANGED`). DELETE removes the stored object and nulls + * the column. OWNER-only on both verbs — branding is a settings surface. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + uploadOrganizationLogo, + uploadOrganizationBanner, + deleteOrganizationLogo, + deleteOrganizationBanner, + ALLOWED_ORG_BRANDING_IMAGE_TYPES, + ORG_LOGO_MAX_SIZE, + ORG_BANNER_MAX_SIZE, +} from "@/lib/supabase"; + +const AssetSchema = z.enum(["logo", "banner"]); +type Asset = z.infer; + +const ASSET_COLUMN: Record = { + logo: "logo", + banner: "bannerImage", +}; + +const ASSET_MAX_SIZE: Record = { + logo: ORG_LOGO_MAX_SIZE, + banner: ORG_BANNER_MAX_SIZE, +}; + +function parseAsset(raw: string): Asset | null { + const parsed = AssetSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} + +function badAsset() { + return NextResponse.json( + { error: "Invalid asset — must be 'logo' or 'banner'" }, + { status: 400 }, + ); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ orgId: string; asset: string }> }, +) { + const { orgId, asset: rawAsset } = await params; + const asset = parseAsset(rawAsset); + if (!asset) return badAsset(); + + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return NextResponse.json( + { error: "Expected multipart/form-data body with a 'file' field" }, + { status: 400 }, + ); + } + + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json( + { error: "No file provided" }, + { status: 400 }, + ); + } + + if (!ALLOWED_ORG_BRANDING_IMAGE_TYPES.includes(file.type)) { + return NextResponse.json( + { + error: + "Invalid file type. Please upload a JPEG, PNG, WebP, or SVG image.", + }, + { status: 400 }, + ); + } + + const maxSize = ASSET_MAX_SIZE[asset]; + if (file.size > maxSize) { + const limitMb = Math.round(maxSize / (1024 * 1024)); + return NextResponse.json( + { error: `File size exceeds ${limitMb}MB limit` }, + { status: 400 }, + ); + } + + const uploadResult = + asset === "logo" + ? await uploadOrganizationLogo({ organizationId: orgId, file }) + : await uploadOrganizationBanner({ organizationId: orgId, file }); + + if (!uploadResult.success || !uploadResult.fileUrl) { + return NextResponse.json( + { error: uploadResult.error ?? "Upload failed" }, + { status: 500 }, + ); + } + + const column = ASSET_COLUMN[asset]; + const fileUrl = uploadResult.fileUrl; + const storagePath = uploadResult.storagePath; + + try { + const updated = await prisma.$transaction(async (tx) => { + // #768 — branding lives on OrgBrandingProfile (1:1 sibling). + // Upsert so the first edit creates the row. + const profile = await tx.orgBrandingProfile.upsert({ + where: { organizationId: orgId }, + create: { organizationId: orgId, [column]: fileUrl }, + update: { [column]: fileUrl }, + select: { logo: true, bannerImage: true }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: `Organization ${asset} updated`, + details: { asset, storagePath, fileUrl }, + }, + }); + + return { id: orgId, logo: profile.logo, bannerImage: profile.bannerImage }; + }); + + return NextResponse.json({ organization: updated }); + } catch (err) { + console.error(`Failed to persist organization ${asset}:`, err); + return NextResponse.json( + { error: "Failed to update organization branding" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ orgId: string; asset: string }> }, +) { + const { orgId, asset: rawAsset } = await params; + const asset = parseAsset(rawAsset); + if (!asset) return badAsset(); + + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const column = ASSET_COLUMN[asset]; + + const current = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { + id: true, + brandingProfile: { select: { logo: true, bannerImage: true } }, + }, + }); + if (!current) { + return NextResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + if (!current.brandingProfile?.[column]) { + return NextResponse.json({ + organization: current, + message: `No ${asset} to delete`, + }); + } + + // Storage delete first; the audit row + DB null happen in a single tx. + // If storage deletion fails we still null the column so the UI doesn't + // keep pointing at a URL that may have been partially purged. The + // helpers log their own errors. + const storageDeleted = + asset === "logo" + ? await deleteOrganizationLogo(orgId) + : await deleteOrganizationBanner(orgId); + if (!storageDeleted) { + console.warn( + `Storage delete for organization ${asset} (${orgId}) reported failure — continuing to null the column`, + ); + } + + try { + const updated = await prisma.$transaction(async (tx) => { + const profile = await tx.orgBrandingProfile.update({ + where: { organizationId: orgId }, + data: { [column]: null }, + select: { logo: true, bannerImage: true }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: `Organization ${asset} removed`, + details: { asset, removed: true }, + }, + }); + + return { id: orgId, logo: profile.logo, bannerImage: profile.bannerImage }; + }); + + return NextResponse.json({ organization: updated }); + } catch (err) { + console.error(`Failed to clear organization ${asset}:`, err); + return NextResponse.json( + { error: "Failed to update organization branding" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/checkout/overage-preview/route.ts b/app/api/organizations/[orgId]/checkout/overage-preview/route.ts new file mode 100644 index 000000000..fcee66fe0 --- /dev/null +++ b/app/api/organizations/[orgId]/checkout/overage-preview/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { previewOverageForBooking } from "@/lib/payments/billing/overage-preview"; +import type { CoveredPlanType } from "@prisma/client"; + +/** + * #777 §C — advisory pre-checkout overage preview. Any ACTIVE member can ask + * "will booking this plan via this org breach my cap, and what does it cost?". + * + * The price is read SERVER-SIDE from the plan (no client-supplied amount, no + * paise/rupee ambiguity); it's the plan's list price, so the preview is a safe + * (slight over-) estimate vs the post-discount charge. The authoritative charge + * is still computed at checkout by `recordOverageAtCheckout`. + */ +const QuerySchema = z.object({ + planType: z.enum(["CONSULTATION", "CLASS", "WEBINAR", "SUBSCRIPTION"]), + planId: z.string().min(1), + sessions: z.coerce.number().int().min(1).max(1000).default(1), +}); + +async function planListPricePaise( + planType: CoveredPlanType, + planId: string, +): Promise { + switch (planType) { + case "CONSULTATION": + return ( + ( + await prisma.consultationPlan.findUnique({ + where: { id: planId }, + select: { price: true }, + }) + )?.price ?? null + ); + case "SUBSCRIPTION": + return ( + ( + await prisma.subscriptionPlan.findUnique({ + where: { id: planId }, + select: { price: true }, + }) + )?.price ?? null + ); + case "WEBINAR": + return ( + ( + await prisma.webinarPlan.findUnique({ + where: { id: planId }, + select: { price: true }, + }) + )?.price ?? null + ); + case "CLASS": + return ( + ( + await prisma.classPlan.findUnique({ + where: { id: planId }, + select: { price: true }, + }) + )?.price ?? null + ); + } +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse({ + planType: url.searchParams.get("planType"), + planId: url.searchParams.get("planId"), + sessions: url.searchParams.get("sessions") ?? undefined, + }); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid preview params", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const pricePaise = await planListPricePaise( + parsed.data.planType, + parsed.data.planId, + ); + if (pricePaise == null) { + return NextResponse.json({ error: "Plan not found" }, { status: 404 }); + } + + const result = await previewOverageForBooking({ + organizationId: orgId, + membershipId: access.member.id, + coveredPlanType: parsed.data.planType, + bookingPricePaise: pricePaise, + engagementsConsumed: parsed.data.sessions, + }); + + return NextResponse.json(result); +} diff --git a/app/api/organizations/[orgId]/consent/route.ts b/app/api/organizations/[orgId]/consent/route.ts new file mode 100644 index 000000000..860446699 --- /dev/null +++ b/app/api/organizations/[orgId]/consent/route.ts @@ -0,0 +1,251 @@ +/** + * GET /api/organizations/[orgId]/consent + * POST /api/organizations/[orgId]/consent + * + * DPDP (India) consent-artifact surface, scoped to an org. Consent records + * live on the global `ConsentArtifact` table — the org scope here filters + * to artifacts granted by members of this organization, which gives admins + * a compliance-dashboard view without exposing other orgs' records. + * + * POST writes a tamper-evident consent row via `buildConsentArtifact` + * (lib/compliance/dpdp.ts). The SHA-256 hash is real; the surrounding + * consent-manager + notice-versioning workflow is documented in the dpdp + * stub header. + * + * Retention: `auditRetainedUntil` = grantedAt + 7y per DPDP Rules (Nov + * 2025). A daily cron sweeper (jobs/compliance/consent-retention-sweeper) + * purges expired rows — this endpoint does NOT delete. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { buildConsentArtifact, withdrawConsent } from "@/lib/compliance/dpdp"; + +// Schedule VIII of the Indian Constitution enumerates 22 languages. +// Plus English as the lingua franca for enterprise UIs. Accept ISO 639-1 +// codes here; the language-label mapping lives client-side. +const LanguageSchema = z + .string() + .min(2) + .max(10) + .regex(/^[a-z]{2,3}(-[A-Z]{2})?$/, "ISO 639-1/2 language code required"); + +const CreateBodySchema = z.object({ + userId: z.string().min(1).max(128), + purposeCodes: z.array(z.string().min(1).max(64)).min(1).max(20), + language: LanguageSchema, + consentManager: z.string().min(1).max(120).nullable().optional(), + version: z.coerce.number().int().min(1), +}); + +const QuerySchema = z.object({ + userId: z.string().min(1).max(128).optional(), + active: z.enum(["true", "false"]).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsedQuery = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsedQuery.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsedQuery.error.flatten() }, + { status: 400 }, + ); + } + const q = parsedQuery.data; + + // Scope to this org via the user → memberships relation so Postgres + // does the filter with a JOIN rather than pulling every member userId + // into application memory and blasting them back as an `IN (...)` list. + // When `q.userId` is set we still constrain via the same relational + // filter so non-members return an empty list instead of leaking cross- + // org records. + const consents = await prisma.consentArtifact.findMany({ + where: { + ...(q.userId && { userId: q.userId }), + user: { memberships: { some: { organizationId: orgId } } }, + ...(q.active === "true" && { withdrawnAt: null }), + ...(q.active === "false" && { withdrawnAt: { not: null } }), + }, + orderBy: { grantedAt: "desc" }, + take: q.limit, + }); + + return NextResponse.json({ data: consents }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Cross-org check: caller must be recording consent for an actual + // member of this org. + const member = await prisma.membership.findUnique({ + where: { + userId_organizationId: { userId: body.userId, organizationId: orgId }, + }, + select: { id: true }, + }); + if (!member) { + return NextResponse.json( + { error: "User is not a member of this organization" }, + { status: 404 }, + ); + } + + const draft = buildConsentArtifact({ + userId: body.userId, + dataFiduciary: `org:${orgId}`, + purposeCodes: body.purposeCodes, + language: body.language, + consentManager: body.consentManager ?? null, + version: body.version, + }); + + const [consent] = await prisma.$transaction([ + prisma.consentArtifact.create({ data: draft }), + prisma.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: member.id, + category: "CONSENT", + action: AUDIT_ACTIONS.CONSENT.CONSENT_GRANTED, + // PII hygiene (DPDP §8, §11): do NOT spell out the data + // principal's userId in the description — audit-log descriptions + // are surfaced in the admin UI and exported via CSV/SIEM, which + // widens the blast radius for a PII leak. The targetMembershipId + // column already connects this log back to the exact member, and + // `details` is a structured JSON blob that auditors can pivot on + // without splashing the id in free text. + description: `Consent granted for member ${member.id}`, + details: { + membershipId: member.id, + purposeCodes: body.purposeCodes, + language: body.language, + version: body.version, + hash: draft.hash, + }, + }, + }), + ]); + + return NextResponse.json({ consent }, { status: 201 }); +} + +/** + * DELETE /api/organizations/[orgId]/consent?userId=&purposeCode= + * + * Stamps `withdrawnAt=now()` on the user's active ConsentArtifacts. + * + * - Withdrawal is irreversible in our model: a subsequent "re-grant" + * goes through `POST` and produces a NEW artifact with a fresh hash. + * That keeps the chain-of-custody intact for DPDP auditors. + * + * - `purposeCode` scopes the withdrawal to artifacts whose purpose-code + * list contains that value. Omit it to withdraw ALL active consents + * for the user (full DPDP §12 opt-out). + * + * - Admins (MANAGER+) can trigger withdrawal on behalf of a member — + * this is the org-side of the data principal's right to withdraw. + * Self-service withdrawal from the user's own account settings goes + * through a different route (to be added) that requires the + * authenticated user to match `userId` rather than MANAGER access. + */ +const DeleteQuerySchema = z.object({ + userId: z.string().min(1).max(128), + purposeCode: z.string().min(1).max(64).optional(), +}); + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsed = DeleteQuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { userId, purposeCode } = parsed.data; + + // Cross-org guard: same logic as POST — only members of this org can + // have their consent withdrawn through this endpoint. + const member = await prisma.membership.findUnique({ + where: { + userId_organizationId: { userId, organizationId: orgId }, + }, + select: { id: true }, + }); + if (!member) { + return NextResponse.json( + { error: "User is not a member of this organization" }, + { status: 404 }, + ); + } + + const { withdrawnCount } = await withdrawConsent({ userId, purposeCode }); + + if (withdrawnCount > 0) { + await prisma.orgAuditLog + .create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: member.id, + category: "CONSENT", + action: AUDIT_ACTIONS.CONSENT.CONSENT_WITHDRAWN, + // Same PII-hygiene rule as CONSENT_GRANTED: no raw userId + // in the description; the membership FK is the pivot. + description: purposeCode + ? `Consent withdrawn (purpose=${purposeCode}) for member ${member.id}` + : `All consents withdrawn for member ${member.id}`, + details: { + membershipId: member.id, + purposeCode: purposeCode ?? null, + withdrawnCount, + }, + }, + }) + .catch((err) => + console.error("[consent DELETE] audit write failed", err), + ); + } + + return NextResponse.json({ withdrawnCount }); +} diff --git a/app/api/organizations/[orgId]/contracts/[contractId]/route.ts b/app/api/organizations/[orgId]/contracts/[contractId]/route.ts new file mode 100644 index 000000000..9640df206 --- /dev/null +++ b/app/api/organizations/[orgId]/contracts/[contractId]/route.ts @@ -0,0 +1,368 @@ +/** + * GET /api/organizations/[orgId]/contracts/[contractId] + * PATCH /api/organizations/[orgId]/contracts/[contractId] + * DELETE /api/organizations/[orgId]/contracts/[contractId] + * + * Contracts form the root of the Program/Invoice hierarchy, so the + * DELETE path is narrow: only DRAFT contracts (no programs, no + * invoices) can be hard-deleted. Active contracts must be TERMINATED + * via PATCH — the audit trail would otherwise lose continuity. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { transitionContract } from "@/lib/enterprise/transitions"; +import { getContractLockState } from "@/lib/enterprise/config-lock"; + +// Term fields that lock once the contract leaves DRAFT or starts billing +// (#777 §B). `autoRenew` is a safe forward-looking toggle — editable always. +const TERM_FIELDS = [ + "effectiveFrom", + "effectiveTo", + "paymentTermsDays", +] as const; + +const ContractStatusSchema = z.enum([ + "DRAFT", + "ACTIVE", + "EXPIRED", + "TERMINATED", +]); + +const PatchBodySchema = z + .object({ + status: ContractStatusSchema.optional(), + signedAt: z.coerce.date().nullable().optional(), + effectiveFrom: z.coerce.date().optional(), + effectiveTo: z.coerce.date().nullable().optional(), + paymentTermsDays: z.coerce.number().int().min(1).max(120).optional(), + autoRenew: z.coerce.boolean().optional(), + terms: z.unknown().optional(), + purchaseOrderId: z.string().min(1).nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; contractId: string }>; + }, +) { + const { orgId, contractId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + const contract = await prisma.contract.findFirst({ + where: { id: contractId, organizationId: orgId }, + include: { + billingAccount: true, + purchaseOrder: true, + programs: { + include: { + licensedSeatConfig: true, + creditPoolConfig: true, + _count: { select: { assignments: true } }, + }, + }, + subscription: true, + }, + }); + if (!contract) { + return NextResponse.json({ error: "Contract not found" }, { status: 404 }); + } + // Surface the in-use lock so the detail/edit drawer can disable term + // fields (effective dates, payment terms) without a second round-trip + // (#777 §B). autoRenew stays editable regardless. + const { locked } = await getContractLockState(contractId, contract.status); + return NextResponse.json({ contract: { ...contract, locked } }); +} + +// TODO(#777 server-actions): kept as a Route Handler + useMutation to match the +// rest of the dashboard. New first-party form mutations should prefer a Server +// Action (co-located write + revalidate, progressive enhancement) per the +// agreed direction — migrate this when the dashboard converges on that pattern. +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; contractId: string }>; + }, +) { + const { orgId, contractId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "OWNER", + canSponsor: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.contract.findFirst({ + where: { id: contractId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Contract not found"), { + httpStatus: 404, + }); + } + + // Reject term edits (effective dates, payment terms) once the + // contract is in use — those terms are committed the moment it's + // signed or starts billing (#777 §B). autoRenew/status stay open. + const touchesTerms = TERM_FIELDS.some((f) => body[f] !== undefined); + if (touchesTerms) { + const { locked } = await getContractLockState( + contractId, + current.status, + ); + if (locked) { + throw Object.assign( + new Error( + "Contract terms are locked once it's signed or billing has started. Only auto-renew can be changed.", + ), + { httpStatus: 409, code: "CONTRACT_TERMS_LOCKED" }, + ); + } + } + + if (body.purchaseOrderId) { + const po = await tx.purchaseOrder.findUnique({ + where: { id: body.purchaseOrderId }, + select: { organizationId: true }, + }); + if (!po || po.organizationId !== orgId) { + throw Object.assign( + new Error("PurchaseOrder does not belong to this organization"), + { httpStatus: 400 }, + ); + } + } + + // Anti-orphan guard: terminating an ACTIVE contract that still has + // ProgramAssignments inside their current cycle would leave those + // members entitled to a benefit with no parent contract — checkout + // would then 500 on the assignment lookup. Force the operator to + // cancel the assignments (or wait for the cycle to roll) before + // they can terminate. EXPIRED is fine: the cycle naturally ended. + if ( + body.status === "TERMINATED" && + current.status === "ACTIVE" + ) { + const now = new Date(); + const liveAssignmentCount = await tx.programAssignment.count({ + where: { + program: { contractId }, + periodEnd: { gte: now }, + }, + }); + if (liveAssignmentCount > 0) { + throw Object.assign( + new Error( + `Cannot terminate a contract with ${liveAssignmentCount} active assignment(s) in the current cycle. Cancel the assignments first or wait for the cycle to expire.`, + ), + { httpStatus: 409 }, + ); + } + + // #779 §A — terminating a contract while invoices billed under it are + // still owed (ISSUED/OVERDUE) would sever the money trail. DRAFT + // invoices haven't billed yet, so they don't block. + const outstandingInvoices = await tx.organizationInvoice.count({ + where: { + contractId, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + }); + if (outstandingInvoices > 0) { + throw Object.assign( + new Error("CONTRACT_HAS_OUTSTANDING_INVOICES"), + { + httpStatus: 409, + code: "CONTRACT_HAS_OUTSTANDING_INVOICES", + counts: { outstandingInvoices }, + }, + ); + } + } + + const scalarData = { + ...(body.signedAt !== undefined && { signedAt: body.signedAt }), + ...(body.effectiveFrom !== undefined && { + effectiveFrom: body.effectiveFrom, + }), + ...(body.effectiveTo !== undefined && { + effectiveTo: body.effectiveTo, + }), + ...(body.paymentTermsDays !== undefined && { + paymentTermsDays: body.paymentTermsDays, + }), + ...(body.autoRenew !== undefined && { autoRenew: body.autoRenew }), + ...(body.terms !== undefined && { + terms: JSON.parse(JSON.stringify(body.terms)), + }), + ...(body.purchaseOrderId !== undefined && { + purchaseOrderId: body.purchaseOrderId, + }), + }; + + if (body.status !== undefined && body.status !== current.status) { + // CAS — the allowed-from guard rides the WHERE, so a concurrent + // transition (or a stale tab re-activating a TERMINATED contract) + // matches zero rows and 409s. Status transitions get dedicated audit + // actions so the timeline reads cleanly. + const action = + body.status === "ACTIVE" + ? AUDIT_ACTIONS.CONTRACT.CONTRACT_SIGNED + : body.status === "TERMINATED" + ? AUDIT_ACTIONS.CONTRACT.CONTRACT_TERMINATED + : body.status === "EXPIRED" + ? AUDIT_ACTIONS.CONTRACT.CONTRACT_EXPIRED + : AUDIT_ACTIONS.CONTRACT.CONTRACT_CREATED; + await transitionContract(tx, { + where: { id: contractId, organizationId: orgId }, + to: body.status, + data: scalarData, + audit: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "CONTRACT", + action, + description: `Contract ${contractId}: ${current.status} → ${body.status}`, + details: { + contractId, + from: current.status, + to: body.status, + }, + }, + }); + + // #779 §A — TERMINATED cascade: a terminated contract takes its + // programs (ACTIVE → EXPIRED) and their still-ACTIVE assignments + // (→ CLOSED) down with it, in this same tx, so nothing is left + // drawing against a dead contract. + if (body.status === "TERMINATED") { + await tx.program.updateMany({ + where: { contractId, status: "ACTIVE" }, + data: { status: "EXPIRED" }, + }); + await tx.programAssignment.updateMany({ + where: { program: { contractId }, status: "ACTIVE" }, + data: { status: "CLOSED" }, + }); + } + } else if (Object.keys(scalarData).length > 0) { + await tx.contract.update({ + where: { id: contractId }, + data: scalarData, + }); + } + + // updateMany returns no row — re-read in-tx for the response body. + return tx.contract.findUniqueOrThrow({ where: { id: contractId } }); + }); + + return NextResponse.json({ contract: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + const code = + "code" in err && typeof err.code === "string" ? err.code : undefined; + // #779 §A — forward counts so the UI can render the outstanding-invoice + // wind-down message alongside the existing terms-lock code. + const counts = + "counts" in err && err.counts && typeof err.counts === "object" + ? err.counts + : undefined; + return NextResponse.json( + { error: err.message, ...(code && { code }), ...(counts && { counts }) }, + { status }, + ); + } + throw err; + } +} + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; contractId: string }>; + }, +) { + const { orgId, contractId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "OWNER", + canSponsor: true, + }); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.contract.findFirst({ + where: { id: contractId, organizationId: orgId }, + include: { _count: { select: { programs: true } } }, + }); + if (!current) { + throw Object.assign(new Error("Contract not found"), { + httpStatus: 404, + }); + } + if (current.status !== "DRAFT") { + throw Object.assign( + new Error( + "Only DRAFT contracts can be deleted. Use PATCH status=TERMINATED for active contracts.", + ), + { httpStatus: 409 }, + ); + } + if (current._count.programs > 0) { + throw Object.assign( + new Error("Cannot delete a contract that has programs attached."), + { httpStatus: 409 }, + ); + } + await tx.contract.delete({ where: { id: contractId } }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "CONTRACT", + action: AUDIT_ACTIONS.CONTRACT.CONTRACT_TERMINATED, + description: `DRAFT contract ${contractId} deleted`, + details: { contractId }, + }, + }); + }); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/contracts/[contractId]/supersede/route.ts b/app/api/organizations/[orgId]/contracts/[contractId]/supersede/route.ts new file mode 100644 index 000000000..ca140168d --- /dev/null +++ b/app/api/organizations/[orgId]/contracts/[contractId]/supersede/route.ts @@ -0,0 +1,170 @@ +/** + * POST /api/organizations/[orgId]/contracts/[contractId]/supersede + * + * #779 §A — contracts are immutable once in use (terms lock at signing); the + * only way to change them is to SUPERSEDE: mint a successor with the new terms, + * re-point the programs, and retire the old row with the chain recorded. + * AMENDMENT — mid-term change: successor starts now, old → TERMINATED. + * RENEWAL — term rollover: successor starts at old effectiveTo (same + * duration by default), old → EXPIRED. Mirrors the auto-renew + * cron (jobs/contracts/auto-renew-contracts.ts) for the manual path. + * Invoices keep their old contractId — the money trail stays on the term that + * billed them. The `supersededByContractId @unique` is the double-run backstop. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const BodySchema = z.object({ + reason: z.enum(["AMENDMENT", "RENEWAL"]), + // New terms — anything omitted carries over from the old contract. + effectiveFrom: z.coerce.date().optional(), + effectiveTo: z.coerce.date().nullable().optional(), + paymentTermsDays: z.coerce.number().int().min(1).max(120).optional(), + autoRenew: z.coerce.boolean().optional(), + rateCardId: z.string().min(1).nullable().optional(), +}); + +export async function POST( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; contractId: string }>; + }, +) { + const { orgId, contractId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "OWNER", + canSponsor: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = BodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const result = await prisma.$transaction(async (tx) => { + const old = await tx.contract.findFirst({ + where: { id: contractId, organizationId: orgId }, + }); + if (!old) { + throw Object.assign(new Error("Contract not found"), { + httpStatus: 404, + }); + } + if (old.status !== "ACTIVE") { + throw Object.assign( + new Error("Only an ACTIVE contract can be superseded"), + { httpStatus: 409, code: "CONTRACT_NOT_ACTIVE" }, + ); + } + if (old.supersededByContractId) { + throw Object.assign(new Error("Contract already superseded"), { + httpStatus: 409, + code: "CONTRACT_ALREADY_SUPERSEDED", + }); + } + + const now = new Date(); + // RENEWAL chains off the old term's end; AMENDMENT cuts over now. + const effectiveFrom = + body.effectiveFrom ?? + (body.reason === "RENEWAL" ? (old.effectiveTo ?? now) : now); + // RENEWAL default = same duration as the old term; AMENDMENT keeps the + // old end date (the term length isn't changing, only the terms). + const defaultTo = + body.reason === "RENEWAL" + ? old.effectiveTo + ? new Date( + effectiveFrom.getTime() + + (old.effectiveTo.getTime() - old.effectiveFrom.getTime()), + ) + : null + : old.effectiveTo; + const effectiveTo = + body.effectiveTo !== undefined ? body.effectiveTo : defaultTo; + + const successor = await tx.contract.create({ + data: { + organizationId: old.organizationId, + billingAccountId: old.billingAccountId, + purchaseOrderId: old.purchaseOrderId, + status: "ACTIVE", + // The supersede action is the signing event for the new terms. + signedAt: now, + effectiveFrom, + effectiveTo, + paymentTermsDays: body.paymentTermsDays ?? old.paymentTermsDays, + autoRenew: body.autoRenew ?? old.autoRenew, + rateCardId: + body.rateCardId !== undefined ? body.rateCardId : old.rateCardId, + }, + }); + + // Re-point programs so entitlements continue under the new terms — and + // so the cycle engine (which requires an ACTIVE contract) keeps rolling + // their assignments. Invoices stay on the old contract. + await tx.program.updateMany({ + where: { contractId: old.id }, + data: { contractId: successor.id }, + }); + + await tx.contract.update({ + where: { id: old.id }, + data: { + // AMENDMENT replaces a live term → TERMINATED; RENEWAL closes a + // completed term → EXPIRED. + status: body.reason === "AMENDMENT" ? "TERMINATED" : "EXPIRED", + supersededByContractId: successor.id, + supersededAt: now, + supersessionReason: body.reason, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "CONTRACT", + action: AUDIT_ACTIONS.CONTRACT.CONTRACT_SUPERSEDED, + description: `Contract ${old.id} superseded by ${successor.id} (${body.reason})`, + details: { + contractId: old.id, + successorContractId: successor.id, + reason: body.reason, + effectiveFrom: effectiveFrom.toISOString(), + effectiveTo: effectiveTo?.toISOString() ?? null, + }, + }, + }); + + return successor; + }); + + return NextResponse.json( + { contract: result, supersededContractId: contractId }, + { status: 201 }, + ); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = typeof err.httpStatus === "number" ? err.httpStatus : 500; + const code = "code" in err ? err.code : undefined; + return NextResponse.json( + { error: err.message, ...(code ? { code } : {}) }, + { status }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/contracts/route.ts b/app/api/organizations/[orgId]/contracts/route.ts new file mode 100644 index 000000000..3910ce38f --- /dev/null +++ b/app/api/organizations/[orgId]/contracts/route.ts @@ -0,0 +1,268 @@ +/** + * GET /api/organizations/[orgId]/contracts + * POST /api/organizations/[orgId]/contracts + * + * Contracts are the negotiated commercial relationship between an org + * and Familiarise. Every Program hangs off a Contract; every Invoice + * optionally references one for audit. Only OWNERs can create contracts + * — creating one has budget implications and can't be unlocked by a + * MAINTAINER without an explicit promotion. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const ContractStatusSchema = z.enum([ + "DRAFT", + "ACTIVE", + "EXPIRED", + "TERMINATED", +]); + +const LicenseCycleSchema = z.enum(["MONTHLY", "QUARTERLY", "ANNUAL"]); + +const CreateBodySchema = z + .object({ + billingAccountId: z.string().min(1), + // PurchaseOrder is optional — India enterprise orgs have + // requiresPO=true, but we surface the UX constraint at the org + // level. Server-side we only enforce the FK shape. + purchaseOrderId: z.string().min(1).nullable().optional(), + // `effectiveFrom` defaults to now so a contract created via API + // takes effect immediately unless the caller specifies otherwise. + effectiveFrom: z.coerce.date().default(() => new Date()), + effectiveTo: z.coerce.date().nullable().optional(), + paymentTermsDays: z.coerce.number().int().min(1).max(120).default(60), + autoRenew: z.coerce.boolean().default(false), + terms: z.unknown().optional(), + status: ContractStatusSchema.default("DRAFT"), + // LICENSE-funded contracts can include a flat-fee BillingSubscription + // at create time. Both fields are optional and only meaningful when + // the BillingAccount has fundingSource=LICENSE. When provided, the + // server creates Contract + BillingSubscription atomically in one tx + // so the LICENSE commercial value (annual fee + cycle) is recorded. + licenseFeePaise: z.coerce.number().int().min(1).optional(), + licenseCycle: LicenseCycleSchema.optional(), + }) + .refine( + (v) => v.effectiveTo === null || v.effectiveTo === undefined + ? true + : v.effectiveTo.getTime() > v.effectiveFrom.getTime(), + { + message: "effectiveTo must be strictly after effectiveFrom", + path: ["effectiveTo"], + }, + ); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + const url = new URL(req.url); + const statusRaw = url.searchParams.get("status"); + const status = statusRaw + ? ContractStatusSchema.safeParse(statusRaw) + : null; + + const contracts = await prisma.contract.findMany({ + where: { + organizationId: orgId, + ...(status?.success ? { status: status.data } : {}), + }, + include: { + billingAccount: { + select: { id: true, fundingSource: true, currency: true }, + }, + purchaseOrder: { + select: { id: true, poNumber: true, status: true }, + }, + programs: { + select: { id: true, name: true, type: true, status: true }, + }, + subscription: { + select: { + id: true, + model: true, + cycle: true, + flatFeePaise: true, + }, + }, + _count: { select: { programs: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ data: contracts }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // Contracts are commercial agreements — nothing should bind the + // platform to an org that hasn't cleared verification yet. + const access = await requireOrgAccess(orgId, { + minimumRole: "OWNER", + canSponsor: true, + requireActive: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // BillingAccount ownership check — the caller can only link contracts + // to a BillingAccount owned by the same org they're admin of. A stolen + // BillingAccount id from another tenant is rejected here rather than + // hitting a FK error later. + const billingAccount = await prisma.billingAccount.findUnique({ + where: { id: body.billingAccountId }, + select: { + ownerOrgId: true, + currency: true, + fundingSource: true, + subscription: { select: { id: true } }, + }, + }); + if (!billingAccount || billingAccount.ownerOrgId !== orgId) { + return NextResponse.json( + { error: "BillingAccount does not belong to this organization" }, + { status: 400 }, + ); + } + + // LICENSE subscription gate: license fields are only meaningful when + // funding=LICENSE, and we don't currently support overwriting an + // existing subscription via contract create (renewals are a separate + // flow). Fail loud rather than silently dropping the operator's input. + const wantsLicenseSubscription = + body.licenseFeePaise !== undefined && body.licenseCycle !== undefined; + if (wantsLicenseSubscription) { + if (billingAccount.fundingSource !== "LICENSE") { + return NextResponse.json( + { + error: + "License fee fields are only allowed when the BillingAccount funding source is LICENSE", + }, + { status: 400 }, + ); + } + if (billingAccount.subscription) { + return NextResponse.json( + { + error: + "A BillingSubscription already exists for this BillingAccount. Subscription updates aren't supported via contract creation yet — terminate the existing subscription first.", + }, + { status: 409 }, + ); + } + } + + if (body.purchaseOrderId) { + const po = await prisma.purchaseOrder.findUnique({ + where: { id: body.purchaseOrderId }, + select: { organizationId: true }, + }); + if (!po || po.organizationId !== orgId) { + return NextResponse.json( + { error: "PurchaseOrder does not belong to this organization" }, + { status: 400 }, + ); + } + } + + const contract = await prisma.$transaction(async (tx) => { + const created = await tx.contract.create({ + data: { + organizationId: orgId, + billingAccountId: body.billingAccountId, + purchaseOrderId: body.purchaseOrderId ?? null, + status: body.status, + effectiveFrom: body.effectiveFrom, + effectiveTo: body.effectiveTo ?? null, + paymentTermsDays: body.paymentTermsDays, + autoRenew: body.autoRenew, + }, + }); + + if (wantsLicenseSubscription) { + const cycleEnd = computeCycleEnd(body.effectiveFrom, body.licenseCycle!); + await tx.billingSubscription.create({ + data: { + contractId: created.id, + billingAccountId: body.billingAccountId, + model: "FLAT_FEE", + cycle: body.licenseCycle!, + ratePerSeatPaise: null, + flatFeePaise: body.licenseFeePaise!, + activeSeatCount: 0, + currentCycleStart: body.effectiveFrom, + currentCycleEnd: cycleEnd, + nextInvoiceDate: cycleEnd, + startsAt: body.effectiveFrom, + endsAt: body.effectiveTo ?? null, + }, + }); + } + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "CONTRACT", + action: AUDIT_ACTIONS.CONTRACT.CONTRACT_CREATED, + description: `Contract created, effective from ${body.effectiveFrom.toISOString()}`, + details: { + contractId: created.id, + billingAccountId: body.billingAccountId, + paymentTermsDays: body.paymentTermsDays, + ...(wantsLicenseSubscription + ? { + licenseFeePaise: body.licenseFeePaise, + licenseCycle: body.licenseCycle, + } + : {}), + }, + }, + }); + return created; + }); + + return NextResponse.json({ contract }, { status: 201 }); +} + +/** + * Compute the end of a billing cycle given a start date and cycle type. + * Used to seed BillingSubscription.currentCycleEnd + nextInvoiceDate at + * contract create time. Mirrors the cycle math elsewhere in the codebase + * (jobs/billing/generate-subscription-invoices.ts uses the same +1mo / + * +3mo / +1yr offsets). + */ +function computeCycleEnd( + start: Date, + cycle: "MONTHLY" | "QUARTERLY" | "ANNUAL", +): Date { + const end = new Date(start); + if (cycle === "MONTHLY") end.setMonth(end.getMonth() + 1); + else if (cycle === "QUARTERLY") end.setMonth(end.getMonth() + 3); + else end.setFullYear(end.getFullYear() + 1); + return end; +} diff --git a/app/api/organizations/[orgId]/data-exports/[exportId]/download/route.ts b/app/api/organizations/[orgId]/data-exports/[exportId]/download/route.ts new file mode 100644 index 000000000..b010ece41 --- /dev/null +++ b/app/api/organizations/[orgId]/data-exports/[exportId]/download/route.ts @@ -0,0 +1,69 @@ +/** + * GET /api/organizations/[orgId]/data-exports/[exportId]/download + * + * Returns a Supabase Storage signed URL for the export bundle when + * status=READY and the bundle hasn't expired. The route writes a + * `DATA_EXPORT_DOWNLOADED` audit row before issuing the redirect so + * the audit trail captures "who pulled what bundle when". + * + * Gate: OWNER + BILLING_ADMIN, same as the request route. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; exportId: string }>; + }, +) { + const { orgId, exportId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const job = await prisma.orgDataExportJob.findFirst({ + where: { id: exportId, organizationId: orgId }, + }); + if (!job) { + return NextResponse.json( + { error: "Export job not found" }, + { status: 404 }, + ); + } + if (job.status !== "READY" || !job.fileUrl) { + return NextResponse.json( + { + error: `Export is ${job.status}; download unavailable`, + code: "EXPORT_NOT_READY", + }, + { status: 409 }, + ); + } + if (job.expiresAt && job.expiresAt < new Date()) { + return NextResponse.json( + { + error: "Export bundle has expired; request a fresh one", + code: "EXPORT_EXPIRED", + }, + { status: 410 }, + ); + } + + await prisma.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.DATA_EXPORT_DOWNLOADED, + description: `Downloaded export bundle ${exportId}`, + details: { exportId, fileSizeBytes: job.fileSizeBytes?.toString() ?? null }, + }, + }); + + return NextResponse.json({ url: job.fileUrl, expiresAt: job.expiresAt }); +} diff --git a/app/api/organizations/[orgId]/data-exports/route.ts b/app/api/organizations/[orgId]/data-exports/route.ts new file mode 100644 index 000000000..10e28b3fd --- /dev/null +++ b/app/api/organizations/[orgId]/data-exports/route.ts @@ -0,0 +1,87 @@ +/** + * GET /api/organizations/[orgId]/data-exports — list export jobs (last 30 days). + * POST /api/organizations/[orgId]/data-exports — request a fresh export bundle. + * + * DPDP §11 right-to-access surface. The user can pull a JSON+CSV + * archive of every entity scoped to their org (members, contracts, + * programs, invoices, earnings, payouts, audit log). The worker + * (`scripts/cleanup/process-data-exports.ts`) does the heavy lifting + * asynchronously; this route just files the request and returns the + * row so the dashboard can poll for status. + * + * Gate: OWNER + BILLING_ADMIN. Export bundles include financial PII, + * so the same governance floor as billing-account mutations applies. + * + * Rate limit: 1 per 24h per org (`orgDataExportLimiter`). Building a + * full bundle is O(N × entities); we don't want a single org pulling + * 24 bundles in a day even if their integrators allow it. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { applyRateLimit, orgDataExportLimiter } from "@/lib/rate-limit"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const exports = await prisma.orgDataExportJob.findMany({ + where: { organizationId: orgId, createdAt: { gte: thirtyDaysAgo } }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + id: true, + status: true, + requestedByMembershipId: true, + fileSizeBytes: true, + expiresAt: true, + error: true, + createdAt: true, + startedAt: true, + completedAt: true, + }, + }); + return NextResponse.json({ data: exports }); +} + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const rl = await applyRateLimit(orgDataExportLimiter, `org:${orgId}`); + if (rl) return rl; + + const created = await prisma.$transaction(async (tx) => { + const job = await tx.orgDataExportJob.create({ + data: { + organizationId: orgId, + requestedByMembershipId: access.member.id, + status: "PENDING", + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.DATA_EXPORT_REQUESTED, + description: `Requested org data export`, + details: { exportId: job.id }, + }, + }); + return job; + }); + + return NextResponse.json({ export: created }, { status: 202 }); +} diff --git a/app/api/organizations/[orgId]/disputes/route.ts b/app/api/organizations/[orgId]/disputes/route.ts new file mode 100644 index 000000000..70d02d9a7 --- /dev/null +++ b/app/api/organizations/[orgId]/disputes/route.ts @@ -0,0 +1,76 @@ +/** + * GET /api/organizations/[orgId]/disputes + * + * Per-org dispute/chargeback surface (#776 §C). Lists disputes raised against + * org-funded payments (Payment.organizationId == orgId) so an operator can see + * what's contested and how it was settled. Read-only and finance-gated — the + * chargeback money-path (org-wallet-first clawback) settles server-side in the + * gateway webhook (`handleDisputeUpdated`), not here. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +const DisputeStatusSchema = z.enum([ + "WARNING_NEEDS_RESPONSE", + "WARNING_UNDER_REVIEW", + "WARNING_CLOSED", + "NEEDS_RESPONSE", + "UNDER_REVIEW", + "CHARGE_REFUNDED", + "WON", + "LOST", +]); + +const QuerySchema = z.object({ + status: DisputeStatusSchema.optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // Finance READ surface — MANAGER+ (incl. BILLING_ADMIN), matching the + // payouts/billing read gate and the sidebar's isFinance visibility. + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const q = parsed.data; + + const disputes = await prisma.dispute.findMany({ + where: { + payment: { organizationId: orgId }, + ...(q.status && { status: q.status }), + }, + orderBy: { createdAt: "desc" }, + take: q.limit, + select: { + id: true, + disputeId: true, + amountPaise: true, + currency: true, + reason: true, + status: true, + dueBy: true, + createdAt: true, + updatedAt: true, + payment: { select: { id: true, billingAccountId: true } }, + }, + }); + + return NextResponse.json({ data: disputes }); +} diff --git a/app/api/organizations/[orgId]/documents/route.ts b/app/api/organizations/[orgId]/documents/route.ts new file mode 100644 index 000000000..3ae25734a --- /dev/null +++ b/app/api/organizations/[orgId]/documents/route.ts @@ -0,0 +1,49 @@ +/** + * GET /api/organizations/[orgId]/documents + * + * Org-scoped AppointmentDocument list (#674 / B1-hybrid). MANAGER+ at + * the org. Documents inherit org context via the parent + * `Appointment.organizationId`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { listDocumentsScoped } from "@/lib/api/scope/list-documents"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + reviewStatus: z + .enum(["PENDING", "APPROVED", "REJECTED", "IN_REVIEW", "NEEDS_REVISION"]) + .optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + reviewStatus: url.searchParams.get("reviewStatus") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listDocumentsScoped({ + scope: { kind: "org", orgId }, + userId: access.session.user.id, + reviewStatus: filters.data.reviewStatus, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/organizations/[orgId]/domain-claims/[domain]/route.ts b/app/api/organizations/[orgId]/domain-claims/[domain]/route.ts new file mode 100644 index 000000000..658f2e66e --- /dev/null +++ b/app/api/organizations/[orgId]/domain-claims/[domain]/route.ts @@ -0,0 +1,99 @@ +/** + * DELETE /api/organizations/[orgId]/domain-claims/[domain] + * + * Releases a previously-claimed domain. The URL path uses the domain + * string directly (after URI-decode) rather than the row uuid, so an + * admin can hit `DELETE .../domain-claims/wipro.com` without first having + * to look up the row id. + * + * Safety guard: refuse the release if SSO enforcement would be left in an + * inconsistent state (no domains + no providers on an enforceSSO=true + * org), to prevent locking every user out. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; domain: string }>; + }, +) { + const { orgId, domain: rawDomain } = await params; + const domain = decodeURIComponent(rawDomain).toLowerCase().trim(); + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const claim = await tx.orgDomainClaim.findUnique({ + where: { domain }, + }); + if (!claim || claim.organizationId !== orgId) { + throw Object.assign(new Error("Domain claim not found"), { + httpStatus: 404, + }); + } + + const settings = await tx.organizationSSOSettings.findUnique({ + where: { organizationId: orgId }, + }); + if (settings?.enforceSSO) { + const providerCount = await tx.ssoProvider.count({ + where: { organizationId: orgId }, + }); + const otherDomains = settings.allowedEmailDomains.filter( + (d) => d !== domain, + ); + if (providerCount === 0 && otherDomains.length === 0) { + throw Object.assign( + new Error( + "Cannot release the last claimed domain while enforceSSO=true and no SSO providers configured.", + ), + { httpStatus: 409 }, + ); + } + } + + await tx.orgDomainClaim.delete({ where: { domain } }); + + // If the released domain was also listed in allowedEmailDomains, + // drop it from there too so the two surfaces stay consistent. + if (settings?.allowedEmailDomains.includes(domain)) { + await tx.organizationSSOSettings.update({ + where: { organizationId: orgId }, + data: { + allowedEmailDomains: settings.allowedEmailDomains.filter( + (d) => d !== domain, + ), + }, + }); + } + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.DOMAIN_RELEASED, + description: `Domain '${domain}' released`, + details: { domain }, + }, + }); + }); + + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/domain-claims/[domain]/verify/route.ts b/app/api/organizations/[orgId]/domain-claims/[domain]/verify/route.ts new file mode 100644 index 000000000..237ad8421 --- /dev/null +++ b/app/api/organizations/[orgId]/domain-claims/[domain]/verify/route.ts @@ -0,0 +1,153 @@ +/** + * POST /api/organizations/[orgId]/domain-claims/[domain]/verify + * + * Verifies the OWNER actually controls the claimed domain by looking up + * a TXT record at `_familiarise-verify.` and confirming it + * matches the claim's stored `verificationToken`. On match, flips + * `verifiedAt` from NULL → now() and emits `DOMAIN_VERIFIED` audit. + * + * Why this matters: `OrgDomainClaim.domain` is globally unique. Without + * DNS proof, a malicious OWNER could claim `google.com` purely to + * intercept SSO domain-check routing for stolen cookies. TXT-proof + * binds claims to actual domain control. + * + * Idempotent: re-running after successful verification is a no-op + * (`verifiedAt` is stable; the response still returns the record). + * + * Non-2xx responses: + * - 404: claim not found or not owned by this org + * - 422: TXT record missing or doesn't match the stored token (with + * `code: "DNS_VERIFICATION_FAILED"`) + * - 502: DNS resolution itself failed (transient — retryable) + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { promises as dns } from "node:dns"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function POST( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; domain: string }>; + }, +) { + const { orgId, domain: rawDomain } = await params; + const domain = decodeURIComponent(rawDomain).toLowerCase().trim(); + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const claim = await prisma.orgDomainClaim.findUnique({ + where: { domain }, + select: { + id: true, + organizationId: true, + verificationToken: true, + verifiedAt: true, + }, + }); + if (!claim || claim.organizationId !== orgId) { + return NextResponse.json( + { error: "Domain claim not found" }, + { status: 404 }, + ); + } + + // Already verified — idempotent return. Don't re-hit DNS. + if (claim.verifiedAt) { + return NextResponse.json({ + verifiedAt: claim.verifiedAt, + alreadyVerified: true, + }); + } + + if (!claim.verificationToken) { + // Legacy pre-arch4 claim with no token — flag to caller so UX can + // route them through a fresh claim flow. + return NextResponse.json( + { + error: + "This claim was created before DNS verification was required. Re-create the claim to verify ownership.", + code: "LEGACY_CLAIM_NO_TOKEN", + }, + { status: 422 }, + ); + } + + const lookupHost = `_familiarise-verify.${domain}`; + let records: string[][]; + try { + records = await dns.resolveTxt(lookupHost); + } catch (err) { + const isMissing = + err instanceof Error && + "code" in err && + (err.code === "ENOTFOUND" || err.code === "ENODATA"); + if (isMissing) { + return NextResponse.json( + { + error: `No TXT record found at ${lookupHost}. Add the record shown on the claim and retry.`, + code: "DNS_VERIFICATION_FAILED", + }, + { status: 422 }, + ); + } + console.error( + JSON.stringify({ + event: "domain_verify_dns_resolve_failed", + orgId, + domain, + message: err instanceof Error ? err.message : String(err), + }), + ); + return NextResponse.json( + { + error: "DNS lookup failed — please retry in a moment.", + code: "DNS_RESOLVE_ERROR", + }, + { status: 502 }, + ); + } + + // `resolveTxt` returns `string[][]` — each inner array is a single + // record's chunks (DNS TXT chunks are 255-byte max, so long values + // span multiple chunks). We look for any record whose concatenated + // chunks equal the expected token. + const expected = claim.verificationToken; + const matched = records.some( + (chunks) => chunks.join("").trim() === expected, + ); + if (!matched) { + return NextResponse.json( + { + error: `TXT record at ${lookupHost} did not match the expected token. Check the value on your DNS provider and retry.`, + code: "DNS_VERIFICATION_FAILED", + observedCount: records.length, + }, + { status: 422 }, + ); + } + + const verifiedAt = new Date(); + await prisma.$transaction(async (tx) => { + await tx.orgDomainClaim.update({ + where: { id: claim.id }, + data: { verifiedAt }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.DOMAIN_VERIFIED, + description: `Domain '${domain}' verified via DNS TXT`, + details: { domain, claimId: claim.id }, + }, + }); + }); + + return NextResponse.json({ verifiedAt, alreadyVerified: false }); +} diff --git a/app/api/organizations/[orgId]/domain-claims/route.ts b/app/api/organizations/[orgId]/domain-claims/route.ts new file mode 100644 index 000000000..8a36febfd --- /dev/null +++ b/app/api/organizations/[orgId]/domain-claims/route.ts @@ -0,0 +1,160 @@ +/** + * GET /api/organizations/[orgId]/domain-claims + * POST /api/organizations/[orgId]/domain-claims + * + * An OrgDomainClaim maps an email domain (e.g. `wipro.com`) to a single + * organization, enabling domain-based auto-join during SSO login and + * invitation flows. Domains are globally unique — two orgs cannot both + * claim the same domain, since that would make domain→org resolution + * ambiguous at login time. + * + * Two-step verification: POST here records the claim + generates a + * `verificationToken`. The OWNER places that token at + * `_familiarise-verify.` as a DNS TXT record, then calls POST + * /domain-claims/[domain]/verify to flip `verifiedAt`. Unverified + * claims are retained for audit but not honored as identity boundaries + * (SSO auto-join + invite auto-routing require `verifiedAt IS NOT NULL`). + * The two-step flow protects against an OWNER hijacking a competitor's + * domain in the global unique-index race — without TXT proof of control, + * an unverified claim can't do real damage. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { randomBytes } from "node:crypto"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { DomainSchema } from "@/lib/enterprise/validators"; + +/** + * Generate a domain-verification token. URL-safe base64 without padding + * gives us ~128 bits of entropy in ~22 characters — plenty for TXT + * record lookup but short enough to paste into a DNS provider's UI + * without wrapping. + */ +function generateVerificationToken(): string { + return `familiarise-verify=${randomBytes(16).toString("base64url")}`; +} + +const CreateBodySchema = z.object({ + domain: DomainSchema, +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const claims = await prisma.orgDomainClaim.findMany({ + where: { organizationId: orgId }, + orderBy: { claimedAt: "desc" }, + }); + + return NextResponse.json({ data: claims }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const created = await prisma.$transaction(async (tx) => { + const existing = await tx.orgDomainClaim.findUnique({ + where: { domain: body.domain }, + include: { organization: { select: { id: true, name: true } } }, + }); + if (existing) { + const mine = existing.organizationId === orgId; + throw Object.assign( + new Error( + mine + ? `Domain '${body.domain}' is already claimed by this organization` + : `Domain '${body.domain}' is already claimed by another organization`, + ), + { httpStatus: 409 }, + ); + } + + const verificationToken = generateVerificationToken(); + const claim = await tx.orgDomainClaim.create({ + data: { + organizationId: orgId, + domain: body.domain, + verificationToken, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.DOMAIN_CLAIMED, + description: `Domain '${body.domain}' claimed (pending DNS verification)`, + details: { domain: body.domain, claimId: claim.id }, + }, + }); + + return claim; + }); + + // Expose the TXT record name + value the OWNER needs to add. The + // client renders a copy-paste block on the settings page. + return NextResponse.json( + { + domainClaim: created, + verification: { + recordName: `_familiarise-verify.${created.domain}`, + recordValue: created.verificationToken, + recordType: "TXT", + instructionsUrl: "/docs/enterprise/20-iam-and-security/01-sso-and-authentication.md", + }, + }, + { status: 201 }, + ); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + // P2002 race: two concurrent OWNERs claim the same domain at the + // same instant. The pre-check inside the tx misses one of them + // because the read-then-write isn't atomic under Read Committed + // isolation. The unique index on `OrgDomainClaim.domain` is the + // backstop — surface it as a clean 409 instead of a 500 so the UI + // can show "Domain already claimed" instead of a generic error. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return NextResponse.json( + { + error: `Domain '${body.domain}' is already claimed`, + code: "DOMAIN_ALREADY_CLAIMED", + }, + { status: 409 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/earnings/route.ts b/app/api/organizations/[orgId]/earnings/route.ts new file mode 100644 index 000000000..90044707c --- /dev/null +++ b/app/api/organizations/[orgId]/earnings/route.ts @@ -0,0 +1,134 @@ +/** + * GET /api/organizations/[orgId]/earnings + * + * Lists `OrganizationEarnings` rows for a hosting org, optionally filtered + * by status and time window. Earnings are the per-payment 3-way split + * artifact (platform / org / consultant shares) and carry the rate-card + * snapshot that was applied. Unlike payouts (which aggregate earnings), + * each earnings row is immutable once written — the rate snapshot fields + * ensure a later RateCard bump cannot retroactively rewrite settlement. + * + * Query params: + * status=PENDING|HELD|READY|PAID|REFUNDED optional single status filter + * from=ISO-8601 createdAt >= from + * to=ISO-8601 createdAt < to + * payoutId=... rows rolled into a given payout + * limit=1..200 default 50 + * cursor=... opaque cursor (last row id) + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { sumPaise } from "@/lib/payments/utils/money"; + +const EarningStatusSchema = z.enum([ + "PENDING", + "HELD", + "READY", + "PAID", + "REFUNDED", +]); + +const QuerySchema = z.object({ + status: EarningStatusSchema.optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + payoutId: z.string().uuid().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + cursor: z.string().uuid().optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + if (!access.org.canHost) { + return NextResponse.json( + { error: "Organization does not host — no earnings to list" }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const parsedQuery = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsedQuery.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsedQuery.error.flatten() }, + { status: 400 }, + ); + } + const q = parsedQuery.data; + + const [earnings, aggregates] = await Promise.all([ + prisma.organizationEarnings.findMany({ + where: { + organizationId: orgId, + ...(q.status && { status: q.status }), + ...(q.payoutId !== undefined && { orgPayoutId: q.payoutId }), + ...(q.from || q.to + ? { + createdAt: { + ...(q.from && { gte: q.from }), + ...(q.to && { lt: q.to }), + }, + } + : {}), + }, + orderBy: { createdAt: "desc" }, + take: q.limit + 1, + ...(q.cursor && { cursor: { id: q.cursor }, skip: 1 }), + include: { + payment: { + select: { + id: true, + amount: true, + originalAmount: true, + currency: true, + paymentStatus: true, + appointmentId: true, + createdAt: true, + }, + }, + orgPayout: { + select: { id: true, status: true, processedAt: true }, + }, + }, + }), + prisma.organizationEarnings.groupBy({ + by: ["status"], + where: { organizationId: orgId }, + _sum: { + orgSharePaise: true, + platformFeePaise: true, + consultantSharePaise: true, + refundedAmountPaise: true, + }, + _count: { _all: true }, + }), + ]); + + const hasMore = earnings.length > q.limit; + const rows = hasMore ? earnings.slice(0, q.limit) : earnings; + const nextCursor = hasMore ? (rows[rows.length - 1]?.id ?? null) : null; + + return NextResponse.json({ + data: rows, + pagination: { hasMore, nextCursor, limit: q.limit }, + aggregates: aggregates.map((g) => ({ + status: g.status, + count: g._count._all, + orgSharePaise: sumPaise(g._sum.orgSharePaise), + platformFeePaise: sumPaise(g._sum.platformFeePaise), + consultantSharePaise: sumPaise(g._sum.consultantSharePaise), + refundedAmountPaise: sumPaise(g._sum.refundedAmountPaise), + })), + }); +} diff --git a/app/api/organizations/[orgId]/invitations/[invitationId]/route.ts b/app/api/organizations/[orgId]/invitations/[invitationId]/route.ts new file mode 100644 index 000000000..4bbd9b58d --- /dev/null +++ b/app/api/organizations/[orgId]/invitations/[invitationId]/route.ts @@ -0,0 +1,84 @@ +/** + * GET /api/organizations/[orgId]/invitations/[invitationId] + * DELETE /api/organizations/[orgId]/invitations/[invitationId] + * + * DELETE soft-cancels a pending invitation so the accept endpoint will + * reject the token. We keep the row with status=canceled instead of + * hard-deleting so audit trails stay intact and the inviter can see + * that it was revoked rather than it silently disappearing from the + * list. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; invitationId: string }>; + }, +) { + const { orgId, invitationId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + const invitation = await prisma.invitation.findFirst({ + where: { id: invitationId, organizationId: orgId }, + }); + if (!invitation) { + return NextResponse.json({ error: "Invitation not found" }, { status: 404 }); + } + return NextResponse.json({ invitation }); +} + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; invitationId: string }>; + }, +) { + const { orgId, invitationId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + // Conditional update: only pending invitations are revocable. An + // already-accepted or already-canceled row is left untouched so the + // audit log doesn't double-emit REVOKE on retry. + const result = await prisma.$transaction(async (tx) => { + const updated = await tx.invitation.updateMany({ + where: { + id: invitationId, + organizationId: orgId, + status: "pending", + }, + data: { status: "canceled" }, + }); + if (updated.count === 0) return { revoked: false }; + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "MEMBER", + action: AUDIT_ACTIONS.MEMBER.INVITE_REVOKED, + description: `Revoked invitation ${invitationId}`, + details: { invitationId }, + }, + }); + return { revoked: true }; + }); + + if (!result.revoked) { + return NextResponse.json( + { error: "Invitation not pending (may already be accepted or canceled)" }, + { status: 409 }, + ); + } + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/organizations/[orgId]/invitations/route.ts b/app/api/organizations/[orgId]/invitations/route.ts new file mode 100644 index 000000000..cc666dd78 --- /dev/null +++ b/app/api/organizations/[orgId]/invitations/route.ts @@ -0,0 +1,290 @@ +/** + * GET /api/organizations/[orgId]/invitations + * POST /api/organizations/[orgId]/invitations + * + * Backed by BetterAuth's `Invitation` table — we keep the invitation + * token lifecycle inside BetterAuth so the accept flow can verify the + * token natively. The typed `Membership` row is created separately at + * accept time (see /api/organizations/invitations/accept/route.ts). + * + * Self-service cannot invite into EXPERT or SUPPORT roles. EXPERT + * requires canHost=true and the apply flow, SUPPORT is assigned from + * Settings by an OWNER. The guard lives in the Zod schema. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { HostInvitableMemberRoleSchema } from "@/lib/labels/org-labels"; +import crypto from "node:crypto"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { isAtLeastRole } from "@/lib/auth/role-ranks"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + DomainVerificationRequiredError, + hasVerifiedDomain, + UNVERIFIED_ORG_SEAT_CAP, +} from "@/lib/enterprise/governance"; +import { notifyOrgInviteSent } from "@/lib/novu/org-workflows"; +import { applyRateLimit, orgInviteLimiter } from "@/lib/rate-limit"; + +// #817 — the canonical invitable set lives in org-labels; a local duplicate +// here drifted (BILLING_ADMIN went missing) so the route now imports it. +// EXPERT is gated by canHost below; SUPPORT stays owner-only (Settings). + +const InviteBodySchema = z.object({ + email: z.string().email(), + role: HostInvitableMemberRoleSchema, + // Default expiry: 14 days. Overridable up to 30 to avoid long-lived + // invite tokens sitting in inboxes indefinitely. + expiresInDays: z.coerce.number().int().min(1).max(30).default(14), +}); + +const StatusFilterSchema = z.enum([ + "pending", + "accepted", + "rejected", + "expired", + "canceled", +]); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const rawStatus = url.searchParams.get("status"); + const status = rawStatus + ? StatusFilterSchema.safeParse(rawStatus) + : null; + + const invitations = await prisma.invitation.findMany({ + where: { + organizationId: orgId, + ...(status?.success ? { status: status.data } : {}), + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + role: true, + status: true, + expiresAt: true, + createdAt: true, + inviterId: true, + }, + }); + + return NextResponse.json({ data: invitations }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // Invitations require an ACTIVE org. Pre-verification we return + // 409 ORG_NOT_VERIFIED instead of spawning orphan tokens that + // would never get an email sent. The UI banner explains the state. + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + requireActive: true, + }); + if (access.error) return access.error; + + // Per-org sliding-window cap: 20 invitations per hour. Keyed on orgId + // (not IP) so a credential-stuffing attacker that rotates IPs still + // trips the limit. Prevents audit-log and Novu notification spam. + const rateLimited = await applyRateLimit(orgInviteLimiter, orgId); + if (rateLimited) return rateLimited; + + const raw = await req.json().catch(() => null); + const parsed = InviteBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { email, role, expiresInDays } = parsed.data; + + // OWNER role gate (#789): only an OWNER can invite another OWNER. Without it + // a MAINTAINER could invite an OWNER and, once accepted, gain the + // security-sensitive surface by proxy — the same hole the members PATCH route + // already closes. The accept route trusts the invitation's stored role, so + // the check has to live here at invite time. + if (role === "OWNER" && !isAtLeastRole(access.member.role, "OWNER")) { + return NextResponse.json( + { + error: "Only an OWNER can invite a member as OWNER", + code: "OWNER_ROLE_REQUIRES_OWNER", + }, + { status: 403 }, + ); + } + + // EXPERT is only valid for orgs that host consultants. Sponsor-only + // orgs have no payout account / RateCard / settlement path for an + // EXPERT's earnings, so the role is rejected even if a stale + // dashboard payload smuggles it through. Returning the typed code + // lets humanizeOrgError surface a precise message. + if (role === "EXPERT" && !access.org.canHost) { + return NextResponse.json( + { + error: "EXPERT can only be assigned on host-capable organizations", + code: "EXPERT_REQUIRES_CANHOST", + }, + { status: 400 }, + ); + } + + // LEARNER mirrors EXPERT: host-only orgs have no Contract / Program / + // Wallet to fund the learner's sessions, so the role is rejected at + // the invite boundary. Same defence-in-depth pattern. + if (role === "LEARNER" && !access.org.canSponsor) { + return NextResponse.json( + { + error: "LEARNER can only be assigned on sponsor-capable organizations", + code: "LEARNER_REQUIRES_CANSPONSOR", + }, + { status: 400 }, + ); + } + + const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + + // De-dupe active invitations by (orgId, email). An inviter retrying + // from the UI shouldn't spawn two tokens that both resolve to the + // same membership — the second POST extends the expiry instead. + // + // The findFirst → create/update sequence is wrapped in a Serializable + // tx because two concurrent POSTs against the same (orgId, email) would + // otherwise both observe `existing = null` under the default Read + // Committed isolation and both INSERT, leaving two pending rows. + // + // TODO(infra/partial-unique): once Prisma graduates the `partialIndexes` + // preview feature to stable we can lean on a true partial unique + // constraint in schema.prisma (see Invitation model) and demote this + // back to the default isolation level. Tracked in: + // https://github.com/Practitionist/familiarise_web/issues/685 + let wasExisting = false; + let invitation; + try { + invitation = await prisma.$transaction( + async (tx) => { + const existing = await tx.invitation.findFirst({ + where: { + organizationId: orgId, + email, + status: "pending", + }, + }); + wasExisting = !!existing; + + // PR-1d / #675: an unverified org may onboard a small founding + // team but is hard-capped until at least one OrgDomainClaim is + // verified. Skip the gate for re-invites (the seat is already + // counted in the active+pending sum from the original send). + if (!existing) { + const verified = await hasVerifiedDomain(tx, orgId); + if (!verified) { + const [activeMembers, pendingInvites] = await Promise.all([ + tx.membership.count({ + where: { organizationId: orgId, status: "ACTIVE" }, + }), + tx.invitation.count({ + where: { organizationId: orgId, status: "pending" }, + }), + ]); + if (activeMembers + pendingInvites >= UNVERIFIED_ORG_SEAT_CAP) { + throw new DomainVerificationRequiredError("BULK_SEATS"); + } + } + } + + const token = existing?.id ?? crypto.randomUUID(); + const record = existing + ? await tx.invitation.update({ + where: { id: existing.id }, + data: { role, expiresAt }, + }) + : await tx.invitation.create({ + data: { + id: token, + organizationId: orgId, + email, + role, + status: "pending", + expiresAt, + inviterId: access.session.user.id, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "MEMBER", + action: existing + ? AUDIT_ACTIONS.MEMBER.INVITE_RESENT + : AUDIT_ACTIONS.MEMBER.INVITE_SENT, + description: `${existing ? "Re-sent" : "Sent"} invite to ${email} as ${role}`, + details: { email, role, expiresAt: expiresAt.toISOString() }, + }, + }); + + return record; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } catch (err) { + if (err instanceof DomainVerificationRequiredError) { + return NextResponse.json( + { + error: `Verify a domain to invite more than ${UNVERIFIED_ORG_SEAT_CAP} members`, + code: err.code, + }, + { status: err.httpStatus }, + ); + } + // P2002 from a future partial unique index would land here; today + // we hit it only if the Serializable retry budget exhausts. Convert + // to 409 so the client can simply re-render the existing invitation. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return NextResponse.json( + { + error: "Pending invitation already exists for this email", + code: "INVITATION_EXISTS", + }, + { status: 409 }, + ); + } + throw err; + } + + // Side-effect: trigger Novu email delivery to the invitee. Non-blocking + // — on failure we still return the invitation response. The existing + // email-send flow (lib/email.ts / Resend) continues to run; Novu is + // additive so in-app bell delivery works once the invitee has a user + // account. + const origin = new URL(req.url).origin; + notifyOrgInviteSent(email, { + inviterName: access.session.user.name ?? access.session.user.email, + orgName: access.org.name, + role, + inviteUrl: `${origin}/organizations/invite/${invitation.id}`, + expiresAt: expiresAt.toISOString(), + }).catch((err) => + console.error("[notifyOrgInviteSent] failed:", err), + ); + + return NextResponse.json({ invitation }, { status: wasExisting ? 200 : 201 }); +} diff --git a/app/api/organizations/[orgId]/members/[memberId]/route.ts b/app/api/organizations/[orgId]/members/[memberId]/route.ts new file mode 100644 index 000000000..7aa5477f8 --- /dev/null +++ b/app/api/organizations/[orgId]/members/[memberId]/route.ts @@ -0,0 +1,692 @@ +/** + * GET /api/organizations/[orgId]/members/[memberId] + * PATCH /api/organizations/[orgId]/members/[memberId] + * DELETE /api/organizations/[orgId]/members/[memberId] + * + * `memberId` is a `Membership.id` (not a User id). Operations produce an + * audit log row in the same transaction as the mutation. + * + * Last-OWNER safety: the API refuses to demote or remove the only active + * OWNER so an org can never end up ownerless. The check runs inside the + * transaction so a concurrent second request can't race past it. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { isAtLeastRole } from "@/lib/auth/role-ranks"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { dispatchWebhookEvent } from "@/lib/enterprise/outbound-webhooks/dispatch"; +import { isBlockedRoleTransition } from "@/lib/enterprise/role-transitions"; +import { transitionMembership } from "@/lib/enterprise/transitions"; +import { + applyMembershipRoleEffects, + bumpUserSessionGeneration, + recomputeConsultantIsIndependent, +} from "@/lib/api/organizations/membership-transitions"; +import { notifyOrgExpertRemoved } from "@/lib/novu/service"; + +// Mirror the full Prisma MemberRole enum. The earlier hand-rolled list +// omitted BILLING_ADMIN — invitable via POST /members but un-PATCH-able +// here, so OWNERs couldn't promote a MAINTAINER to BILLING_ADMIN via +// the dashboard ("Invalid body" 400). Caught during the 2026-06 role +// audit. We could import lib/labels/org-labels.ts:MemberRoleSchema to +// share the source — kept local for now to avoid cross-cutting churn, +// but the values MUST stay in sync with the Prisma enum + the shared +// schema or we re-introduce the same drift bug. +const MemberRoleSchema = z.enum([ + "OWNER", + "MAINTAINER", + "BILLING_ADMIN", + "MANAGER", + "EXPERT", + "LEARNER", + "SUPPORT", +]); + +const MemberStatusSchema = z.enum(["PENDING", "ACTIVE", "SUSPENDED", "REMOVED"]); + +const PatchBodySchema = z + .object({ + role: MemberRoleSchema.optional(), + status: MemberStatusSchema.optional(), + departmentLabel: z.string().max(100).nullable().optional(), + // #729 — explicit EXPERT payout routing. Only applied when the effective + // role is EXPERT (otherwise ignored); overrides the role-change default. + payoutRecipient: z.enum(["SELF", "ORGANIZATION"]).optional(), + }) + .refine( + (v) => + v.role !== undefined || + v.status !== undefined || + v.departmentLabel !== undefined || + v.payoutRecipient !== undefined, + { + message: + "PATCH body must contain at least one of role/status/departmentLabel/payoutRecipient", + }, + ); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; memberId: string }>; + }, +) { + const { orgId, memberId } = await params; + // MANAGER+ can read other members' details. LEARNER+SUPPORT can only + // fetch THEIR OWN membership — otherwise any member of the org could + // enumerate peers' emails/names/profile ids. Member-list (index) view + // remains separately gated; this is the detail endpoint. + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const membership = await prisma.membership.findFirst({ + where: { id: memberId, organizationId: orgId }, + include: { + user: { + select: { id: true, name: true, email: true, image: true }, + }, + consulteeProfile: { select: { id: true } }, + consultantProfile: { select: { id: true } }, + }, + }); + if (!membership) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + + const isSelf = membership.id === access.member.id; + const isManagerPlus = isAtLeastRole(access.member.role, "MANAGER"); + if (!isSelf && !isManagerPlus) { + return NextResponse.json( + { error: "Insufficient role to view other members" }, + { status: 403 }, + ); + } + + return NextResponse.json({ membership }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; memberId: string }>; + }, +) { + const { orgId, memberId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const patch = parsed.data; + + try { + const result = await prisma.$transaction(async (tx) => { + const current = await tx.membership.findFirst({ + where: { id: memberId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Member not found"), { httpStatus: 404 }); + } + + // Disjoint LEARNER/EXPERT boundary. These two roles imply + // different platform profiles (ConsulteeProfile vs ConsultantProfile) + // and different billing postures (consumer vs provider), so the + // product rule is to force a remove + re-invite instead of + // mutating in place. Returning the machine-readable code lets the + // dashboard surface the humanized copy via humanizeOrgError. + if ( + patch.role !== undefined && + patch.role !== current.role && + isBlockedRoleTransition(current.role, patch.role) + ) { + throw Object.assign( + new Error("ROLE_TRANSITION_BLOCKED"), + { httpStatus: 409 }, + ); + } + + // OWNER role gate: only OWNERs can assign or revoke the OWNER role. + // A MAINTAINER renaming someone to OWNER would effectively grant + // themselves extra privileges by proxy. + const touchesOwnerRole = + patch.role === "OWNER" || current.role === "OWNER"; + if (touchesOwnerRole && !isAtLeastRole(access.member.role, "OWNER")) { + throw Object.assign( + new Error("Only an OWNER can assign or revoke the OWNER role"), + { httpStatus: 403 }, + ); + } + + // Self-role-change guard. Caught during the 2026-06 MAINTAINER role + // audit — a MAINTAINER could PATCH their own membership to LEARNER + // (or any non-OWNER role), losing admin access and bypassing the + // #729 strict identity gate (PATCH lazy-creates the profile, POST + // refuses). The footgun: a MAINTAINER self-demotes accidentally and + // needs an OWNER to restore them. Role changes belong to a + // peer-or-superior review path; the actor cannot grade their own + // membership. OWNERs are likewise blocked from changing their own + // role — OWNER handoff goes through a dedicated promote-then-demote + // path that the last-OWNER guard at L172-187 already protects. + const isSelfRoleChange = + memberId === access.member.id && + patch.role !== undefined && + patch.role !== current.role; + if (isSelfRoleChange) { + throw Object.assign( + new Error( + "You cannot change your own role. Ask another operator to do it for you.", + ), + { httpStatus: 403 }, + ); + } + + // Last-OWNER guard. Runs inside the TX so two concurrent demotes + // can't both believe there's a second owner. + const isDemotingOwner = + current.role === "OWNER" && + patch.role !== undefined && + patch.role !== "OWNER"; + const isRemovingOwner = + current.role === "OWNER" && patch.status === "REMOVED"; + if (isDemotingOwner || isRemovingOwner) { + const activeOwnerCount = await tx.membership.count({ + where: { + organizationId: orgId, + role: "OWNER", + status: "ACTIVE", + id: { not: memberId }, + }, + }); + if (activeOwnerCount === 0) { + throw Object.assign( + new Error( + "Cannot demote or remove the only active OWNER. Promote another member to OWNER first.", + ), + { httpStatus: 409 }, + ); + } + } + + // Role-driven profile reconciliation. When the role changes we + // hydrate / clear the consultee / consultant FKs through the + // shared helper so PATCH stays in sync with POST and invite-accept. + // payoutRecipient is reset to the role default (SELF) on role + // change; pre-existing overrides are not preserved across a role + // move. + const roleEffects = + patch.role !== undefined && patch.role !== current.role + ? await applyMembershipRoleEffects(tx, { + userId: current.userId, + role: patch.role, + }) + : null; + + // #729 — explicit payout-recipient choice, honoured only when the + // resulting role is EXPERT. Applied AFTER the role-effect default so an + // operator's choice wins over the reset on a role change. + const effectiveRole = patch.role ?? current.role; + const explicitPayoutRecipient = + patch.payoutRecipient !== undefined && effectiveRole === "EXPERT" + ? patch.payoutRecipient + : undefined; + + // Status moves are CAS-guarded (a concurrent REMOVE/ERASE landing first + // matches zero rows and 409s instead of being resurrected); the + // remaining fields ride a plain update in the same tx. + if (patch.status !== undefined && patch.status !== current.status) { + await transitionMembership(tx, { + where: { id: memberId, organizationId: orgId }, + to: patch.status, + }); + + // PATCH → REMOVED is the same operation as DELETE — run the same + // assignment cascade so the member's slot frees up (see the DELETE + // handler's rationale). + if (patch.status === "REMOVED") { + const now = new Date(); + await tx.programAssignment.updateMany({ + where: { + membershipId: memberId, + periodEnd: { gte: now }, + status: { in: ["ACTIVE", "PAUSED"] }, + }, + data: { periodEnd: now, status: "CANCELLED" }, + }); + } + } + + const otherData = { + ...(patch.role !== undefined && { role: patch.role }), + ...(patch.departmentLabel !== undefined && { + departmentLabel: patch.departmentLabel, + }), + ...(roleEffects && { + consulteeProfileId: roleEffects.consulteeProfileId, + consultantProfileId: roleEffects.consultantProfileId, + payoutRecipient: roleEffects.payoutRecipient, + }), + ...(explicitPayoutRecipient !== undefined && { + payoutRecipient: explicitPayoutRecipient, + }), + }; + const updated = + Object.keys(otherData).length > 0 + ? await tx.membership.update({ + where: { id: memberId }, + data: otherData, + }) + : await tx.membership.findUniqueOrThrow({ where: { id: memberId } }); + + // Bump the user's session-generation marker so the customSession + // hook picks up the role / status change on their next request + // instead of waiting up to 24h for BetterAuth's session rotation + // (Phase B.5). Triggers for any field change that affects the + // effective permission set: role, status, or departmentLabel. + // departmentLabel is included because it shows up in the session + // payload (`organizationMemberships[].departmentLabel`). + if ( + patch.role !== undefined || + patch.status !== undefined || + patch.departmentLabel !== undefined + ) { + await bumpUserSessionGeneration(tx, current.userId); + } + + // A4: recompute ConsultantProfile.isIndependent if this PATCH touches + // an EXPERT membership. PATCH cannot promote *into* EXPERT (Zod schema + // blocks LEARNER↔EXPERT and the patch.role union excludes EXPERT in + // practice), but PATCH can move a current EXPERT into an operator + // role or flip an EXPERT membership's status away from / back to + // ACTIVE — both shift the consultant's HOST-membership count. + if ( + current.role === "EXPERT" && + current.consultantProfileId && + (patch.role !== undefined || patch.status !== undefined) + ) { + await recomputeConsultantIsIndependent( + tx, + current.consultantProfileId, + ); + } + + const auditActions: string[] = []; + if (patch.role !== undefined && patch.role !== current.role) { + auditActions.push(AUDIT_ACTIONS.MEMBER.ROLE_CHANGE); + } + if (patch.status !== undefined && patch.status !== current.status) { + auditActions.push(AUDIT_ACTIONS.MEMBER.STATUS_CHANGE); + } + for (const action of auditActions) { + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: memberId, + category: "MEMBER", + action, + description: + action === AUDIT_ACTIONS.MEMBER.ROLE_CHANGE + ? `Role: ${current.role} → ${patch.role}` + : `Status: ${current.status} → ${patch.status}`, + details: { + from: { + role: current.role, + status: current.status, + }, + to: { + role: patch.role ?? current.role, + status: patch.status ?? current.status, + }, + }, + }, + }); + } + + return updated; + }); + + return NextResponse.json({ membership: result }); + } catch (err) { + // Structured error handling keeps the switch between 404/403/409 + // explicit — never leak a 500 for user-facing validation issues. + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} + +export async function DELETE( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; memberId: string }>; + }, +) { + const { orgId, memberId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + // #779 §C — in-flight-money override. Only an OWNER may force past the + // pre-check; MAINTAINERs must clear the money first. + const force = req.nextUrl.searchParams.get("force") === "true"; + const isOwner = access.member.role === "OWNER"; + + // A7: capture context for the post-commit Novu fire. Resolved BEFORE + // the transaction so the notification payload is ready to dispatch the + // moment the soft-delete commits. Set inside the tx; fired after commit. + let notifyContext: { + consultantUserId: string; + payload: import("@/lib/novu/workflows").OrgExpertRemovedPayload; + } | null = null; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.membership.findFirst({ + where: { id: memberId, organizationId: orgId }, + include: { user: { select: { id: true } } }, + }); + if (!current) { + throw Object.assign(new Error("Member not found"), { httpStatus: 404 }); + } + + // Self-DELETE guard. The trash icon on your own row would + // otherwise let a MAINTAINER (or any operator) self-fire in + // one click — they'd lose org access immediately and need + // another OWNER to restore. "Leave organization" is a real + // use case but belongs to a dedicated confirmation flow with + // explicit copy ("you will lose access immediately"), not the + // same trash button used to evict others. Until that flow + // exists, refuse self-DELETE here. Caught during the 2026-06 + // MAINTAINER role audit alongside the self-role-change guard + // in PATCH. + if (memberId === access.member.id) { + throw Object.assign( + new Error( + "You cannot remove yourself. Ask another operator, or use the Leave organization flow when it ships.", + ), + { httpStatus: 403 }, + ); + } + + if (current.role === "OWNER") { + // OWNER-only gate — mirrors PATCH at L154-161. Without this, a + // MAINTAINER (rank 80) who passed `requireOrgAccess(..., "MAINTAINER")` + // could DELETE an OWNER row as long as it wasn't the last OWNER, + // because the last-OWNER guard below only protects org continuity, + // not privilege escalation. Caught during the 2026-06 MAINTAINER + // role audit. + if (!isAtLeastRole(access.member.role, "OWNER")) { + throw Object.assign( + new Error("Only an OWNER can remove an OWNER"), + { httpStatus: 403 }, + ); + } + // Last-OWNER guard — unchanged semantically. Now applied to + // soft-delete (status → REMOVED) so a sole OWNER can't orphan + // the org by removing themselves. + const activeOwnerCount = await tx.membership.count({ + where: { + organizationId: orgId, + role: "OWNER", + status: "ACTIVE", + id: { not: memberId }, + }, + }); + if (activeOwnerCount === 0) { + throw Object.assign( + new Error( + "Cannot remove the only active OWNER. Promote another member first.", + ), + { httpStatus: 409 }, + ); + } + } + + if (current.status === "REMOVED") { + // Idempotent: a repeat DELETE is a no-op that still returns 204. + return; + } + + // #779 §C — in-flight-money pre-check. Removing a member while real + // money tied to THEM is still moving would strand it: a pending/accrued + // overage charge, unpaid consultant earnings, an in-progress refund, or + // an open dispute all need a settled owner. Scoped via the member's + // payments (payment.userId = member.userId); earnings via their + // ConsultantProfile. An OWNER may override with ?force=true once they've + // accepted the consequences. + const [overageInflight, unpaidEarnings, pendingRefunds, openDisputes] = + await Promise.all([ + tx.overageEvent.count({ + where: { + chargeStatus: { in: ["PENDING", "ACCRUED"] }, + payment: { userId: current.userId }, + }, + }), + current.consultantProfileId + ? tx.consultantEarnings.count({ + where: { + consultantProfileId: current.consultantProfileId, + status: { not: "PAID" }, + }, + }) + : Promise.resolve(0), + tx.refund.count({ + where: { + status: "PENDING", + payment: { userId: current.userId }, + }, + }), + tx.dispute.count({ + where: { + // Open = anything not yet terminal (WON/LOST/CHARGE_REFUNDED/ + // WARNING_CLOSED). Money may still move until then. + status: { + in: [ + "WARNING_NEEDS_RESPONSE", + "WARNING_UNDER_REVIEW", + "NEEDS_RESPONSE", + "UNDER_REVIEW", + ], + }, + payment: { userId: current.userId }, + }, + }), + ]); + const inflightTotal = + overageInflight + unpaidEarnings + pendingRefunds + openDisputes; + if (inflightTotal > 0 && !(force && isOwner)) { + throw Object.assign(new Error("MEMBER_HAS_INFLIGHT_MONEY"), { + httpStatus: 409, + code: "MEMBER_HAS_INFLIGHT_MONEY", + counts: { + overageInflight, + unpaidEarnings, + pendingRefunds, + openDisputes, + }, + }); + } + const forced = inflightTotal > 0; + + // Soft-delete (status=REMOVED) instead of hard-delete: audit rows + // reference Membership via `actorMembershipId`/`targetMembershipId`, + // payouts, earnings, and wallet entries do too. A hard delete + // would cascade across half the compliance tables. REMOVED is a + // tombstone — it hides the row from all listing endpoints, blocks + // login attempts, but keeps the history queryable. The CAS makes a + // concurrent double-DELETE 409 instead of re-running the cascade. + await transitionMembership(tx, { + where: { id: memberId, organizationId: orgId }, + to: "REMOVED", + }); + + // Bump the user's session-generation marker so their next + // request hits the customSession refetch path and observes the + // missing org membership (Phase B.5). Without this, a removed + // member can keep acting on org-scoped routes for up to 24h + // because the cached memberships array still lists the org. + await bumpUserSessionGeneration(tx, current.userId); + + // A4: if this was an EXPERT membership, recompute the consultant's + // isIndependent flag now that one HOST tie is gone. If it was the + // last active EXPERT membership at any HOST org the flag flips back + // to true and the consultant re-appears as "independent" on + // /explore/experts. + if (current.role === "EXPERT" && current.consultantProfileId) { + await recomputeConsultantIsIndependent( + tx, + current.consultantProfileId, + ); + } + + // A7 note: past `OrganizationEarnings` are NOT touched on member + // removal. Sessions the consultant already delivered are settled + // commitments — the org earned its share at booking time, and the + // consultant's `ConsultantEarnings` row will pay out independently. + // Cancelling already-accrued earnings here would create + // reconciliation drift (LED-3 / LED-4 invariants would break). + // Forward-looking: once the consultant is REMOVED, no NEW + // `OrganizationEarnings` rows are created (resolveOrgSplit returns + // null when no active EXPERT membership at a canHost org exists). + + // Cascade: terminate any active ProgramAssignments. Without this, + // a removed member's assignments still match the + // `periodStart <= now AND periodEnd >= now` filter, so their slot + // continues to count against the program cap and a replacement + // member can't be added. We close the period at `now` rather than + // deleting so engagementsUsed history + UsageLedgerEntry rows + // remain queryable for reconciliation. + // #779 §A — close the period AND stamp status=CANCELLED (member + // removed mid-cycle) so the assignment lifecycle is explicit, not + // inferred from periodEnd alone. + // Status guard: only live allocations cascade — a ROLLED/CLOSED row + // must never be re-stamped CANCELLED by a (forced) re-removal. + const now = new Date(); + const terminated = await tx.programAssignment.updateMany({ + where: { + membershipId: memberId, + periodEnd: { gte: now }, + status: { in: ["ACTIVE", "PAUSED"] }, + }, + data: { periodEnd: now, status: "CANCELLED" }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: memberId, + category: "MEMBER", + action: AUDIT_ACTIONS.MEMBER.MEMBER_REMOVED, + description: `Removed member ${memberId} (soft delete)`, + details: { + role: current.role, + previousStatus: current.status, + assignmentsTerminated: terminated.count, + // #779 §C — record an OWNER force-override past the in-flight money + // pre-check so the audit trail shows the money was knowingly left. + ...(forced && { forced: true }), + }, + }, + }); + + // Outbound webhook: notify integrations that the membership is + // gone (HRIS deprovisioning, SaaS-license reclaim). Dispatched + // inside the transaction so a rollback (e.g. anti-lockout veto) + // also rolls back the webhook delivery row. + await dispatchWebhookEvent({ + prisma: tx, + organizationId: orgId, + eventType: "member.removed", + payload: { + membershipId: memberId, + userId: current.userId, + role: current.role, + previousStatus: current.status, + }, + }); + + // A7: stage the Novu fire (executed AFTER the tx commits so a + // rollback never pages a notification for a delete that didn't + // happen). Only EXPERT removals trigger the personal notification — + // operator role removals don't need this. + if (current.role === "EXPERT" && current.user) { + const org = await tx.organization.findUnique({ + where: { id: orgId }, + select: { name: true, slug: true }, + }); + const actor = await tx.user.findUnique({ + where: { id: access.session.user.id }, + select: { name: true, email: true }, + }); + if (org) { + notifyContext = { + consultantUserId: current.user.id, + payload: { + orgName: org.name, + orgSlug: org.slug, + removedByName: actor?.name ?? actor?.email ?? "An operator", + reason: null, + dashboardUrl: `${process.env.NEXT_PUBLIC_APP_URL ?? ""}/dashboard/consultant`, + }, + }; + } + } + }); + + // A7: fire-and-forget Novu trigger after commit. A failure here MUST + // NOT roll back the soft-delete (the membership is already gone in + // the DB); log the error and continue. + if (notifyContext !== null) { + const ctx = notifyContext as { + consultantUserId: string; + payload: import("@/lib/novu/workflows").OrgExpertRemovedPayload; + }; + try { + await notifyOrgExpertRemoved(ctx.consultantUserId, ctx.payload); + } catch (notifyErr) { + console.error( + "[member-delete] Novu notify failed (non-fatal):", + notifyErr, + ); + } + } + + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + // #779 §C — forward the structured code + breakdown counts so the UI + // renders the in-flight-money wind-down message. + const code = + "code" in err && typeof err.code === "string" ? err.code : undefined; + const counts = + "counts" in err && err.counts && typeof err.counts === "object" + ? err.counts + : undefined; + return NextResponse.json( + { error: err.message, ...(code && { code }), ...(counts && { counts }) }, + { status }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/members/bulk/route.ts b/app/api/organizations/[orgId]/members/bulk/route.ts new file mode 100644 index 000000000..b0042499d --- /dev/null +++ b/app/api/organizations/[orgId]/members/bulk/route.ts @@ -0,0 +1,38 @@ +/** + * Explicit 405 stub for `/api/organizations/[orgId]/members/bulk`. + * + * Bulk member operations (multi-remove, multi-role-change, CSV import) + * are intentionally NOT supported in v1. The single-member PATCH + * endpoint at `/api/organizations/[orgId]/members/[memberId]` is the + * only sanctioned mutation surface — that route runs the last-OWNER + * anti-lockout guard inside a Serializable transaction, and a bulk + * operation would either need to replicate that guard per-row (slow, + * inconsistent) or skip it (lockout risk). + * + * Returning a deterministic 405 here closes the door on a future + * accidental implementation that bypasses the lockout invariant. + * docs/enterprise/40-compliance-and-data/02-deletion-policy.md is the source of truth. + */ + +import { NextResponse } from "next/server"; + +// Build a fresh Response per call. A Web Response body is one-shot — +// returning a single shared `NextResponse.json(...)` instance from +// multiple handlers (or for multiple concurrent requests on the same +// method) means the second consumer hits "Body has already been read". +// The factory keeps each request isolated. +const notSupported = () => + NextResponse.json( + { + error: "BULK_REMOVAL_NOT_SUPPORTED", + message: + "Bulk member operations are not supported. Use PATCH /api/organizations//members/ per member; the single-member route enforces the anti-lockout guard.", + }, + { status: 405, headers: { Allow: "" } }, + ); + +export const GET = () => notSupported(); +export const POST = () => notSupported(); +export const PUT = () => notSupported(); +export const PATCH = () => notSupported(); +export const DELETE = () => notSupported(); diff --git a/app/api/organizations/[orgId]/members/route.ts b/app/api/organizations/[orgId]/members/route.ts new file mode 100644 index 000000000..1a409a826 --- /dev/null +++ b/app/api/organizations/[orgId]/members/route.ts @@ -0,0 +1,440 @@ +/** + * GET /api/organizations/[orgId]/members + * POST /api/organizations/[orgId]/members + * + * The single members endpoint subsumes the old /consultants and /learners + * views — callers pass `?role=EXPERT` or `?role=LEARNER` to filter. Also + * accepts a comma-separated role list (`?role=EXPERT,LEARNER`) for the + * union case, plus `status` / `departmentLabel` / `q` / pagination. + * + * Everything is parsed through Zod. Runtime narrowing never relies on + * `as` assertions. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { MemberRoleSchema } from "@/lib/labels/org-labels"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { + canSeeOperatorSurface, + canSeeFinanceSurface, + isAtLeastRole, +} from "@/lib/auth/role-ranks"; +import type { MemberRole, MemberStatus } from "@prisma/client"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { dispatchWebhookEvent } from "@/lib/enterprise/outbound-webhooks/dispatch"; +import { isBlockedRoleTransition } from "@/lib/enterprise/role-transitions"; +import { + applyMembershipRoleEffects, + bumpUserSessionGeneration, +} from "@/lib/api/organizations/membership-transitions"; + +// #817 — the canonical full-enum Zod mirror lives in org-labels; the local +// duplicate here drifted (BILLING_ADMIN went missing), so import it instead. + +const MemberStatusSchema = z.enum(["PENDING", "ACTIVE", "SUSPENDED", "REMOVED"]); + +/** + * Accepts a string like "EXPERT" or "EXPERT,LEARNER" and returns the + * narrowed list. Empty/invalid → undefined (no filter applied). + */ +function parseRoleFilter(raw: string | null): MemberRole[] | undefined { + if (!raw) return undefined; + const parts = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const parsed: MemberRole[] = []; + for (const p of parts) { + const result = MemberRoleSchema.safeParse(p); + if (result.success) parsed.push(result.data); + } + return parsed.length ? parsed : undefined; +} + +function parseStatusFilter(raw: string | null): MemberStatus[] | undefined { + if (!raw) return undefined; + const parts = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const parsed: MemberStatus[] = []; + for (const p of parts) { + const result = MemberStatusSchema.safeParse(p); + if (result.success) parsed.push(result.data); + } + return parsed.length ? parsed : undefined; +} + +const ListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + perPage: z.coerce.number().int().min(1).max(100).default(20), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + // #777 FDE Group B P2 — the roster is an operational/finance read + // surface, not member-facing. Bare membership let any LEARNER/EXPERT + // pull the full directory. Floor it to the operator set (OWNER/ + // MAINTAINER/MANAGER/SUPPORT) plus finance (BILLING_ADMIN reconciles + + // builds the consent member-picker). Only LEARNER/EXPERT are excluded. + const role = access.member.role; + if (!canSeeOperatorSurface(role) && !canSeeFinanceSurface(role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const roles = parseRoleFilter(url.searchParams.get("role")); + const statuses = parseStatusFilter(url.searchParams.get("status")); + const departmentLabel = + url.searchParams.get("departmentLabel")?.trim() || undefined; + const q = url.searchParams.get("q")?.trim() || undefined; + const parsedPagination = ListQuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsedPagination.success) { + return NextResponse.json( + { error: "Invalid pagination", detail: parsedPagination.error.flatten() }, + { status: 400 }, + ); + } + const { page, perPage } = parsedPagination.data; + + // Query-param-driven filter; role and status accept lists so the same + // endpoint serves the sidebar filters (?role=LEARNER) and consultants + // management (?role=EXPERT&status=ACTIVE) without new routes. + const where = { + organizationId: orgId, + ...(roles && { role: { in: roles } }), + ...(statuses && { status: { in: statuses } }), + ...(departmentLabel && { departmentLabel }), + ...(q && { + user: { + OR: [ + { name: { contains: q, mode: "insensitive" as const } }, + { email: { contains: q, mode: "insensitive" as const } }, + ], + }, + }), + }; + + const [total, data] = await prisma.$transaction([ + prisma.membership.count({ where }), + prisma.membership.findMany({ + where, + include: { + user: { + select: { id: true, name: true, email: true, image: true }, + }, + // ConsultantProfile + ConsulteeProfile are 1:1 optionals. Always + // including them here means the consultants page gets + // `headline / rating / isVerified` in one round-trip without + // needing a separate /consultants endpoint. + consultantProfile: { + select: { + id: true, + headline: true, + rating: true, + isVerified: true, + }, + }, + consulteeProfile: { + select: { id: true }, + }, + }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * perPage, + take: perPage, + }), + ]); + + return NextResponse.json({ + data, + meta: { total, page, perPage }, + }); +} + +/** + * Direct-add a member by email (dashboard path) or userId + * (SSO auto-provisioning / admin tooling). Exactly one of the two + * identifiers must be present; the server resolves email → userId + * and returns 404 USER_NOT_FOUND when the account doesn't exist. + */ +const CreateBodySchema = z + .object({ + userId: z.string().min(1).optional(), + email: z.string().email().optional(), + role: MemberRoleSchema, + departmentLabel: z.string().max(100).optional().nullable(), + }) + .refine((v) => !!(v.userId || v.email), { + message: "userId or email is required", + }) + .refine((v) => !(v.userId && v.email), { + message: "Provide userId OR email, not both", + }); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // Mirrors the /invitations guard: pre-verification we block direct-add + // too, otherwise the banner promise ("inviting members unlocks once + // verified") is misleading. SSO auto-provisioning goes through the + // session.create hook, not this route, so it is unaffected. + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + requireActive: true, + }); + if (access.error) return access.error; + + const bodyRaw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(bodyRaw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { userId: providedUserId, email, role, departmentLabel } = parsed.data; + + // OWNER role gate (#789): only an OWNER can mint another OWNER. This is the + // same guard the members PATCH route applies; without it a MAINTAINER could + // direct-add an ACTIVE OWNER and grant themselves the security-sensitive + // surface (billing, deletion, ownership transfer, SSO/SCIM) by proxy. + if (role === "OWNER" && !isAtLeastRole(access.member.role, "OWNER")) { + return NextResponse.json( + { + error: "Only an OWNER can assign the OWNER role", + code: "OWNER_ROLE_REQUIRES_OWNER", + }, + { status: 403 }, + ); + } + + // EXPERT requires the org to actually host consultants — otherwise + // there's no rate card / payout account to settle their earnings. + // Mirrors the gate in app/api/organizations/[orgId]/invitations/route.ts. + if (role === "EXPERT" && !access.org.canHost) { + return NextResponse.json( + { + error: "EXPERT can only be assigned on host-capable organizations", + code: "EXPERT_REQUIRES_CANHOST", + }, + { status: 400 }, + ); + } + + // LEARNER mirrors EXPERT: requires the org to actually sponsor sessions. + // Host-only orgs have no Contract / Program / Wallet / BillingAccount + // path, so a LEARNER membership would be a hollow shell with no funded + // bookings and a blank /my-program. Reject at the boundary. + if (role === "LEARNER" && !access.org.canSponsor) { + return NextResponse.json( + { + error: "LEARNER can only be assigned on sponsor-capable organizations", + code: "LEARNER_REQUIRES_CANSPONSOR", + }, + { status: 400 }, + ); + } + + // The dashboard sends email; SSO / admin tooling sends userId. Profile + // FK hydration is handled inside the transaction below by + // applyMembershipRoleEffects (lazy-creates ConsulteeProfile / + // ConsultantProfile when needed for LEARNER / EXPERT). + const user = providedUserId + ? await prisma.user.findUnique({ + where: { id: providedUserId }, + select: { id: true }, + }) + : await prisma.user.findUnique({ + where: { email: email!.toLowerCase() }, + select: { id: true }, + }); + if (!user) { + return NextResponse.json({ error: "USER_NOT_FOUND" }, { status: 404 }); + } + const userId = user.id; + + // #729 §AC4/AC5 + #819 — who-is-acting identity rule. Direct-add is an + // ADMIN acting on someone else, so BOTH roles require a pre-existing + // profile: the shared `applyMembershipRoleEffects` helper would + // otherwise lazy-create one and silently promote a stranger to a + // consultant / consumer identity they never asked for. Contrast + // invitation-accept, where the LEARNER gate is deliberately absent — + // accepting is the user's OWN consenting click, so the lightweight + // ConsulteeProfile lazy-creates there (EXPERT stays strict on every + // surface). SSO JIT auto-join keeps its lazy path as a separate + // provisioning channel with its own authorization layer. The + // who-is-acting pins in __tests__/enterprise/invitation-accept.test.ts + // keep the three surfaces from drifting apart. + if (role === "EXPERT") { + const existingConsultant = await prisma.consultantProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + if (!existingConsultant) { + return NextResponse.json( + { error: "NOT_A_CONSULTANT" }, + { status: 400 }, + ); + } + } + if (role === "LEARNER") { + const existingConsultee = await prisma.consulteeProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + if (!existingConsultee) { + return NextResponse.json( + { error: "NOT_A_CONSULTEE" }, + { status: 400 }, + ); + } + } + + // Idempotency: a duplicate POST for a currently-active (or pending/ + // suspended) member is a conflict. A REMOVED row is *not* a conflict — + // that path flips the existing row back to ACTIVE instead of 409'ing + // (so re-adding someone who was off-boarded doesn't require cleaning up + // the tombstone first). + const existing = await prisma.membership.findUnique({ + where: { userId_organizationId: { userId, organizationId: orgId } }, + select: { id: true, status: true, role: true }, + }); + + if (existing && existing.status !== "REMOVED") { + return NextResponse.json( + { error: "User is already a member of this organization" }, + { status: 409 }, + ); + } + + let membership; + try { + membership = await prisma.$transaction(async (tx) => { + // Profile FK + payoutRecipient defaults from the shared helper. The + // helper lazy-creates a ConsulteeProfile (LEARNER) or + // ConsultantProfile (EXPERT) inside the same tx if the user does + // not already have one, so direct-add stays consistent with + // invitations/accept and SSO auto-join. + const roleEffects = await applyMembershipRoleEffects(tx, { + userId, + role, + }); + if (existing) { + // Reactivation keeps the same Membership row (preserves downstream + // FKs on ProgramAssignment / audit trail). That means the LEARNER + // <-> EXPERT boundary applies here too: a previously-LEARNER user + // can't be re-added as EXPERT on the same Membership. They need a + // brand-new Membership, which (given we soft-delete rather than + // hard-delete) effectively means they can't swap roles inside + // this org. + if ( + existing.status === "REMOVED" && + isBlockedRoleTransition(existing.role, role) + ) { + throw Object.assign( + new Error("ROLE_TRANSITION_BLOCKED"), + { httpStatus: 409 }, + ); + } + + // REMOVED → ACTIVE reactivation. Keep the same membership row so + // downstream FKs (ProgramAssignment, audit trail, etc.) stay intact. + const reactivated = await tx.membership.update({ + where: { id: existing.id }, + data: { + role, + status: "ACTIVE", + departmentLabel: departmentLabel ?? null, + consulteeProfileId: roleEffects.consulteeProfileId, + consultantProfileId: roleEffects.consultantProfileId, + payoutRecipient: roleEffects.payoutRecipient, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: reactivated.id, + category: "MEMBER", + action: AUDIT_ACTIONS.MEMBER.MEMBER_REACTIVATED, + description: `Reactivated ${userId} as ${role}`, + details: { role, departmentLabel: departmentLabel ?? null }, + }, + }); + await bumpUserSessionGeneration(tx, userId); + return reactivated; + } + + const created = await tx.membership.create({ + data: { + userId, + organizationId: orgId, + role, + status: "ACTIVE", + departmentLabel: departmentLabel ?? null, + consulteeProfileId: roleEffects.consulteeProfileId, + consultantProfileId: roleEffects.consultantProfileId, + payoutRecipient: roleEffects.payoutRecipient, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: created.id, + category: "MEMBER", + action: AUDIT_ACTIONS.MEMBER.MEMBER_ADDED, + description: `Added ${userId} as ${role}`, + details: { role, departmentLabel: departmentLabel ?? null }, + }, + }); + // Bump the user's session-generation marker so the next request + // through customSession refetches and includes this new org + // membership without waiting for BetterAuth's 24h session rotation. + // Audit Phase B.5. + await bumpUserSessionGeneration(tx, userId); + + // Outbound webhook: notify subscribed integrations (HRIS sync, + // customer-success tools, ERP). The dispatch helper inserts + // delivery rows on the SAME transaction so if this whole block + // rolls back, the webhook rows roll back too — the receiver only + // sees a member.added event for memberships that actually committed. + await dispatchWebhookEvent({ + prisma: tx, + organizationId: orgId, + eventType: "member.added", + payload: { + membershipId: created.id, + userId, + role, + departmentLabel: departmentLabel ?? null, + }, + }); + return created; + }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } + + return NextResponse.json( + { membership }, + { status: existing ? 200 : 201 }, + ); +} diff --git a/app/api/organizations/[orgId]/payout-account/route.ts b/app/api/organizations/[orgId]/payout-account/route.ts new file mode 100644 index 000000000..0eb9558ab --- /dev/null +++ b/app/api/organizations/[orgId]/payout-account/route.ts @@ -0,0 +1,194 @@ +/** + * GET /api/organizations/[orgId]/payout-account + * PUT /api/organizations/[orgId]/payout-account + * + * Hosting-side bank/payout credentials (canHost=true orgs). The record is + * 1:1 with Organization — a PUT either creates or updates. Full account + * numbers are encrypted before storage; the public-readable last-four and + * status flow is what UI surfaces render. + * + * Verification lifecycle (`status`) moves PENDING_VERIFICATION → VERIFIED + * through a side-channel (Razorpay contact + fund-account creation). This + * endpoint only writes the raw record; the verification job flips status. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { encodeAccountEnvelope } from "@/lib/payments/payouts/account-crypto"; + +const UpsertBodySchema = z.object({ + accountHolderName: z.string().min(1).max(200), + // Full account number — stored encrypted. Last-four is derived. + accountNumber: z.string().min(4).max(34), + bankName: z.string().min(1).max(120), + ifscCode: z.string().length(11).optional(), + routingNumber: z.string().min(1).max(20).nullable().optional(), + swiftCode: z.string().min(8).max(11).nullable().optional(), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + if (!access.org.canHost) { + return NextResponse.json( + { error: "Organization does not host (canHost=false)" }, + { status: 404 }, + ); + } + + const payoutAccount = await prisma.organizationPayoutAccount.findUnique({ + where: { organizationId: orgId }, + select: { + id: true, + accountHolderName: true, + accountNumberLast4: true, + bankName: true, + ifscCode: true, + routingNumber: true, + swiftCode: true, + stripeConnectId: true, + razorpayContactId: true, + razorpayFundAccountId: true, + status: true, + verifiedAt: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!payoutAccount) { + return NextResponse.json( + { payoutAccount: null, exists: false }, + { status: 200 }, + ); + } + return NextResponse.json({ payoutAccount, exists: true }); +} + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + if (!access.org.canHost) { + return NextResponse.json( + { + error: "Organization does not host. Enable canHost before setting a payout account.", + }, + { status: 409 }, + ); + } + + const raw = await req.json().catch(() => null); + const parsed = UpsertBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + const last4 = body.accountNumber.slice(-4); + let encrypted: string; + try { + encrypted = encodeAccountEnvelope(body.accountNumber); + } catch (err) { + return NextResponse.json( + { + error: "Payout encryption is not configured on this server", + detail: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ); + } + + const upserted = await prisma.$transaction(async (tx) => { + const existing = await tx.organizationPayoutAccount.findUnique({ + where: { organizationId: orgId }, + }); + + // Changing bank details resets verification. A different account + // number means the side-channel verification artifacts no longer + // correspond to this record. + const next = await tx.organizationPayoutAccount.upsert({ + where: { organizationId: orgId }, + create: { + organizationId: orgId, + accountHolderName: body.accountHolderName, + accountNumberEncrypted: encrypted, + accountNumberLast4: last4, + bankName: body.bankName, + ifscCode: body.ifscCode ?? null, + routingNumber: body.routingNumber ?? null, + swiftCode: body.swiftCode ?? null, + status: "PENDING_VERIFICATION", + }, + update: { + accountHolderName: body.accountHolderName, + accountNumberEncrypted: encrypted, + accountNumberLast4: last4, + bankName: body.bankName, + ifscCode: body.ifscCode ?? null, + routingNumber: body.routingNumber ?? null, + swiftCode: body.swiftCode ?? null, + // Force re-verification on any account-number change. + ...(existing?.accountNumberLast4 !== last4 && { + status: "PENDING_VERIFICATION", + verifiedAt: null, + razorpayContactId: null, + razorpayFundAccountId: null, + }), + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: existing + ? "Payout account updated" + : "Payout account created", + details: { + accountLast4: last4, + bankName: body.bankName, + ifscCode: body.ifscCode ?? null, + verificationReset: existing?.accountNumberLast4 !== last4, + }, + }, + }); + + return next; + }); + + return NextResponse.json( + { + payoutAccount: { + id: upserted.id, + accountHolderName: upserted.accountHolderName, + accountNumberLast4: upserted.accountNumberLast4, + bankName: upserted.bankName, + ifscCode: upserted.ifscCode, + routingNumber: upserted.routingNumber, + swiftCode: upserted.swiftCode, + status: upserted.status, + verifiedAt: upserted.verifiedAt, + createdAt: upserted.createdAt, + updatedAt: upserted.updatedAt, + }, + }, + { status: 200 }, + ); +} diff --git a/app/api/organizations/[orgId]/payouts/[payoutId]/route.ts b/app/api/organizations/[orgId]/payouts/[payoutId]/route.ts new file mode 100644 index 000000000..700dfa95b --- /dev/null +++ b/app/api/organizations/[orgId]/payouts/[payoutId]/route.ts @@ -0,0 +1,178 @@ +/** + * GET /api/organizations/[orgId]/payouts/[payoutId] + * PATCH /api/organizations/[orgId]/payouts/[payoutId] + * + * GET returns a single payout with its attached earnings. PATCH is narrow: + * it permits only manual admin transitions that don't require bank-side + * interaction. Real state transitions (PENDING → PROCESSING → COMPLETED) + * come from the payout cron that talks to the gateway. + * + * Allowed manual transitions here: + * PENDING → CANCELLED (releases earnings back to READY) + * PENDING → APPROVED (explicit manager sign-off before cron runs) + * FAILED → CANCELLED (abandon a failed payout; releases earnings) + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +// Why: payout PATCH covers state mutations (mark sent, cancel) which are +// finance-team actions; allow BILLING_ADMIN alongside OWNER. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const PatchStatusSchema = z.enum(["APPROVED", "CANCELLED"]); + +const PatchBodySchema = z + .object({ + status: PatchStatusSchema.optional(), + notes: z.string().max(2000).optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; payoutId: string }>; + }, +) { + const { orgId, payoutId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const payout = await prisma.organizationPayout.findFirst({ + where: { id: payoutId, organizationId: orgId }, + include: { + earnings: { + select: { + id: true, + paymentId: true, + grossAmountPaise: true, + orgSharePaise: true, + platformFeePaise: true, + consultantSharePaise: true, + refundedAmountPaise: true, + currency: true, + status: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }, + }, + }); + if (!payout) { + return NextResponse.json({ error: "Payout not found" }, { status: 404 }); + } + return NextResponse.json({ payout }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; payoutId: string }>; + }, +) { + const { orgId, payoutId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.organizationPayout.findFirst({ + where: { id: payoutId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Payout not found"), { httpStatus: 404 }); + } + + if (body.status) { + const allowed: Record = { + PENDING: ["APPROVED", "CANCELLED"], + APPROVED: ["CANCELLED"], + PROCESSING: [], + COMPLETED: [], + FAILED: ["CANCELLED"], + CANCELLED: [], + }; + const next = allowed[current.status] ?? []; + if (!next.includes(body.status)) { + throw Object.assign( + new Error( + `Cannot transition payout from ${current.status} to ${body.status} manually`, + ), + { httpStatus: 409 }, + ); + } + } + + const next = await tx.organizationPayout.update({ + where: { id: payoutId }, + data: { + ...(body.status && { status: body.status }), + }, + }); + + // CANCELLED releases the earnings back to READY so a subsequent + // payout run can pick them up. We write a PAYOUT_REVERSED entry + // (positive, matching the original -netPayout debit) so the ledger + // nets to zero for the cancelled window — reusing PAYOUT_SENT for + // both sides makes analytics queries lie ("payouts sent" would + // double-count every cancel). + if (body.status === "CANCELLED") { + await tx.organizationEarnings.updateMany({ + where: { orgPayoutId: payoutId }, + data: { status: "READY", orgPayoutId: null }, + }); + } + + if (body.status && body.status !== current.status) { + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PAYOUT", + action: + body.status === "CANCELLED" + ? AUDIT_ACTIONS.PAYOUT.PAYOUT_CANCELLED + : AUDIT_ACTIONS.PAYOUT.PAYOUT_INITIATED, + description: `Payout ${payoutId}: ${current.status} → ${body.status}`, + details: { + payoutId, + from: current.status, + to: body.status, + notes: body.notes ?? null, + }, + }, + }); + } + + return next; + }); + + return NextResponse.json({ payout: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/payouts/route.ts b/app/api/organizations/[orgId]/payouts/route.ts new file mode 100644 index 000000000..43ce5efea --- /dev/null +++ b/app/api/organizations/[orgId]/payouts/route.ts @@ -0,0 +1,307 @@ +/** + * GET /api/organizations/[orgId]/payouts + * POST /api/organizations/[orgId]/payouts + * + * Hosting-side settlements: roll READY earnings into an `OrganizationPayout` + * 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. + * + * Actual fund-movement (RazorpayX / Cashfree) happens asynchronously in + * jobs/payouts/** — this endpoint only records the intent and reserves the + * earnings. `status` starts at `PENDING` and transitions via that job. + * + * India statutory fields (`tdsAmountPaise`, `mustPayByDate`, …) are nullable + * at creation and filled by the cron; the route accepts hints but does not + * derive them. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +// Why: payout initiation is a finance-team action; downgrade from +// requireOrgOwner so BILLING_ADMIN can trigger payouts without escalation. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const PayoutStatusSchema = z.enum([ + "PENDING", + "APPROVED", + "PROCESSING", + "COMPLETED", + "FAILED", + "CANCELLED", +]); + +const PaymentGatewaySchema = z.enum([ + "STRIPE", + "RAZORPAY", + "LEMON_SQUEEZY", + "XFLOW", + "CARD", +]); + +const CreatePayoutBodySchema = z + .object({ + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), + paymentGateway: PaymentGatewaySchema.default("RAZORPAY"), + notes: z.string().max(2000).optional(), + }) + .refine((v) => v.periodEnd.getTime() > v.periodStart.getTime(), { + message: "periodEnd must be after periodStart", + }); + +const QuerySchema = z.object({ + status: PayoutStatusSchema.optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + if (!access.org.canHost) { + return NextResponse.json( + { error: "Organization does not host — no payouts to list" }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const parsedQuery = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsedQuery.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsedQuery.error.flatten() }, + { status: 400 }, + ); + } + const q = parsedQuery.data; + + const payouts = await prisma.organizationPayout.findMany({ + where: { + organizationId: orgId, + ...(q.status && { status: q.status }), + ...(q.from || q.to + ? { + createdAt: { + ...(q.from && { gte: q.from }), + ...(q.to && { lt: q.to }), + }, + } + : {}), + }, + orderBy: { createdAt: "desc" }, + take: q.limit, + include: { + _count: { select: { earnings: true } }, + }, + }); + + return NextResponse.json({ data: payouts }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + if (!access.org.canHost) { + return NextResponse.json( + { error: "Organization does not host — payouts are unavailable" }, + { status: 409 }, + ); + } + + const raw = await req.json().catch(() => null); + const parsed = CreatePayoutBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const payout = await prisma.$transaction(async (tx) => { + // Require a verified payout account. An unverified account means + // the side-channel hasn't finished provisioning RazorpayX contact + + // fund-account, so fund movement cannot actually succeed. + const payoutAccount = await tx.organizationPayoutAccount.findUnique({ + where: { organizationId: orgId }, + }); + if (!payoutAccount) { + throw Object.assign( + new Error("No payout account configured for this organization"), + { httpStatus: 409 }, + ); + } + if (payoutAccount.status !== "VERIFIED") { + throw Object.assign( + new Error( + `Payout account is ${payoutAccount.status} — cannot create payouts until VERIFIED`, + ), + { httpStatus: 409 }, + ); + } + + // Race-safe claim pattern: + // (1) Create the payout row first (with zero totals as placeholders). + // (2) Atomically claim READY earnings by assigning them orgPayoutId + // in a single UPDATE — Postgres' row-level locks serialise any + // concurrent POST, so two requests can never claim the same + // 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. + // 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({ + data: { + organizationId: orgId, + amountPaise: 0, + currency: "INR", + status: "PENDING", + paymentGateway: body.paymentGateway, + periodStart: body.periodStart, + periodEnd: body.periodEnd, + grossRevenuePaise: 0, + platformFeePaise: 0, + refundsPaise: 0, + netPayoutPaise: 0, + }, + }); + + const claim = await tx.organizationEarnings.updateMany({ + where: { + organizationId: orgId, + status: "READY", + orgPayoutId: null, + createdAt: { gte: body.periodStart, lt: body.periodEnd }, + }, + data: { orgPayoutId: created.id }, + }); + if (claim.count === 0) { + throw Object.assign( + new Error("No READY earnings in the requested window"), + { httpStatus: 409 }, + ); + } + + const readyEarnings = await tx.organizationEarnings.findMany({ + where: { orgPayoutId: created.id }, + select: { + id: true, + grossAmountPaise: true, + platformFeePaise: true, + orgSharePaise: true, + refundedAmountPaise: true, + currency: true, + }, + }); + + const first = readyEarnings[0]; + if (!first) { + throw Object.assign( + new Error("No READY earnings in the requested window"), + { httpStatus: 409 }, + ); + } + const mixedCurrency = readyEarnings.some( + (e) => e.currency !== first.currency, + ); + if (mixedCurrency) { + throw Object.assign( + new Error( + "Cannot roll earnings in mixed currencies into a single payout. Split the window.", + ), + { httpStatus: 409 }, + ); + } + + const totals = readyEarnings.reduce( + (acc, e) => { + acc.gross += e.grossAmountPaise; + acc.platformFeePaise += e.platformFeePaise; + acc.orgShare += e.orgSharePaise; + acc.refunds += e.refundedAmountPaise; + return acc; + }, + { gross: 0, platformFeePaise: 0, orgShare: 0, refunds: 0 }, + ); + // Net payout to the org = orgShare - refunds. TDS is withheld by + // the cron if applicable, so this is the PRE-tax net. + const netPayout = totals.orgShare - totals.refunds; + if (netPayout <= 0) { + throw Object.assign( + new Error( + `Net payout would be ${netPayout} paise — refunds exceed earnings. Reconcile first.`, + ), + { httpStatus: 409 }, + ); + } + + const updated = await tx.organizationPayout.update({ + where: { id: created.id }, + data: { + amountPaise: netPayout, + currency: first.currency, + grossRevenuePaise: totals.gross, + platformFeePaise: totals.platformFeePaise, + refundsPaise: totals.refunds, + netPayoutPaise: netPayout, + }, + }); + + // Flip the claimed earnings READY → PAID. The cron that flips the + // payout to COMPLETED does not touch earnings.status. + await tx.organizationEarnings.updateMany({ + where: { orgPayoutId: created.id, status: "READY" }, + data: { status: "PAID" }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PAYOUT", + action: AUDIT_ACTIONS.PAYOUT.PAYOUT_INITIATED, + description: `Payout initiated: ${readyEarnings.length} earnings, net ${netPayout} paise ${first.currency}`, + details: { + payoutId: created.id, + earningsCount: readyEarnings.length, + netPayoutPaise: netPayout, + grossPaise: totals.gross, + platformFeePaise: totals.platformFeePaise, + refundsPaise: totals.refunds, + }, + }, + }); + + return updated; + }); + + return NextResponse.json({ payout }, { status: 201 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]/route.ts b/app/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]/route.ts new file mode 100644 index 000000000..9170e6944 --- /dev/null +++ b/app/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]/route.ts @@ -0,0 +1,261 @@ +/** + * GET /api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId] + * PATCH /api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId] + * DELETE /api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId] + * + * DELETE is narrow: an assignment with recorded BookingUtilizations + * cannot be deleted — the usage ledger would lose its anchor. Instead, + * PATCH engagementsUsed / overageCount or let the periodEnd pass naturally. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { adjustActiveSeatCount } from "@/lib/api/organizations/seat-count"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const PatchBodySchema = z + .object({ + periodStart: z.coerce.date().optional(), + periodEnd: z.coerce.date().optional(), + // #779 §B — end the allocation early WITHOUT removing the member (the + // member-removal cascade is the only other path). Sets status=CANCELLED, + // ends the period now, frees the seat. History (utilizations) stays. + cancel: z.literal(true).optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }) + .refine((v) => !(v.cancel && (v.periodStart || v.periodEnd)), { + message: "cancel cannot be combined with period edits", + }); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string; assignmentId: string }>; + }, +) { + const { orgId, programId, assignmentId } = await params; + // Read widened to any ACTIVE member so a LEARNER can see their own + // assignment details (utilization, limits) without MANAGER access. + // PATCH/DELETE remain MANAGER+canSponsor. + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + if (!access.org.canSponsor) { + return NextResponse.json( + { error: "Organization does not sponsor programs" }, + { status: 404 }, + ); + } + + const assignment = await prisma.programAssignment.findFirst({ + where: { + id: assignmentId, + programId, + program: { contract: { organizationId: orgId } }, + }, + include: { + membership: { include: { user: { select: { id: true, name: true, email: true } } } }, + utilizations: { orderBy: { createdAt: "desc" }, take: 50 }, + }, + }); + if (!assignment) { + return NextResponse.json({ error: "Assignment not found" }, { status: 404 }); + } + return NextResponse.json({ assignment }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string; assignmentId: string }>; + }, +) { + const { orgId, programId, assignmentId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.programAssignment.findFirst({ + where: { + id: assignmentId, + programId, + program: { contract: { organizationId: orgId } }, + }, + }); + if (!current) { + throw Object.assign(new Error("Assignment not found"), { + httpStatus: 404, + }); + } + + // #779 §B — early cancellation. Claim only an ACTIVE row so a concurrent + // cancel / cycle-rollover can't double-free the seat. periodEnd clamps to + // periodStart for a not-yet-started allocation (no negative period). + if (body.cancel) { + const cancelEnd = new Date( + Math.max(Date.now(), current.periodStart.getTime()), + ); + const claimed = await tx.programAssignment.updateMany({ + where: { id: assignmentId, status: "ACTIVE" }, + data: { status: "CANCELLED", periodEnd: cancelEnd }, + }); + if (claimed.count === 0) { + throw Object.assign( + new Error( + "Assignment is not active (already rolled, closed, or cancelled)", + ), + { httpStatus: 409, code: "ASSIGNMENT_NOT_ACTIVE" }, + ); + } + await adjustActiveSeatCount(tx, { programId, delta: -1 }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: current.membershipId, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_UNASSIGNED, + description: `Program assignment cancelled early for program ${programId}`, + details: { programId, assignmentId, cancelledEarly: true }, + }, + }); + return tx.programAssignment.findUniqueOrThrow({ + where: { id: assignmentId }, + }); + } + + const nextStart = body.periodStart ?? current.periodStart; + const nextEnd = body.periodEnd ?? current.periodEnd; + if (nextEnd.getTime() <= nextStart.getTime()) { + throw Object.assign( + new Error("periodEnd must be after periodStart"), + { httpStatus: 400 }, + ); + } + const next = await tx.programAssignment.update({ + where: { id: assignmentId }, + data: { + periodStart: nextStart, + periodEnd: nextEnd, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: current.membershipId, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_ASSIGNMENT_UPDATED, + description: `Program assignment period updated for program ${programId}`, + details: { + programId, + assignmentId, + from: { + periodStart: current.periodStart.toISOString(), + periodEnd: current.periodEnd.toISOString(), + }, + to: { + periodStart: nextStart.toISOString(), + periodEnd: nextEnd.toISOString(), + }, + }, + }, + }); + return next; + }); + return NextResponse.json({ assignment: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string; assignmentId: string }>; + }, +) { + const { orgId, programId, assignmentId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.programAssignment.findFirst({ + where: { + id: assignmentId, + programId, + program: { contract: { organizationId: orgId } }, + }, + include: { _count: { select: { utilizations: true } } }, + }); + if (!current) { + throw Object.assign(new Error("Assignment not found"), { + httpStatus: 404, + }); + } + if (current._count.utilizations > 0) { + throw Object.assign( + new Error( + "Cannot delete an assignment with recorded utilizations. Let the period expire or archive the program instead.", + ), + { httpStatus: 409 }, + ); + } + await tx.programAssignment.delete({ where: { id: assignmentId } }); + // For LICENSED_SEAT programs the seat is freed; for others this is + // a no-op and `adjustActiveSeatCount` returns applied:false. + await adjustActiveSeatCount(tx, { programId, delta: -1 }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: current.membershipId, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_UNASSIGNED, + description: `Unassigned ${current.membershipId} from program ${programId}`, + details: { programId, assignmentId }, + }, + }); + }); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/programs/[programId]/assignments/route.ts b/app/api/organizations/[orgId]/programs/[programId]/assignments/route.ts new file mode 100644 index 000000000..7a3972fac --- /dev/null +++ b/app/api/organizations/[orgId]/programs/[programId]/assignments/route.ts @@ -0,0 +1,186 @@ +/** + * GET /api/organizations/[orgId]/programs/[programId]/assignments + * POST /api/organizations/[orgId]/programs/[programId]/assignments + * + * Per-member program entitlements. GET lists assignments for the + * program; POST creates one via `claimProgramAssignment`, which handles + * the upsert + period uniqueness invariant. + * + * Activating a LICENSED_SEAT assignment bumps `activeSeatCount` on the + * config — the enforcement happens on the next billing cycle when + * generate-subscription-invoices reads this value. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { claimProgramAssignment } from "@/lib/api/organizations/program-helpers"; +import { adjustActiveSeatCount } from "@/lib/api/organizations/seat-count"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const CreateBodySchema = z.object({ + membershipId: z.string().min(1), + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), +}); + +export async function GET( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string }>; + }, +) { + const { orgId, programId } = await params; + // Read widened to any ACTIVE member. LEARNERs need to see who else + // is assigned for seat-pool visibility ("how many seats left?"). + // Write endpoints (POST/DELETE) stay MANAGER+canSponsor. + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + if (!access.org.canSponsor) { + return NextResponse.json( + { error: "Organization does not sponsor programs" }, + { status: 404 }, + ); + } + + // Belt-and-braces: don't leak assignments from a program in a + // sibling org even if the caller knows the programId. + const program = await prisma.program.findFirst({ + where: { id: programId, contract: { organizationId: orgId } }, + select: { id: true }, + }); + if (!program) { + return NextResponse.json({ error: "Program not found" }, { status: 404 }); + } + + const url = new URL(req.url); + const membershipId = url.searchParams.get("membershipId") ?? undefined; + + const assignments = await prisma.programAssignment.findMany({ + where: { + programId, + ...(membershipId && { membershipId }), + }, + include: { + membership: { + select: { + id: true, + role: true, + user: { select: { id: true, name: true, email: true } }, + }, + }, + }, + orderBy: { periodStart: "desc" }, + }); + + return NextResponse.json({ data: assignments }); +} + +export async function POST( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string }>; + }, +) { + const { orgId, programId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + if (body.periodEnd.getTime() <= body.periodStart.getTime()) { + return NextResponse.json( + { error: "periodEnd must be after periodStart" }, + { status: 400 }, + ); + } + + // Cross-org guards: program in this org, membership in this org. + // One trip to the DB per object keeps the error messages specific — + // a single findFirst union would surface a generic "not found". + const program = await prisma.program.findFirst({ + where: { id: programId, contract: { organizationId: orgId } }, + select: { id: true, status: true }, + }); + if (!program) { + return NextResponse.json({ error: "Program not found" }, { status: 404 }); + } + if (program.status !== "ACTIVE") { + return NextResponse.json( + { error: `Cannot assign to a ${program.status} program` }, + { status: 409 }, + ); + } + + const membership = await prisma.membership.findFirst({ + where: { id: body.membershipId, organizationId: orgId }, + select: { id: true, role: true }, + }); + if (!membership) { + return NextResponse.json( + { error: "Membership does not belong to this organization" }, + { status: 400 }, + ); + } + + const assignment = await prisma.$transaction(async (tx) => { + // claimProgramAssignment reports whether THIS call created the row (atomic + // INSERT … ON CONFLICT DO NOTHING). Seat-count only on a genuine create, so + // a re-claim or two concurrent identical POSTs increment activeSeatCount + // exactly once (the old preexisting-probe was a check-then-act race). + const { assignment: created, created: isNew } = await claimProgramAssignment( + tx, + { + programId, + membershipId: body.membershipId, + periodStart: body.periodStart, + periodEnd: body.periodEnd, + }, + ); + if (isNew) { + await adjustActiveSeatCount(tx, { programId, delta: +1 }); + // #779 — set-point for the persistent money-config lock: the FIRST genuine + // assignment freezes LOCKED_PROGRAM_FIELDS. updateMany gated on + // configLockedAt:null so a re-stamp (already-locked program, later + // assignment) is a no-op and the original lock instant is preserved. + await tx.program.updateMany({ + where: { id: programId, configLockedAt: null }, + data: { configLockedAt: new Date() }, + }); + } + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + targetMembershipId: body.membershipId, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_ASSIGNED, + description: `Assigned membership ${body.membershipId} to program ${programId}`, + details: { + programId, + membershipId: body.membershipId, + periodStart: body.periodStart.toISOString(), + periodEnd: body.periodEnd.toISOString(), + }, + }, + }); + return created; + }); + + return NextResponse.json({ assignment }, { status: 201 }); +} diff --git a/app/api/organizations/[orgId]/programs/[programId]/route.ts b/app/api/organizations/[orgId]/programs/[programId]/route.ts new file mode 100644 index 000000000..11506e8cc --- /dev/null +++ b/app/api/organizations/[orgId]/programs/[programId]/route.ts @@ -0,0 +1,513 @@ +/** + * GET /api/organizations/[orgId]/programs/[programId] + * PATCH /api/organizations/[orgId]/programs/[programId] + * DELETE /api/organizations/[orgId]/programs/[programId] + * + * DELETE is DRAFT-only (same posture as /contracts). Active programs + * must be PAUSED via PATCH first — this preserves the audit trail and + * prevents orphaning ProgramAssignments. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { transitionProgram } from "@/lib/enterprise/transitions"; +import { getProgramLockState } from "@/lib/enterprise/config-lock"; + +const ProgramStatusSchema = z.enum([ + "ACTIVE", + "PAUSED", + "EXPIRED", + "CANCELLED", +]); + +const CoveredPlanTypeSchema = z.enum([ + "CONSULTATION", + "CLASS", + "WEBINAR", + "SUBSCRIPTION", +]); + +const OverageBehaviorSchema = z.enum(["BLOCK", "CHARGE_MEMBER", "CHARGE_ORG"]); + +const PatchBodySchema = z + .object({ + name: z.string().min(2).max(120).optional(), + status: ProgramStatusSchema.optional(), + // #777 §B — archive/unarchive (soft-hide; never hard-delete once in use). + archived: z.boolean().optional(), + coveredPlanTypes: z.array(CoveredPlanTypeSchema).optional(), + allowedCategories: z.array(z.string()).optional(), + // Money config — locked once the program is in use (#777 §B). Type isn't + // editable post-create (it picks which config table exists); the per-type + // money fields below route to licensedSeatConfig / creditPoolConfig. + ratePerSeatPaise: z.coerce.number().int().min(0).optional(), + coveredEngagementsPerCycle: z.coerce + .number() + .int() + .min(1) + .nullable() + .optional(), + creditBudgetPerCycle: z.coerce.number().int().min(1).optional(), + overageBehavior: OverageBehaviorSchema.optional(), + overageSurchargeBps: z.coerce.number().int().min(0).nullable().optional(), + priceCapPerEngagementPaise: z.coerce + .number() + .int() + .min(0) + .nullable() + .optional(), + maxOveragePerCyclePaise: z.coerce + .number() + .int() + .min(0) + .nullable() + .optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +// Fields whose edit rewrites already-settled money — gated by the in-use +// lock (#777 §B). `coveredPlanTypes` counts: it decides what a seat covers. +const MONEY_FIELDS = [ + "coveredPlanTypes", + "ratePerSeatPaise", + "coveredEngagementsPerCycle", + "creditBudgetPerCycle", + "overageBehavior", + "overageSurchargeBps", + "priceCapPerEngagementPaise", + "maxOveragePerCyclePaise", +] as const; + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string }>; + }, +) { + const { orgId, programId } = await params; + // Read widened to any ACTIVE member: a LEARNER assigned to a program + // needs to see the program's rules (covered plan types, pool balance) + // to understand what they can book. Mutations stay MANAGER+ below. + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + if (!access.org.canSponsor) { + return NextResponse.json( + { error: "Organization does not sponsor programs" }, + { status: 404 }, + ); + } + + const program = await prisma.program.findFirst({ + where: { id: programId, contract: { organizationId: orgId } }, + include: { + licensedSeatConfig: true, + creditPoolConfig: true, + contract: { + select: { + id: true, + status: true, + effectiveFrom: true, + effectiveTo: true, + }, + }, + _count: { select: { assignments: true } }, + }, + }); + if (!program) { + return NextResponse.json({ error: "Program not found" }, { status: 404 }); + } + // Surface the in-use lock so the edit dialog can disable money fields + // without a second round-trip (#777 §B). + const { locked } = await getProgramLockState(programId); + return NextResponse.json({ program: { ...program, locked } }); +} + +// TODO(#777 server-actions): kept as a Route Handler + useMutation to match the +// rest of the dashboard. New first-party form mutations should prefer a Server +// Action (co-located write + revalidate, progressive enhancement) per the +// agreed direction — migrate this when the dashboard converges on that pattern. +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string }>; + }, +) { + const { orgId, programId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Reject any money-field edit on a program that's already in use — a + // retroactive change would rewrite bookings settled at the old terms. + // `name`/`status`/`allowedCategories` stay editable always (#777 §B). + const touchesMoney = MONEY_FIELDS.some((f) => body[f] !== undefined); + if (touchesMoney) { + const { locked } = await getProgramLockState(programId); + if (locked) { + return NextResponse.json( + { + error: + "Program is in use — money config is locked. Only the name can be changed.", + code: "PROGRAM_CONFIG_LOCKED", + }, + { status: 409 }, + ); + } + } + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.program.findFirst({ + where: { id: programId, contract: { organizationId: orgId } }, + include: { licensedSeatConfig: true, creditPoolConfig: true }, + }); + if (!current) { + throw Object.assign(new Error("Program not found"), { + httpStatus: 404, + }); + } + + // #768 #14/#15 — the create route validates overage combos on the WHOLE + // config; a piecemeal PATCH could still assemble CHARGE_* with no + // circuit-breaker ceiling (unbounded liability) or dead knobs. Merge + // current + patch and re-check the combined state. + if (touchesMoney) { + const cfg = current.licensedSeatConfig ?? current.creditPoolConfig; + const merged = { + overageBehavior: + body.overageBehavior ?? cfg?.overageBehavior ?? "BLOCK", + overageSurchargeBps: + body.overageSurchargeBps !== undefined + ? body.overageSurchargeBps + : (cfg?.overageSurchargeBps ?? null), + maxOveragePerCyclePaise: + body.maxOveragePerCyclePaise !== undefined + ? body.maxOveragePerCyclePaise + : (cfg?.maxOveragePerCyclePaise ?? null), + coveredEngagementsPerCycle: + body.coveredEngagementsPerCycle !== undefined + ? body.coveredEngagementsPerCycle + : (current.licensedSeatConfig?.coveredEngagementsPerCycle ?? + null), + }; + const fail = (message: string) => { + throw Object.assign(new Error(message), { + httpStatus: 400, + code: "INVALID_OVERAGE_CONFIG", + }); + }; + if ( + current.type === "LICENSED_SEAT" && + merged.coveredEngagementsPerCycle == null && + (merged.overageBehavior !== "BLOCK" || + (merged.overageSurchargeBps ?? 0) > 0 || + merged.maxOveragePerCyclePaise != null) + ) { + fail( + "Overage settings have no effect while coveredEngagementsPerCycle is unlimited — clear them or set a cap.", + ); + } + if ( + merged.overageBehavior !== "BLOCK" && + (merged.coveredEngagementsPerCycle != null || + current.type === "CREDIT_POOL") && + (merged.maxOveragePerCyclePaise == null || + merged.maxOveragePerCyclePaise < 1) + ) { + fail( + `overageBehavior=${merged.overageBehavior} requires a positive maxOveragePerCyclePaise circuit-breaker ceiling.`, + ); + } + if ( + merged.overageBehavior === "BLOCK" && + (merged.overageSurchargeBps ?? 0) > 0 + ) { + fail( + "overageSurchargeBps has no effect with overageBehavior=BLOCK — remove it or pick CHARGE_MEMBER/CHARGE_ORG.", + ); + } + } + + // #777 §B — archiving guard: an archived program is skipped by the cycle + // engine, so live allocations under it would zombie (never roll, never + // close). Force the operator to cancel them (or let the cycle end) first. + if (body.archived === true) { + const activeAssignments = await tx.programAssignment.count({ + where: { + programId, + status: "ACTIVE", + periodEnd: { gte: new Date() }, + }, + }); + if (activeAssignments > 0) { + throw Object.assign( + new Error( + `Cannot archive a program with ${activeAssignments} active assignment(s). Cancel them or let the cycle end first.`, + ), + { + httpStatus: 409, + code: "PROGRAM_HAS_ACTIVE_ASSIGNMENTS", + }, + ); + } + } + + const programData = { + ...(body.name !== undefined && { name: body.name }), + ...(body.archived !== undefined && { + archivedAt: body.archived ? new Date() : null, + }), + ...(body.coveredPlanTypes !== undefined && { + coveredPlanTypes: body.coveredPlanTypes, + }), + ...(body.allowedCategories !== undefined && { + allowedCategories: body.allowedCategories, + }), + }; + + if (body.status !== undefined && body.status !== current.status) { + // CAS — allowed-from rides the WHERE (tenancy via the contract + // relation), so a concurrent transition or a stale tab reactivating a + // CANCELLED/EXPIRED program matches zero rows and 409s. + await transitionProgram(tx, { + where: { id: programId, contract: { organizationId: orgId } }, + to: body.status, + data: programData, + }); + + // Cancelling must take the live assignments down in the same tx — + // otherwise members keep drawing entitlements from a dead program + // (checkout honors ACTIVE assignments). periodEnd: now mirrors the + // member-removal cascade so the periodEnd>=now filter dies with the + // status, not after it. + let assignmentsCancelled = 0; + if (body.status === "CANCELLED") { + const cascaded = await tx.programAssignment.updateMany({ + where: { programId, status: { in: ["ACTIVE", "PAUSED"] } }, + data: { status: "CANCELLED", periodEnd: new Date() }, + }); + assignmentsCancelled = cascaded.count; + } + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: + body.status === "PAUSED" + ? AUDIT_ACTIONS.PROGRAM.PROGRAM_PAUSED + : body.status === "ACTIVE" + ? AUDIT_ACTIONS.PROGRAM.PROGRAM_RESUMED + : body.status === "CANCELLED" + ? AUDIT_ACTIONS.PROGRAM.PROGRAM_CANCELLED + : AUDIT_ACTIONS.PROGRAM.PROGRAM_EXPIRED, + description: `Program ${programId}: ${current.status} → ${body.status}`, + details: { + programId, + from: current.status, + to: body.status, + ...(body.status === "CANCELLED" && { assignmentsCancelled }), + }, + }, + }); + } else if (Object.keys(programData).length > 0) { + await tx.program.update({ + where: { id: programId }, + data: programData, + }); + } + + const next = await tx.program.findUniqueOrThrow({ + where: { id: programId }, + }); + + // Per-type money fields route to the live config table. `type` is + // immutable post-create, so the existing config row is the target — + // unreachable fields (e.g. ratePerSeatPaise on a CREDIT_POOL) are + // simply absent from the body and skipped. + if (current.type === "LICENSED_SEAT") { + const seatData = { + ...(body.ratePerSeatPaise !== undefined && { + ratePerSeatPaise: body.ratePerSeatPaise, + }), + ...(body.coveredEngagementsPerCycle !== undefined && { + coveredEngagementsPerCycle: body.coveredEngagementsPerCycle, + }), + ...(body.overageBehavior !== undefined && { + overageBehavior: body.overageBehavior, + }), + ...(body.overageSurchargeBps !== undefined && { + overageSurchargeBps: body.overageSurchargeBps, + }), + ...(body.priceCapPerEngagementPaise !== undefined && { + priceCapPerEngagementPaise: body.priceCapPerEngagementPaise, + }), + ...(body.maxOveragePerCyclePaise !== undefined && { + maxOveragePerCyclePaise: body.maxOveragePerCyclePaise, + }), + }; + if (Object.keys(seatData).length > 0) { + await tx.licensedSeatConfig.update({ + where: { programId }, + data: seatData, + }); + } + } else if (current.type === "CREDIT_POOL") { + const poolData = { + ...(body.creditBudgetPerCycle !== undefined && { + creditBudgetPerCycle: body.creditBudgetPerCycle, + }), + ...(body.overageBehavior !== undefined && { + overageBehavior: body.overageBehavior, + }), + ...(body.overageSurchargeBps !== undefined && { + overageSurchargeBps: body.overageSurchargeBps, + }), + ...(body.maxOveragePerCyclePaise !== undefined && { + maxOveragePerCyclePaise: body.maxOveragePerCyclePaise, + }), + }; + if (Object.keys(poolData).length > 0) { + await tx.creditPoolConfig.update({ + where: { programId }, + data: poolData, + }); + } + } + + // Archive/unarchive gets its own audit action (#777 §B). + if ( + body.archived !== undefined && + body.archived !== (current.archivedAt != null) + ) { + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_ARCHIVED, + description: `Program ${programId} ${body.archived ? "archived" : "unarchived"}`, + details: { programId, archived: body.archived }, + }, + }); + } + + return next; + }); + return NextResponse.json({ program: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; programId: string }>; + }, +) { + const { orgId, programId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + try { + // Serializable isolation closes the race where assignment-creation + // and program-deletion run concurrently: both transactions read + // assignments=0, both proceed, and the delete cascades the + // newly-created assignment. Postgres detects the read/write + // dependency cycle under SERIALIZABLE and aborts one with P2034 + // (which Prisma surfaces as a retryable serialization error). The + // explicit assignment count + utilization check inside the tx still + // runs first as a fast-fail. + await prisma.$transaction( + async (tx) => { + const current = await tx.program.findFirst({ + where: { id: programId, contract: { organizationId: orgId } }, + include: { _count: { select: { assignments: true } } }, + }); + if (!current) { + throw Object.assign(new Error("Program not found"), { + httpStatus: 404, + }); + } + if (current._count.assignments > 0) { + throw Object.assign( + new Error( + "Cannot delete a program with active assignments. Pause it instead (PATCH status=PAUSED).", + ), + { httpStatus: 409 }, + ); + } + + // Even when assignments=0, a current-cycle BookingUtilization + // can exist via a reversed-but-not-removed history row. Refuse + // the hard delete if any utilization in the current period is + // still queryable — the audit trail would otherwise lose its + // foreign-key target. + const utilizationStillPresent = await tx.bookingUtilization.findFirst({ + where: { programAssignment: { programId } }, + select: { id: true }, + }); + if (utilizationStillPresent) { + throw Object.assign( + new Error( + "Program has historical utilization rows. Pause via PATCH status=CANCELLED instead of deleting.", + ), + { httpStatus: 409 }, + ); + } + + await tx.program.delete({ where: { id: programId } }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_DELETED, + description: `Program ${programId} deleted (no assignments)`, + details: { programId }, + }, + }); + }, + { isolationLevel: "Serializable" }, + ); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/programs/route.ts b/app/api/organizations/[orgId]/programs/route.ts new file mode 100644 index 000000000..9a70c0dbd --- /dev/null +++ b/app/api/organizations/[orgId]/programs/route.ts @@ -0,0 +1,419 @@ +/** + * GET /api/organizations/[orgId]/programs + * POST /api/organizations/[orgId]/programs + * + * Programs are the commercial primitive — every ProgramAssignment hangs + * off one. v1 supports LICENSED_SEAT and CREDIT_POOL; PROJECT and + * RETAINER are reserved in the Prisma enum for v2 but not yet accepted + * at the create endpoint. See schema.prisma for the full subtype story. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + capabilityOf, + isReachableOrgFundingPath, +} from "@/lib/enterprise/reachable-paths"; +import { sumPaise } from "@/lib/payments/utils/money"; + +const CoveredPlanTypeSchema = z.enum([ + "CONSULTATION", + "CLASS", + "WEBINAR", + "SUBSCRIPTION", +]); + +const BillingCycleSchema = z.enum(["MONTHLY", "QUARTERLY", "ANNUAL"]); +// TODO(#715): CHARGE_MEMBER and CHARGE_ORG are accepted here and +// `recordBookingUtilization` correctly flags `wasOverage` for bookings +// past the cap, but the downstream financial side effect is still in +// flight — member-side card charge for CHARGE_MEMBER and invoice-accrual +// leg for CHARGE_ORG. Until #715 ships, the safe production grid is +// BLOCK only; the wizard surfaces a WIP banner when either of the other +// two is selected so operators don't ship a silent under-charge. +const OverageBehaviorSchema = z.enum(["BLOCK", "CHARGE_MEMBER", "CHARGE_ORG"]); + +// #768 #14/#15 — overage-combo guards shared by both config schemas: +// - CHARGE_* without a positive maxOveragePerCyclePaise = unbounded +// runaway liability (the breaker is the only hard stop); +// - surcharge with BLOCK = dead knob (nothing is ever charged). +const refineOverageCombo = ( + v: { + overageBehavior: "BLOCK" | "CHARGE_MEMBER" | "CHARGE_ORG"; + overageSurchargeBps?: number | null; + maxOveragePerCyclePaise?: number | null; + }, + ctx: z.RefinementCtx, +) => { + if (v.overageBehavior !== "BLOCK") { + if (v.maxOveragePerCyclePaise == null || v.maxOveragePerCyclePaise < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["maxOveragePerCyclePaise"], + message: `overageBehavior=${v.overageBehavior} requires a positive maxOveragePerCyclePaise circuit-breaker ceiling`, + }); + } + } else if ((v.overageSurchargeBps ?? 0) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["overageSurchargeBps"], + message: + "overageSurchargeBps has no effect with overageBehavior=BLOCK — remove it or pick CHARGE_MEMBER/CHARGE_ORG", + }); + } +}; + +// Create bodies are discriminated by `type` so the nested config schema +// only accepts the right shape for each subtype. A LICENSED_SEAT body +// with creditPoolConfig fails validation at the edge, not at the DB. +const LicensedSeatConfigSchema = z + .object({ + ratePerSeatPaise: z.coerce.number().int().min(0), + cycle: BillingCycleSchema, + coveredEngagementsPerCycle: z.coerce + .number() + .int() + .min(0) + .nullable() + .optional(), + overageBehavior: OverageBehaviorSchema.default("BLOCK"), + priceCapPerEngagementPaise: z.coerce + .number() + .int() + .min(0) + .nullable() + .optional(), + // #775 — bps markup on the pass-through overage marginal (null = no markup). + overageSurchargeBps: z.coerce.number().int().min(0).nullable().optional(), + // #768 #14/#15 — per-cycle overage ceiling (circuit breaker; null = none). + maxOveragePerCyclePaise: z.coerce + .number() + .int() + .min(0) + .nullable() + .optional(), + }) + .superRefine((v, ctx) => { + // Unlimited coverage (null cap) never produces an overage — every overage + // knob is dead config; reject rather than persist a misleading program. + if (v.coveredEngagementsPerCycle == null) { + const deadKnobs: Array<[string, boolean]> = [ + ["overageBehavior", v.overageBehavior !== "BLOCK"], + ["overageSurchargeBps", (v.overageSurchargeBps ?? 0) > 0], + ["maxOveragePerCyclePaise", v.maxOveragePerCyclePaise != null], + ["priceCapPerEngagementPaise", v.priceCapPerEngagementPaise != null], + ]; + for (const [field, isDead] of deadKnobs) { + if (isDead) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} has no effect when coveredEngagementsPerCycle is unlimited (null)`, + }); + } + } + return; + } + refineOverageCombo(v, ctx); + }); + +// 1 credit = ₹1 = 100 paise (fixed; see schema.prisma). The pool resets +// every `cycle`. Premium-tier multipliers were dropped from v1 — bespoke +// per-expert rates live on a Program rate-card override. +// +// TODO(#715, #716): CREDIT_POOL works end-to-end at the schema + lazy- +// debit + reconcile layer, but the refund-back-to-pool path and the +// consolidated-invoice round-trip have not been acceptance-tested +// against a finance-grade tenant yet. The wizard surfaces a WIP banner +// when CREDIT_POOL is picked so operators see the soak status before +// committing a real customer to it. +const CreditPoolConfigSchema = z + .object({ + cycle: BillingCycleSchema, + creditBudgetPerCycle: z.coerce.number().int().min(1), + // #775 — over-budget routing + markup + ceiling (parity with LICENSED_SEAT). + overageBehavior: OverageBehaviorSchema.default("BLOCK"), + overageSurchargeBps: z.coerce.number().int().min(0).nullable().optional(), + maxOveragePerCyclePaise: z.coerce + .number() + .int() + .min(0) + .nullable() + .optional(), + }) + // Pool budgets are always finite (creditBudgetPerCycle ≥ 1), so only the shared + // combo guards apply here. + .superRefine(refineOverageCombo); + +const CreateBodySchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("LICENSED_SEAT"), + contractId: z.string().min(1), + name: z.string().min(2).max(120), + coveredPlanTypes: z.array(CoveredPlanTypeSchema).default([]), + allowedCategories: z.array(z.string()).default([]), + licensedSeatConfig: LicensedSeatConfigSchema, + // #751 — explicit operator acknowledgement of overlapping coverage. + forceOverlap: z.boolean().default(false), + }), + z.object({ + type: z.literal("CREDIT_POOL"), + contractId: z.string().min(1), + name: z.string().min(2).max(120), + coveredPlanTypes: z.array(CoveredPlanTypeSchema).default([]), + allowedCategories: z.array(z.string()).default([]), + creditPoolConfig: CreditPoolConfigSchema, + forceOverlap: z.boolean().default(false), + }), +]); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // Read is widened to any ACTIVE org member: LEARNERs legitimately need + // to see "which programs am I under" (drives the home dashboard, + // booking UI, and utilization widgets). Mutations (POST below) stay + // MANAGER+canSponsor — see docs/enterprise/00-foundations/04-roles-and-permissions.md. + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + if (!access.org.canSponsor) { + // Hosting-only orgs genuinely do not have programs; surface 404 so + // the nav treats this as "feature off" rather than "forbidden". + return NextResponse.json( + { error: "Organization does not sponsor — no programs to list" }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const contractId = url.searchParams.get("contractId") ?? undefined; + // #777 §B — archived programs are hidden from the active list by default; + // ?includeArchived=true surfaces them (history view). + const includeArchived = url.searchParams.get("includeArchived") === "true"; + + const programs = await prisma.program.findMany({ + where: { + contract: { organizationId: orgId }, + ...(contractId && { contractId }), + ...(!includeArchived && { archivedAt: null }), + }, + include: { + licensedSeatConfig: true, + creditPoolConfig: true, + _count: { select: { assignments: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + // #777 §H — per-program usage across current-cycle assignments, so the list + // can show a utilization column without a per-row round-trip. Aggregated. + const now = new Date(); + const usage = programs.length + ? await prisma.programAssignment.groupBy({ + by: ["programId"], + // ACTIVE + in-window only: a future (not-yet-started) or + // cancelled/rolled row would inflate the capacity multiplier the + // utilization column derives from _count. + where: { + programId: { in: programs.map((p) => p.id) }, + status: "ACTIVE", + periodStart: { lte: now }, + periodEnd: { gte: now }, + }, + _sum: { engagementsUsed: true, consumedPaise: true }, + _count: { _all: true }, + }) + : []; + const usageByProgram = new Map(usage.map((u) => [u.programId, u])); + + const data = programs.map((p) => { + const u = usageByProgram.get(p.id); + return { + ...p, + utilization: { + activeAssignments: u?._count._all ?? 0, + engagementsUsed: u?._sum.engagementsUsed ?? 0, + consumedPaise: sumPaise(u?._sum.consumedPaise), + }, + }; + }); + + return NextResponse.json({ data }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + minimumRole: "MAINTAINER", + canSponsor: true, + }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Contract ownership check — same pattern as BillingAccount in + // /contracts: reject a stolen id from another tenant before we hit + // the FK layer with a 500. We also pull the parent's billingAccount + // fundingSource so the reachable-path gate below can reject any + // (capability x fundingSource x programType) combo the v0 matrix + // (#768) doesn't sanction before it ever hits the DB. + const contract = await prisma.contract.findUnique({ + where: { id: body.contractId }, + select: { + organizationId: true, + status: true, + billingAccount: { select: { fundingSource: true } }, + }, + }); + if (!contract || contract.organizationId !== orgId) { + return NextResponse.json( + { error: "Contract does not belong to this organization" }, + { status: 400 }, + ); + } + if (contract.status === "TERMINATED" || contract.status === "EXPIRED") { + return NextResponse.json( + { error: `Cannot attach a program to a ${contract.status} contract` }, + { status: 409 }, + ); + } + + // Single gate for every illegal (capability x fundingSource x + // programType) combo — subsumes the old BOGUS_LICENSE_CREDIT_POOL + // special-case (the v0 matrix #768 already excludes SPONSOR + LICENSE + + // CREDIT_POOL). The wizard hides unreachable options; this closes the + // API loophole so a curious client can't construct one directly. + const capability = capabilityOf(access.org.canSponsor, access.org.canHost); + const fundingSource = contract.billingAccount?.fundingSource ?? null; + if ( + !capability || + !isReachableOrgFundingPath(capability, fundingSource, body.type) + ) { + return NextResponse.json( + { + error: `${body.type} programs are not allowed for a ${capability ?? "non-sponsoring"} organization on a ${fundingSource ?? "unknown"}-funded contract. This combination isn't part of the supported funding matrix.`, + code: "UNREACHABLE_FUNDING_PATH", + }, + { status: 400 }, + ); + } + + // #751 — two ACTIVE programs on the same contract with intersecting + // coveredPlanTypes make checkout's program resolution ambiguous (the + // booking lands on whichever resolves first) and can double-entitle a + // member. An empty coveredPlanTypes covers everything, so it intersects + // any other program. Refuse unless the operator explicitly forces it. + if (!body.forceOverlap) { + const siblings = await prisma.program.findMany({ + where: { + contractId: body.contractId, + status: "ACTIVE", + archivedAt: null, + }, + select: { id: true, name: true, coveredPlanTypes: true }, + }); + const coversAll = body.coveredPlanTypes.length === 0; + const overlapping = siblings.filter( + (s) => + coversAll || + s.coveredPlanTypes.length === 0 || + s.coveredPlanTypes.some((t) => body.coveredPlanTypes.includes(t)), + ); + if (overlapping.length > 0) { + return NextResponse.json( + { + error: `Coverage overlaps ${overlapping.length} active program(s) on this contract: ${overlapping + .map((p) => p.name) + .join( + ", ", + )}. Bookings matching both resolve unpredictably. Pass forceOverlap: true to create it anyway.`, + code: "PROGRAM_COVERAGE_OVERLAP", + overlappingProgramIds: overlapping.map((p) => p.id), + }, + { status: 409 }, + ); + } + } + + const program = await prisma.$transaction(async (tx) => { + const created = await tx.program.create({ + data: { + contractId: body.contractId, + type: body.type, + name: body.name, + coveredPlanTypes: body.coveredPlanTypes, + allowedCategories: body.allowedCategories, + ...(body.type === "LICENSED_SEAT" && { + licensedSeatConfig: { + create: { + ratePerSeatPaise: body.licensedSeatConfig.ratePerSeatPaise, + cycle: body.licensedSeatConfig.cycle, + coveredEngagementsPerCycle: + body.licensedSeatConfig.coveredEngagementsPerCycle ?? null, + overageBehavior: body.licensedSeatConfig.overageBehavior, + priceCapPerEngagementPaise: + body.licensedSeatConfig.priceCapPerEngagementPaise ?? null, + overageSurchargeBps: + body.licensedSeatConfig.overageSurchargeBps ?? null, + maxOveragePerCyclePaise: + body.licensedSeatConfig.maxOveragePerCyclePaise ?? null, + }, + }, + }), + ...(body.type === "CREDIT_POOL" && { + creditPoolConfig: { + create: { + cycle: body.creditPoolConfig.cycle, + creditBudgetPerCycle: body.creditPoolConfig.creditBudgetPerCycle, + overageBehavior: body.creditPoolConfig.overageBehavior, + overageSurchargeBps: + body.creditPoolConfig.overageSurchargeBps ?? null, + maxOveragePerCyclePaise: + body.creditPoolConfig.maxOveragePerCyclePaise ?? null, + }, + }, + }), + }, + include: { + licensedSeatConfig: true, + creditPoolConfig: true, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.PROGRAM_CREATED, + description: `Program ${created.name} (${created.type}) created under contract ${body.contractId}`, + details: { + programId: created.id, + contractId: body.contractId, + type: body.type, + }, + }, + }); + + return created; + }); + + return NextResponse.json({ program }, { status: 201 }); +} diff --git a/app/api/organizations/[orgId]/rate-cards/[cardId]/route.ts b/app/api/organizations/[orgId]/rate-cards/[cardId]/route.ts new file mode 100644 index 000000000..71d353307 --- /dev/null +++ b/app/api/organizations/[orgId]/rate-cards/[cardId]/route.ts @@ -0,0 +1,181 @@ +/** + * GET /api/organizations/[orgId]/rate-cards/[cardId] + * PATCH /api/organizations/[orgId]/rate-cards/[cardId] + * + * A RateCard is append-only where the split is concerned — the bps values + * must never be mutated once any earning has been settled against them. + * PATCH therefore only accepts metadata tweaks (`minGrossPaise`, + * `maxGrossPaise`) and an explicit `effectiveTo` close-out (retire a card + * without replacing it, e.g. when the program it served was cancelled). + * + * To "change" the split, POST a new RateCard — `bumpRateCard` atomically + * closes the previous card and creates the new one. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: rate card PATCH (effectiveTo edits, split rotations) is a +// finance-team mutation; downgrade from OWNER-only. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const PatchBodySchema = z + .object({ + minGrossPaise: z.coerce.number().int().min(0).nullable().optional(), + maxGrossPaise: z.coerce.number().int().min(0).nullable().optional(), + // Allow explicit retire — but only FORWARD (can't rewrite history). + effectiveTo: z.coerce.date().nullable().optional(), + reason: z.string().max(500).optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; cardId: string }>; + }, +) { + const { orgId, cardId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER", canHost: true }); + if (access.error) return access.error; + + const card = await prisma.rateCard.findFirst({ + where: { + id: cardId, + OR: [ + { ownerOrgId: orgId }, + { ownerContract: { organizationId: orgId } }, + ], + }, + include: { + ownerContract: { + select: { id: true, status: true, organizationId: true }, + }, + _count: { + select: { + membershipOverrides: true, + contracts: true, + }, + }, + }, + }); + if (!card) { + return NextResponse.json({ error: "RateCard not found" }, { status: 404 }); + } + return NextResponse.json({ rateCard: card }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; cardId: string }>; + }, +) { + const { orgId, cardId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canHost: true }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.rateCard.findFirst({ + where: { + id: cardId, + OR: [ + { ownerOrgId: orgId }, + { ownerContract: { organizationId: orgId } }, + ], + }, + }); + if (!current) { + throw Object.assign(new Error("RateCard not found"), { + httpStatus: 404, + }); + } + + // Guard against rewriting the past: if effectiveTo is specified, + // it must be strictly after effectiveFrom and not earlier than now + // (retiring into the past would invalidate already-settled + // earnings). + if (body.effectiveTo !== undefined && body.effectiveTo !== null) { + const now = new Date(); + if (body.effectiveTo.getTime() <= current.effectiveFrom.getTime()) { + throw Object.assign( + new Error( + "effectiveTo must be after effectiveFrom (rate cards are append-only)", + ), + { httpStatus: 409 }, + ); + } + if (body.effectiveTo.getTime() < now.getTime()) { + throw Object.assign( + new Error( + "Cannot set effectiveTo in the past — retire at 'now' or later", + ), + { httpStatus: 409 }, + ); + } + } + + const next = await tx.rateCard.update({ + where: { id: cardId }, + data: { + ...(body.minGrossPaise !== undefined && { + minGrossPaise: body.minGrossPaise, + }), + ...(body.maxGrossPaise !== undefined && { + maxGrossPaise: body.maxGrossPaise, + }), + ...(body.effectiveTo !== undefined && { + effectiveTo: body.effectiveTo, + }), + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.RATE_CARD_BUMPED, + description: `RateCard ${cardId} metadata updated`, + details: { + rateCardId: cardId, + minGrossPaise: next.minGrossPaise, + maxGrossPaise: next.maxGrossPaise, + effectiveTo: next.effectiveTo, + reason: body.reason ?? null, + }, + }, + }); + + return next; + }); + + return NextResponse.json({ rateCard: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/rate-cards/route.ts b/app/api/organizations/[orgId]/rate-cards/route.ts new file mode 100644 index 000000000..8ae49ccda --- /dev/null +++ b/app/api/organizations/[orgId]/rate-cards/route.ts @@ -0,0 +1,210 @@ +/** + * GET /api/organizations/[orgId]/rate-cards + * POST /api/organizations/[orgId]/rate-cards + * + * Org-scoped rate cards govern the 3-way split (platform / org / consultant) + * in basis points. Bps are integer — no float drift — and must sum to 10000. + * + * A RateCard is never mutated after creation; a "change" rotates via + * `bumpRateCard` in lib/api/organizations/rate-card.ts, which closes the + * previous card's `effectiveTo` and creates a new row with `effectiveFrom = + * now()`. This preserves the historical split each earning was settled + * against — see `OrganizationEarnings.platformBpsApplied`. + * + * Query params on GET: + * scope=current|all (default current — live cards only) + * planType=CONSULTATION|CLASS|WEBINAR|SUBSCRIPTION + * planId= + * contractId= org-scoped by default; narrow via contractId + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +// Why: rate card creation/edit is a finance-team mutation; downgrade +// from OWNER-only so BILLING_ADMIN can configure splits without escalation. +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { bumpRateCard } from "@/lib/api/organizations/rate-card"; + +const CoveredPlanTypeSchema = z.enum([ + "CONSULTATION", + "CLASS", + "WEBINAR", + "SUBSCRIPTION", +]); + +const QuerySchema = z.object({ + scope: z.enum(["current", "all"]).default("current"), + planType: CoveredPlanTypeSchema.optional(), + planId: z.string().uuid().optional(), + contractId: z.string().uuid().optional(), +}); + +const CreateBodySchema = z + .object({ + contractId: z.string().uuid().nullable().optional(), + planType: CoveredPlanTypeSchema.nullable().optional(), + planId: z.string().uuid().nullable().optional(), + platformBps: z.coerce.number().int().min(0).max(10_000), + orgBps: z.coerce.number().int().min(0).max(10_000), + consultantBps: z.coerce.number().int().min(0).max(10_000), + effectiveAt: z.coerce.date().optional(), + minGrossPaise: z.coerce.number().int().min(0).nullable().optional(), + maxGrossPaise: z.coerce.number().int().min(0).nullable().optional(), + reason: z.string().max(500).optional(), + }) + .refine((v) => v.platformBps + v.orgBps + v.consultantBps === 10_000, { + message: "platformBps + orgBps + consultantBps must equal 10000", + }); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + // Read access widened to any ACTIVE member of a canHost org. Rate + // cards encode the org's revenue-split policy (e.g. "consultants + // earn 80%"), which the consultant rightly needs to know — keeping + // the GET MANAGER-gated meant an EXPERT had no way to confirm their + // commission. Mutations (POST + bumpRateCard) stay OWNER-gated. + const access = await requireOrgAccess(orgId, { canHost: true }); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsedQuery = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsedQuery.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsedQuery.error.flatten() }, + { status: 400 }, + ); + } + const q = parsedQuery.data; + + // When filtering by contractId, verify the contract belongs to this + // org. This prevents a caller from enumerating foreign rate cards by + // guessing contract ids. + if (q.contractId) { + const contract = await prisma.contract.findFirst({ + where: { id: q.contractId, organizationId: orgId }, + select: { id: true }, + }); + if (!contract) { + return NextResponse.json( + { error: "Contract not found for this organization" }, + { status: 404 }, + ); + } + } + + const now = new Date(); + + const rateCards = await prisma.rateCard.findMany({ + where: { + ...(q.contractId + ? { ownerContractId: q.contractId } + : { ownerOrgId: orgId }), + ...(q.planType !== undefined && { planType: q.planType }), + ...(q.planId !== undefined && { planId: q.planId }), + ...(q.scope === "current" && { + effectiveFrom: { lte: now }, + OR: [{ effectiveTo: null }, { effectiveTo: { gt: now } }], + }), + }, + orderBy: [{ effectiveFrom: "desc" }], + }); + + return NextResponse.json({ data: rateCards }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId, { canHost: true }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const card = await prisma.$transaction(async (tx) => { + // Cross-org check: contract must belong to this org when scoping + // the card to a contract. + if (body.contractId) { + const contract = await tx.contract.findFirst({ + where: { id: body.contractId, organizationId: orgId }, + select: { id: true }, + }); + if (!contract) { + throw Object.assign( + new Error("Contract not found for this organization"), + { httpStatus: 404 }, + ); + } + } + + const created = await bumpRateCard(tx, { + scope: body.contractId + ? { ownerContractId: body.contractId } + : { ownerOrgId: orgId }, + planType: body.planType ?? null, + planId: body.planId ?? null, + next: { + platformBps: body.platformBps, + orgBps: body.orgBps, + consultantBps: body.consultantBps, + }, + minGrossPaise: body.minGrossPaise, + maxGrossPaise: body.maxGrossPaise, + effectiveAt: body.effectiveAt, + reason: body.reason, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.RATE_CARD_BUMPED, + description: body.contractId + ? `Rate card bumped for contract ${body.contractId}` + : `Org-default rate card bumped`, + details: { + rateCardId: created.id, + contractId: body.contractId ?? null, + planType: body.planType ?? null, + planId: body.planId ?? null, + platformBps: body.platformBps, + orgBps: body.orgBps, + consultantBps: body.consultantBps, + effectiveFrom: created.effectiveFrom, + reason: body.reason ?? null, + }, + }, + }); + + return created; + }); + + return NextResponse.json({ rateCard: card }, { status: 201 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/recordings/route.ts b/app/api/organizations/[orgId]/recordings/route.ts new file mode 100644 index 000000000..c9e582f39 --- /dev/null +++ b/app/api/organizations/[orgId]/recordings/route.ts @@ -0,0 +1,57 @@ +/** + * GET /api/organizations/[orgId]/recordings + * + * Org-scoped Recording list (#674 / B1-hybrid). MANAGER+ at the org. + * Recordings carry a denormalized `organizationId` (kept in sync at + * checkout / via the backfill script) so this is a one-hop lookup. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { listRecordingsScoped } from "@/lib/api/scope/list-recordings"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + status: z + .enum([ + "RECORDING", + "PROCESSING", + "READY", + "TRANSFERRING", + "AVAILABLE", + "FAILED", + "EXPIRED", + ]) + .optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + status: url.searchParams.get("status") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listRecordingsScoped({ + scope: { kind: "org", orgId }, + userId: access.session.user.id, + status: filters.data.status, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/organizations/[orgId]/reimbursements/export/route.ts b/app/api/organizations/[orgId]/reimbursements/export/route.ts new file mode 100644 index 000000000..78d3742db --- /dev/null +++ b/app/api/organizations/[orgId]/reimbursements/export/route.ts @@ -0,0 +1,115 @@ +/** + * GET /api/organizations/[orgId]/reimbursements/export + * + * C4: streams a CSV of Payments tagged to the org with PERSONAL + * fundingSource. MANAGER+ at the org. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +const QuerySchema = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + userId: z.string().optional(), +}); + +function csvEscape(s: string): string { + if (s.includes(",") || s.includes('"') || s.includes("\n")) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const billingAccount = await prisma.billingAccount.findUnique({ + where: { ownerOrgId: orgId }, + select: { fundingSource: true }, + }); + if (!billingAccount || billingAccount.fundingSource !== "PERSONAL") { + return NextResponse.json( + { error: "Reimbursements export only for PERSONAL-funded orgs." }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + userId: url.searchParams.get("userId") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + + const where = { + organizationId: orgId, + paymentStatus: "SUCCEEDED" as const, + ...(filters.data.userId && { userId: filters.data.userId }), + ...(filters.data.from || filters.data.to + ? { + createdAt: { + ...(filters.data.from && { gte: new Date(filters.data.from) }), + ...(filters.data.to && { lte: new Date(filters.data.to) }), + }, + } + : {}), + }; + + const items = await prisma.payment.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 10_000, // hard ceiling; bigger exports should use the API + client-side paging + }); + + const header = [ + "Date", + "Member name", + "Member email", + "Description", + "Amount (paise)", + "Currency", + "Payment ID", + "Payment intent", + ]; + const rows: string[] = [header.join(",")]; + for (const p of items) { + rows.push( + [ + p.createdAt.toISOString(), + csvEscape(p.user.name ?? ""), + csvEscape(p.user.email), + csvEscape(p.description ?? ""), + String(p.amount), + p.currency, + p.id, + p.paymentIntent, + ].join(","), + ); + } + const csv = rows.join("\n"); + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="reimbursements-${orgId}-${Date.now()}.csv"`, + }, + }); +} diff --git a/app/api/organizations/[orgId]/reimbursements/route.ts b/app/api/organizations/[orgId]/reimbursements/route.ts new file mode 100644 index 000000000..536bad537 --- /dev/null +++ b/app/api/organizations/[orgId]/reimbursements/route.ts @@ -0,0 +1,133 @@ +/** + * GET /api/organizations/[orgId]/reimbursements + * + * C4: lists Payments tagged to the org where the org's BillingAccount + * is on PERSONAL fundingSource — i.e., members paid out of pocket and + * the org needs a reimbursement report. MANAGER+ at the org. + * + * Returns rows + per-member totals in INR paise. CSV export lives at + * `/export/route.ts`. + * + * Query params: + * - `from`, `to` — ISO date range (inclusive) + * - `userId` — filter to a single member + * - `page`, `perPage` — pagination + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { parsePagination } from "@/lib/enterprise/validators"; +import { sumPaise } from "@/lib/payments/utils/money"; + +const QuerySchema = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + userId: z.string().optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + // Conditional render: only orgs whose BillingAccount.fundingSource is + // PERSONAL get reimbursement views. Other funding modes (WALLET, + // INVOICE, LICENSE) settle through their own dashboards. + const billingAccount = await prisma.billingAccount.findUnique({ + where: { ownerOrgId: orgId }, + select: { fundingSource: true }, + }); + if (!billingAccount || billingAccount.fundingSource !== "PERSONAL") { + return NextResponse.json( + { + error: + "Reimbursements view is only available for organizations on PERSONAL funding.", + }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + userId: url.searchParams.get("userId") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const where = { + organizationId: orgId, + paymentStatus: "SUCCEEDED" as const, + ...(filters.data.userId && { userId: filters.data.userId }), + ...(filters.data.from || filters.data.to + ? { + createdAt: { + ...(filters.data.from && { gte: new Date(filters.data.from) }), + ...(filters.data.to && { lte: new Date(filters.data.to) }), + }, + } + : {}), + }; + + const [total, items, totalPaiseAgg] = await prisma.$transaction([ + prisma.payment.count({ where }), + prisma.payment.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: pagination.pageSize, + skip: (pagination.page - 1) * pagination.pageSize, + }), + prisma.payment.aggregate({ + where, + _sum: { amount: true }, + }), + ]); + // groupBy lives outside the $transaction tuple — Prisma 7's + // groupBy/aggregate types don't compose into the array tuple cleanly. + const byMember = await prisma.payment.groupBy({ + by: ["userId"], + where, + _sum: { amount: true }, + _count: { _all: true }, + }); + + // Hydrate member names for the by-member roll-up. + const userIds = byMember.map((b) => b.userId); + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, email: true }, + }); + const userMap = new Map(users.map((u) => [u.id, u])); + + return NextResponse.json({ + items, + total, + page: pagination.page, + perPage: pagination.pageSize, + totalPaise: sumPaise(totalPaiseAgg._sum.amount), + byMember: byMember.map((b) => ({ + userId: b.userId, + name: userMap.get(b.userId)?.name ?? null, + email: userMap.get(b.userId)?.email ?? null, + totalPaise: sumPaise(b._sum?.amount), + paymentCount: + typeof b._count === "object" && b._count + ? ((b._count as { _all?: number })._all ?? 0) + : 0, + })), + }); +} diff --git a/app/api/organizations/[orgId]/route.ts b/app/api/organizations/[orgId]/route.ts new file mode 100644 index 000000000..c5620f378 --- /dev/null +++ b/app/api/organizations/[orgId]/route.ts @@ -0,0 +1,680 @@ +/** + * GET /api/organizations/[orgId] + * PATCH /api/organizations/[orgId] + * DELETE /api/organizations/[orgId] + * + * Core org-record CRUD. GET returns the full merged shape the dashboard + * Home uses (capabilities, billing account summary, hosting-side summary, + * counts). PATCH accepts a narrow set of owner-editable fields and guards + * capability flips so we never end up with canSponsor=false && canHost=false. + * DELETE is owner-only AND only for orgs with no active contracts/invoices + * — otherwise admins must DEACTIVATE via the admin-verify endpoint. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { isAtLeastRole } from "@/lib/auth/role-ranks"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { transitionOrganization } from "@/lib/enterprise/transitions"; +import { withSerializableRetry } from "@/lib/db/serializable-retry"; +import { encryptPAN } from "@/lib/payments/tax/pan-crypto"; + +const SizeBucketSchema = z.enum([ + "SMALL_1_50", + "MEDIUM_51_200", + "LARGE_201_1000", + "ENTERPRISE_1000_PLUS", +]); +const GstRegStatusSchema = z.enum(["REGULAR", "COMPOSITION", "UNREGISTERED"]); + +const PatchBodySchema = z + .object({ + name: z.string().trim().min(2).max(200).optional(), + slug: z + .string() + .trim() + .toLowerCase() + .min(2) + .max(80) + .regex(/^[a-z0-9-]+$/, "Slug may only contain lowercase letters, digits, and hyphens") + .optional(), + description: z.string().max(5000).nullable().optional(), + industry: z.string().max(120).nullable().optional(), + website: z.string().url().nullable().optional(), + sizeBucket: SizeBucketSchema.nullable().optional(), + logo: z.string().url().nullable().optional(), + bannerImage: z.string().url().nullable().optional(), + primaryColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "Hex colour required") + .nullable() + .optional(), + secondaryColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "Hex colour required") + .nullable() + .optional(), + billingEmail: z.string().email().optional(), + canSponsor: z.boolean().optional(), + canHost: z.boolean().optional(), + requiresPO: z.boolean().optional(), + paymentTermsDays: z.coerce.number().int().min(0).max(180).optional(), + gstin: z.string().length(15).nullable().optional(), + pan: z.string().length(10).nullable().optional(), + gstRegStatus: GstRegStatusSchema.optional(), + gstStateCode: z.string().length(2).nullable().optional(), + defaultCancellationPolicy: z.string().max(5000).nullable().optional(), + defaultRefundPolicy: z.string().max(5000).nullable().optional(), + isPublic: z.boolean().optional(), + expectedVersion: z.coerce.number().int().min(1).optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }) + // Capability flips gate the whole billing/payout subsystem — a stale tab must + // get a 409, never last-write-wins. Other fields stay back-compatible. + .refine( + (v) => + (v.canSponsor === undefined && v.canHost === undefined) || + v.expectedVersion !== undefined, + { + message: "expectedVersion is required when changing capabilities", + path: ["expectedVersion"], + }, + ); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "LEARNER"); + if (access.error) return access.error; + + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + include: { + billingAccount: { + select: { + id: true, + fundingSource: true, + currency: true, + walletBalance: true, + creditLimit: true, + }, + }, + payoutAccount: { + select: { + id: true, + status: true, + accountNumberLast4: true, + bankName: true, + }, + }, + _count: { + select: { + memberships: true, + contracts: true, + invoices: true, + purchaseOrders: true, + auditLogs: true, + }, + }, + }, + }); + if (!org) { + return NextResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + organization: org, + membership: { role: access.member.role, status: access.member.status }, + }); +} + +// #779 §A — field-level RBAC instead of a blanket OWNER gate. Descriptive / +// branding fields are operational (MAINTAINER+); billing contact + NET-X terms +// are the finance remit (BILLING_ADMIN or OWNER); everything else — slug, +// capabilities, tax identity, policies, isPublic — stays OWNER-only. +const MAINTAINER_FIELDS = new Set([ + "name", + "description", + "industry", + "website", + "sizeBucket", + "logo", + "bannerImage", + "primaryColor", + "secondaryColor", +]); +const BILLING_ADMIN_FIELDS = new Set(["billingEmail", "paymentTermsDays"]); + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Field-level gate: OWNER passes everything; otherwise every touched field + // must be inside the caller's remit. 403 names the offending fields so the + // dashboard can explain instead of a silent failure. + const role = access.member.role; + if (!isAtLeastRole(role, "OWNER")) { + const allowed = new Set(); + if (isAtLeastRole(role, "MAINTAINER")) { + MAINTAINER_FIELDS.forEach((f) => allowed.add(f)); + } + if (role === "BILLING_ADMIN") { + BILLING_ADMIN_FIELDS.forEach((f) => allowed.add(f)); + } + const forbidden = Object.keys(body).filter((k) => !allowed.has(k)); + if (allowed.size === 0 || forbidden.length > 0) { + return NextResponse.json( + { + error: "Insufficient role for these fields", + code: "FIELD_RBAC_FORBIDDEN", + fields: forbidden.length > 0 ? forbidden : Object.keys(body), + }, + { status: 403 }, + ); + } + } + + try { + // Serializable closes the TOCTOU between the wind-down COUNT checks below + // and the UPDATE (S2 in the state audit): a concurrent invoice/assignment + // insert aborts one side with P2034 (retried, then 503) instead of + // slipping into the window and stranding obligations behind a flipped flag. + const updated = await withSerializableRetry(() => + prisma.$transaction(async (tx) => { + const current = await tx.organization.findUnique({ + where: { id: orgId }, + include: { billingAccount: { select: { id: true, walletBalance: true } } }, + }); + if (!current) { + throw Object.assign(new Error("Organization not found"), { + httpStatus: 404, + }); + } + + // Optimistic lock — a stale tab (multi-tab toggle race) 409s instead of + // last-write-wins. The CAS also takes the row lock, serializing the rich + // nested update below behind it. + if (body.expectedVersion !== undefined) { + const cas = await tx.organization.updateMany({ + where: { id: orgId, version: body.expectedVersion }, + data: { version: { increment: 1 } }, + }); + if (cas.count === 0) { + throw Object.assign( + new Error("Settings were changed in another session — reload and retry"), + { + httpStatus: 409, + code: "VERSION_CONFLICT", + currentVersion: current.version, + }, + ); + } + } + + const nextCanSponsor = body.canSponsor ?? current.canSponsor; + const nextCanHost = body.canHost ?? current.canHost; + if (!nextCanSponsor && !nextCanHost) { + throw Object.assign( + new Error( + "Cannot disable both capabilities — at least one of canSponsor/canHost must remain true.", + ), + { httpStatus: 409 }, + ); + } + + // Turning canSponsor OFF with a non-zero wallet would orphan the + // money. The owner must drain or refund the wallet first. + if ( + body.canSponsor === false && + (current.billingAccount?.walletBalance ?? 0) > 0 + ) { + throw Object.assign( + new Error( + "Cannot disable canSponsor while wallet has a non-zero balance", + ), + { httpStatus: 409 }, + ); + } + + // #779 §A — canSponsor wind-down: beyond the wallet, the org must + // settle outstanding invoices and let live sponsorships lapse before + // it can stop sponsoring. ISSUED/OVERDUE = billed-but-unpaid; + // ACTIVE assignments still in-cycle (periodEnd>=now) draw real spend. + if (body.canSponsor === false) { + const now = new Date(); + const [outstandingInvoices, liveAssignments] = await Promise.all([ + tx.organizationInvoice.count({ + where: { + organizationId: orgId, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + }), + tx.programAssignment.count({ + where: { + status: "ACTIVE", + periodEnd: { gte: now }, + program: { contract: { organizationId: orgId } }, + }, + }), + ]); + if (outstandingInvoices > 0 || liveAssignments > 0) { + throw Object.assign( + new Error("CANSPONSOR_WINDDOWN_REQUIRED"), + { + httpStatus: 409, + code: "CANSPONSOR_WINDDOWN_REQUIRED", + counts: { outstandingInvoices, liveAssignments }, + }, + ); + } + } + + // #779 §A — canHost wind-down: the org can't stop hosting while it + // still has experts on the roster or payout money in flight. + // unsettledEarnings = org-share rows not yet attached to a payout + // (orgPayoutId null) OR attached but not PAID — either way the money + // hasn't reached the org's bank. + if (body.canHost === false) { + const [experts, pendingPayouts, unsettledEarnings] = await Promise.all([ + tx.membership.count({ + where: { + organizationId: orgId, + role: "EXPERT", + status: { in: ["ACTIVE", "PENDING"] }, + }, + }), + tx.organizationPayout.count({ + where: { + organizationId: orgId, + status: { in: ["PENDING", "APPROVED", "PROCESSING"] }, + }, + }), + tx.organizationEarnings.count({ + where: { + organizationId: orgId, + OR: [{ orgPayoutId: null }, { status: { not: "PAID" } }], + }, + }), + ]); + if (experts > 0 || pendingPayouts > 0 || unsettledEarnings > 0) { + throw Object.assign( + new Error("CANHOST_WINDDOWN_REQUIRED"), + { + httpStatus: 409, + code: "CANHOST_WINDDOWN_REQUIRED", + counts: { experts, pendingPayouts, unsettledEarnings }, + }, + ); + } + } + + // Slug uniqueness — only check on actual change so a no-op PATCH + // (e.g., wizard resubmit) doesn't 409 against the org's own row. + if (body.slug && body.slug !== current.slug) { + const slugTaken = await tx.organization.findUnique({ + where: { slug: body.slug }, + select: { id: true }, + }); + if (slugTaken && slugTaken.id !== orgId) { + throw Object.assign( + new Error(`Slug "${body.slug}" is already taken`), + { httpStatus: 409 }, + ); + } + } + + const next = await tx.organization.update({ + where: { id: orgId }, + data: { + ...(body.name !== undefined && { name: body.name }), + ...(body.slug !== undefined && { slug: body.slug }), + ...(body.billingEmail !== undefined && { billingEmail: body.billingEmail }), + ...(body.canSponsor !== undefined && { canSponsor: body.canSponsor }), + ...(body.canHost !== undefined && { canHost: body.canHost }), + ...(body.requiresPO !== undefined && { requiresPO: body.requiresPO }), + ...(body.paymentTermsDays !== undefined && { + paymentTermsDays: body.paymentTermsDays, + }), + // logo / bannerImage / primaryColor / secondaryColor / description / + // industry / website / sizeBucket live on the OrgBrandingProfile + // satellite (#768 lockdown #6), not Organization — write them via + // the brandingProfile relation. Same runtime/tsc dynamic as the + // taxInfo block below: the conditional-spread pattern hides the + // mistake from tsc until Prisma rejects it at runtime. + ...(body.logo !== undefined || + body.bannerImage !== undefined || + body.primaryColor !== undefined || + body.secondaryColor !== undefined || + body.description !== undefined || + body.industry !== undefined || + body.website !== undefined || + body.sizeBucket !== undefined + ? { + brandingProfile: { + upsert: { + create: { + ...(body.logo !== undefined && { logo: body.logo }), + ...(body.bannerImage !== undefined && { + bannerImage: body.bannerImage, + }), + ...(body.primaryColor !== undefined && { + primaryColor: body.primaryColor, + }), + ...(body.secondaryColor !== undefined && { + secondaryColor: body.secondaryColor, + }), + ...(body.description !== undefined && { + description: body.description, + }), + ...(body.industry !== undefined && { + industry: body.industry, + }), + ...(body.website !== undefined && { website: body.website }), + ...(body.sizeBucket !== undefined && { + sizeBucket: body.sizeBucket, + }), + }, + update: { + ...(body.logo !== undefined && { logo: body.logo }), + ...(body.bannerImage !== undefined && { + bannerImage: body.bannerImage, + }), + ...(body.primaryColor !== undefined && { + primaryColor: body.primaryColor, + }), + ...(body.secondaryColor !== undefined && { + secondaryColor: body.secondaryColor, + }), + ...(body.description !== undefined && { + description: body.description, + }), + ...(body.industry !== undefined && { + industry: body.industry, + }), + ...(body.website !== undefined && { website: body.website }), + ...(body.sizeBucket !== undefined && { + sizeBucket: body.sizeBucket, + }), + }, + }, + }, + } + : {}), + // gstin / pan / gstRegStatus / gstStateCode live on the + // OrganizationTaxInfo satellite, not Organization — write them via the + // taxInfo relation (a direct write here is a Prisma runtime error the + // conditional-spread pattern hides from tsc). + ...(body.gstin !== undefined || + body.pan !== undefined || + body.gstRegStatus !== undefined || + body.gstStateCode !== undefined + ? { + taxInfo: { + upsert: { + create: { + ...(body.gstin !== undefined && { gstin: body.gstin }), + ...(body.gstRegStatus !== undefined && { + gstRegStatus: body.gstRegStatus, + }), + ...(body.gstStateCode !== undefined && { + gstStateCode: body.gstStateCode, + }), + ...(body.pan + ? (() => { + const { encrypted, last4 } = encryptPAN(body.pan); + return { panEncrypted: encrypted, panLast4: last4 }; + })() + : {}), + }, + update: { + ...(body.gstin !== undefined && { gstin: body.gstin }), + ...(body.gstRegStatus !== undefined && { + gstRegStatus: body.gstRegStatus, + }), + ...(body.gstStateCode !== undefined && { + gstStateCode: body.gstStateCode, + }), + ...(body.pan !== undefined && + (body.pan + ? (() => { + const { encrypted, last4 } = encryptPAN(body.pan); + return { + panEncrypted: encrypted, + panLast4: last4, + }; + })() + : { panEncrypted: null, panLast4: null })), + }, + }, + }, + } + : {}), + ...(body.defaultCancellationPolicy !== undefined && { + defaultCancellationPolicy: body.defaultCancellationPolicy, + }), + ...(body.defaultRefundPolicy !== undefined && { + defaultRefundPolicy: body.defaultRefundPolicy, + }), + ...(body.isPublic !== undefined && { isPublic: body.isPublic }), + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: "Organization record updated", + details: { patch: body }, + }, + }); + + return next; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); + + return NextResponse.json({ organization: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + // #779 §A — forward the structured wind-down code + counts so the UI + // can render the per-blocker message instead of a bare 409. The + // VERSION_CONFLICT branch additionally 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 counts = + "counts" in err && err.counts && typeof err.counts === "object" + ? err.counts + : undefined; + const currentVersion = + "currentVersion" in err && typeof err.currentVersion === "number" + ? err.currentVersion + : undefined; + return NextResponse.json( + { + error: err.message, + ...(code && { code }), + ...(counts && { counts }), + ...(currentVersion !== undefined && { currentVersion }), + }, + { status }, + ); + } + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2034" + ) { + return NextResponse.json( + { error: "Transaction conflict — please retry", code: "P2034" }, + { status: 503 }, + ); + } + throw err; + } +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + try { + const outcome = await prisma.$transaction(async (tx) => { + // #781 §B — three-way delete. LIVE obligations block (wind-down + // first, per the #779 guard doctrine). Settled financial HISTORY + // makes the org soft-delete (DEACTIVATED + deletedAt + contact-PII + // scrub) — the Restrict FKs on earnings/payouts make a hard delete + // impossible at the DB level anyway. Only a money-untouched shell + // may hard-delete. + const current = await tx.organization.findUnique({ + where: { id: orgId }, + select: { + deletedAt: true, + billingAccount: { select: { walletBalance: true } }, + _count: { + select: { + contracts: { where: { status: { in: ["DRAFT", "ACTIVE"] } } }, + invoices: { where: { status: { in: ["ISSUED", "OVERDUE"] } } }, + purchaseOrders: { where: { remainingAmountPaise: { gt: 0 } } }, + earnings: { + where: { + status: { in: ["PENDING_TRUST", "PENDING", "HELD", "READY"] }, + }, + }, + payouts: { + where: { status: { in: ["PENDING", "APPROVED", "PROCESSING"] } }, + }, + }, + }, + }, + }); + if (!current || current.deletedAt) { + throw Object.assign(new Error("Organization not found"), { + httpStatus: 404, + }); + } + + const live: string[] = []; + if (current._count.contracts > 0) + live.push(`${current._count.contracts} draft/active contract(s)`); + if (current._count.invoices > 0) + live.push(`${current._count.invoices} unpaid invoice(s)`); + if (current._count.purchaseOrders > 0) + live.push(`${current._count.purchaseOrders} open purchase order(s)`); + if (current._count.earnings > 0) + live.push(`${current._count.earnings} unsettled earning(s)`); + if (current._count.payouts > 0) + live.push(`${current._count.payouts} in-flight payout(s)`); + if ((current.billingAccount?.walletBalance ?? 0) !== 0) + live.push("a non-zero wallet balance"); + if (live.length > 0) { + throw Object.assign( + new Error( + `Wind-down required before deletion: this organization still has ${live.join(", ")}.`, + ), + { httpStatus: 409 }, + ); + } + + const history = await tx.organization.findUniqueOrThrow({ + where: { id: orgId }, + select: { + _count: { + select: { + contracts: true, + invoices: true, + purchaseOrders: true, + earnings: true, + payouts: true, + }, + }, + billingAccountId: true, + }, + }); + const hasHistory = + history._count.contracts + + history._count.invoices + + history._count.purchaseOrders + + history._count.earnings + + history._count.payouts > + 0 || history.billingAccountId !== null; + + if (!hasHistory) { + await tx.organization.delete({ where: { id: orgId } }); + return "hard" as const; + } + + // Soft delete: name/slug/GSTIN/PAN stay (issued invoices reference + // them — statutory retention); personal contact details are scrubbed + // per DPDP. The CAS in transitionOrganization makes DEACTIVATED + // unreachable from itself — a concurrent second DELETE 409s instead of + // re-stamping deletedAt. + await transitionOrganization(tx, { + where: { id: orgId }, + to: "DEACTIVATED", + data: { + deletedAt: new Date(), + billingEmail: null, + billingContactName: null, + billingContactEmail: null, + billingContactPhone: null, + supportContactName: null, + supportContactEmail: null, + escalationContactEmail: null, + }, + audit: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.ORG_SOFT_DELETED, + description: + "Organization soft-deleted (financial history retained)", + }, + }); + return "soft" as const; + }); + + return outcome === "hard" + ? new NextResponse(null, { status: 204 }) + : NextResponse.json({ softDeleted: true }, { status: 200 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/scim/group-mappings/[mappingId]/route.ts b/app/api/organizations/[orgId]/scim/group-mappings/[mappingId]/route.ts new file mode 100644 index 000000000..83ca3ed96 --- /dev/null +++ b/app/api/organizations/[orgId]/scim/group-mappings/[mappingId]/route.ts @@ -0,0 +1,63 @@ +/** + * DELETE /api/organizations/[orgId]/scim/group-mappings/[mappingId] + * + * Remove a SCIM group → MemberRole mapping. Existing memberships are + * NOT mutated by this — they keep whichever role they had at last + * provisioning. Re-provisioning a user without the mapping in place + * downgrades them to LEARNER per the least-privilege default in + * `resolveRoleFromGroupNames`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; mappingId: string }>; + }, +) { + const { orgId, mappingId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.scimGroupMapping.findFirst({ + where: { id: mappingId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Group mapping not found"), { + httpStatus: 404, + }); + } + await tx.scimGroupMapping.delete({ where: { id: mappingId } }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.SCIM_GROUP_UNMAPPED, + description: `Removed SCIM group mapping '${current.scimGroupName}'`, + details: { + scimGroupName: current.scimGroupName, + role: current.role, + }, + }, + }); + }); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + return NextResponse.json( + { error: err.message }, + { status: (err as { httpStatus?: number }).httpStatus ?? 500 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/scim/group-mappings/route.ts b/app/api/organizations/[orgId]/scim/group-mappings/route.ts new file mode 100644 index 000000000..04d8b3de6 --- /dev/null +++ b/app/api/organizations/[orgId]/scim/group-mappings/route.ts @@ -0,0 +1,95 @@ +/** + * GET /api/organizations/[orgId]/scim/group-mappings + * POST /api/organizations/[orgId]/scim/group-mappings + * + * Per-org mapping from IdP group names (e.g. `IT-Admins`, + * `Engineering-Leads`) to local `MemberRole` values. The SCIM user + * upsert at `lib/scim/operations.ts` reads from this table to resolve + * the right role per SCIM-pushed user. + * + * OWNER-only — same governance reasoning as token CRUD. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { MemberRoleSchema } from "@/lib/labels/org-labels"; + +const CreateBodySchema = z.object({ + scimGroupName: z.string().trim().min(1).max(255), + role: MemberRoleSchema, +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const mappings = await prisma.scimGroupMapping.findMany({ + where: { organizationId: orgId }, + orderBy: { scimGroupName: "asc" }, + }); + return NextResponse.json({ data: mappings }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + try { + const created = await prisma.$transaction(async (tx) => { + const mapping = await tx.scimGroupMapping.create({ + data: { + organizationId: orgId, + scimGroupName: parsed.data.scimGroupName, + role: parsed.data.role, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.SCIM_GROUP_MAPPED, + description: `Mapped SCIM group '${parsed.data.scimGroupName}' to ${parsed.data.role}`, + details: parsed.data, + }, + }); + return mapping; + }); + return NextResponse.json({ mapping: created }, { status: 201 }); + } catch (err) { + // P2002 unique-constraint hit — the org already has this group name + // mapped. Surface a friendly 409 instead of leaking the Prisma error. + if ( + err && + typeof err === "object" && + "code" in err && + (err as { code: string }).code === "P2002" + ) { + return NextResponse.json( + { error: `Group '${parsed.data.scimGroupName}' is already mapped`, code: "SCIM_GROUP_DUPLICATE" }, + { status: 409 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/scim/tokens/[tokenId]/route.ts b/app/api/organizations/[orgId]/scim/tokens/[tokenId]/route.ts new file mode 100644 index 000000000..6cd80cf87 --- /dev/null +++ b/app/api/organizations/[orgId]/scim/tokens/[tokenId]/route.ts @@ -0,0 +1,70 @@ +/** + * DELETE /api/organizations/[orgId]/scim/tokens/[tokenId] + * + * Soft-revoke a SCIM token. The row stays in `ScimToken` (status + * flipped to REVOKED + `revokedAt` stamped) so subsequent IdP poll + * attempts get a clear `401 "Token has been revoked"` response AND + * trip the `SCIM_TOKEN_USED_AFTER_REVOKE` audit row. A hard-delete + * would just give the IdP a vanilla `401 Unknown token` and lose the + * "you forgot to update Okta after rotating" signal. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; tokenId: string }>; + }, +) { + const { orgId, tokenId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const token = await tx.scimToken.findFirst({ + where: { id: tokenId, organizationId: orgId }, + }); + if (!token) { + throw Object.assign(new Error("SCIM token not found"), { + httpStatus: 404, + }); + } + if (token.status === "REVOKED") { + // Idempotent — calling DELETE twice on a revoked token is a no-op + // (the IdP may retry the click). Return 204 either way so callers + // can't distinguish "I just revoked this" from "already revoked". + return; + } + await tx.scimToken.update({ + where: { id: tokenId }, + data: { status: "REVOKED", revokedAt: new Date() }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.SCIM_TOKEN_REVOKED, + description: `Revoked SCIM token '${token.label}'`, + details: { tokenId, label: token.label }, + }, + }); + }); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + return NextResponse.json( + { error: err.message }, + { status: (err as { httpStatus?: number }).httpStatus ?? 500 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/scim/tokens/route.ts b/app/api/organizations/[orgId]/scim/tokens/route.ts new file mode 100644 index 000000000..2449e5c28 --- /dev/null +++ b/app/api/organizations/[orgId]/scim/tokens/route.ts @@ -0,0 +1,108 @@ +/** + * GET /api/organizations/[orgId]/scim/tokens — list ACTIVE/REVOKED tokens. + * POST /api/organizations/[orgId]/scim/tokens — mint a new bearer token. + * + * OWNER-only. SCIM token CRUD is governance-sensitive (a leaked token + * provisions arbitrary users into the org) so it stays under the + * highest-trust gate; BILLING_ADMIN does not have access. + * + * The raw token is returned ONCE on POST. We persist only its SHA-256 + * hash on `ScimToken.tokenHash` (which carries a unique index), so a + * lost token requires creating a fresh one — there's no recovery path. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { createHash, randomBytes } from "node:crypto"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const CreateBodySchema = z.object({ + label: z.string().trim().min(1).max(120), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const tokens = await prisma.scimToken.findMany({ + where: { organizationId: orgId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + label: true, + status: true, + createdAt: true, + lastUsedAt: true, + revokedAt: true, + }, + }); + return NextResponse.json({ data: tokens }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + // 48 bytes → 64 base64url chars. Wider than HMAC-SHA256 needs but + // matches Okta + Azure's expected bearer-token length. The receiver + // (us) hashes immediately; the IdP stores the raw value. + const rawToken = randomBytes(48).toString("base64url"); + const tokenHash = createHash("sha256").update(rawToken).digest("hex"); + + const created = await prisma.$transaction(async (tx) => { + const token = await tx.scimToken.create({ + data: { + organizationId: orgId, + tokenHash, + label: parsed.data.label, + status: "ACTIVE", + createdByMembershipId: access.member.id, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.SCIM_TOKEN_CREATED, + description: `Created SCIM token '${parsed.data.label}'`, + details: { tokenId: token.id, label: parsed.data.label }, + }, + }); + return token; + }); + + return NextResponse.json( + { + token: { + id: created.id, + label: created.label, + status: created.status, + createdAt: created.createdAt, + // ONE-TIME reveal. The dashboard wraps this in a copy-to-clipboard + // modal with a "you won't see this again" affordance. + rawToken, + }, + }, + { status: 201 }, + ); +} diff --git a/app/api/organizations/[orgId]/settings/route.ts b/app/api/organizations/[orgId]/settings/route.ts new file mode 100644 index 000000000..0882cf1af --- /dev/null +++ b/app/api/organizations/[orgId]/settings/route.ts @@ -0,0 +1,86 @@ +/** + * Organization settings — GET / PATCH alias of /api/organizations/[orgId]. + * + * Kept as a separate route so the dashboard "settings" page has a stable URL + * that doesn't tangle with the resource itself. The implementation is a thin + * pass-through to the same Prisma logic. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + try { + const access = await requireOrgAccess(orgId, { minimumRole: "LEARNER" }); + if (access.error) return access.error; + + // `profile` mirrors the Organization row — callers need the + // capability booleans + paymentTermsDays + description/website/etc. + // BillingAccount is included so the settings page can show the + // funding source without a second round-trip. taxInfo (#777 §B) + // hydrates the Tax & compliance section — non-secret fields only; + // panEncrypted never leaves the server. + const [organization, billingAccount, taxInfo] = await Promise.all([ + prisma.organization.findUnique({ + where: { id: orgId }, + select: { + id: true, + name: true, + slug: true, + brandingProfile: { select: { logo: true } }, + }, + }), + prisma.billingAccount.findFirst({ + where: { ownerOrgId: orgId }, + select: { fundingSource: true, currency: true, creditLimit: true }, + }), + prisma.organizationTaxInfo.findUnique({ + where: { organizationId: orgId }, + select: { + gstin: true, + gstStateCode: true, + gstRegStatus: true, + panLast4: true, + }, + }), + ]); + + return NextResponse.json({ + organization: organization + ? { + id: organization.id, + name: organization.name, + slug: organization.slug, + logo: organization.brandingProfile?.logo ?? null, + } + : null, + profile: { + ...access.org, + billingAccount, + taxInfo, + }, + }); + } catch (error) { + console.error( + JSON.stringify({ + event: "org_settings_fetch_failed", + route: "GET /api/organizations/[orgId]/settings", + orgId, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }), + ); + return NextResponse.json( + { error: "Failed to fetch settings" }, + { status: 500 }, + ); + } +} + +// PATCH delegates to the resource route by re-exporting its handler. +export { PATCH } from "../route"; diff --git a/app/api/organizations/[orgId]/sso/break-glass/route.ts b/app/api/organizations/[orgId]/sso/break-glass/route.ts new file mode 100644 index 000000000..8d2c86f48 --- /dev/null +++ b/app/api/organizations/[orgId]/sso/break-glass/route.ts @@ -0,0 +1,117 @@ +/** + * POST /api/organizations/[orgId]/sso/break-glass + * DELETE /api/organizations/[orgId]/sso/break-glass + * + * #779 §E — time-boxed IdP-outage escape hatch. When SSO is enforced and + * the org's IdP is down, an OWNER opens a window during which password + * login is permitted again for the claimed domain (the auth layer skips + * the enforceSSO gate while `breakGlassUntil > now` — see + * lib/sso/enforce-session.ts). DELETE closes the window early. + * + * Who/why is not stored on columns — it lives in the OrgAuditLog row this + * route emits. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const PostBodySchema = z.object({ + hours: z.number().int().min(1).max(72).default(4), + reason: z.string().min(5), +}); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PostBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { hours, reason } = parsed.data; + + const settings = await prisma.organizationSSOSettings.findUnique({ + where: { organizationId: orgId }, + select: { enforceSSO: true }, + }); + // #779 §E — nothing to break if SSO isn't enforced here. + if (!settings?.enforceSSO) { + return NextResponse.json( + { error: "SSO is not enforced for this organization" }, + { status: 404 }, + ); + } + + const until = new Date(Date.now() + hours * 60 * 60 * 1000); + + const updated = await prisma.$transaction(async (tx) => { + const next = await tx.organizationSSOSettings.update({ + where: { organizationId: orgId }, + data: { breakGlassUntil: until }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: "SSO break-glass opened", + details: { reason, hours, until: until.toISOString() }, + }, + }); + return next; + }); + + return NextResponse.json({ breakGlassUntil: updated.breakGlassUntil }); +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const settings = await prisma.organizationSSOSettings.findUnique({ + where: { organizationId: orgId }, + select: { enforceSSO: true }, + }); + // #779 §E — nothing to clear if SSO isn't enforced here. + if (!settings?.enforceSSO) { + return NextResponse.json( + { error: "SSO is not enforced for this organization" }, + { status: 404 }, + ); + } + + await prisma.$transaction(async (tx) => { + await tx.organizationSSOSettings.update({ + where: { organizationId: orgId }, + data: { breakGlassUntil: null }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, + description: "SSO break-glass closed", + details: {}, + }, + }); + }); + + return NextResponse.json({ breakGlassUntil: null }); +} diff --git a/app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts b/app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts new file mode 100644 index 000000000..1ea627d4a --- /dev/null +++ b/app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts @@ -0,0 +1,169 @@ +/** + * GET /api/organizations/[orgId]/sso/providers/[providerId] + * DELETE /api/organizations/[orgId]/sso/providers/[providerId] + * + * Detail + delete for a single SSO provider registration. PATCH is NOT + * offered — identity-provider config edits are risky (a silent typo in + * `entryPoint` or `cert` locks users out), so the UX is delete-and-recreate. + * + * The URL path uses the human `providerId` slug, not the internal row + * uuid, to match the IdP-side setup flow (admins copy the slug into their + * IdP's metadata). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { deriveAcsUrl, deriveMetadataUrl } from "@/lib/sso/derive-urls"; +import { notifyOrgSsoProviderDeleted } from "@/lib/novu/org-workflows"; + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; providerId: string }>; + }, +) { + const { orgId, providerId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const provider = await prisma.ssoProvider.findFirst({ + where: { providerId, organizationId: orgId }, + }); + if (!provider) { + return NextResponse.json( + { error: "SSO provider not found" }, + { status: 404 }, + ); + } + + // Only OWNER roles get the full config JSON in the payload. Lower + // roles see redacted markers — cert/client-secret values would leak + // sensitive IdP credentials otherwise. + const isOwner = access.member.role === "OWNER"; + const type: "saml" | "oidc" | null = provider.samlConfig + ? "saml" + : provider.oidcConfig + ? "oidc" + : null; + + // A provider row with neither config is a half-written record (e.g. + // an admin started a SAML setup, dropped the cert, never finished). + // Fabricating an `acsUrl` from a null type would write a misleading + // value into the admin UI; return null instead so the page can + // render the "configuration incomplete" state correctly. + const acsUrl = type ? deriveAcsUrl(provider.providerId, type) : null; + + return NextResponse.json({ + provider: { + id: provider.id, + providerId: provider.providerId, + issuer: provider.issuer, + domain: provider.domain, + providerType: type, + acsUrl, + metadataUrl: deriveMetadataUrl(provider.providerId), + oidcConfig: isOwner + ? provider.oidcConfig + : provider.oidcConfig + ? "[redacted]" + : null, + samlConfig: isOwner + ? provider.samlConfig + : provider.samlConfig + ? "[redacted]" + : null, + }, + }); +} + +export async function DELETE( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; providerId: string }>; + }, +) { + const { orgId, providerId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.ssoProvider.findFirst({ + where: { providerId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("SSO provider not found"), { + httpStatus: 404, + }); + } + + // Refuse if removing the last provider would leave the org in an + // inconsistent state — enforceSSO=true with zero providers and no + // allowed domains would lock every user out. Admins must drop + // enforcement or add a domain first. + const settings = await tx.organizationSSOSettings.findUnique({ + where: { organizationId: orgId }, + }); + if (settings?.enforceSSO) { + const remaining = await tx.ssoProvider.count({ + where: { organizationId: orgId, id: { not: current.id } }, + }); + const effectiveDomains = settings.allowedEmailDomains ?? []; + if (remaining === 0 && effectiveDomains.length === 0) { + throw Object.assign( + new Error( + "Cannot delete the last SSO provider while enforceSSO=true and no allowed domains. Disable enforcement or add a domain first.", + ), + { httpStatus: 409 }, + ); + } + } + + await tx.ssoProvider.delete({ where: { id: current.id } }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SSO_DISABLED, + description: `SSO provider '${providerId}' removed`, + details: { + providerId, + domain: current.domain, + issuer: current.issuer, + }, + }, + }); + }); + + // Security alert: notify the org's OWNER roster via Novu. SSO + // deletion is a high-impact action — if a malicious OWNER strips + // SSO, every other OWNER sees it on their bell immediately. + const origin = new URL(req.url).origin; + notifyOrgSsoProviderDeleted(orgId, { + orgName: access.org.name, + providerId, + deletedByName: + access.session.user.name ?? access.session.user.email, + dashboardUrl: `${origin}/dashboard/organization/${orgId}/settings/sso`, + }).catch((err) => + console.error("[notifyOrgSsoProviderDeleted] failed:", err), + ); + + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/sso/providers/route.ts b/app/api/organizations/[orgId]/sso/providers/route.ts new file mode 100644 index 000000000..685595b86 --- /dev/null +++ b/app/api/organizations/[orgId]/sso/providers/route.ts @@ -0,0 +1,232 @@ +/** + * GET /api/organizations/[orgId]/sso/providers + * POST /api/organizations/[orgId]/sso/providers + * + * SSO IdP registrations scoped to this organization. Rows live in + * `SsoProvider` (BetterAuth-managed, not Prisma-owned at auth time — we + * write it, BetterAuth's sso() plugin reads it). Each row holds the + * provider-type-specific config as JSON strings (`oidcConfig` / + * `samlConfig`) so BetterAuth can parse it on login attempts. + * + * ACS + metadata URLs are DERIVED from providerId (see + * lib/sso/derive-urls.ts) — never accepted from the client — so IdP-side + * setup instructions stay aligned with what BetterAuth actually mounts. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { randomUUID } from "crypto"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { createProviderSchema } from "@/lib/sso/provider-schemas"; +import { deriveAcsUrl, deriveMetadataUrl } from "@/lib/sso/derive-urls"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const providers = await prisma.ssoProvider.findMany({ + where: { organizationId: orgId }, + select: { + id: true, + providerId: true, + issuer: true, + domain: true, + // `samlConfig` and `oidcConfig` are JSON-encoded strings (see + // `prisma/schema.prisma` lines 4073-4074). We only need to know + // *which* is populated to drive ACS URL inference below — the + // contents stay opaque to the list view (the detail endpoint + // is the one that decodes + redacts secrets). Audit Phase B.2. + samlConfig: true, + oidcConfig: true, + }, + }); + + // Augment each with its derived ACS + metadata URLs so the dashboard + // doesn't have to re-compute them client-side. Type inference matters + // because OIDC providers use a different callback path; the + // pre-audit-B.2 code hardcoded `null` which always picked the SAML + // URL — fine for SAML providers, wrong for OIDC providers and very + // confusing for admins configuring OIDC in their IdP console. + const augmented = providers.map(({ samlConfig, oidcConfig, ...p }) => { + const type: "saml" | "oidc" | null = samlConfig + ? "saml" + : oidcConfig + ? "oidc" + : null; + return { + ...p, + acsUrl: deriveAcsUrl(p.providerId, type), + metadataUrl: deriveMetadataUrl(p.providerId), + }; + }); + + return NextResponse.json({ data: augmented }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = createProviderSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Cross-check: providerType-specific config must be present. + if (body.providerType === "saml" && !body.samlConfig) { + return NextResponse.json( + { error: "samlConfig is required for providerType=saml" }, + { status: 400 }, + ); + } + if (body.providerType === "oidc" && !body.oidcConfig) { + return NextResponse.json( + { error: "oidcConfig is required for providerType=oidc" }, + { status: 400 }, + ); + } + + try { + const provider = await prisma.$transaction(async (tx) => { + // Domain ownership gate — must come BEFORE the dup-providerId + // check, because a 422 "domain not owned" is the more + // actionable error to surface for an operator who pasted the + // wrong domain. + // + // Pre-audit-B.3 this org could create an SsoProvider for any + // domain string. The runtime SSO-enforcement hook at + // `lib/auth.ts` + `lib/sso/enforce-session.ts` then refused to + // honor the provider (because no verified OrgDomainClaim + // existed), but the registration step itself was silent. That + // "defended by accident" stance leaves the org-admin staring + // at a registered provider that mysteriously never fires. + // + // Explicit gates: 422 DOMAIN_NOT_OWNED if no claim under this + // org; 422 DOMAIN_NOT_VERIFIED if the claim exists but + // verifiedAt IS NULL. The auth runtime keeps its + // belt-and-suspenders check, but now operators see the + // problem at the point of action. + const normalizedDomain = body.domain.toLowerCase(); + const claim = await tx.orgDomainClaim.findUnique({ + where: { domain: normalizedDomain }, + select: { organizationId: true, verifiedAt: true }, + }); + if (!claim || claim.organizationId !== orgId) { + throw Object.assign( + new Error( + `Domain '${body.domain}' is not claimed by this organization. Claim and verify the domain first under Settings → SSO → Domains.`, + ), + { httpStatus: 422, code: "DOMAIN_NOT_OWNED" }, + ); + } + if (!claim.verifiedAt) { + throw Object.assign( + new Error( + `Domain '${body.domain}' is claimed but not yet verified. Add the required DNS TXT record and complete verification before registering an SSO provider.`, + ), + { httpStatus: 422, code: "DOMAIN_NOT_VERIFIED" }, + ); + } + + const dupProviderId = await tx.ssoProvider.findUnique({ + where: { providerId: body.providerId }, + select: { id: true }, + }); + if (dupProviderId) { + throw Object.assign( + new Error( + `providerId '${body.providerId}' is already in use. Pick a globally-unique slug.`, + ), + { httpStatus: 409 }, + ); + } + + const dupDomain = await tx.ssoProvider.findFirst({ + where: { organizationId: orgId, domain: normalizedDomain }, + select: { id: true }, + }); + if (dupDomain) { + throw Object.assign( + new Error( + `Domain '${body.domain}' is already registered with another provider for this org.`, + ), + { httpStatus: 409 }, + ); + } + + const created = await tx.ssoProvider.create({ + data: { + id: randomUUID(), + providerId: body.providerId, + issuer: body.issuer, + domain: body.domain.toLowerCase(), + organizationId: orgId, + oidcConfig: body.oidcConfig + ? JSON.stringify(body.oidcConfig) + : null, + samlConfig: body.samlConfig + ? JSON.stringify(body.samlConfig) + : null, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.SSO_ENABLED, + description: `SSO provider '${body.providerId}' (${body.providerType}) registered for domain ${body.domain}`, + details: { + providerId: body.providerId, + providerType: body.providerType, + domain: body.domain, + issuer: body.issuer, + }, + }, + }); + + return created; + }); + + return NextResponse.json( + { + provider: { + id: provider.id, + providerId: provider.providerId, + issuer: provider.issuer, + domain: provider.domain, + acsUrl: deriveAcsUrl(provider.providerId, body.providerType), + metadataUrl: deriveMetadataUrl(provider.providerId), + }, + }, + { status: 201 }, + ); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + const code = + "code" in err && typeof err.code === "string" ? err.code : undefined; + return NextResponse.json( + code ? { error: err.message, code } : { error: err.message }, + { status }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/sso/route.ts b/app/api/organizations/[orgId]/sso/route.ts new file mode 100644 index 000000000..eea5360d4 --- /dev/null +++ b/app/api/organizations/[orgId]/sso/route.ts @@ -0,0 +1,215 @@ +/** + * GET /api/organizations/[orgId]/sso + * PATCH /api/organizations/[orgId]/sso + * + * Org-level SSO settings (separate from the individual IdP configs under + * /sso/providers). This endpoint governs: + * - allowedEmailDomains — which domains qualify for auto-join + * - enforceSSO — require SSO for all sign-ins + * - defaultRoleForAutoJoin — role newly auto-joined users receive + * + * Settings are upserted on PATCH — the record exists 1:1 with Organization, + * and missing == defaults (empty domains / no enforcement / LEARNER default). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { DomainSchema } from "@/lib/enterprise/validators"; +import { JitDefaultRoleSchema } from "@/lib/labels/org-labels"; +import { + DomainVerificationRequiredError, + hasVerifiedDomain, +} from "@/lib/enterprise/governance"; + +const PatchBodySchema = z + .object({ + allowedEmailDomains: z.array(DomainSchema).max(50).optional(), + enforceSSO: z.boolean().optional(), + // JIT auto-join is locked to LEARNER. Admins promote new members + // explicitly after first signin via /dashboard/.../members. This + // closes a privilege-escalation hole where `defaultRoleForAutoJoin + // = "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(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "PATCH body must contain at least one field", + }); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const [settings, providers, claims] = await Promise.all([ + prisma.organizationSSOSettings.findUnique({ + where: { organizationId: orgId }, + }), + prisma.ssoProvider.findMany({ + where: { organizationId: orgId }, + select: { + id: true, + providerId: true, + issuer: true, + domain: true, + samlConfig: true, + oidcConfig: true, + }, + }), + prisma.orgDomainClaim.findMany({ + where: { organizationId: orgId }, + select: { id: true, domain: true, claimedAt: true }, + }), + ]); + + return NextResponse.json({ + settings: settings ?? { + organizationId: orgId, + allowedEmailDomains: [], + enforceSSO: false, + defaultRoleForAutoJoin: "LEARNER", + }, + providers: providers.map(({ samlConfig, oidcConfig, ...rest }) => ({ + ...rest, + providerType: samlConfig ? "saml" : oidcConfig ? "oidc" : null, + })), + domainClaims: claims, + }); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + const updated = await prisma.$transaction(async (tx) => { + const existing = await tx.organizationSSOSettings.findUnique({ + 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 }, + ); + } + } + + // 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, + }, + }, + }, + }); + + return next; + }); + + 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 }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/stream/calls/route.ts b/app/api/organizations/[orgId]/stream/calls/route.ts new file mode 100644 index 000000000..6b23b7778 --- /dev/null +++ b/app/api/organizations/[orgId]/stream/calls/route.ts @@ -0,0 +1,99 @@ +/** + * GET /api/organizations/[orgId]/stream/calls + * + * Org-scoped Stream call + recording metadata export. MANAGER+ gate + * because the call log is a compliance surface (who met with whom, + * when, for how long). Reads from local MeetingSession (indexed by + * organizationId, #674) rather than Stream's API — every page load + * would otherwise re-do the join over the network. + * + * Recordings are joined eagerly so a dashboard listing 50 calls + * doesn't fan out 50 separate `listRecordings` HTTP calls. The local + * `Recording` table is the source of truth for the URL + + * `streamUrlExpiresAt` — the Stream S3 link expiry policy is what + * forces our 90-day default retention to be longer-than-Stream so + * we always have a window to transfer to permanent storage. + * + * Every successful GET writes a `STREAM_CALLS_EXPORTED` audit row. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER" }); + if (access.error) return access.error; + + const url = new URL(req.url); + const page = Math.max(1, Number(url.searchParams.get("page") ?? 1)); + const perPage = Math.min( + 100, + Math.max(1, Number(url.searchParams.get("perPage") ?? 25)), + ); + // Only emit recording metadata when the caller asks — most "list + // calls" hits don't need it and the eager join costs us index + // hits + bytes on the wire. + const withRecordings = url.searchParams.get("withRecordings") === "1"; + + const where = { organizationId: orgId }; + + const [totalResults, sessions] = await prisma.$transaction([ + prisma.meetingSession.count({ where }), + prisma.meetingSession.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * perPage, + take: perPage, + select: { + id: true, + streamCallId: true, + platform: true, + isRecording: true, + recordingStartedAt: true, + endedAt: true, + endedReason: true, + createdAt: true, + updatedAt: true, + recordings: withRecordings + ? { + select: { + id: true, + title: true, + recordingUrl: true, + supabaseUrl: true, + status: true, + storageType: true, + durationInMinutes: true, + streamUrlExpiresAt: true, + createdAt: true, + }, + } + : undefined, + }, + }), + ]); + + // Single audit row per export call, not per-row, so the audit log + // isn't drowned in noise. `details.count` carries the volume. + await prisma.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.STREAM_CALLS_EXPORTED, + description: `Listed ${sessions.length} Stream calls`, + details: { page, perPage, count: sessions.length, withRecordings }, + }, + }); + + return NextResponse.json({ + data: sessions, + meta: { totalResults, page, perPage }, + }); +} diff --git a/app/api/organizations/[orgId]/stream/channels/route.ts b/app/api/organizations/[orgId]/stream/channels/route.ts new file mode 100644 index 000000000..219e7729b --- /dev/null +++ b/app/api/organizations/[orgId]/stream/channels/route.ts @@ -0,0 +1,124 @@ +/** + * GET /api/organizations/[orgId]/stream/channels + * + * Lists Stream Chat channels tagged with `custom.organization_id = `. + * Surfaces the messaging side of an org's footprint to MANAGER+ org workspace operators + * for compliance, member-management, and audit workflows. Backed by Stream's + * native `queryChannels` so we don't shadow channel state in our DB. + * + * AUTH: MANAGER+ on the target org (matches the rest of the org-workspace + * surface area; viewing chat metadata is on par with viewing audit logs). + * + * PAGINATION: Stream caps `queryChannels` at 30 per call; we ship 20/page + * with offset-based pagination to keep the URL simple. `?page=` is 1-based. + * + * RESPONSE: minimal shape so the client can render a directory table + * without fetching messages. + * + * TODO: Equivalent `/stream/calls` endpoint for video. Stream Video's + * `queryCalls` API is custom-field filterable but uses a different SDK + * surface (`@stream-io/node-sdk`) and slightly different filter syntax; + * deferring to a follow-up issue. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { getStreamChatClient } from "@/lib/stream-client"; +import { streamLogger } from "@/lib/stream-logger"; + +const QuerySchema = z.object({ + // 1-based page number; offset is computed server-side. Capped at a + // generous 50 (~1000 channels) to keep Stream's pagination from + // degrading; orgs above that should use search/filter UI. + page: z.coerce.number().int().min(1).max(50).default(1), +}); + +const PAGE_SIZE = 20; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { page } = parsed.data; + const offset = (page - 1) * PAGE_SIZE; + + try { + const client = getStreamChatClient(); + // Stream's filter language: top-level keys map to channel custom data + // when prefixed (or matched implicitly via custom_field_name). Since + // our helper writes `organization_id` at the top level of channel + // custom data, the equality match below is the canonical form. + const channels = await client.queryChannels( + // Cast through unknown — stream-chat's `ChannelFilters` typing is + // strict about known fields and rejects custom keys, but the + // server accepts arbitrary custom-data filters at runtime. + { organization_id: { $eq: orgId } } as unknown as Parameters< + typeof client.queryChannels + >[0], + [{ last_message_at: -1 }], + { + limit: PAGE_SIZE, + offset, + // Don't fetch messages — we only need metadata for the list. + message_limit: 0, + // Don't fetch full member rosters; member_count is enough. + member_limit: 0, + }, + ); + + const rows = channels.map((ch) => { + const data = ch.data as Record | undefined; + const lastMessageAt = data?.last_message_at; + return { + cid: ch.cid, + id: ch.id, + type: ch.type, + name: typeof data?.name === "string" ? (data.name as string) : null, + memberCount: + typeof data?.member_count === "number" + ? (data.member_count as number) + : null, + lastMessageAt: + typeof lastMessageAt === "string" + ? lastMessageAt + : lastMessageAt instanceof Date + ? lastMessageAt.toISOString() + : null, + }; + }); + + return NextResponse.json({ + page, + pageSize: PAGE_SIZE, + // `hasMore` is best-effort — Stream doesn't return a total count. + // If we got a full page back, assume another exists. + hasMore: rows.length === PAGE_SIZE, + rows, + }); + } catch (err) { + streamLogger.error("Failed to query org channels", err, { orgId, page }); + return NextResponse.json( + { + error: "Failed to query channels", + detail: err instanceof Error ? err.message : "unknown", + }, + { status: 502 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/trials/route.ts b/app/api/organizations/[orgId]/trials/route.ts new file mode 100644 index 000000000..d437c8993 --- /dev/null +++ b/app/api/organizations/[orgId]/trials/route.ts @@ -0,0 +1,48 @@ +/** + * GET /api/organizations/[orgId]/trials + * + * Org-scoped trial-session list (#674 / B1-hybrid). MANAGER+ at the org. + * Forces `scope = org:`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { listTrialsScoped } from "@/lib/api/scope/list-trials"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + status: z + .enum(["PENDING", "SCHEDULED", "COMPLETED", "CONVERTED", "CANCELLED", "REJECTED"]) + .optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + status: url.searchParams.get("status") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listTrialsScoped({ + scope: { kind: "org", orgId }, + userId: access.session.user.id, + status: filters.data.status, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/organizations/[orgId]/verification/resubmit/route.ts b/app/api/organizations/[orgId]/verification/resubmit/route.ts new file mode 100644 index 000000000..3b859a12d --- /dev/null +++ b/app/api/organizations/[orgId]/verification/resubmit/route.ts @@ -0,0 +1,88 @@ +/** + * POST /api/organizations/[orgId]/verification/resubmit + * + * #779 §A — self-serve verification resubmit. After a platform admin + * rejects an org (status stays PENDING_VERIFICATION, `verificationReason` + * + `verificationRejectedAt` set), an OWNER/MAINTAINER fixes the issue and + * re-submits: bumps `verificationSubmittedAt`, clears the reason + + * rejection stamp so the admin queue picks it up fresh. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MAINTAINER"); + if (access.error) return access.error; + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.organization.findUnique({ + where: { id: orgId }, + select: { + status: true, + verificationRejectedAt: true, + }, + }); + // requireOrgAccess already 404s a missing org; this is defensive. + if (!current) { + throw Object.assign(new Error("Organization not found"), { + httpStatus: 404, + }); + } + + // #779 §A — only a previously-rejected, still-pending org can resubmit. + if ( + current.status !== "PENDING_VERIFICATION" || + current.verificationRejectedAt === null + ) { + throw Object.assign(new Error("NOTHING_TO_RESUBMIT"), { + httpStatus: 409, + }); + } + + const next = await tx.organization.update({ + where: { id: orgId }, + data: { + verificationSubmittedAt: new Date(), + verificationReason: null, + verificationRejectedAt: null, + }, + select: { + status: true, + verificationReason: true, + verificationSubmittedAt: true, + verificationRejectedAt: true, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.VERIFICATION_RESUBMITTED, + description: "Verification resubmitted", + details: { resubmittedAt: next.verificationSubmittedAt?.toISOString() }, + }, + }); + + return next; + }); + + return NextResponse.json({ verification: updated }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/waitlist/route.ts b/app/api/organizations/[orgId]/waitlist/route.ts new file mode 100644 index 000000000..f4c1af5d0 --- /dev/null +++ b/app/api/organizations/[orgId]/waitlist/route.ts @@ -0,0 +1,48 @@ +/** + * GET /api/organizations/[orgId]/waitlist + * + * Org-scoped waitlist list (#674 / B1-hybrid). MANAGER+ at the org. + * Forces `scope = org:`. Mirrors `/api/organizations/[orgId]/appointments`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { listWaitlistScoped } from "@/lib/api/scope/list-waitlist"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + status: z + .enum(["WAITING", "NOTIFIED", "EXPIRED", "BOOKED", "CANCELLED"]) + .optional(), +}); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const filters = QuerySchema.safeParse({ + status: url.searchParams.get("status") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listWaitlistScoped({ + scope: { kind: "org", orgId }, + userId: access.session.user.id, + status: filters.data.status, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/[deliveryId]/redeliver/route.ts b/app/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/[deliveryId]/redeliver/route.ts new file mode 100644 index 000000000..c0085cf55 --- /dev/null +++ b/app/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/[deliveryId]/redeliver/route.ts @@ -0,0 +1,129 @@ +/** + * POST /api/organizations/[orgId]/webhooks/[endpointId]/deliveries/[deliveryId]/redeliver + * + * Re-queues an existing delivery row by flipping its status back to + * PENDING and clearing the failure metadata. The worker picks it up on + * the next tick. + * + * Why we copy the existing payload, not snapshot the live event + * -------------------------------------------------------------- + * The event-at-original-creation is the deliverable contract. Replaying + * the SAME payload (signed with the current secret) is the integrator's + * expected recovery path: "your receiver was down at 03:00 UTC, click + * Replay to push the original body through." Re-snapshotting the live + * entity would carry whatever drift has happened in the interim (an + * invoice that was later voided, a member that was then suspended) + * which is not what the receiver originally subscribed to. + * + * Why OWNER + BILLING_ADMIN (not MANAGER): replaying a webhook fires + * external side effects in the receiver — finance-team or owner scope. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { applyRateLimit, orgWebhookLimiter } from "@/lib/rate-limit"; + +export async function POST( + _req: NextRequest, + { + params, + }: { + params: Promise<{ + orgId: string; + endpointId: string; + deliveryId: string; + }>; + }, +) { + const { orgId, endpointId, deliveryId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const rl = await applyRateLimit(orgWebhookLimiter, `org:${orgId}`); + if (rl) return rl; + + try { + const updated = await prisma.$transaction(async (tx) => { + const delivery = await tx.outboundWebhookDelivery.findFirst({ + where: { id: deliveryId, webhookEndpointId: endpointId }, + include: { + endpoint: { select: { organizationId: true, status: true } }, + }, + }); + if (!delivery || delivery.endpoint.organizationId !== orgId) { + throw Object.assign(new Error("Delivery not found"), { + httpStatus: 404, + }); + } + if (delivery.endpoint.status !== "ACTIVE") { + // Refuse to replay against an explicitly-paused endpoint — + // the worker would just abort it again with the same FAILED + // status. Better to surface the operator-actionable error here. + throw Object.assign( + new Error( + "Cannot replay a delivery whose endpoint is not ACTIVE. Resume the endpoint first.", + ), + { httpStatus: 409 }, + ); + } + // CAS — only settled rows may be replayed. PENDING is already queued, + // RETRY already has a slot, and resetting an IN_FLIGHT row mid-delivery + // would double-send and orphan the worker's claim. + const requeued = await tx.outboundWebhookDelivery.updateMany({ + where: { + id: deliveryId, + status: { in: ["SUCCESS", "FAILED", "DEAD_LETTER"] }, + }, + // Reset attempt counters + clear retry slot so the worker + // treats this as a fresh PENDING row at the next tick. + data: { + status: "PENDING", + attempts: 0, + nextRetryAt: null, + lastError: null, + httpStatusCode: null, + signature: null, + deliveredAt: null, + }, + }); + if (requeued.count === 0) { + throw Object.assign( + new Error( + "Delivery is already queued or in flight — only settled (success/failed/dead-letter) deliveries can be replayed.", + ), + { httpStatus: 409 }, + ); + } + const next = await tx.outboundWebhookDelivery.findUniqueOrThrow({ + where: { id: deliveryId }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "WEBHOOK", + action: AUDIT_ACTIONS.WEBHOOK.WEBHOOK_DELIVERY_REDELIVERED, + description: `Re-queued delivery ${deliveryId} (event ${delivery.eventType})`, + details: { deliveryId, endpointId, eventType: delivery.eventType }, + }, + }); + return next; + }); + return NextResponse.json({ + delivery: { + id: updated.id, + status: updated.status, + }, + }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + return NextResponse.json( + { error: err.message }, + { status: (err as { httpStatus?: number }).httpStatus ?? 500 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/route.ts b/app/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/route.ts new file mode 100644 index 000000000..41d17dff1 --- /dev/null +++ b/app/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/route.ts @@ -0,0 +1,77 @@ +/** + * GET /api/organizations/[orgId]/webhooks/[endpointId]/deliveries + * + * Paginated delivery log. MANAGER+ — the same role-floor as the + * endpoint list because the delivery body itself can carry PII + * (the original event payload mirrors the route that triggered it). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; endpointId: string }>; + }, +) { + const { orgId, endpointId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER" }); + if (access.error) return access.error; + + // Verify the endpoint belongs to this org BEFORE touching the + // delivery table — otherwise a guessed endpointId from a sibling + // org would leak that org's delivery payloads. + const endpoint = await prisma.webhookEndpoint.findFirst({ + where: { id: endpointId, organizationId: orgId }, + select: { id: true }, + }); + if (!endpoint) { + return NextResponse.json( + { error: "Webhook endpoint not found" }, + { status: 404 }, + ); + } + + const url = new URL(req.url); + const page = Math.max(1, Number(url.searchParams.get("page") ?? 1)); + const perPage = Math.min( + 100, + Math.max(1, Number(url.searchParams.get("perPage") ?? 25)), + ); + + const [total, deliveries] = await prisma.$transaction([ + prisma.outboundWebhookDelivery.count({ + where: { webhookEndpointId: endpointId }, + }), + prisma.outboundWebhookDelivery.findMany({ + where: { webhookEndpointId: endpointId }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * perPage, + take: perPage, + select: { + id: true, + eventType: true, + status: true, + httpStatusCode: true, + attempts: true, + nextRetryAt: true, + lastError: true, + createdAt: true, + deliveredAt: true, + // Payload is intentionally included — operators need it to + // diagnose receiver-side parse failures. The MANAGER role-floor + // is the gate; below that role the row would not be readable. + payload: true, + }, + }), + ]); + + return NextResponse.json({ + data: deliveries, + meta: { total, page, perPage }, + }); +} diff --git a/app/api/organizations/[orgId]/webhooks/[endpointId]/rotate-secret/route.ts b/app/api/organizations/[orgId]/webhooks/[endpointId]/rotate-secret/route.ts new file mode 100644 index 000000000..b84bbc05a --- /dev/null +++ b/app/api/organizations/[orgId]/webhooks/[endpointId]/rotate-secret/route.ts @@ -0,0 +1,94 @@ +/** + * POST /api/organizations/[orgId]/webhooks/[endpointId]/rotate-secret + * + * Mints a fresh 32-byte secret for the endpoint and returns it ONCE. + * The prior secret is stashed and `secretRotatedAt` is stamped so the + * worker dual-signs deliveries with BOTH secrets for a 24h grace + * window (see signing.ts / worker.ts) — receivers stay green while they + * roll their env var. Use case: a leaked secret, or rotation hygiene on + * a compliance schedule. + * + * OWNER-only on purpose — rotating the secret is sensitive from the + * integrator's perspective (the 24h dual-sign window softens the + * cutover, but their verification code still must adopt the new secret + * before the window closes), so we want the gate to be the + * highest-trust role. BILLING_ADMIN can pause/disable but not rotate. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgOwner } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { generateEndpointSecret } from "@/lib/enterprise/outbound-webhooks/signing"; +import { applyRateLimit, orgWebhookLimiter } from "@/lib/rate-limit"; + +export async function POST( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; endpointId: string }>; + }, +) { + const { orgId, endpointId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + const rl = await applyRateLimit(orgWebhookLimiter, `org:${orgId}`); + if (rl) return rl; + + const newSecret = generateEndpointSecret(); + + try { + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.webhookEndpoint.findFirst({ + where: { id: endpointId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Webhook endpoint not found"), { + httpStatus: 404, + }); + } + const next = await tx.webhookEndpoint.update({ + where: { id: endpointId }, + data: { + secret: newSecret, + // NOTE: holds the previous secret VALUE (not a hash) during the + // rotation grace window so the worker can dual-sign; field name + // is legacy — rename at the next schema reset. #768 + previousSecretHash: current.secret, + secretRotatedAt: new Date(), + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "WEBHOOK", + action: AUDIT_ACTIONS.WEBHOOK.WEBHOOK_SECRET_ROTATED, + description: `Rotated secret for webhook endpoint ${current.url}`, + details: { endpointId, url: current.url, graceWindowHours: 24 }, + }, + }); + return next; + }); + + return NextResponse.json({ + endpoint: { + id: updated.id, + url: updated.url, + status: updated.status, + secret: newSecret, + updatedAt: updated.updatedAt, + }, + }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + return NextResponse.json( + { error: err.message }, + { status: (err as { httpStatus?: number }).httpStatus ?? 500 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/webhooks/[endpointId]/route.ts b/app/api/organizations/[orgId]/webhooks/[endpointId]/route.ts new file mode 100644 index 000000000..81deb3c6c --- /dev/null +++ b/app/api/organizations/[orgId]/webhooks/[endpointId]/route.ts @@ -0,0 +1,225 @@ +/** + * GET /api/organizations/[orgId]/webhooks/[endpointId] + * PATCH /api/organizations/[orgId]/webhooks/[endpointId] + * DELETE /api/organizations/[orgId]/webhooks/[endpointId] + * + * Detail + mutation routes for a single webhook endpoint. PATCH allows + * editing the URL, event subscriptions, and status (ACTIVE / PAUSED / + * DISABLED) — OWNER + BILLING_ADMIN. DELETE is OWNER-only because + * removing an endpoint while deliveries are mid-retry would orphan + * those rows (they'd cascade-delete via the FK). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { + requireOrgAccess, + requireOrgOwner, +} from "@/lib/auth-helpers"; +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + OUTBOUND_WEBHOOK_EVENTS, + isOutboundWebhookEvent, +} from "@/lib/enterprise/outbound-webhooks/event-types"; +import { applyRateLimit, orgWebhookLimiter } from "@/lib/rate-limit"; + +const REDACTED_SECRET = "[redacted]"; + +const HttpsUrl = z + .string() + .url() + .refine((u) => u.startsWith("https://"), { + message: "Webhook URL must use https://", + }) + .refine((u) => u.length <= 2048, { + message: "Webhook URL must be ≤2048 characters", + }); + +const PatchBodySchema = z.object({ + url: HttpsUrl.optional(), + status: z.enum(["ACTIVE", "PAUSED", "DISABLED"]).optional(), + eventSubscriptions: z + .array(z.string()) + .min(1) + .refine((arr) => arr.every(isOutboundWebhookEvent), { + message: `Unknown event type. Allowed: ${OUTBOUND_WEBHOOK_EVENTS.join(", ")}`, + }) + .optional(), +}); + +export async function GET( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; endpointId: string }>; + }, +) { + const { orgId, endpointId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER" }); + if (access.error) return access.error; + + const endpoint = await prisma.webhookEndpoint.findFirst({ + where: { id: endpointId, organizationId: orgId }, + select: { + id: true, + url: true, + status: true, + eventSubscriptions: true, + failureCount: true, + lastSuccessAt: true, + lastFailureAt: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!endpoint) { + return NextResponse.json( + { error: "Webhook endpoint not found" }, + { status: 404 }, + ); + } + return NextResponse.json({ + endpoint: { ...endpoint, secret: REDACTED_SECRET }, + }); +} + +export async function PATCH( + req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; endpointId: string }>; + }, +) { + const { orgId, endpointId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const rl = await applyRateLimit(orgWebhookLimiter, `org:${orgId}`); + if (rl) return rl; + + const raw = await req.json().catch(() => null); + const parsed = PatchBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + if (Object.keys(parsed.data).length === 0) { + // Reject empty-PATCH explicitly so the audit log never carries a + // "no-op" row. An accidental empty body is almost always a bug in + // the caller. + return NextResponse.json( + { error: "No mutable fields in body" }, + { status: 400 }, + ); + } + + const updated = await prisma.$transaction(async (tx) => { + const current = await tx.webhookEndpoint.findFirst({ + where: { id: endpointId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Webhook endpoint not found"), { + httpStatus: 404, + }); + } + const next = await tx.webhookEndpoint.update({ + where: { id: endpointId }, + data: { + ...(parsed.data.url !== undefined && { url: parsed.data.url }), + ...(parsed.data.status !== undefined && { status: parsed.data.status }), + ...(parsed.data.eventSubscriptions !== undefined && { + eventSubscriptions: parsed.data.eventSubscriptions, + }), + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "WEBHOOK", + action: + parsed.data.status === "PAUSED" + ? AUDIT_ACTIONS.WEBHOOK.WEBHOOK_ENDPOINT_PAUSED + : parsed.data.status === "ACTIVE" && current.status !== "ACTIVE" + ? AUDIT_ACTIONS.WEBHOOK.WEBHOOK_ENDPOINT_RESUMED + : AUDIT_ACTIONS.WEBHOOK.WEBHOOK_ENDPOINT_UPDATED, + description: `Updated webhook endpoint ${next.url}`, + details: { + endpointId, + changedFields: Object.keys(parsed.data), + fromStatus: current.status, + toStatus: next.status, + }, + }, + }); + return next; + }); + + return NextResponse.json({ + endpoint: { + id: updated.id, + url: updated.url, + status: updated.status, + eventSubscriptions: updated.eventSubscriptions, + secret: REDACTED_SECRET, + updatedAt: updated.updatedAt, + }, + }); +} + +export async function DELETE( + _req: NextRequest, + { + params, + }: { + params: Promise<{ orgId: string; endpointId: string }>; + }, +) { + const { orgId, endpointId } = await params; + // Why OWNER-only (not BILLING_ADMIN): deletion cascades to every + // pending/retrying delivery row (`onDelete: Cascade` on the FK). An + // org that's actively integrating with a third-party would lose all + // in-flight events on a single misclick. Restrict to OWNER so the + // action requires deliberate elevation. + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + try { + await prisma.$transaction(async (tx) => { + const current = await tx.webhookEndpoint.findFirst({ + where: { id: endpointId, organizationId: orgId }, + }); + if (!current) { + throw Object.assign(new Error("Webhook endpoint not found"), { + httpStatus: 404, + }); + } + await tx.webhookEndpoint.delete({ where: { id: endpointId } }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "WEBHOOK", + action: AUDIT_ACTIONS.WEBHOOK.WEBHOOK_ENDPOINT_DELETED, + description: `Deleted webhook endpoint ${current.url}`, + details: { endpointId, url: current.url }, + }, + }); + }); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + return NextResponse.json( + { error: err.message }, + { status: (err as { httpStatus?: number }).httpStatus ?? 500 }, + ); + } + throw err; + } +} diff --git a/app/api/organizations/[orgId]/webhooks/route.ts b/app/api/organizations/[orgId]/webhooks/route.ts new file mode 100644 index 000000000..647132938 --- /dev/null +++ b/app/api/organizations/[orgId]/webhooks/route.ts @@ -0,0 +1,164 @@ +/** + * GET /api/organizations/[orgId]/webhooks + * POST /api/organizations/[orgId]/webhooks + * + * Org-scoped CRUD for outbound webhook endpoints. The secret returned + * on POST is the **only** time the caller sees the raw value — every + * subsequent GET redacts it to a fixed marker so a compromised dashboard + * session can't exfiltrate signing material. Rotate via the dedicated + * `/rotate-secret` route (OWNER-only) if it's lost. + * + * GET is MANAGER+ (read-only view shows up on the billing dashboard's + * Integrations card). POST is OWNER + BILLING_ADMIN per the gate + * matrix in `docs/enterprise/00-foundations/04-roles-and-permissions.md`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + OUTBOUND_WEBHOOK_EVENTS, + isOutboundWebhookEvent, +} from "@/lib/enterprise/outbound-webhooks/event-types"; +import { generateEndpointSecret } from "@/lib/enterprise/outbound-webhooks/signing"; +import { applyRateLimit, orgWebhookLimiter } from "@/lib/rate-limit"; + +const REDACTED_SECRET = "[redacted]"; + +/** + * Why the URL is constrained to https:// only + * ------------------------------------------- + * Outbound webhooks carry PII (member emails, invoice line items). A + * plain-http URL would leak that PII over the network. The schema + * refuses anything other than `https://` at registration time so we + * don't have to revalidate at delivery time. + */ +const HttpsUrl = z + .string() + .url() + .refine((u) => u.startsWith("https://"), { + message: "Webhook URL must use https://", + }) + .refine((u) => u.length <= 2048, { + message: "Webhook URL must be ≤2048 characters", + }); + +const CreateBodySchema = z.object({ + url: HttpsUrl, + /** + * Subset of `OUTBOUND_WEBHOOK_EVENTS`. We narrow each entry via the + * exported type guard so unknown event types fail the Zod parse + * (rather than silently writing junk into the eventSubscriptions + * array that the dispatch helper would never match). + */ + eventSubscriptions: z + .array(z.string()) + .min(1, "Subscribe to at least one event") + .refine((arr) => arr.every(isOutboundWebhookEvent), { + message: `Unknown event type. Allowed: ${OUTBOUND_WEBHOOK_EVENTS.join(", ")}`, + }), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "MANAGER" }); + if (access.error) return access.error; + + const endpoints = await prisma.webhookEndpoint.findMany({ + where: { organizationId: orgId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + url: true, + status: true, + eventSubscriptions: true, + failureCount: true, + lastSuccessAt: true, + lastFailureAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Redact-by-construction: the SELECT above never reads `secret`, so + // there's no value to leak even by serialization mistake. + return NextResponse.json({ + data: endpoints.map((e) => ({ ...e, secret: REDACTED_SECRET })), + }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgBillingAdminOrOwner(orgId); + if (access.error) return access.error; + + const rl = await applyRateLimit(orgWebhookLimiter, `org:${orgId}`); + if (rl) return rl; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + // 32-byte secret minted ONCE; written to the DB and returned to the + // caller verbatim. The dashboard surfaces a "copy now, you won't see + // it again" UX so admins are nudged into storing it in their secrets + // manager before navigating away. + const secret = generateEndpointSecret(); + + const created = await prisma.$transaction(async (tx) => { + const endpoint = await tx.webhookEndpoint.create({ + data: { + organizationId: orgId, + url: parsed.data.url, + secret, + status: "ACTIVE", + eventSubscriptions: parsed.data.eventSubscriptions, + createdByMembershipId: access.member.id, + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "WEBHOOK", + action: AUDIT_ACTIONS.WEBHOOK.WEBHOOK_ENDPOINT_CREATED, + description: `Created webhook endpoint ${endpoint.url}`, + details: { + endpointId: endpoint.id, + url: endpoint.url, + eventSubscriptions: endpoint.eventSubscriptions, + }, + }, + }); + return endpoint; + }); + + return NextResponse.json( + { + endpoint: { + id: created.id, + url: created.url, + status: created.status, + eventSubscriptions: created.eventSubscriptions, + // ONE-TIME secret reveal — see top-of-file comment. + secret, + createdAt: created.createdAt, + }, + }, + { status: 201 }, + ); +} diff --git a/app/api/organizations/invitations/accept/route.ts b/app/api/organizations/invitations/accept/route.ts new file mode 100644 index 000000000..a554b2cef --- /dev/null +++ b/app/api/organizations/invitations/accept/route.ts @@ -0,0 +1,301 @@ +/** + * POST /api/organizations/invitations/accept + * + * Accepts a pending BetterAuth `Invitation` by id and creates the typed + * `Membership` row in the same transaction. Also creates the BetterAuth + * `Member` sibling so BetterAuth's org-scoped session flows keep working + * — the two tables are linked via `Membership.betterAuthMemberId`. + * + * Token race: two concurrent accepts from the same email could both pass + * the pre-check. `updateMany WHERE status = pending` gives us an atomic + * claim — only the first caller transitions the invitation to accepted, + * the second sees count=0 and reports 409. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { MemberRoleSchema } from "@/lib/labels/org-labels"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { isOnboardingBlocked } from "@/lib/enterprise/org-status"; +import { + applyMembershipRoleEffects, + bumpUserSessionGeneration, +} from "@/lib/api/organizations/membership-transitions"; +import { notifyOrgInviteAccepted } from "@/lib/novu/org-workflows"; + +const AcceptBodySchema = z.object({ + invitationId: z.string().min(1), +}); + +export async function POST(req: NextRequest) { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const raw = await req.json().catch(() => null); + const parsed = AcceptBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { invitationId } = parsed.data; + + // Verify the invitation against the authenticated user's email before + // doing anything mutative. Preventing accept-by-id-guessing means a + // stolen URL from a user's inbox still can't be redeemed by someone + // else's account. + const invitation = await prisma.invitation.findUnique({ + where: { id: invitationId }, + select: { + id: true, + organizationId: true, + email: true, + role: true, + status: true, + expiresAt: true, + }, + }); + if (!invitation) { + return NextResponse.json({ error: "Invitation not found" }, { status: 404 }); + } + // Closure-friendly non-null alias. TS doesn't carry the + // null-narrowed flow type into the inner `runAcceptTx` function + // declaration below; binding to a fresh const preserves the + // narrowed type for closure reads. + const inv = invitation; + if (inv.email.toLowerCase() !== auth.session.user.email.toLowerCase()) { + return NextResponse.json( + { error: "This invitation is not addressed to you" }, + { status: 403 }, + ); + } + if (inv.expiresAt.getTime() < Date.now()) { + return NextResponse.json( + { error: "Invitation has expired" }, + { status: 410 }, + ); + } + + // Narrow the stored string to a MemberRole. BetterAuth's Invitation + // table stores role as a free-form string, so validate before using. + const roleResult = MemberRoleSchema.safeParse(inv.role); + if (!roleResult.success) { + return NextResponse.json( + { error: `Unknown invitation role: ${inv.role}` }, + { status: 400 }, + ); + } + const normalizedRole = roleResult.data; + + const userId = auth.session.user.id; + + // ENT-5: A second concurrent accept for the same (user, org) pair can + // race past the in-tx existence check and hit P2002 on Membership's + // (userId, organizationId) unique. Retry once: the second attempt will + // see the row created by the winner and fall into the alreadyMember + // idempotent branch. Bound the retry to keep this from masking real + // bugs. + const MAX_ATTEMPTS = 2; + let lastErr: unknown; + let result: + | Awaited> + | undefined; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + result = await runAcceptTx(); + lastErr = undefined; + break; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" && + attempt < MAX_ATTEMPTS + ) { + lastErr = err; + continue; + } + lastErr = err; + break; + } + } + if (!result) { + if (lastErr instanceof Error && "httpStatus" in lastErr) { + const status = + typeof lastErr.httpStatus === "number" ? lastErr.httpStatus : 500; + return NextResponse.json({ error: lastErr.message }, { status }); + } + throw lastErr ?? new Error("Invitation accept failed for unknown reason"); + } + + // Side-effect: notify the org's operator roster that someone new + // joined. Skip when the caller was already a member — the "accept" + // button was just idempotent, nothing newsworthy happened. + if (!result.alreadyMember) { + const origin = new URL(req.url).origin; + notifyOrgInviteAccepted(result.organization.id, { + accepteeName: auth.session.user.name ?? auth.session.user.email, + accepteeEmail: auth.session.user.email, + orgName: result.organization.name, + role: result.membership.role, + dashboardUrl: `${origin}/dashboard/organization/${result.organization.id}/members`, + }).catch((err) => + console.error("[notifyOrgInviteAccepted] failed:", err), + ); + } + + // Client contract (app/organizations/invite/[token]/page.tsx): expects + // { organization: { id, name }, role?: string, alreadyMember?: boolean } + // so it can redirect to /dashboard/organization/:id/home after accept. + // Returning a bare `{ membership }` silently broke the redirect. + return NextResponse.json( + { + organization: result.organization, + role: result.membership.role, + alreadyMember: result.alreadyMember, + membership: result.membership, + }, + { status: result.alreadyMember ? 200 : 201 }, + ); + + async function runAcceptTx() { + return prisma.$transaction(async (tx) => { + // Atomic claim — only the first concurrent accept wins. Follow-up + // retries get count=0 and fall into the 409 branch below. + const claim = await tx.invitation.updateMany({ + where: { id: invitationId, status: "pending" }, + data: { status: "accepted", userId }, + }); + if (claim.count === 0) { + throw Object.assign( + new Error("Invitation is no longer pending"), + { httpStatus: 409 }, + ); + } + + // Re-fetch org status inside the tx so a SUSPENDED/DEACTIVATED org + // can't be onboarded into via a stale invite link. The pre-check + // outside the tx is not enough — an admin could suspend the org + // mid-flight between the email click and the POST. + const org = await tx.organization.findUnique({ + where: { id: inv.organizationId }, + select: { id: true, name: true, status: true }, + }); + if (!org) { + throw Object.assign( + new Error("Organization no longer exists"), + { httpStatus: 404 }, + ); + } + if (isOnboardingBlocked(org.status)) { + throw Object.assign( + new Error( + `Organization is ${org.status.toLowerCase()}; cannot accept new members`, + ), + { httpStatus: 403 }, + ); + } + + // User may already have a Membership in this org from a direct + // admin add or an SSO auto-join. Idempotent upsert keeps the + // UI's "accept" button safe to click twice. + const existing = await tx.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId: inv.organizationId, + }, + }, + }); + if (existing) { + return { membership: existing, organization: org, alreadyMember: true }; + } + + // #729 §AC4/AC5 + #819 — who-is-acting identity rule. Accepting an + // invitation is the USER'S OWN consenting action, so the lightweight + // ConsulteeProfile may be lazy-created here (lib/auth.ts names + // "invite-accept as LEARNER" as a sanctioned creation point — gating + // it broke sponsored-employee onboarding). EXPERT stays strict: a + // consultant identity carries domain/rates/verification/payout + // prerequisites that no invite click can substitute for. Admin + // direct-add (POST /members) stays strict for BOTH roles, and SSO + // JIT keeps its own lazy path. + if (normalizedRole === "EXPERT") { + const existingConsultant = await tx.consultantProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + if (!existingConsultant) { + throw Object.assign( + new Error("NOT_A_CONSULTANT"), + { httpStatus: 400 }, + ); + } + } + + // Profile FK + payoutRecipient defaults are computed by the + // shared helper (see lib/api/organizations/membership-transitions.ts). + // LEARNER lazy-creates ConsulteeProfile (first consumer action). + // Operator roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs + // null. The EXPERT pre-check above means the helper never reaches + // its EXPERT lazy-create branch from this surface. Multi-org experts + // and learners stay first-class; see + // docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md. + const roleEffects = await applyMembershipRoleEffects(tx, { + userId, + role: normalizedRole, + }); + + // BetterAuth Member row is kept for org-scoped session flows. + // Membership.betterAuthMemberId preserves the linkage even after + // BetterAuth's own adapter writes are done. + const betterAuthMember = await tx.member.create({ + data: { + organizationId: inv.organizationId, + userId, + // BetterAuth's Member.role is a free-form string; we write the + // typed MemberRole value here so third-party tools that read + // the BetterAuth table see the correct role. + role: normalizedRole, + }, + }); + + const created = await tx.membership.create({ + data: { + userId, + organizationId: inv.organizationId, + role: normalizedRole, + status: "ACTIVE", + consulteeProfileId: roleEffects.consulteeProfileId, + consultantProfileId: roleEffects.consultantProfileId, + payoutRecipient: roleEffects.payoutRecipient, + betterAuthMemberId: betterAuthMember.id, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: inv.organizationId, + actorMembershipId: created.id, + targetMembershipId: created.id, + category: "MEMBER", + action: AUDIT_ACTIONS.MEMBER.INVITE_ACCEPTED, + description: `User ${userId} accepted invitation to join as ${normalizedRole}`, + details: { invitationId: inv.id, role: normalizedRole }, + }, + }); + + // Bump the user's session-generation marker so the next request + // through customSession picks up the new org membership without + // waiting for BetterAuth's 24h session-rotation window. The + // accepter sees the org in their sidebar / org-switcher on the + // next page load instead of after a manual logout. Audit B.5. + await bumpUserSessionGeneration(tx, userId); + + return { membership: created, organization: org, alreadyMember: false }; + }); + } +} diff --git a/app/api/organizations/public/route.ts b/app/api/organizations/public/route.ts new file mode 100644 index 000000000..e6092dd29 --- /dev/null +++ b/app/api/organizations/public/route.ts @@ -0,0 +1,110 @@ +/** + * GET /api/organizations/public + * + * Public (unauthenticated) listing of HOST/HYBRID organizations that have + * opted in to marketplace discovery via Organization.isPublic=true. + * + * SPONSOR-only orgs (canHost=false) are never included — they are B2B clients, + * not marketplace participants, and exposing them publicly would be a privacy + * concern for corporate clients who want confidentiality. + * + * Query params: + * industry — filter by org.industry (case-insensitive contains) + * search — full-text search on name/description (case-insensitive) + * page — 1-indexed, default 1 + * limit — default 12, max 50 + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { apiError } from "@/lib/errors"; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = req.nextUrl; + const page = Math.max(1, parseInt(searchParams.get("page") || "1") || 1); + const limit = Math.min( + Math.max(1, parseInt(searchParams.get("limit") || "12") || 12), + 50, + ); + const industry = searchParams.get("industry")?.trim(); + const search = searchParams.get("search")?.trim(); + + const skip = (page - 1) * limit; + + const where = { + isPublic: true, + canHost: true, + status: "ACTIVE" as const, + ...(industry && { + industry: { contains: industry, mode: "insensitive" as const }, + }), + ...(search && { + OR: [ + { name: { contains: search, mode: "insensitive" as const } }, + { description: { contains: search, mode: "insensitive" as const } }, + ], + }), + }; + + const [orgs, total] = await Promise.all([ + prisma.organization.findMany({ + where, + select: { + id: true, + name: true, + slug: true, + canSponsor: true, + canHost: true, + brandingProfile: { + select: { + logo: true, + bannerImage: true, + description: true, + industry: true, + website: true, + }, + }, + _count: { + select: { + memberships: { + where: { role: "EXPERT", status: "ACTIVE" }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + skip, + take: limit, + }), + prisma.organization.count({ where }), + ]); + + const data = orgs.map((org) => ({ + id: org.id, + name: org.name, + slug: org.slug, + logo: org.brandingProfile?.logo ?? null, + bannerImage: org.brandingProfile?.bannerImage ?? null, + description: org.brandingProfile?.description ?? null, + industry: org.brandingProfile?.industry ?? null, + website: org.brandingProfile?.website ?? null, + capabilityKind: org.canSponsor ? "hybrid" : "host", + expertCount: org._count.memberships, + })); + + return NextResponse.json( + { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }, + { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + }, + }, + ); + } catch (error) { + return apiError({ tag: "[Organizations.Public.GET]", error }); + } +} diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts new file mode 100644 index 000000000..f8066084c --- /dev/null +++ b/app/api/organizations/route.ts @@ -0,0 +1,480 @@ +/** + * GET /api/organizations + * POST /api/organizations + * + * GET returns the orgs the caller is a member of — the list behind the + * "your organizations" switcher in the UI. Each entry carries the light + * fields the switcher needs (capability booleans, fundingSource, role). + * + * POST creates a new Organization + BillingAccount + OWNER Membership in + * a single transaction. The caller is the OWNER of the created org; a + * matching BetterAuth `Member` row is also written (so the org-scope + * session gets populated on the next login). + * + * Invariants: + * - slug is unique and lower-cased; + * - at least one capability must be true (canSponsor OR canHost); + * - canSponsor=true → BillingAccount created with the chosen fundingSource. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { randomUUID } from "crypto"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { isValidGstin } from "@/lib/compliance/gst"; +import { isValidPan } from "@/lib/compliance/tds"; +import { encryptPAN } from "@/lib/payments/tax/pan-crypto"; +import { ENABLE_HOST_ORGS } from "@/lib/feature-flags"; + +// PROJECT is reserved in the Prisma enum for the v2 milestone workflow +// (scoped project-billing engine), but not accepted at the API boundary +// yet — callers that pick it would otherwise silently fall into the +// TAG_ONLY path in checkout. Re-add here once checkout has a dedicated +// PROJECT branch. +const FundingSourceSchema = z.enum([ + "PERSONAL", + "LICENSE", + "WALLET", + "INVOICE", +]); +const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); +const DataRegionSchema = z.enum(["IN", "US", "EU"]); +const SizeBucketSchema = z.enum([ + "SMALL_1_50", + "MEDIUM_51_200", + "LARGE_201_1000", + "ENTERPRISE_1000_PLUS", +]); + +const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +/** + * Carries an HTTP status (and optional machine-readable code) on a thrown + * Error so the route's bottom-of-handler catch can serialise it without + * untagged-Error guesswork. Replaces the previous `Object.assign(new + * Error(...), { httpStatus: 409 })` pattern, which forced every reader + * of the catch to cast through `unknown` to pull the status back out. + */ +class HttpError extends Error { + readonly httpStatus: number; + readonly code?: string; + + constructor(message: string, httpStatus: number, code?: string) { + super(message); + this.name = "HttpError"; + this.httpStatus = httpStatus; + this.code = code; + } +} + +const CreateBodySchema = z + .object({ + name: z.string().trim().min(2).max(200), + slug: z + .string() + .trim() + .toLowerCase() + .min(2) + .max(63) + .regex(SLUG_REGEX, "slug must be lower-case alphanumeric with hyphens") + .optional(), + canSponsor: z.boolean().default(true), + canHost: z.boolean().default(false), + fundingSource: FundingSourceSchema.default("PERSONAL"), + billingEmail: z.string().email(), + currency: CurrencySchema.default("INR"), + paymentTermsDays: z.coerce.number().int().min(0).max(180).optional(), + description: z.string().max(5000).nullable().optional(), + industry: z.string().max(120).nullable().optional(), + website: z.string().url().nullable().optional(), + sizeBucket: SizeBucketSchema.nullable().optional(), + dataResidencyRegion: DataRegionSchema.default("IN"), + // GSTIN / PAN: format-only validation here; live API verification + // (NIC GST taxpayer search, sanctions screening) lands in PR-2. + // Format validators reject the obvious "made up" values that the + // book-everything-then-ghost fraud pattern (#687) relies on. + gstin: z + .string() + .nullable() + .optional() + .refine( + (v) => v === null || v === undefined || isValidGstin(v), + { message: "INVALID_GSTIN_FORMAT" }, + ), + pan: z + .string() + .nullable() + .optional() + .refine( + (v) => v === null || v === undefined || isValidPan(v), + { message: "INVALID_PAN_FORMAT" }, + ), + requiresPO: z.boolean().default(false), + }) + .refine((v) => v.canSponsor || v.canHost, { + message: "At least one of canSponsor or canHost must be true", + }); + +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63); +} + +export async function GET() { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const userId = auth.session.user.id; + const memberships = await prisma.membership.findMany({ + where: { userId, status: "ACTIVE" }, + include: { + organization: { + select: { + id: true, + name: true, + slug: true, + status: true, + canSponsor: true, + canHost: true, + brandingProfile: { select: { logo: true } }, + billingAccount: { + select: { fundingSource: true, walletBalance: true, currency: true }, + }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ + data: memberships.map((m) => ({ + membershipId: m.id, + role: m.role, + status: m.status, + organization: { + id: m.organization.id, + name: m.organization.name, + slug: m.organization.slug, + status: m.organization.status, + canSponsor: m.organization.canSponsor, + canHost: m.organization.canHost, + // Flatten brandingProfile.logo so the UI sees the same shape as before. + logo: m.organization.brandingProfile?.logo ?? null, + billingAccount: m.organization.billingAccount, + }, + })), + }); +} + +export async function POST(req: NextRequest) { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + // Only UserRole.ORG_WORKSPACE can create organizations. Platform ADMIN can + // also seed orgs (used by fixtures and back-office tooling). CONSULTANT + // and CONSULTEE are distinct user types — they join orgs via invitation, + // they don't create them. Blocks UI-bypass attempts via direct API. + const creatorRole = auth.session.user.role; + if (creatorRole !== "ORG_WORKSPACE" && creatorRole !== "ADMIN") { + return NextResponse.json( + { + error: + "Only organization administrators can create organizations. Sign up with the Organization Owner role to continue.", + }, + { status: 403 }, + ); + } + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + const desiredSlug = body.slug ?? slugify(body.name); + if (!SLUG_REGEX.test(desiredSlug)) { + return NextResponse.json( + { error: "Generated slug is invalid — pass `slug` explicitly." }, + { status: 400 }, + ); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const dupSlug = await tx.organization.findUnique({ + where: { slug: desiredSlug }, + select: { id: true }, + }); + if (dupSlug) { + throw new HttpError( + `Slug '${desiredSlug}' is already taken`, + 409, + "SLUG_TAKEN", + ); + } + + // Create the org first WITHOUT billingAccountId so we can then + // create the BillingAccount (it needs ownerOrgId). Then patch + // back the link. + const orgTmpId = randomUUID(); + // Why we hard-gate canHost here rather than via a WIP banner: + // the reviewer's feedback on PR #655 was "WIP banners are not + // production gates". The host-side settlement is gated by + // ENABLE_HOST_ORGS (lib/payments/payouts/earnings-service.ts:124); + // without that flag flipped, accepting canHost would let the org + // be created but its earnings flow would silently no-op. We + // reject at the API boundary instead so an OWNER receives a + // typed 400 rather than a half-functional org. + if (body.canHost && !ENABLE_HOST_ORGS) { + throw Object.assign( + new Error( + "Host-capable orgs are gated by ENABLE_HOST_ORGS. Contact ops to flip the flag for your tenant.", + ), + { httpStatus: 400, code: "HOST_ORGS_GATED" }, + ); + } + const org = await tx.organization.create({ + data: { + id: orgTmpId, + name: body.name, + slug: desiredSlug, + canSponsor: body.canSponsor, + canHost: body.canHost, + dataResidencyRegion: body.dataResidencyRegion, + contractCurrency: body.currency, + reportingCurrency: body.currency, + billingEmail: body.billingEmail, + paymentTermsDays: body.paymentTermsDays ?? 60, + // #768 — branding fields live on OrgBrandingProfile. Upserted + // below in the same transaction when any branding column is set. + ...(body.description || body.industry || body.website || body.sizeBucket + ? { + brandingProfile: { + create: { + description: body.description ?? null, + industry: body.industry ?? null, + website: body.website ?? null, + sizeBucket: body.sizeBucket ?? null, + }, + }, + } + : {}), + // #771 D10 — tax identity lives on the OrganizationTaxInfo satellite. + taxInfo: { + create: { + gstin: body.gstin ?? null, + // #768 — PAN stored encrypted (parity with ConsultantTaxInfo). + ...(body.pan + ? (() => { + const { encrypted, last4 } = encryptPAN(body.pan); + return { panEncrypted: encrypted, panLast4: last4 }; + })() + : {}), + }, + }, + requiresPO: body.requiresPO, + status: "PENDING_VERIFICATION", + }, + }); + + let billingAccountId: string | null = null; + if (body.canSponsor) { + const ba = await tx.billingAccount.create({ + data: { + ownerOrgId: org.id, + billingEmail: body.billingEmail, + currency: body.currency, + fundingSource: body.fundingSource, + walletBalance: body.fundingSource === "WALLET" ? 0 : null, + }, + }); + billingAccountId = ba.id; + await tx.organization.update({ + where: { id: org.id }, + data: { billingAccountId: ba.id }, + }); + } + + // OWNER Membership + a matching BetterAuth Member for invitation + // + org-scope session support. The Member row's id becomes the + // betterAuthMemberId pointer on Membership so the bridge is live + // from the moment the org exists. + const betterAuthMember = await tx.member.create({ + data: { + organizationId: org.id, + userId: auth.session.user.id, + role: "OWNER", + }, + }); + const membership = await tx.membership.create({ + data: { + userId: auth.session.user.id, + organizationId: org.id, + status: "ACTIVE", + role: "OWNER", + betterAuthMemberId: betterAuthMember.id, + }, + }); + + // Lazy-create the operator-side profile for this user. Idempotent + // across the creator's 2nd+ org — they already have one + // OrgWorkspaceProfile row pinned by `userId @unique`. The user.update + // via updateMany keeps the call cheap when the link is already set. + const orgWorkspace = await tx.orgWorkspaceProfile.upsert({ + where: { userId: auth.session.user.id }, + create: { userId: auth.session.user.id }, + update: {}, + select: { id: true }, + }); + await tx.user.updateMany({ + where: { id: auth.session.user.id, orgWorkspaceProfileId: null }, + data: { orgWorkspaceProfileId: orgWorkspace.id }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: org.id, + actorMembershipId: membership.id, + targetMembershipId: membership.id, + category: "MEMBER", + action: AUDIT_ACTIONS.MEMBER.MEMBER_ADDED, + description: `Organization '${org.name}' created by OWNER`, + details: { + slug: org.slug, + canSponsor: org.canSponsor, + canHost: org.canHost, + fundingSource: body.canSponsor ? body.fundingSource : null, + }, + }, + }); + + return { + organization: org, + billingAccountId, + membership, + orgWorkspaceProfileId: orgWorkspace.id, + }; + }); + + return NextResponse.json(result, { status: 201 }); + } catch (err) { + // Centralised error mapper. Every branch returns a structured envelope + // so the wizard never falls through to its generic "Failed to create + // organization" toast — which loses every byte of useful diagnostic + // information and was the actual cause of the bug we're fixing here. + // + // Always log the unwrapped error so dev console / Sentry capture the + // real stack trace; the response body intentionally redacts internals + // in production. + const userId = auth.session.user.id; + console.error("POST /api/organizations failed", { + userId, + slug: desiredSlug, + err, + }); + + // 1) Errors we tagged ourselves (slug-409 etc.). + if (err instanceof HttpError) { + return NextResponse.json( + { + error: err.message, + ...(err.code ? { code: err.code } : {}), + }, + { status: err.httpStatus }, + ); + } + + // 2) Prisma known request errors — map the common ones onto useful + // HTTP statuses so the client can branch (especially P2034, where + // a retry is the right answer). + if (err instanceof Prisma.PrismaClientKnownRequestError) { + switch (err.code) { + case "P2002": + return NextResponse.json( + { + error: "Conflict — a unique constraint was violated", + code: "P2002", + detail: { target: err.meta?.target ?? null }, + }, + { status: 409 }, + ); + case "P2003": + return NextResponse.json( + { + error: "Database foreign-key violation", + code: "P2003", + detail: { field: err.meta?.field_name ?? null }, + }, + { status: 500 }, + ); + case "P2025": + return NextResponse.json( + { + error: err.message, + code: "P2025", + }, + { status: 404 }, + ); + case "P2034": + return NextResponse.json( + { + error: "Transaction conflict — please retry", + code: "P2034", + }, + { status: 503 }, + ); + default: + return NextResponse.json( + { + error: "Database error", + code: err.code, + ...(process.env.NODE_ENV !== "production" + ? { message: err.message } + : {}), + }, + { status: 500 }, + ); + } + } + + // 3) Prisma client-side validation (wrong types, unknown columns). + // These mean *we* sent a malformed query — return 400 so the + // client sees a different bucket than a runtime DB error. + if (err instanceof Prisma.PrismaClientValidationError) { + return NextResponse.json( + { + error: "Invalid query for Prisma client", + code: "PRISMA_VALIDATION", + ...(process.env.NODE_ENV !== "production" + ? { message: err.message } + : {}), + }, + { status: 400 }, + ); + } + + // 4) Genuine unknown — still return JSON so the client doesn't see an + // empty body. In production the message is suppressed. + return NextResponse.json( + { + error: "Internal server error", + code: "INTERNAL", + ...(process.env.NODE_ENV !== "production" + ? { message: err instanceof Error ? err.message : String(err) } + : {}), + }, + { status: 500 }, + ); + } +} diff --git a/app/api/overage/[overageEventId]/order/route.ts b/app/api/overage/[overageEventId]/order/route.ts new file mode 100644 index 000000000..9078d3a82 --- /dev/null +++ b/app/api/overage/[overageEventId]/order/route.ts @@ -0,0 +1,126 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { createRazorpayOrder } from "@/lib/payments/core/razorpay"; +import { PaymentStatus } from "@prisma/client"; +import { transitionOverage } from "@/lib/payments/billing/overage-transitions"; +import { recarveOverageBase } from "@/lib/payments/billing/overage-base-carve"; + +/** + * #775 — resume-checkout for a CHARGE_MEMBER overage side-charge. + * + * POST /api/overage/[overageEventId]/order + * + * The over-cap booking already created a PENDING side-`Payment` + * (`parentPaymentId` = booking) + this `OverageEvent`. This route mints the + * gateway order for the marginal (kept OUT of the booking's Serializable TX) + * and stamps its id onto the side-Payment's `paymentIntent`, so the gateway + * webhook (`notes.type = "overage_member"`) can settle it. Only the member who + * owes the charge may call it. + */ +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ overageEventId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } + const userId = session.user.id; + const { overageEventId } = await params; + + const event = await prisma.overageEvent.findUnique({ + where: { id: overageEventId }, + select: { + id: true, + overageBehavior: true, + chargeStatus: true, + marginalPaise: true, + currency: true, + payment: { + select: { id: true, userId: true, currency: true, paymentStatus: true }, + }, + }, + }); + + if (!event || event.overageBehavior !== "CHARGE_MEMBER" || !event.payment) { + return NextResponse.json({ error: "Overage charge not found" }, { status: 404 }); + } + if (event.payment.userId !== userId) { + // Don't leak existence to a non-owner. + return NextResponse.json({ error: "Overage charge not found" }, { status: 404 }); + } + if (event.payment.paymentStatus === PaymentStatus.SUCCEEDED || event.chargeStatus === "CHARGED") { + return NextResponse.json({ error: "This overage has already been paid" }, { status: 409 }); + } + if (event.chargeStatus === "REVERSED") { + return NextResponse.json({ error: "This overage was reversed" }, { status: 409 }); + } + if (event.marginalPaise <= 0) { + return NextResponse.json({ error: "Nothing to pay" }, { status: 400 }); + } + + // Retry edge BEFORE minting the order: a FAILED charge had its basePaise + // restored to the org's parent accrual (#812 §P0), so resuming must carve it + // back out atomically with the FAILED→PENDING flip — otherwise the member's + // payment double-collects with the org's invoice. If the parent was already + // rolled onto an invoice while FAILED, the retry is refused (the org has + // been billed for the base; collecting it from the member too is wrong). + // No-op when already PENDING (still carved). If the mint below fails after + // this commits, the event sits PENDING/recarved until the abandoned-sweep + // re-FAILs it and restores — self-healing. + const retryBlocked = await prisma.$transaction(async (tx) => { + const moved = await transitionOverage(tx, { id: event.id }, "PENDING"); + if (moved === 0) return false; + const recarve = await recarveOverageBase(tx, { overageEventId: event.id }); + if (recarve === "invoiced") { + throw Object.assign(new Error("OVERAGE_RETRY_AFTER_INVOICE"), { + httpStatus: 409, + }); + } + return false; + }).catch((err) => { + if (err instanceof Error && "httpStatus" in err) return true; + throw err; + }); + if (retryBlocked) { + return NextResponse.json( + { + error: + "This charge can no longer be paid — your organization has already been billed for it. Contact support if you believe this is wrong.", + }, + { status: 409 }, + ); + } + + const order = await createRazorpayOrder({ + amount: event.marginalPaise, + currency: event.payment.currency, + paymentGateway: "RAZORPAY", + metadata: { + // appointmentId/appointmentType are required by the shared order-metadata + // type but unused on this path — the webhook routes on `type` before any + // appointment-metadata validation. + appointmentId: "", + appointmentType: "", + type: "overage_member", + overageEventId: event.id, + sidePaymentId: event.payment.id, + }, + }); + + // Stamp the real gateway order onto the side-Payment so the webhook can find + // it; reset to PENDING if a prior attempt FAILED. + await prisma.payment.update({ + where: { id: event.payment.id }, + data: { paymentIntent: order.id, paymentStatus: PaymentStatus.PENDING }, + }); + + return NextResponse.json({ + orderId: order.id, + amount: order.amount, + currency: order.currency, + keyId: process.env.RAZORPAY_KEY_ID ?? null, + }); +} diff --git a/app/api/overage/route.ts b/app/api/overage/route.ts new file mode 100644 index 000000000..76ee1458d --- /dev/null +++ b/app/api/overage/route.ts @@ -0,0 +1,61 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +/** + * #775 — list the caller's outstanding CHARGE_MEMBER overage charges. + * + * GET /api/overage + * + * Backs the member-facing "pay your overage" surface. Only PENDING/FAILED + * member-owed side-charges are listed — CHARGED/REVERSED ones have settled and + * drop out. Ownership is scoped through the side-`Payment.userId`. + */ +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } + const userId = session.user.id; + + const rows = await prisma.overageEvent.findMany({ + where: { + overageBehavior: "CHARGE_MEMBER", + chargeStatus: { in: ["PENDING", "FAILED"] }, + payment: { is: { userId } }, + }, + select: { + id: true, + marginalPaise: true, + currency: true, + chargeStatus: true, + createdAt: true, + programAssignment: { + select: { + program: { + select: { + name: true, + contract: { + select: { organization: { select: { name: true } } }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ + charges: rows.map((r) => ({ + id: r.id, + amountPaise: r.marginalPaise, + currency: r.currency, + status: r.chargeStatus, + programName: r.programAssignment.program.name, + orgName: r.programAssignment.program.contract.organization.name, + createdAt: r.createdAt, + })), + }); +} diff --git a/app/api/payments/disputes/route.ts b/app/api/payments/disputes/route.ts index 80adca359..b40b41e71 100644 --- a/app/api/payments/disputes/route.ts +++ b/app/api/payments/disputes/route.ts @@ -102,7 +102,7 @@ export async function GET(req: NextRequest) { disputes: disputes.map((d) => ({ id: d.id, disputeId: d.disputeId, - amount: d.amount, + amount: d.amountPaise, currency: d.currency, status: d.status, reason: d.reason, diff --git a/app/api/payments/refunds/route.ts b/app/api/payments/refunds/route.ts deleted file mode 100644 index 75d89fb92..000000000 --- a/app/api/payments/refunds/route.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Refunds API - * Handles refund creation, retrieval, and listing - */ - -import { createRefund, listRefunds } from "@/lib/payments"; -import prisma from "@/lib/prisma"; -import { - classifyError, - logClassifiedError, -} from "@/lib/errors/classification/payment-error-classification"; -import { Prisma } from "@prisma/client"; -import crypto from "crypto"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -import { getSession } from "@/lib/auth-server"; -// ============================================================================ -// Validation Schemas -// ============================================================================ - -const createRefundSchema = z.object({ - paymentId: z.string().min(1, "Payment ID is required"), - amount: z.number().positive().optional(), - reason: z.string().optional(), - // NEW-1: When true, allows refunding even if consultant earnings are already paid out. - // The platform absorbs the loss. Requires explicit admin acknowledgement. - forceRefund: z.boolean().optional().default(false), -}); - -const _getRefundsSchema = z.object({ - paymentId: z.string().optional(), - limit: z.number().int().min(1).max(100).default(10), -}); - -// ============================================================================ -// POST /api/payments/refunds - Create Refund -// ============================================================================ - -export async function POST(req: NextRequest) { - try { - // Authentication - const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Admin/Staff check - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - }); - - if (user?.role !== "ADMIN" && user?.role !== "STAFF") { - return NextResponse.json( - { error: "Forbidden - Admin access required" }, - { status: 403 }, - ); - } - - // Validate request - const body = await req.json(); - const { paymentId, amount, reason, forceRefund } = - createRefundSchema.parse(body); - - // Fetch exchange rate for international payment audit (before transaction) - let refundExchangeRate: number | null = null; - let refundDisplayCurrency: string | null = null; - - const paymentForRateCheck = await prisma.payment.findUnique({ - where: { id: paymentId }, - select: { isInternational: true, displayCurrencyAtCheckout: true }, - }); - - if ( - paymentForRateCheck?.isInternational && - paymentForRateCheck.displayCurrencyAtCheckout - ) { - refundDisplayCurrency = paymentForRateCheck.displayCurrencyAtCheckout; - try { - const { getExchangeRates } = await import("@/lib/currency"); - const rates = await getExchangeRates(); - refundExchangeRate = rates[refundDisplayCurrency] ?? null; - } catch { - console.warn("Failed to fetch exchange rate for refund audit"); - } - } - - // ========================================================================== - // TWO-PHASE REFUND PATTERN - // ========================================================================== - // Phase 1: Create PENDING refund record (claims the amount, prevents race conditions) - // Phase 2: Call external payment gateway (outside transaction) - // Phase 3: Update refund status based on gateway result - // - // This prevents: - // - Double refunds (PENDING record claims the amount atomically) - // - Long-running transactions (API call is outside) - // - Data loss (we always have a record for reconciliation) - // ========================================================================== - - // PHASE 1: Create PENDING refund record in a transaction - // This atomically validates and claims the refund amount - const phase1Result = await prisma.$transaction(async (tx) => { - // Get payment with refunds and associated earnings inside transaction - const payment = await tx.payment.findUnique({ - where: { id: paymentId }, - include: { - user: { select: { id: true, email: true, name: true } }, - appointment: true, - refunds: true, - earnings: true, - }, - }); - - if (!payment) { - throw new Error("Payment not found"); - } - - if (payment.paymentStatus !== "SUCCEEDED") { - throw new Error("Only successful payments can be refunded"); - } - - // NEW-1: Block refund if consultant earnings have already been paid out. - // Once the consultant has received their payout, issuing a refund means - // the platform absorbs the full loss. Require explicit forceRefund flag. - if ( - payment.earnings.length > 0 && - payment.earnings.some((e) => e.status === "PAID") && - !forceRefund - ) { - throw new Error( - "Cannot refund: consultant earnings have already been paid out. " + - "Issuing this refund means the platform absorbs the loss. " + - "Set forceRefund: true to proceed, or initiate a clawback from the consultant first.", - ); - } - - // Calculate total already refunded (SUCCEEDED) + pending refunds (PENDING) - // Including PENDING prevents race conditions - if another request created - // a PENDING refund, we'll see it and fail validation - const totalRefundedOrPending = payment.refunds - .filter((r) => r.status === "SUCCEEDED" || r.status === "PENDING") - .reduce((sum, r) => sum + r.amount, 0); - - if (totalRefundedOrPending >= payment.amount) { - throw new Error("Payment has already been fully refunded"); - } - - const refundAmount = amount || payment.amount - totalRefundedOrPending; - - if (refundAmount > payment.amount - totalRefundedOrPending) { - throw new Error("Refund amount exceeds available balance"); - } - - // Create PENDING refund record - this "claims" the amount - // Uses a placeholder refundId that will be updated after gateway call - const pendingRefund = await tx.refund.create({ - data: { - amount: refundAmount, - currency: payment.currency, - reason, - status: "PENDING", - refundId: `pending_${crypto.randomUUID()}`, - paymentGateway: payment.paymentGateway, - metadata: { forceRefund: forceRefund ?? false }, - exchangeRateAtRefund: refundExchangeRate, - displayCurrency: refundDisplayCurrency, - paymentId: payment.id, - }, - }); - - return { payment, pendingRefund, refundAmount }; - }); - - const { payment, pendingRefund, refundAmount } = phase1Result; - - // PHASE 2: Call external payment gateway OUTSIDE transaction - let refundResult; - try { - refundResult = await createRefund({ - paymentIntentId: payment.paymentIntent, - amount: refundAmount, - reason, - }); - } catch (gatewayError) { - // Gateway call failed - mark refund as FAILED - await prisma.refund.update({ - where: { id: pendingRefund.id }, - data: { - status: "FAILED", - metadata: { - error: - gatewayError instanceof Error - ? gatewayError.message - : "Gateway call failed", - }, - }, - }); - - throw gatewayError; - } - - // PHASE 3: Update refund record with gateway result - const finalRefund = await prisma.refund.update({ - where: { id: pendingRefund.id }, - data: { - status: refundResult.status, - refundId: refundResult.refundId, - metadata: refundResult.metadata as Prisma.InputJsonValue, - }, - }); - - return NextResponse.json({ - success: true, - refund: { - id: finalRefund.id, - refundId: refundResult.refundId, - amount: refundResult.amount, - currency: refundResult.currency, - status: refundResult.status, - paymentId: payment.id, - }, - message: "Refund created successfully", - }); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Validation error", details: error.errors }, - { status: 400 }, - ); - } - - const classified = classifyError(error, "Failed to create refund"); - logClassifiedError("Refunds", classified, error); - - return NextResponse.json( - { error: classified.errorMessage }, - { status: classified.httpStatus }, - ); - } -} - -// ============================================================================ -// GET /api/payments/refunds - List Refunds -// ============================================================================ - -export async function GET(req: NextRequest) { - try { - // Authentication - const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Admin/Staff check - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - }); - - if (user?.role !== "ADMIN" && user?.role !== "STAFF") { - return NextResponse.json( - { error: "Forbidden - Admin access required" }, - { status: 403 }, - ); - } - - // Parse query params - const searchParams = req.nextUrl.searchParams; - const paymentId = searchParams.get("paymentId"); - const limit = parseInt(searchParams.get("limit") || "10"); - - if (paymentId) { - // Get payment to check gateway - const payment = await prisma.payment.findUnique({ - where: { id: paymentId }, - }); - - if (!payment) { - return NextResponse.json( - { error: "Payment not found" }, - { status: 404 }, - ); - } - - // List refunds for specific payment from gateway - const refunds = await listRefunds( - payment.paymentIntent, - payment.paymentGateway, - limit, - ); - - return NextResponse.json({ - refunds, - paymentId, - count: refunds.length, - }); - } else { - // List all refunds from database - const refunds = await prisma.refund.findMany({ - take: limit, - orderBy: { createdAt: "desc" }, - include: { - payment: { - include: { - user: { select: { id: true, email: true, name: true } }, - appointment: { select: { id: true, appointmentType: true } }, - }, - }, - }, - }); - - return NextResponse.json({ - refunds: refunds.map((r) => ({ - id: r.id, - refundId: r.refundId, - amount: r.amount, - currency: r.currency, - status: r.status, - reason: r.reason, - gateway: r.paymentGateway, - createdAt: r.createdAt, - payment: { - id: r.payment.id, - amount: r.payment.amount, - user: r.payment.user, - appointment: r.payment.appointment, - }, - })), - count: refunds.length, - }); - } - } catch (error) { - console.error("Refunds listing error:", error); - - return NextResponse.json( - { - error: - error instanceof Error ? error.message : "Failed to list refunds", - }, - { status: 500 }, - ); - } -} diff --git a/app/api/plans/classes/[classPlanId]/recordings/route.ts b/app/api/plans/classes/[classPlanId]/recordings/route.ts index e46151d11..0bc4a74b0 100644 --- a/app/api/plans/classes/[classPlanId]/recordings/route.ts +++ b/app/api/plans/classes/[classPlanId]/recordings/route.ts @@ -54,7 +54,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) { hasAccess = classPlan.consultantProfileId === session.user.consultantProfileId; if (!hasAccess && session.user.consultantProfileId) { - const collab = await prisma.classCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { classPlanId, consultantProfileId: session.user.consultantProfileId, diff --git a/app/api/plans/classes/[classPlanId]/route.ts b/app/api/plans/classes/[classPlanId]/route.ts index 5cde0a731..9a8328f5a 100644 --- a/app/api/plans/classes/[classPlanId]/route.ts +++ b/app/api/plans/classes/[classPlanId]/route.ts @@ -212,7 +212,7 @@ export async function DELETE( } // Check for active collaborators (PENDING or ACCEPTED) - const activeCollaborators = await prisma.classCollaborator.count({ + const activeCollaborators = await prisma.collaborator.count({ where: { classPlanId, status: { in: ["PENDING", "ACCEPTED"] }, diff --git a/app/api/plans/consultations/route.ts b/app/api/plans/consultations/route.ts index d2a679310..251eef822 100644 --- a/app/api/plans/consultations/route.ts +++ b/app/api/plans/consultations/route.ts @@ -2,6 +2,7 @@ import prisma from "@/lib/prisma"; import { NextRequest, NextResponse } from "next/server"; import { ConsultationPlanSchema } from "@/schemas/plans"; import { findOrCreateTopics, transformTopicsToStrings } from "@/lib/topics"; +import { marketplaceVisibilityWhere } from "@/lib/api/plans/visibility"; import { getSession } from "@/lib/auth-server"; export async function GET(request: NextRequest) { @@ -12,7 +13,11 @@ export async function GET(request: NextRequest) { const limit = parseInt(searchParams.get("limit") || "10"); const skip = (page - 1) * limit; - const where = consultantId ? { consultantProfileId: consultantId } : {}; + // #726 — public marketplace must not surface ORG_ONLY plans. + const where = { + ...(consultantId ? { consultantProfileId: consultantId } : {}), + ...marketplaceVisibilityWhere(), + }; const [consultationPlans, total] = await Promise.all([ prisma.consultationPlan.findMany({ diff --git a/app/api/plans/shared/plan-filters.ts b/app/api/plans/shared/plan-filters.ts index 266915e89..96e2ed267 100644 --- a/app/api/plans/shared/plan-filters.ts +++ b/app/api/plans/shared/plan-filters.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { Prisma } from "@prisma/client"; +import { Prisma, type OrgPlanVisibility } from "@prisma/client"; +import { MARKETPLACE_VISIBILITY } from "@/lib/api/plans/visibility"; export interface PlanFilterParams { consultantId: string | null; @@ -58,6 +59,7 @@ export interface PlanWhereClause { title?: { contains: string; mode: "insensitive" }; topics?: { some: { id: { in: string[] } } }; consultantProfile?: { domainId: string }; + visibility?: { in: OrgPlanVisibility[] }; } /** @@ -68,7 +70,13 @@ export interface PlanWhereClause { export function buildPlanWhereClause( filters: PlanFilterParams, ): PlanWhereClause { - const where: PlanWhereClause = {}; + // #726 — public marketplace must not surface ORG_ONLY plans. The filter + // is applied unconditionally here because every caller of this helper + // is a public surface; org-internal catalog endpoints have their own + // where-builders. + const where: PlanWhereClause = { + visibility: { in: MARKETPLACE_VISIBILITY }, + }; if (filters.consultantId) { where.consultantProfileId = filters.consultantId; diff --git a/app/api/plans/subscriptions/route.ts b/app/api/plans/subscriptions/route.ts index b29d1c0ec..c201fa585 100644 --- a/app/api/plans/subscriptions/route.ts +++ b/app/api/plans/subscriptions/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { SubscriptionPlanSchema } from "@/schemas/plans"; import { findOrCreateTopics, transformTopicsToStrings } from "@/lib/topics"; import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService"; +import { marketplaceVisibilityWhere } from "@/lib/api/plans/visibility"; import { getSession } from "@/lib/auth-server"; export async function GET(request: NextRequest) { @@ -13,7 +14,11 @@ export async function GET(request: NextRequest) { const limit = parseInt(searchParams.get("limit") || "10"); const skip = (page - 1) * limit; - const where = consultantId ? { consultantProfileId: consultantId } : {}; + // #726 — public marketplace must not surface ORG_ONLY plans. + const where = { + ...(consultantId ? { consultantProfileId: consultantId } : {}), + ...marketplaceVisibilityWhere(), + }; const [subscriptionPlans, total] = await Promise.all([ prisma.subscriptionPlan.findMany({ diff --git a/app/api/plans/webinars/[webinarPlanId]/recordings/route.ts b/app/api/plans/webinars/[webinarPlanId]/recordings/route.ts index 9167951f4..eccc4d73d 100644 --- a/app/api/plans/webinars/[webinarPlanId]/recordings/route.ts +++ b/app/api/plans/webinars/[webinarPlanId]/recordings/route.ts @@ -54,7 +54,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) { hasAccess = webinarPlan.consultantProfileId === session.user.consultantProfileId; if (!hasAccess && session.user.consultantProfileId) { - const collab = await prisma.webinarCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { webinarPlanId, consultantProfileId: session.user.consultantProfileId, diff --git a/app/api/plans/webinars/[webinarPlanId]/route.ts b/app/api/plans/webinars/[webinarPlanId]/route.ts index 334c96f00..ff3027821 100644 --- a/app/api/plans/webinars/[webinarPlanId]/route.ts +++ b/app/api/plans/webinars/[webinarPlanId]/route.ts @@ -179,7 +179,7 @@ export async function DELETE( } // Check for active collaborators (PENDING or ACCEPTED) - const activeCollaborators = await prisma.webinarCollaborator.count({ + const activeCollaborators = await prisma.collaborator.count({ where: { webinarPlanId, status: { in: ["PENDING", "ACCEPTED"] }, diff --git a/app/api/recordings/route.ts b/app/api/recordings/route.ts new file mode 100644 index 000000000..e2014cb03 --- /dev/null +++ b/app/api/recordings/route.ts @@ -0,0 +1,77 @@ +/** + * GET /api/recordings + * + * Personal Recording list with optional `?orgScope=` filter + * (#674 / B1-hybrid). Org-scoped sibling at + * `/api/organizations/[orgId]/recordings`. + * + * No pre-existing dashboard route for recordings — this IS the + * canonical list endpoint. Per-consultant recordings page consumes + * this with `?orgScope=personal`. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { listRecordingsScoped } from "@/lib/api/scope/list-recordings"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; +import { parsePagination } from "@/lib/enterprise/validators"; + +const QuerySchema = z.object({ + status: z + .enum([ + "RECORDING", + "PROCESSING", + "READY", + "TRANSFERRING", + "AVAILABLE", + "FAILED", + "EXPIRED", + ]) + .optional(), +}); + +export async function GET(req: NextRequest) { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + const session = auth.session; + + const memberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + + const url = new URL(req.url); + const scopeResolution = resolveOrgScope({ + raw: url.searchParams.get("orgScope"), + memberships, + userRole: (session.user as { role?: string }).role, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + + const filters = QuerySchema.safeParse({ + status: url.searchParams.get("status") ?? undefined, + }); + if (!filters.success) { + return NextResponse.json( + { error: "Invalid query", detail: filters.error.flatten() }, + { status: 400 }, + ); + } + const pagination = parsePagination(url); + + const result = await listRecordingsScoped({ + scope: scopeResolution.scope, + userId: session.user.id, + status: filters.data.status, + page: pagination.page, + perPage: pagination.pageSize, + }); + return NextResponse.json(result); +} diff --git a/app/api/referrals/route.ts b/app/api/referrals/route.ts index 90f152613..8c0ea891c 100644 --- a/app/api/referrals/route.ts +++ b/app/api/referrals/route.ts @@ -2,6 +2,20 @@ import { NextResponse } from "next/server"; import { getSession } from "@/lib/auth-server"; import { getUserReferrals } from "@/lib/referrals/service"; +/** + * #674 org-scope decision: referrals are intentionally PERSONAL-ONLY. + * + * A user's referral code follows the user across every org they're a + * member of — it's a personal acquisition incentive, not a tenant + * artifact. Org context filtering at this endpoint would erase the + * (correct) cross-org reach of the program. If a future requirement + * needs per-org referral attribution (e.g. "Acme's HR sponsored a + * batch invite campaign"), add an `originatingOrganizationId` column + * on Referral rather than retrofitting `organizationId` here. + * + * Documented under the May 2026 audit's "scope filter coverage" line + * item as DELIBERATE, not a leak. + */ export async function GET() { try { const session = await getSession(); diff --git a/app/api/slots/appointments/route.ts b/app/api/slots/appointments/route.ts index 5798e1b80..c8ea4ca8c 100644 --- a/app/api/slots/appointments/route.ts +++ b/app/api/slots/appointments/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { AppointmentsType, Prisma, RequestStatus } from "@prisma/client"; import { requireApiAuth, isPrivileged } from "@/lib/auth-helpers"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; export async function GET(request: NextRequest) { const authResult = await requireApiAuth(); @@ -101,6 +102,37 @@ export async function GET(request: NextRequest) { ); } + // #674 personal-vs-org scope filter. The leak this closes: a consultant + // who hosts under Acme + Zeta would see appointments from both orgs in + // a single "Appointments" tab regardless of which org context the + // dashboard had selected. Filter via the denormalized + // Appointment.organizationId column populated by the #674 backfill. + const callerMembershipsForScope = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMembershipsForScope, + userRole: session.user.role, + // Self-scoped: non-admin callers are already locked to their own + // profileId via `hasOwnFilter` above, so `?orgScope=all` here just + // means "all of MY data" — safe for any role. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + const apptOrgFilter: Partial = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : {}; + try { const startDate = searchParams.get("startDate"); const endDate = searchParams.get("endDate"); @@ -124,6 +156,7 @@ export async function GET(request: NextRequest) { consultationId, subscriptionId, }, + apptOrgFilter, ); return NextResponse.json({ data: appointments }); @@ -155,8 +188,12 @@ async function getAppointments( consultationId?: string | null; subscriptionId?: string | null; }, + /// #674 org-scope filter resolved upstream — { organizationId: null } + /// for personal scope, { organizationId: } for an org context, + /// {} for admin all-scope. Spread into the appointment WHERE clause. + orgScopeFilter: Partial = {}, ) { - const whereClause: Prisma.AppointmentWhereInput = {}; + const whereClause: Prisma.AppointmentWhereInput = { ...orgScopeFilter }; // Date range filtering for appointments. This is the primary filter. // It looks for appointments where any of its slots overlap with the given date range. diff --git a/app/api/slots/availability/weekly/route.ts b/app/api/slots/availability/weekly/route.ts index 733fd6af1..e3cc7acbf 100644 --- a/app/api/slots/availability/weekly/route.ts +++ b/app/api/slots/availability/weekly/route.ts @@ -8,6 +8,7 @@ import { getTimezoneOffsetMinutes, } from "@/utils/slotAllocation/slotTimeUtils"; import { getSession } from "@/lib/auth-server"; +import { toLocalMinutes, toLocalDay } from "@/utils/slotAllocation/localTime"; export async function GET(req: NextRequest) { try { @@ -177,6 +178,13 @@ export async function POST(req: NextRequest) { const utcOffsetMinutes = consultantProfile.user?.timezone ? getTimezoneOffsetMinutes(consultantProfile.user.timezone) : 0; + // #503 — persist the DST-proof source of truth alongside the frozen + // offset; the slot math migrates read-side in the follow-up. + const timezone = consultantProfile.user?.timezone ?? null; + const localStartMinutes = toLocalMinutes(startTimeUtc, utcOffsetMinutes); + const localEndMinutes = toLocalMinutes(endTimeUtc, utcOffsetMinutes); + const localStartDay = toLocalDay(startDay, startTimeUtc, utcOffsetMinutes); + const localEndDay = toLocalDay(endDay, endTimeUtc, utcOffsetMinutes); const newWeeklySlot = await prisma.slotOfAvailabilityWeekly.create({ data: { @@ -186,6 +194,11 @@ export async function POST(req: NextRequest) { startTimeUtc, endTimeUtc, utcOffsetMinutes, + timezone, + localStartMinutes, + localEndMinutes, + localStartDay, + localEndDay, }, include: { consultantProfile: { diff --git a/app/api/slots/request-for-approval/route.ts b/app/api/slots/request-for-approval/route.ts index 42cff41b8..7bf6864af 100644 --- a/app/api/slots/request-for-approval/route.ts +++ b/app/api/slots/request-for-approval/route.ts @@ -7,6 +7,7 @@ import { SlotValidationService } from "@/utils/slotAllocation/SlotValidationServ import { notifyNewBookingRequest } from "@/lib/novu"; import { RequestForApprovalSchema } from "@/schemas/slots"; import { requestApprovalLimiter, applyRateLimit } from "@/lib/rate-limit"; +import { ensureConsulteeProfile } from "@/lib/profiles/ensure-consultee-profile"; import { getSession } from "@/lib/auth-server"; export async function POST(req: NextRequest) { @@ -44,7 +45,9 @@ export async function POST(req: NextRequest) { const startTime = new Date(slotStartTimeInUTC); const endTime = new Date(slotEndTimeInUTC); - // Get the consultee profile + // Lazy-create ConsulteeProfile on first consumer action — org-workspace + // operators and consultants who book approvals will otherwise 404 here. + await ensureConsulteeProfile(prisma, session.user.id); const consulteeProfile = await prisma.consulteeProfile.findUnique({ where: { userId: session.user.id }, include: { user: true }, diff --git a/app/api/staff/appointments/route.ts b/app/api/staff/appointments/route.ts index 00a09e72a..f93d21ea8 100644 --- a/app/api/staff/appointments/route.ts +++ b/app/api/staff/appointments/route.ts @@ -23,6 +23,10 @@ export async function GET(req: NextRequest) { const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "20"); const offset = (page - 1) * limit; + // #674 comment 7 — optional org-scope filter for support staff + // drilling into a single tenant's appointments. Uses + // Appointment.organizationId (added by the B1-hybrid migration). + const orgId = searchParams.get("orgId"); // Build where clause const where: Prisma.AppointmentWhereInput = {}; @@ -31,6 +35,10 @@ export async function GET(req: NextRequest) { where.appointmentType = type; } + if (orgId) { + where.organizationId = orgId; + } + // Filter by date at the database level using slotsOfAppointment if (dateFrom || dateTo) { where.slotsOfAppointment = { diff --git a/app/api/staff/invoices/route.ts b/app/api/staff/invoices/route.ts index 364e43d5a..35fd868d6 100644 --- a/app/api/staff/invoices/route.ts +++ b/app/api/staff/invoices/route.ts @@ -24,6 +24,8 @@ export async function GET(req: NextRequest) { const result = await getOperatorInvoices({ status: searchParams.get("status") as PaymentStatus | null, search: searchParams.get("search"), + // #674 comment 7 — optional org-scope filter (Payment.organizationId). + orgId: searchParams.get("orgId"), limit: parseInt(searchParams.get("limit") || "20"), offset: parseInt(searchParams.get("offset") || "0"), }); diff --git a/app/api/staff/payouts/route.ts b/app/api/staff/payouts/route.ts index d6bea6aba..aea265fad 100644 --- a/app/api/staff/payouts/route.ts +++ b/app/api/staff/payouts/route.ts @@ -24,6 +24,8 @@ export async function GET(req: NextRequest) { const result = await getOperatorPayouts({ status: searchParams.get("status") as PayoutStatus | null, search: searchParams.get("search"), + // #674 comment 7 — org-scope filter via earnings.payment.organizationId. + orgId: searchParams.get("orgId"), limit: parseInt(searchParams.get("limit") || "50"), offset: parseInt(searchParams.get("offset") || "0"), }); diff --git a/app/api/staff/support-tickets/[ticketId]/route.ts b/app/api/staff/support-tickets/[ticketId]/route.ts index 0db440525..7c5358e4e 100644 --- a/app/api/staff/support-tickets/[ticketId]/route.ts +++ b/app/api/staff/support-tickets/[ticketId]/route.ts @@ -138,7 +138,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) { where: { id: ticket.refundId }, select: { id: true, - amount: true, + amountPaise: true, currency: true, status: true, reason: true, diff --git a/app/api/stream/channels/create/route.ts b/app/api/stream/channels/create/route.ts index 463ad66cd..e77087237 100644 --- a/app/api/stream/channels/create/route.ts +++ b/app/api/stream/channels/create/route.ts @@ -115,13 +115,17 @@ export async function POST(req: NextRequest) { channelName, }); - result = await createChannel({ + // #B2 Stream.io org tagging — generic admin-created custom channels + // are not bound to an org event; pass `null` explicitly so the new + // param is unambiguous (vs. forgotten). + result = await createChannel({ channelType: channelType as "messaging" | "team", channelId, channelName, members: members || [createdById], createdById, additionalData: { custom: true }, + organizationId: null, }); } diff --git a/app/api/stream/recordings/[recordingId]/route.ts b/app/api/stream/recordings/[recordingId]/route.ts index 87c758c76..336b9865d 100644 --- a/app/api/stream/recordings/[recordingId]/route.ts +++ b/app/api/stream/recordings/[recordingId]/route.ts @@ -57,7 +57,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) { appointment.webinar.webinarPlan.consultantProfileId === consultantProfileId; if (!hasAccess && consultantProfileId) { - const collab = await prisma.webinarCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { webinarPlanId: appointment.webinar.webinarPlan.id, consultantProfileId, @@ -71,7 +71,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) { appointment.class.classPlan.consultantProfileId === consultantProfileId; if (!hasAccess && consultantProfileId) { - const collab = await prisma.classCollaborator.findFirst({ + const collab = await prisma.collaborator.findFirst({ where: { classPlanId: appointment.class.classPlan.id, consultantProfileId, diff --git a/app/api/trials/route.ts b/app/api/trials/route.ts index 5ff4b3927..6bc1b183b 100644 --- a/app/api/trials/route.ts +++ b/app/api/trials/route.ts @@ -6,6 +6,7 @@ import { notifyTrialSessionRequested } from "@/lib/novu"; import { CreateTrialSchema } from "@/schemas/trials"; import { getSession } from "@/lib/auth-server"; import { trialRequestLimiter, applyRateLimit } from "@/lib/rate-limit"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; /** * GET /api/trials @@ -75,7 +76,35 @@ export async function GET(request: NextRequest) { } } - const whereClause: Prisma.TrialSessionWhereInput = {}; + // #674 org-scope filter. TrialSession.organizationId is populated by + // the backfill — keeps Acme-context views from leaking Zeta trial + // bookings into the consultant's "Trials" tab. + const callerMemberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + // Non-admin callers are already locked to their own + // consultant/consulteeProfileId (lines 50-73), so `?orgScope=all` + // means "all of MY trials" — safe for any role. + allowAllForOwner: true, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { error: scopeResolution.message, code: scopeResolution.code }, + { status: scopeResolution.status }, + ); + } + + const whereClause: Prisma.TrialSessionWhereInput = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : {}; if (consultantProfileId) { whereClause.consultantProfileId = consultantProfileId; @@ -207,6 +236,7 @@ export async function POST(request: NextRequest) { consultantProfileId, subscriptionPlanId, notes, + organizationId, } = result.data; const isPrivileged = @@ -295,6 +325,36 @@ export async function POST(request: NextRequest) { ); } + // Enterprise: if the caller passed `organizationId`, verify they're + // an ACTIVE LEARNER (or higher) member of that org before we stamp + // attribution. Trials are free, so this is org-tagging for analytics + // (conversion-rate per org) — never a payment claim. Silently + // dropping the field on membership mismatch would let a curious + // user forge org-tagged trial attribution; we return 403 instead so + // the client bug becomes obvious. `findFirst` with `userId` + // resolves against the BetterAuth User id on the session. + let resolvedOrgId: string | null = null; + if (organizationId) { + const membership = await prisma.membership.findFirst({ + where: { + organizationId, + userId: session.user.id, + status: "ACTIVE", + }, + select: { id: true }, + }); + if (!membership) { + return NextResponse.json( + { + error: + "You are not an active member of the specified organization.", + }, + { status: 403 }, + ); + } + resolvedOrgId = organizationId; + } + // Create the trial session const trialSession = await prisma.trialSession.create({ data: { @@ -303,6 +363,7 @@ export async function POST(request: NextRequest) { subscriptionPlanId, notes, status: TrialSessionStatus.PENDING, + organizationId: resolvedOrgId, }, include: { consulteeProfile: { diff --git a/app/api/user/consultants/[id]/route.ts b/app/api/user/consultants/[id]/route.ts index 931867c33..8a9b2b7f1 100644 --- a/app/api/user/consultants/[id]/route.ts +++ b/app/api/user/consultants/[id]/route.ts @@ -1,11 +1,18 @@ import prisma from "@/lib/prisma"; -import { DayOfWeek, Prisma, ScheduleType, SessionType } from "@prisma/client"; +import { + DayOfWeek, + type OrgPlanVisibility, + Prisma, + ScheduleType, + SessionType, +} from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { experienceValidation } from "@/schemas/shared"; import { checkActiveAppointments } from "../utils/consultant-appointments"; import { getSession } from "@/lib/auth-server"; import { apiError } from "@/lib/errors"; +import { toLocalMinutes, toLocalDay } from "@/utils/slotAllocation/localTime"; import { dateToMinuteUtc, validateWeeklySlotTimeOrder, @@ -142,6 +149,15 @@ export async function GET( // Determine which user fields to include based on access level const isPrivilegedAccess = isOwnProfile || isAdmin; + // #726 — public viewers must not see ORG_ONLY plans surfaced via the + // consultant detail page. Privileged viewers (the consultant + // themselves + ADMIN) see everything; the public include narrows + // to PUBLIC + ORG_AND_PUBLIC. + const planVisibilityFilter: { visibility: { in: OrgPlanVisibility[] } } | undefined = + isPrivilegedAccess + ? undefined + : { visibility: { in: ["PUBLIC", "ORG_AND_PUBLIC"] } }; + // Fetch consultant with appropriate user data const consultant = await prisma.consultantProfile.findUnique({ where: { id }, @@ -189,16 +205,23 @@ export async function GET( tags: true, slotsOfAvailabilityWeekly: true, slotsOfAvailabilityCustom: true, - consultationPlans: true, + consultationPlans: planVisibilityFilter + ? { where: planVisibilityFilter } + : true, subscriptionPlans: { + ...(planVisibilityFilter && { where: planVisibilityFilter }), include: { subscriptionContents: { orderBy: { order: "asc" }, }, }, }, - webinarPlans: true, - classPlans: true, + webinarPlans: planVisibilityFilter + ? { where: planVisibilityFilter } + : true, + classPlans: planVisibilityFilter + ? { where: planVisibilityFilter } + : true, reviews: { select: { id: true, rating: true }, take: 5, @@ -362,14 +385,34 @@ export async function PUT( : 0; const weeklySlotData: Prisma.SlotOfAvailabilityWeeklyCreateManyInput[] = - slotsOfAvailabilityWeekly.map((slot) => ({ - consultantProfileId: id, - startDay: slot.dayOfWeekforStartTimeInUTC, - endDay: slot.dayOfWeekforEndTimeInUTC, - startTimeUtc: dateToMinuteUtc(new Date(slot.slotStartTimeInUTC)), - endTimeUtc: dateToMinuteUtc(new Date(slot.slotEndTimeInUTC)), - utcOffsetMinutes, - })); + slotsOfAvailabilityWeekly.map((slot) => { + const startTimeUtc = dateToMinuteUtc( + new Date(slot.slotStartTimeInUTC), + ); + const endTimeUtc = dateToMinuteUtc(new Date(slot.slotEndTimeInUTC)); + return { + consultantProfileId: id, + startDay: slot.dayOfWeekforStartTimeInUTC, + endDay: slot.dayOfWeekforEndTimeInUTC, + startTimeUtc, + endTimeUtc, + utcOffsetMinutes, + // #503 — DST-proof columns written alongside the frozen offset. + timezone: userTimezone, + localStartMinutes: toLocalMinutes(startTimeUtc, utcOffsetMinutes), + localEndMinutes: toLocalMinutes(endTimeUtc, utcOffsetMinutes), + localStartDay: toLocalDay( + slot.dayOfWeekforStartTimeInUTC, + startTimeUtc, + utcOffsetMinutes, + ), + localEndDay: toLocalDay( + slot.dayOfWeekforEndTimeInUTC, + endTimeUtc, + utcOffsetMinutes, + ), + }; + }); // Validate each weekly slot before saving for (const slot of weeklySlotData) { @@ -541,13 +584,53 @@ export async function DELETE( // Verify the caller owns this consultant profile const ownerCheck = await prisma.consultantProfile.findUnique({ where: { id }, - select: { userId: true }, + select: { + userId: true, + deletedAt: true, + // #781 §B — earnings/payouts/TDS Restrict this profile; a profile + // that ever moved money can only soft-delete. + _count: { + select: { earnings: true, payouts: true, tdsRecords: true }, + }, + }, }); if (!ownerCheck || ownerCheck.userId !== session.user.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); } + if (ownerCheck.deletedAt) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const hasMoneyHistory = + ownerCheck._count.earnings + + ownerCheck._count.payouts + + ownerCheck._count.tdsRecords > + 0; + + if (hasMoneyHistory) { + // Soft delete: financial rows (and the PAN they were withheld + // against) survive for statutory retention. Slots go so nothing is + // bookable; plans stay (historical bookings reference them) but the + // browse/checkout surfaces filter deletedAt profiles out. + await prisma.$transaction([ + prisma.slotOfAvailabilityWeekly.deleteMany({ + where: { consultantProfileId: id }, + }), + prisma.slotOfAvailabilityCustom.deleteMany({ + where: { consultantProfileId: id }, + }), + prisma.consultantProfile.update({ + where: { id }, + data: { deletedAt: new Date() }, + }), + ]); + return NextResponse.json({ + message: "Consultant deactivated (financial history retained)", + softDeleted: true, + }); + } - // Delete all related records first + // No money ever moved — full hard delete is safe. await prisma.$transaction([ // Delete slots prisma.slotOfAvailabilityWeekly.deleteMany({ diff --git a/app/api/user/consultants/route.ts b/app/api/user/consultants/route.ts index 85fd1e2f7..72e4f2d7d 100644 --- a/app/api/user/consultants/route.ts +++ b/app/api/user/consultants/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; -import { consultantListInclude } from "@/lib/data/explore-experts"; +import { consultantListInclude, orgMembershipInclude } from "@/lib/data/explore-experts"; import { apiError } from "@/lib/errors"; export async function GET(request: NextRequest) { @@ -44,6 +44,10 @@ export async function GET(request: NextRequest) { conditions.push({ verificationStatus: "VERIFIED" }); } + // #781 §B — soft-deleted profiles leave public surfaces (this route is + // unauthenticated; admin surfaces read soft-deleted rows elsewhere) + conditions.push({ deletedAt: null }); + // Domain filter if (domain) { conditions.push({ domainId: domain }); @@ -115,6 +119,15 @@ export async function GET(request: NextRequest) { conditions.push({ languages: { has: language } }); } + // Affiliation type filter: independent (isIndependent=true) or agency + // (isIndependent=false). When null/absent, show all verified consultants. + const affiliationType = searchParams.get("affiliationType"); + if (affiliationType === "independent") { + conditions.push({ isIndependent: true }); + } else if (affiliationType === "agency") { + conditions.push({ isIndependent: false }); + } + // Search filter if (search) { conditions.push({ @@ -171,15 +184,34 @@ export async function GET(request: NextRequest) { orderBy, skip, take: limit, - include: consultantListInclude, + include: { ...consultantListInclude, ...orgMembershipInclude }, }); // Get total count for pagination const total = await prisma.consultantProfile.count({ where }); + // Map memberships -> organizationBadge for frontend. + // Arch 4-Modified: the include pulls `memberships[0].organization` + // for the first ACTIVE EXPERT membership at a canHost org. + // `c` is typed via Prisma's payload inference from the include shape — + // no explicit type annotation or narrowing cast needed. + const mappedConsultants = consultants.map(({ memberships, ...rest }) => { + const firstOrg = memberships[0]?.organization ?? null; + return { + ...rest, + organizationBadge: firstOrg + ? { + name: firstOrg.name, + slug: firstOrg.slug, + logo: firstOrg.brandingProfile?.logo ?? null, + } + : null, + }; + }); + return NextResponse.json( { - data: consultants, + data: mappedConsultants, meta: { total, page, diff --git a/app/api/users/me/erasure-requests/route.ts b/app/api/users/me/erasure-requests/route.ts new file mode 100644 index 000000000..5333b9993 --- /dev/null +++ b/app/api/users/me/erasure-requests/route.ts @@ -0,0 +1,76 @@ +/** + * GET /api/users/me/erasure-requests — show the user's open/most-recent request. + * POST /api/users/me/erasure-requests — file a DPDP §12 erasure request. + * + * Idempotent POST: if the user already has an open (PENDING / IN_PROGRESS) + * request, the route returns that row instead of creating a duplicate. The + * partial-unique index on `(userId, status IN PENDING|IN_PROGRESS)` is the + * DB-side guarantee; the route's lookup-then-insert pattern is the + * friendly happy-path. + */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; + +const CreateBodySchema = z.object({ + reason: z.string().trim().min(1).max(1000).optional(), +}); + +export async function GET() { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const requests = await prisma.erasureRequest.findMany({ + where: { userId: auth.session.user.id }, + orderBy: { requestedAt: "desc" }, + take: 10, + }); + return NextResponse.json({ data: requests }); +} + +export async function POST(req: Request) { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const raw = await req.json().catch(() => null); + const parsed = CreateBodySchema.safeParse(raw ?? {}); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const userId = auth.session.user.id; + + // Idempotent: short-circuit on an existing open request. + const existing = await prisma.erasureRequest.findFirst({ + where: { + userId, + status: { in: ["PENDING", "IN_PROGRESS"] }, + }, + }); + if (existing) { + return NextResponse.json({ request: existing }, { status: 200 }); + } + + const created = await prisma.$transaction(async (tx) => { + const request = await tx.erasureRequest.create({ + data: { + userId, + status: "PENDING", + reason: parsed.data.reason ?? null, + }, + }); + // No org-scoped audit — the user may not be in any org. We log via + // a SYSTEM-bucket row scoped to NULL organizationId is not allowed + // by the schema, so the user-initiated request is recorded inline + // on the ErasureRequest itself. The processing path writes per-org + // audit rows once we know which orgs are affected. + return request; + }); + + return NextResponse.json({ request: created }, { status: 201 }); +} diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts index aaf941aef..a93253523 100644 --- a/app/api/waitlist/route.ts +++ b/app/api/waitlist/route.ts @@ -9,6 +9,7 @@ import { joinWaitlist, getUserWaitlistEntries } from "@/lib/waitlist"; import { sendWaitlistJoinedEmail } from "@/lib/waitlist/notifications"; import prisma from "@/lib/prisma"; import { waitlistLimiter, applyRateLimit } from "@/lib/rate-limit"; +import { resolveOrgScope } from "@/lib/api/scope/parse"; import { getSession } from "@/lib/auth-server"; /** @@ -105,9 +106,9 @@ export async function POST(request: NextRequest) { } /** - * GET /api/waitlist - Get user's waitlist entries + * GET /api/waitlist - Get user's waitlist entries (org-scope-aware) */ -export async function GET() { +export async function GET(request: NextRequest) { try { const session = await getSession(); @@ -118,7 +119,37 @@ export async function GET() { ); } - const entries = await getUserWaitlistEntries(session.user.id); + // #674 org-scope filter — Waitlist.organizationId is populated by + // the backfill so the consultant's "Waitlist" tab can split per + // tenant context without leaking cross-org entries. + const { searchParams } = new URL(request.url); + const callerMemberships = await prisma.membership.findMany({ + where: { userId: session.user.id, status: "ACTIVE" }, + select: { organizationId: true, status: true }, + }); + const scopeResolution = resolveOrgScope({ + raw: searchParams.get("orgScope"), + memberships: callerMemberships, + userRole: session.user.role, + }); + if (!scopeResolution.ok) { + return NextResponse.json( + { + success: false, + error: scopeResolution.message, + code: scopeResolution.code, + }, + { status: scopeResolution.status }, + ); + } + const orgFilter = + scopeResolution.scope.kind === "personal" + ? { organizationId: null } + : scopeResolution.scope.kind === "org" + ? { organizationId: scopeResolution.scope.orgId } + : {}; + + const entries = await getUserWaitlistEntries(session.user.id, orgFilter); return NextResponse.json({ success: true, diff --git a/app/api/webhooks/lemon-squeezy/route.ts b/app/api/webhooks/lemon-squeezy/route.ts index b88481348..0b33845f0 100644 --- a/app/api/webhooks/lemon-squeezy/route.ts +++ b/app/api/webhooks/lemon-squeezy/route.ts @@ -1,15 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -import crypto from "crypto"; -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { Prisma, PaymentStatus, RequestStatus } from "@prisma/client"; -import { isDbHealthy } from "@/app/api/webhooks/utils"; +import { + isDbHealthy, + verifyHmacWebhookSignature, +} from "@/app/api/webhooks/utils"; export async function POST(req: NextRequest) { try { - const body = await req.text(); - const signature = req.headers.get("x-signature"); - - if (!process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) { + // #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" }, @@ -17,20 +18,21 @@ export async function POST(req: NextRequest) { ); } - // Verify webhook signature for Lemon Squeezy - if (signature) { - const expectedSignature = crypto - .createHmac("sha256", process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) - .update(body) - .digest("hex"); - - if (signature !== `sha256=${expectedSignature}`) { - console.error("Lemon Squeezy webhook signature verification failed"); - return NextResponse.json( - { error: "Invalid signature" }, - { status: 400 }, - ); - } + // #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 @@ -237,7 +239,7 @@ async function handleLemonSqueezyPaymentFailure(paymentIdentifier: string) { } // Helper function to create appointment from payment record -async function createAppointmentFromPayment(_tx: Prisma.TransactionClient, _payment: unknown) { +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( @@ -254,7 +256,7 @@ async function createAppointmentFromPayment(_tx: Prisma.TransactionClient, _paym } // Helper function to confirm existing appointment -async function confirmExistingAppointment(tx: Prisma.TransactionClient, appointmentId: string) { +async function confirmExistingAppointment(tx: Tx, appointmentId: string) { // Make slots non-tentative await tx.slotOfAppointment.updateMany({ where: { appointmentId }, @@ -302,7 +304,7 @@ async function confirmExistingAppointment(tx: Prisma.TransactionClient, appointm } // Helper function to cleanup failed payment appointments -async function cleanupFailedPaymentAppointment(tx: Prisma.TransactionClient, appointmentId: string) { +async function cleanupFailedPaymentAppointment(tx: Tx, appointmentId: string) { const appointment = await tx.appointment.findUnique({ where: { id: appointmentId }, include: { diff --git a/app/api/webhooks/razorpay-dispatch.ts b/app/api/webhooks/razorpay-dispatch.ts new file mode 100644 index 000000000..4b79128e4 --- /dev/null +++ b/app/api/webhooks/razorpay-dispatch.ts @@ -0,0 +1,367 @@ +/** + * Razorpay webhook event dispatch — the eventType → handler switch, extracted + * from the route so it can be shared by (a) the live webhook route's after() + * callback and (b) the B5 stuck-webhook sweeper (#785, task #10) which re-drives + * WebhookEvent rows left processed=false after an after()-callback crash. + * + * Deliberately Next-agnostic (NO `next/server` import) so the tsx sweeper can + * import it without pulling the Next runtime. All handlers it calls are + * idempotent (ledger idempotency keys + status guards), so a replay is safe. + */ +import { + handlePaymentFailure, + handlePaymentSuccess, + handleOrgPaymentSuccess, + handleOrgPaymentFailure, + handleRefundCreated, + handleDisputeCreated, + handleDisputeUpdated, + markWebhookEventProcessed, + handleRazorpayPayoutWebhook, + DeferSignal, +} from "./utils"; +import { + handleOverageMemberSuccess, + handleOverageMemberFailure, +} from "@/lib/payments/webhooks/overage-handlers"; +import { scrubWebhookPayload } from "@/lib/logging/webhook-scrub"; +import { + razorpayPaymentCapturedEventSchema, + razorpayPaymentFailedEventSchema, + razorpayOrderPaidEventSchema, + type RazorpayWebhookEnvelope, +} from "@/schemas/webhooks/razorpay"; +import { razorpayClient } from "@/lib/payments/core/razorpay"; +import { z } from "zod"; + +// Strict inner-entity schemas used to narrow optional envelope fields at the +// point of consumption (one per event family we actually process). +const refundEntitySchema = z.object({ + id: z.string(), + payment_id: z.string(), + amount: z.number(), + currency: z.string().optional(), + status: z.string(), +}); + +const disputeEntitySchema = z.object({ + id: z.string(), + payment_id: z.string(), + amount: z.number(), + currency: z.string().optional(), + reason_code: z.string().optional(), + reason_description: z.string().optional(), + status: z.string(), + respond_by: z.number().nullable().optional(), + deduct_at_onset: z.boolean().optional(), +}); + +const disputeUpdateEntitySchema = z.object({ + id: z.string(), + status: z.string(), +}); + +const payoutEntitySchema = z.object({ + id: z.string(), + status: z.string(), + failure_reason: z.string().nullable().optional(), + // A1+A8: bank-side UTR. Present on `payout.processed`; absent on + // queued/initiated/pending. Plumbed through to OrganizationPayout.gatewayUtr. + utr: z.string().nullable().optional(), +}); + +/** + * Process a Razorpay webhook event. Called via the route's `after()` callback + * AND by the stuck-webhook sweeper on replay. Errors are caught and recorded on + * the WebhookEvent row (via markWebhookEventProcessed) for retry/observability. + */ +export async function processRazorpayWebhookEvent( + event: RazorpayWebhookEnvelope, + eventType: string, + eventId: string, +): Promise { + // PII-scrub the payload before logging — Razorpay payloads can carry + // payer email/phone/contact, partial card/UPI fingerprints, and any + // `notes.*` fields the app populated (referrerEmail etc). See + // lib/logging/webhook-scrub.ts for the redaction rules. + console.log(`🔔 Razorpay Webhook Event: ${eventType}`, { + eventId, + payload: scrubWebhookPayload(event.payload), + }); + + let processingError: string | undefined; + // #813/#812 — set when a handler DEFERS (event valid but its row not yet + // written). On a defer we skip markWebhookEventProcessed so the row stays + // processed=false/error=null for the stuck-event sweeper to re-drive. + let deferred = false; + + try { + switch (eventType) { + case "payment.captured": { + const capturedEvent = razorpayPaymentCapturedEventSchema.parse(event); + const capturedNotes = capturedEvent.payload.payment.entity.notes ?? {}; + if ( + capturedNotes.type === "credit_purchase" || + capturedNotes.type === "invoice_payment" + ) { + await handleOrgPaymentSuccess( + capturedNotes, + capturedEvent.payload.payment.entity.id, + capturedEvent.payload.payment.entity.amount, + ); + } else if (capturedNotes.type === "overage_member") { + // #775 — CHARGE_MEMBER overage side-charge (no appointment; routes + // on the order id stamped at resume-checkout time). + await handleOverageMemberSuccess( + capturedEvent.payload.payment.entity.order_id, + ); + } else { + await handlePaymentSuccess( + capturedEvent.payload.payment.entity.order_id, + capturedNotes, + ); + } + break; + } + + case "order.paid": { + const paidEvent = razorpayOrderPaidEventSchema.parse(event); + const paidNotes = paidEvent.payload.order.entity.notes ?? {}; + if ( + paidNotes.type === "credit_purchase" || + paidNotes.type === "invoice_payment" + ) { + await handleOrgPaymentSuccess(paidNotes); + } else if (paidNotes.type === "overage_member") { + await handleOverageMemberSuccess(paidEvent.payload.order.entity.id); + } else { + await handlePaymentSuccess( + paidEvent.payload.order.entity.id, + paidNotes, + ); + } + break; + } + + case "payment.failed": { + const failedEvent = razorpayPaymentFailedEventSchema.parse(event); + const failedEntity = failedEvent.payload.payment.entity; + const failedNotes = failedEntity.notes ?? {}; + // Org-level top-ups and invoice payments do NOT have a `Payment` + // row (they live on WalletEntry / OrganizationInvoice), so the + // legacy handlePaymentFailure would silently no-op for them. + // Route by notes.type first; fall back to the B2C path. + if ( + failedNotes.type === "credit_purchase" || + failedNotes.type === "invoice_payment" + ) { + await handleOrgPaymentFailure(failedNotes, failedEntity.id); + } else if (failedNotes.type === "overage_member") { + await handleOverageMemberFailure(failedEntity.order_id); + } else { + await handlePaymentFailure(failedEntity.order_id); + } + break; + } + + // Refund events + // FIX #5: Razorpay refunds use payment_id, but our DB stores order_id as + // paymentIntent. Resolve payment_id → order_id via Razorpay API first. + case "refund.created": + case "refund.processed": { + const refundEvent = refundEntitySchema.parse( + event.payload?.refund?.entity, + ); + let paymentIntentId = refundEvent.payment_id; + + if (razorpayClient) { + try { + const rzpPayment = await razorpayClient.payments.fetch( + refundEvent.payment_id, + ); + if (rzpPayment.order_id) { + paymentIntentId = rzpPayment.order_id; + } + } catch (lookupError) { + console.error( + `Failed to resolve Razorpay payment_id ${refundEvent.payment_id} to order_id:`, + lookupError, + ); + } + } + + const refundResult = await handleRefundCreated( + refundEvent.id, + paymentIntentId, + refundEvent.amount, + refundEvent.currency || "INR", + refundEvent.status, + "RAZORPAY", + refundEvent.payment_id, + ); + if (refundResult instanceof DeferSignal) { + deferred = true; + console.log( + `⏳ Deferring refund ${refundEvent.id} for re-drive: ${refundResult.reason}`, + ); + } + break; + } + + case "refund.failed": { + const failedRefundEvent = refundEntitySchema.parse( + event.payload?.refund?.entity, + ); + let failedPaymentIntentId = failedRefundEvent.payment_id; + + if (razorpayClient) { + try { + const rzpPayment = await razorpayClient.payments.fetch( + failedRefundEvent.payment_id, + ); + if (rzpPayment.order_id) { + failedPaymentIntentId = rzpPayment.order_id; + } + } catch (lookupError) { + console.error( + `Failed to resolve Razorpay payment_id ${failedRefundEvent.payment_id} to order_id:`, + lookupError, + ); + } + } + + const failedRefundResult = await handleRefundCreated( + failedRefundEvent.id, + failedPaymentIntentId, + failedRefundEvent.amount, + failedRefundEvent.currency || "INR", + "failed", + "RAZORPAY", + failedRefundEvent.payment_id, + ); + if (failedRefundResult instanceof DeferSignal) { + deferred = true; + console.log( + `⏳ Deferring refund ${failedRefundEvent.id} for re-drive: ${failedRefundResult.reason}`, + ); + } + break; + } + + // L1 FIX: Handle refund.speed_changed (informational only) + case "refund.speed_changed": { + console.log( + `📄 Refund speed changed: ${event.payload?.refund?.entity?.id}`, + ); + break; + } + + // Dispute events + case "payment.dispute.created": { + const disputeCreatedEvent = disputeEntitySchema.parse( + event.payload?.dispute?.entity, + ); + await handleDisputeCreated( + disputeCreatedEvent.id, + disputeCreatedEvent.payment_id, + disputeCreatedEvent.amount, + disputeCreatedEvent.currency || "INR", + disputeCreatedEvent.reason_description || + disputeCreatedEvent.reason_code || + "unknown", + disputeCreatedEvent.status, + disputeCreatedEvent.respond_by ?? null, + disputeCreatedEvent.deduct_at_onset === false, + "RAZORPAY", + ); + break; + } + + // #789 — these two were dropped to `default`. under_review carries the + // gateway moving evidence into review; action_required is the deadline + // signal. Both must advance Dispute.status (mapDisputeStatus already maps + // them: under_review → UNDER_REVIEW, action_required → NEEDS_RESPONSE). + case "payment.dispute.under_review": + case "payment.dispute.action_required": { + const disputeProgressEvent = disputeUpdateEntitySchema.parse( + event.payload?.dispute?.entity, + ); + await handleDisputeUpdated( + disputeProgressEvent.id, + disputeProgressEvent.status, + null, + ); + break; + } + + case "payment.dispute.won": { + const disputeWonEvent = disputeUpdateEntitySchema.parse( + event.payload?.dispute?.entity, + ); + await handleDisputeUpdated(disputeWonEvent.id, "won", null); + break; + } + + case "payment.dispute.lost": { + const disputeLostEvent = disputeUpdateEntitySchema.parse( + event.payload?.dispute?.entity, + ); + await handleDisputeUpdated(disputeLostEvent.id, "lost", null); + break; + } + + case "payment.dispute.closed": { + const disputeClosedEvent = disputeUpdateEntitySchema.parse( + event.payload?.dispute?.entity, + ); + await handleDisputeUpdated( + disputeClosedEvent.id, + disputeClosedEvent.status, + null, + ); + break; + } + + // RazorpayX Payout events. #789 — payout.failed was previously dropped to + // `default` even though handleRazorpayPayoutWebhook + markOrgPayoutFailed + // already handle it, leaving a failed org payout stuck in PROCESSING with + // earnings unreleased; it is now routed alongside the other terminal events. + case "payout.processed": + case "payout.reversed": + case "payout.rejected": + case "payout.failed": + case "payout.queued": + case "payout.pending": + case "payout.cancelled": { + const payoutEvent = payoutEntitySchema.parse( + event.payload?.payout?.entity, + ); + await handleRazorpayPayoutWebhook(eventType, { + id: payoutEvent.id, + status: payoutEvent.status, + failure_reason: payoutEvent.failure_reason ?? undefined, + utr: payoutEvent.utr ?? undefined, + }); + break; + } + + default: + console.log(`📄 Unhandled Razorpay event type: ${eventType}`); + } + } catch (handlerError) { + processingError = + handlerError instanceof Error + ? handlerError.message + : String(handlerError); + console.error( + `Razorpay webhook processing error for ${eventId}:`, + handlerError, + ); + } finally { + // #813/#812 — on a defer, leave the row processed=false/error=null so the + // stuck-event sweeper re-drives it once the awaited payment lands. + if (!deferred) { + await markWebhookEventProcessed(eventId, processingError); + } + } +} diff --git a/app/api/webhooks/razorpay/route.ts b/app/api/webhooks/razorpay/route.ts index 3a85702e4..87af6256f 100644 --- a/app/api/webhooks/razorpay/route.ts +++ b/app/api/webhooks/razorpay/route.ts @@ -1,24 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { after } from "next/server"; +import crypto from "node:crypto"; import { - handlePaymentFailure, - handlePaymentSuccess, - handleRefundCreated, - handleDisputeCreated, - handleDisputeUpdated, verifyWebhookSignature, logWebhookEvent, - markWebhookEventProcessed, - handleRazorpayPayoutWebhook, isDbHealthy, } from "../utils"; +import { recordSystemEvent } from "@/lib/enterprise/system-events"; import { - razorpayBaseEventSchema, - razorpayPaymentCapturedEventSchema, - razorpayPaymentFailedEventSchema, - razorpayOrderPaidEventSchema, + razorpayWebhookEnvelopeSchema, + type RazorpayWebhookEnvelope, } from "../../../../schemas/webhooks/razorpay"; -import { razorpayClient } from "@/lib/payments/core/razorpay"; +// #785 — dispatch switch extracted to a Next-agnostic module so the B5 +// stuck-webhook sweeper (jobs/cleanup/sweep-stuck-webhook-events) can replay +// crashed events through the exact same handler routing. +import { processRazorpayWebhookEvent } from "../razorpay-dispatch"; export async function POST(req: NextRequest) { const secret = process.env.RAZORPAY_WEBHOOK_SECRET; @@ -74,6 +70,13 @@ export async function POST(req: NextRequest) { crypto.timingSafeEqual(sigBuf, expectedBuf); if (!isRazorpayXValid) { + // #776 §K — repeated HMAC failures are a tamper/misconfig signal. + await recordSystemEvent({ + category: "WEBHOOK", + severity: "WARN", + message: "Razorpay webhook HMAC verification failed (RazorpayX secret)", + context: { provider: "razorpayx", event: "payout.*" }, + }); return NextResponse.json( { error: "Invalid signature" }, { status: 400 }, @@ -87,6 +90,13 @@ export async function POST(req: NextRequest) { ); } } else { + // #776 §K — repeated HMAC failures are a tamper/misconfig signal. + await recordSystemEvent({ + category: "WEBHOOK", + severity: "WARN", + message: "Razorpay webhook HMAC verification failed", + context: { provider: "razorpay" }, + }); return NextResponse.json( { error: "Invalid signature" }, { status: 400 }, @@ -109,14 +119,13 @@ export async function POST(req: NextRequest) { // then return 200 immediately and process the event asynchronously via // Next.js `after()` to stay within Razorpay's 5-second webhook timeout. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let event: any; + let event: RazorpayWebhookEnvelope; let eventType: string; - let eventId: string; try { - event = JSON.parse(body); - ({ event: eventType } = razorpayBaseEventSchema.parse(event)); + const rawJson: unknown = JSON.parse(body); + event = razorpayWebhookEnvelopeSchema.parse(rawJson); + eventType = event.event; } catch (parseError) { console.error("Razorpay webhook parse error:", parseError); return NextResponse.json( @@ -127,6 +136,10 @@ export async function POST(req: NextRequest) { // Composite key prevents collisions between different lifecycle events // for the same entity (e.g., payment.captured vs refund.created). + // When no entity is present (malformed payload) we fall back to a + // SHA-256 hash of the raw body so the eventId stays deterministic — + // two identical replayed bodies collapse to the same id, which is what + // we want for dedup. const entityId = event.payload?.payment?.entity?.id || event.payload?.order?.entity?.id || @@ -134,8 +147,8 @@ export async function POST(req: NextRequest) { event.payload?.dispute?.entity?.id || event.payload?.payout?.entity?.id || event.account_id || - `noid_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - eventId = `${eventType}:${entityId}`; + `body_${crypto.createHash("sha256").update(body).digest("hex").slice(0, 16)}`; + const eventId = `${eventType}:${entityId}`; // Idempotency check (synchronous — must complete before returning 200) const { isNew } = await logWebhookEvent( @@ -153,196 +166,8 @@ export async function POST(req: NextRequest) { // Return 200 immediately — process the event asynchronously after(async () => { - await processWebhookEvent(event, eventType, eventId); + await processRazorpayWebhookEvent(event, eventType, eventId); }); return NextResponse.json({ status: "ok" }); } - -/** - * Process a webhook event asynchronously (called via next/server `after()`). - * Errors here are logged and recorded on the webhook event record for retry. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function processWebhookEvent( - event: any, - eventType: string, - eventId: string, -): Promise { - console.log(`🔔 Razorpay Webhook Event: ${eventType}`, { - payload: event.payload, - }); - - let processingError: string | undefined; - - try { - switch (eventType) { - case "payment.captured": { - const capturedEvent = razorpayPaymentCapturedEventSchema.parse(event); - await handlePaymentSuccess( - capturedEvent.payload.payment.entity.order_id, - capturedEvent.payload.payment.entity.notes || {}, - ); - break; - } - - case "order.paid": { - const paidEvent = razorpayOrderPaidEventSchema.parse(event); - await handlePaymentSuccess( - paidEvent.payload.order.entity.id, - paidEvent.payload.order.entity.notes || {}, - ); - break; - } - - case "payment.failed": { - const failedEvent = razorpayPaymentFailedEventSchema.parse(event); - await handlePaymentFailure( - failedEvent.payload.payment.entity.order_id, - ); - break; - } - - // Refund events - // FIX #5: Razorpay refunds use payment_id, but our DB stores order_id as - // paymentIntent. Resolve payment_id → order_id via Razorpay API first. - case "refund.created": - case "refund.processed": { - const refundEvent = event.payload.refund.entity; - let paymentIntentId = refundEvent.payment_id; - - if (razorpayClient) { - try { - const rzpPayment = await razorpayClient.payments.fetch( - refundEvent.payment_id, - ); - if (rzpPayment.order_id) { - paymentIntentId = rzpPayment.order_id; - } - } catch (lookupError) { - console.error( - `Failed to resolve Razorpay payment_id ${refundEvent.payment_id} to order_id:`, - lookupError, - ); - } - } - - await handleRefundCreated( - refundEvent.id, - paymentIntentId, - refundEvent.amount, - refundEvent.currency || "INR", - refundEvent.status, - "RAZORPAY", - ); - break; - } - - case "refund.failed": { - const failedRefundEvent = event.payload.refund.entity; - let failedPaymentIntentId = failedRefundEvent.payment_id; - - if (razorpayClient) { - try { - const rzpPayment = await razorpayClient.payments.fetch( - failedRefundEvent.payment_id, - ); - if (rzpPayment.order_id) { - failedPaymentIntentId = rzpPayment.order_id; - } - } catch (lookupError) { - console.error( - `Failed to resolve Razorpay payment_id ${failedRefundEvent.payment_id} to order_id:`, - lookupError, - ); - } - } - - await handleRefundCreated( - failedRefundEvent.id, - failedPaymentIntentId, - failedRefundEvent.amount, - failedRefundEvent.currency || "INR", - "failed", - "RAZORPAY", - ); - break; - } - - // L1 FIX: Handle refund.speed_changed (informational only) - case "refund.speed_changed": { - console.log( - `📄 Refund speed changed: ${event.payload?.refund?.entity?.id}`, - ); - break; - } - - // Dispute events - case "payment.dispute.created": { - const disputeCreatedEvent = event.payload.dispute.entity; - await handleDisputeCreated( - disputeCreatedEvent.id, - disputeCreatedEvent.payment_id, - disputeCreatedEvent.amount, - disputeCreatedEvent.currency || "INR", - disputeCreatedEvent.reason_description || - disputeCreatedEvent.reason_code, - disputeCreatedEvent.status, - disputeCreatedEvent.respond_by || null, - disputeCreatedEvent.deduct_at_onset === false, - "RAZORPAY", - ); - break; - } - - case "payment.dispute.won": { - const disputeWonEvent = event.payload.dispute.entity; - await handleDisputeUpdated(disputeWonEvent.id, "won", null); - break; - } - - case "payment.dispute.lost": { - const disputeLostEvent = event.payload.dispute.entity; - await handleDisputeUpdated(disputeLostEvent.id, "lost", null); - break; - } - - case "payment.dispute.closed": { - const disputeClosedEvent = event.payload.dispute.entity; - await handleDisputeUpdated( - disputeClosedEvent.id, - disputeClosedEvent.status, - null, - ); - break; - } - - // RazorpayX Payout events - case "payout.processed": - case "payout.reversed": - case "payout.rejected": - case "payout.queued": - case "payout.pending": - case "payout.cancelled": { - const payoutEvent = event.payload.payout.entity; - await handleRazorpayPayoutWebhook(eventType, { - id: payoutEvent.id, - status: payoutEvent.status, - failure_reason: payoutEvent.failure_reason, - }); - break; - } - - default: - console.log(`📄 Unhandled Razorpay event type: ${eventType}`); - } - } catch (handlerError) { - processingError = - handlerError instanceof Error - ? handlerError.message - : String(handlerError); - console.error(`Razorpay webhook processing error for ${eventId}:`, handlerError); - } finally { - await markWebhookEventProcessed(eventId, processingError); - } -} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 98b5922df..7fa60c634 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; import { handlePaymentFailure, handlePaymentSuccess, @@ -11,6 +12,7 @@ import { handleStripePayoutWebhook, isDbHealthy, } from "../utils"; +import { scrubWebhookPayload } from "@/lib/logging/webhook-scrub"; import { stripeBaseEventSchema, stripePaymentIntentSucceededEventSchema, @@ -49,8 +51,13 @@ export async function POST(req: NextRequest) { const event = JSON.parse(body); const { type: eventType } = stripeBaseEventSchema.parse(event); - // Log webhook event for audit trail (idempotency check) - const eventId = event.id || `stripe_${Date.now()}`; + // Log webhook event for audit trail (idempotency check). + // Stripe always sends a unique `evt_...` id, but if it's missing we + // derive a deterministic fallback from the body hash so replays + // still dedup. + const eventId = + event.id || + `stripe_body_${crypto.createHash("sha256").update(body).digest("hex").slice(0, 16)}`; const { isNew } = await logWebhookEvent( "stripe", @@ -65,8 +72,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ status: "ok", duplicate: true }); } + // PII-scrub the payload before logging — Stripe payloads can carry + // `receipt_email`, `billing_details.name/email/phone`, and arbitrary + // `metadata.*` fields set by the application. See + // lib/logging/webhook-scrub.ts for the redaction rules. console.log(`🔔 Stripe Webhook Event: ${eventType}`, { - payload: event.data.object, + eventId, + payload: scrubWebhookPayload(event.data.object), }); let processingError: string | undefined; @@ -170,11 +182,25 @@ export async function POST(req: NextRequest) { break; } - // Stripe Connect Payout/Transfer events + // Stripe Connect Payout/Transfer events. + // + // Payouts are India-first via RazorpayX; Stripe Connect payout + // integration is opt-in. Production environments that haven't + // onboarded Connect will otherwise receive noisy webhooks (e.g. + // for the platform's own Stripe balance movements). Gate both + // the handler and the subsequent `account.updated` / + // `transfer.*` logs behind ENABLE_STRIPE_PAYOUTS so we can + // enable the full Connect flow atomically once ready. case "payout.created": case "payout.paid": case "payout.failed": case "payout.canceled": { + if (process.env.ENABLE_STRIPE_PAYOUTS !== "true") { + console.log( + `⏭️ Stripe Connect payout event ${eventType} ignored (ENABLE_STRIPE_PAYOUTS!=true)`, + ); + break; + } const payoutEvent = event.data.object; await handleStripePayoutWebhook(eventType, { id: payoutEvent.id, @@ -185,21 +211,23 @@ export async function POST(req: NextRequest) { break; } - // Stripe Connect Account events + // Stripe Connect Account events — only meaningful when Connect + // payouts are enabled. case "account.updated": { + if (process.env.ENABLE_STRIPE_PAYOUTS !== "true") break; const accountEvent = event.data.object; console.log(`📄 Stripe Connect account updated: ${accountEvent.id}`, { chargesEnabled: accountEvent.charges_enabled, payoutsEnabled: accountEvent.payouts_enabled, detailsSubmitted: accountEvent.details_submitted, }); - // TODO: Update PayoutAccount status in database if needed break; } // Transfer events (platform to connected account) case "transfer.created": case "transfer.reversed": { + if (process.env.ENABLE_STRIPE_PAYOUTS !== "true") break; const transferEvent = event.data.object; console.log(`📄 Stripe transfer ${eventType}: ${transferEvent.id}`, { amount: transferEvent.amount, diff --git a/app/api/webhooks/utils.ts b/app/api/webhooks/utils.ts index f57a1dc98..42c8e095e 100644 --- a/app/api/webhooks/utils.ts +++ b/app/api/webhooks/utils.ts @@ -1,16 +1,38 @@ +import type { Tx } from "@/lib/prisma"; import prisma from "../../../lib/prisma"; +import { postLedgerTxn } from "@/lib/payments/ledger/post"; +import { sumPaise } from "@/lib/payments/utils/money"; +import { isLegalDisputeTransition } from "@/lib/payments/dispute-status"; import { Prisma, PaymentGateway } from "@prisma/client"; import crypto from "crypto"; import { stripeClient } from "@/lib/payments/core/stripe"; import { razorpayClient } from "@/lib/payments/core/razorpay"; -import { handlePayoutWebhook, refundEarnings } from "@/lib/payments/payouts"; +import { handlePayoutWebhook } from "@/lib/payments/payouts"; import { notifyRefundProcessed, notifyDisputeCreated, notifyDisputeResolved, } from "@/lib/novu"; +import { + notifyOrgInvoicePaid, + notifyOrgWalletTopupConfirmed, +} from "@/lib/novu/org-workflows"; import { reverseCreditsForPayment } from "@/lib/referrals/service"; +import { toCurrencyEnum } from "@/lib/payments/validation/currency-guards"; import { getAppUrl } from "@/lib/url"; +import { + confirmTopUp, + walletCredit, + walletDebit, + WalletInsufficientFundsError, +} from "@/lib/api/organizations/wallet"; +import { + applyRefundCascade, + mintInvoiceRefundCreditNote, + mintRefundCreditNote, +} from "@/lib/payments/operations/refund"; +import { recordTdsReversal } from "@/lib/payments/tax/tds-service"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; // Re-export payment handlers from lib (architectural fix) export { @@ -18,6 +40,473 @@ export { handlePaymentFailure, } from "@/lib/payments/webhooks/handlers"; +/** + * #813/#812 — defer sentinel for the refund-before-capture race. A handler + * returns this (rather than throwing) when an event is processable but the row + * it needs hasn't been written yet. The Razorpay dispatcher SKIPS + * markWebhookEventProcessed on a defer, leaving the WebhookEvent + * processed=false/error=null so the stuck-event sweeper re-drives it; a real + * throw still records the error. See handleRefundCreated. + */ +export class DeferSignal { + constructor(public readonly reason: string) {} +} + +/** + * Handle org-specific payment success (credit_purchase or invoice_payment). + * These bypass the standard handlePaymentSuccess flow because they don't + * involve appointments or booking confirmations. + * + * Razorpay order notes use `organizationId` as the canonical key — the + * legacy `orgProfileId` alias pointed at the now-deleted OrganizationProfile + * table and would silently corrupt audit writes if it ever held a stale + * value. Producers (initiateTopUp, invoice-pay route) set + * `notes.organizationId` directly. + */ +export async function handleOrgPaymentSuccess( + notes: Record, + razorpayPaymentId?: string, + /** + * Authoritative amount captured at the gateway (paise). Must be passed + * by the caller so we can reject `notes.amountPaise` tampering for + * top-ups and reject under-paid invoices. When undefined (e.g. legacy + * `order.paid` path that has no payment id) we fall back to trusting + * notes but refuse to mark an invoice PAID. + */ + gatewayAmountPaise?: number, +): Promise { + // credit_purchase routes to WalletEntry via `confirmTopUp` from + // lib/api/organizations/wallet.ts (idempotent on providerOrderId). + // invoice_payment transitions OrganizationInvoice.status ISSUED → PAID. + if (notes.type === "credit_purchase") { + const { walletEntryOrderId, organizationId, amountPaise } = notes; + if (!walletEntryOrderId) { + console.error("[Webhook] credit_purchase missing walletEntryOrderId"); + return; + } + if (!razorpayPaymentId) { + // order.paid carries the order-level event without a payment id + // on this entity; we still need a payment id to record on the + // WalletEntry. Skip — payment.captured (which DOES include the + // payment id) handles the same logical event idempotently. + console.log( + `[Webhook] credit_purchase ${walletEntryOrderId} order-level event skipped; awaiting payment.captured`, + ); + return; + } + const paise = Number(amountPaise); + if (!Number.isFinite(paise) || paise <= 0) { + console.error( + `[Webhook] credit_purchase ${walletEntryOrderId} has invalid amountPaise notes value: ${amountPaise}`, + ); + return; + } + // Defence-in-depth: `notes.amountPaise` is mutable metadata we + // attach to the Razorpay order. Verify it matches what was actually + // captured before crediting the wallet. A mismatch means either a + // gateway anomaly or a tampered order — we log + return 200 so + // Razorpay stops retrying, but we do NOT credit the wallet. + if (gatewayAmountPaise !== undefined && paise !== gatewayAmountPaise) { + console.error( + `[Webhook] credit_purchase ${walletEntryOrderId} notes.amountPaise=${paise} ≠ gatewayAmount=${gatewayAmountPaise}. Skipping wallet credit.`, + ); + if (organizationId) { + prisma.orgAuditLog + .create({ + data: { + organizationId, + actorMembershipId: null, + category: "WALLET", + action: AUDIT_ACTIONS.WALLET.WALLET_TOPUP, + description: `Top-up amount mismatch for order ${walletEntryOrderId}: notes=${paise}p gateway=${gatewayAmountPaise}p`, + details: { + walletEntryOrderId, + providerPaymentId: razorpayPaymentId, + notesAmountPaise: paise, + gatewayAmountPaise, + }, + }, + }) + .catch((err) => + console.error( + "[Webhook] Failed to write WALLET topup mismatch audit log:", + err, + ), + ); + } + return; + } + try { + const result = await confirmTopUp(prisma, { + providerOrderId: walletEntryOrderId, + providerPaymentId: razorpayPaymentId, + amountPaise: paise, + }); + console.log( + `[Webhook] credit_purchase confirmed=${result.confirmed} order=${walletEntryOrderId} org=${organizationId ?? "?"} balanceAfter=${result.balanceAfter ?? "?"}`, + ); + if (organizationId && result.confirmed) { + prisma.orgAuditLog + .create({ + data: { + organizationId, + actorMembershipId: null, + category: "WALLET", + action: AUDIT_ACTIONS.WALLET.WALLET_TOPUP_CONFIRMED, + description: `Top-up confirmed: ₹${(paise / 100).toLocaleString("en-IN")}`, + details: { + walletEntryOrderId, + providerPaymentId: razorpayPaymentId, + amountPaise: paise, + }, + }, + }) + .catch((err) => + console.error( + "[Webhook] Failed to write WALLET_TOPUP_CONFIRMED audit log:", + err, + ), + ); + + // Novu bell notification to OWNERs. Look up org context inline + // — the webhook is outside the HTTP session scope, so we can't + // lean on `requireOrgAccess` to hand us `access.org`. + const orgRow = await prisma.organization.findUnique({ + where: { id: organizationId }, + select: { + name: true, + billingAccount: { select: { walletBalance: true, currency: true } }, + }, + }); + if (orgRow) { + notifyOrgWalletTopupConfirmed(organizationId, { + orgName: orgRow.name, + amountPaise: paise, + currency: orgRow.billingAccount?.currency ?? "INR", + newBalancePaise: orgRow.billingAccount?.walletBalance ?? 0, + dashboardUrl: `${getAppUrl()}/dashboard/organization/${organizationId}/billing`, + }).catch((err) => + console.error("[notifyOrgWalletTopupConfirmed] failed:", err), + ); + } + } + } catch (err) { + console.error( + `[Webhook] confirmTopUp failed for ${walletEntryOrderId}:`, + err, + ); + throw err; // bubble so the webhook record retains the error for retry + } + } else if (notes.type === "invoice_payment") { + const { invoiceId, organizationId } = notes; + if (!invoiceId) { + console.error("[Webhook] invoice_payment missing invoiceId"); + return; + } + + // Verify the captured amount matches what was billed before we + // flip the invoice to PAID. Without this, a tampered or partial + // capture could mark an invoice paid for less than what was owed. + // If we don't have a gateway amount (order-level event), we wait + // for the payment.captured event — no ISSUED→PAID without proof. + if (gatewayAmountPaise === undefined) { + console.log( + `[Webhook] invoice_payment ${invoiceId} deferred: no gateway amount (awaiting payment.captured)`, + ); + return; + } + const invoiceRow = await prisma.organizationInvoice.findUnique({ + where: { id: invoiceId }, + select: { + id: true, + totalPaise: true, + status: true, + displayCurrency: true, + organizationId: true, + }, + }); + if (!invoiceRow) { + console.error(`[Webhook] invoice_payment ${invoiceId} not found`); + return; + } + if (invoiceRow.totalPaise !== gatewayAmountPaise) { + console.error( + `[Webhook] invoice_payment ${invoiceId} totalPaise=${invoiceRow.totalPaise} ≠ gatewayAmount=${gatewayAmountPaise}. Not marking PAID.`, + ); + if (organizationId) { + prisma.orgAuditLog + .create({ + data: { + organizationId, + actorMembershipId: null, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_PAYMENT_INITIATED, + description: `Invoice ${invoiceId} captured amount mismatch: billed=${invoiceRow.totalPaise}p, captured=${gatewayAmountPaise}p`, + details: { + invoiceId, + providerPaymentId: razorpayPaymentId ?? null, + totalPaise: invoiceRow.totalPaise, + gatewayAmountPaise, + }, + }, + }) + .catch((err) => + console.error( + "[Webhook] Failed to write invoice amount-mismatch audit log:", + err, + ), + ); + } + return; + } + + const resolvedOrgId = invoiceRow.organizationId ?? organizationId; + + // LED-1: invoice claim + INVOICE_PAID settlement write must be atomic. + // Before this PR the settlement write was a fire-and-forget after the + // updateMany — so a transient DB error on the settlement insert left + // the invoice marked PAID with no ledger row, and the nightly + // reconciler would flag drift it could not auto-remediate. Both writes + // now share a transaction. Audit + Novu notifications stay best-effort + // outside the tx — they're operator surfaces, not ledger. + let claimedCount = 0; + try { + const txResult = await prisma.$transaction(async (tx) => { + const claimed = await tx.organizationInvoice.updateMany({ + where: { id: invoiceId, status: { in: ["ISSUED", "OVERDUE"] } }, + data: { + status: "PAID", + paidAt: new Date(), + providerPaymentOrderId: null, + ...(razorpayPaymentId + ? { providerPaymentId: razorpayPaymentId } + : {}), + }, + }); + if (claimed.count === 0) { + return { count: 0 as const }; + } + if (resolvedOrgId) { + // #771 D1/D5 — double-entry (dual-write): org pays the invoice; clear + // the receivable accrued at booking time (INR underlying). + // Dr CASH Cr ORG_RECEIVABLE(org) + if (invoiceRow.totalPaise > 0) { + await postLedgerTxn(tx, { + idempotencyKey: `invoicepaid:${invoiceId}`, + kind: "INVOICE_PAID", + invoiceId, + postings: [ + { + account: { kind: "CASH" }, + direction: "DEBIT", + amountPaise: invoiceRow.totalPaise, + }, + { + account: { + kind: "ORG_RECEIVABLE", + organizationId: resolvedOrgId, + }, + direction: "CREDIT", + amountPaise: invoiceRow.totalPaise, + }, + ], + }); + } + } + // #775 — CHARGE_ORG overage events on this invoice's lines were ACCRUED + // at rollup; the org has now paid, so flip them ACCRUED → CHARGED. + await tx.overageEvent.updateMany({ + where: { + overageBehavior: "CHARGE_ORG", + chargeStatus: "ACCRUED", + invoiceLineItem: { invoiceId }, + }, + data: { chargeStatus: "CHARGED" }, + }); + return { count: claimed.count }; + }); + claimedCount = txResult.count; + } catch (err) { + // Webhook delivery is at-least-once; throwing causes Razorpay to + // retry. The tx already rolled back so a retry sees the original + // ISSUED/OVERDUE state and tries again cleanly. + console.error( + `[Webhook] INVOICE_PAID transaction failed for ${invoiceId}; rolling back:`, + err, + ); + throw err; + } + + if (claimedCount === 0) { + console.log( + `[Webhook] Invoice ${invoiceId} already PAID — skipping (idempotent)`, + ); + return; + } + + console.log(`[Webhook] Invoice paid: ${invoiceId}`); + + if (resolvedOrgId) { + prisma.orgAuditLog + .create({ + data: { + organizationId: resolvedOrgId, + actorMembershipId: null, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_PAID, + description: `Invoice ${invoiceId} paid via webhook`, + details: { + invoiceId, + providerPaymentId: razorpayPaymentId ?? null, + }, + }, + }) + .catch((err) => + console.error( + "[Webhook] Failed to write INVOICE_PAID audit log:", + err, + ), + ); + + // Novu bell notification to OWNERs. Look up the invoice number + + // org name here rather than passing them down — the webhook entry + // site doesn't have them. + const ctx = await prisma.organizationInvoice.findUnique({ + where: { id: invoiceId }, + select: { + invoiceNumber: true, + paidAt: true, + organization: { select: { name: true } }, + }, + }); + if (ctx) { + notifyOrgInvoicePaid(resolvedOrgId, { + invoiceNumber: ctx.invoiceNumber, + orgName: ctx.organization.name, + totalPaise: invoiceRow.totalPaise, + currency: invoiceRow.displayCurrency, + paidAt: (ctx.paidAt ?? new Date()).toISOString(), + dashboardUrl: `${getAppUrl()}/dashboard/organization/${resolvedOrgId}/billing`, + }).catch((err) => console.error("[notifyOrgInvoicePaid] failed:", err)); + } + } + } +} + +/** + * Handle org-specific payment FAILURE (credit_purchase or invoice_payment). + * + * The legacy `handlePaymentFailure` path only knows about the B2C + * `Payment` table — when a user's wallet top-up or invoice-payment fails + * at the gateway, there is no `Payment` row for it. Without this + * handler, a `payment.failed` webhook for an org top-up would silently + * log "Payment record not found" and leave the pending `WalletEntry` + * placeholder stuck in the DB forever (the cleanup cron would GC it + * eventually, but we want to flip state immediately for a snappy UX). + * + * For top-ups: the placeholder WalletEntry (deltaPaise=0, status + * expressed via notes + absence of providerPaymentId) is deleted so the + * caller sees an immediate "payment failed — please retry" state on + * next refresh. `confirmTopUp` is the only path that converts a + * placeholder to a live wallet credit, so deleting here is safe. + * + * For invoices: we clear `providerPaymentOrderId` so the next "Pay" + * click at the UI creates a fresh Razorpay order (the idempotency + * guard we introduced in Phase 1 reused the old order id; a failed + * order must be discarded before retry). + */ +export async function handleOrgPaymentFailure( + notes: Record, + providerPaymentId?: string, +): Promise { + if (notes.type === "credit_purchase") { + const { walletEntryOrderId, organizationId } = notes; + if (!walletEntryOrderId) { + console.error( + "[Webhook] credit_purchase.failed missing walletEntryOrderId", + ); + return; + } + // Only delete placeholders that were never confirmed. A confirmed + // top-up has status=CONFIRMED + providerPaymentId set; a pending + // placeholder has status=PENDING. + const deleted = await prisma.walletTopUp.deleteMany({ + where: { + providerOrderId: walletEntryOrderId, + status: "PENDING", + providerPaymentId: null, + }, + }); + console.log( + `[Webhook] credit_purchase.failed placeholder deleted (count=${deleted.count}) order=${walletEntryOrderId}`, + ); + if (organizationId && deleted.count > 0) { + prisma.orgAuditLog + .create({ + data: { + organizationId, + actorMembershipId: null, + category: "WALLET", + action: AUDIT_ACTIONS.WALLET.WALLET_TOPUP, + description: `Top-up failed at gateway: order ${walletEntryOrderId}`, + details: { + walletEntryOrderId, + providerPaymentId: providerPaymentId ?? null, + outcome: "failed", + }, + }, + }) + .catch((err) => + console.error( + "[Webhook] Failed to write WALLET_TOPUP failure audit log:", + err, + ), + ); + } + } else if (notes.type === "invoice_payment") { + const { invoiceId, organizationId } = notes; + if (!invoiceId) { + console.error("[Webhook] invoice_payment.failed missing invoiceId"); + return; + } + // Clear the stored order id so the UI retry creates a fresh one. + // Leave the invoice status untouched (still ISSUED/OVERDUE). + await prisma.organizationInvoice.updateMany({ + where: { + id: invoiceId, + status: { in: ["ISSUED", "OVERDUE"] }, + }, + data: { providerPaymentOrderId: null }, + }); + console.log( + `[Webhook] invoice_payment.failed cleared provider order id for invoice ${invoiceId}`, + ); + if (organizationId) { + prisma.orgAuditLog + .create({ + data: { + organizationId, + actorMembershipId: null, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_PAYMENT_INITIATED, + description: `Invoice ${invoiceId} payment failed at gateway`, + details: { + invoiceId, + providerPaymentId: providerPaymentId ?? null, + outcome: "failed", + }, + }, + }) + .catch((err) => + console.error( + "[Webhook] Failed to write invoice-payment-failed audit log:", + err, + ), + ); + } + } +} + /** * Lightweight DB health check for webhook handlers. * @@ -28,7 +517,8 @@ export { */ export async function isDbHealthy(): Promise { try { - await prisma.$queryRaw`SELECT 1`; + // ORM connectivity probe (no raw SQL): a LIMIT 1 read proves the connection. + await prisma.user.findFirst({ select: { id: true } }); return true; } catch { return false; @@ -87,12 +577,73 @@ 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 // ============================================================================ /** - * Handle refund created/processed event + * Handle refund created/processed event. + * + * Dispatches to one of three branches based on what the refund is paying + * back: + * 1. B2C appointment payment (`Payment` row keyed on `paymentIntent`): + * reverse consultant earnings + referral credits (legacy path). + * 2. Enterprise wallet top-up (`WalletEntry.providerPaymentId`): + * credit a compensating REFUND WalletEntry so the wallet balance + * decreases and FundingLedger stays balanced. + * 3. Enterprise invoice payment (`OrganizationInvoice.providerPaymentId`): + * mark the invoice REFUNDED and reverse any bookings charged to it + * (program utilisation → wallet credit, if applicable). + * + * If the webhook resolves to none of the above, we log and return. + * `providerPaymentId` (Razorpay `pay_<…>`) is optional and only used by + * the org-level branches; for Stripe we keep the legacy `paymentIntentId` + * contract. */ export async function handleRefundCreated( refundId: string, @@ -101,16 +652,206 @@ export async function handleRefundCreated( currency: string, status: string, gateway: "STRIPE" | "RAZORPAY", + providerPaymentId?: string, ) { return await prisma.$transaction(async (tx) => { - // Find the payment + // Find the payment (B2C appointment path) const payment = await tx.payment.findUnique({ where: { paymentIntent: paymentIntentId }, }); if (!payment) { - console.warn(`Payment not found for refund: ${refundId}`); - return; + // Fall through to enterprise branches. We need the original + // provider payment id (`pay_<…>`) to look up org-level rows. + if (!providerPaymentId) { + console.warn( + `Payment not found for refund ${refundId} and no providerPaymentId supplied; cannot dispatch org-level refund`, + ); + return; + } + + // --- Enterprise wallet top-up refund --- + const topUp = await tx.walletTopUp.findFirst({ + where: { providerPaymentId, status: "CONFIRMED" }, + select: { + id: true, + billingAccountId: true, + amountPaise: true, + providerOrderId: true, + }, + }); + if (topUp) { + const mapped = mapRefundStatus(status); + if (mapped === "SUCCEEDED") { + // Clamp: cannot refund more than was credited to this wallet. + const refundAmt = Math.min(amount, topUp.amountPaise); + const acct = await tx.billingAccount.findUniqueOrThrow({ + where: { id: topUp.billingAccountId }, + select: { currency: true, ownerOrgId: true }, + }); + // Reverse the top-up's double-entry: Dr WALLET / Cr CASH. The + // wallet liability we owe the org shrinks; platform cash returns + // to the gateway. postLedgerTxn is idempotent on idempotencyKey, + // so a webhook redelivery (or two racing workers) is a no-op — + // this replaces the old "already booked?" WalletEntry probe. + const posted = await postLedgerTxn(tx, { + idempotencyKey: `topup-refund:${providerPaymentId}`, + kind: "TOPUP_REFUND", + description: `Refund for top-up ${topUp.providerOrderId} (gateway refund ${refundId})`, + postings: [ + { + account: { + kind: "WALLET", + organizationId: acct.ownerOrgId, + currency: acct.currency, + }, + direction: "DEBIT", + amountPaise: refundAmt, + }, + { + account: { kind: "CASH", currency: acct.currency }, + direction: "CREDIT", + amountPaise: refundAmt, + }, + ], + }); + if (!posted.created) { + console.log( + `💸 Top-up refund already booked for payment ${providerPaymentId}, skipping`, + ); + return; + } + // Decrement the cached wallet balance to match the journal. This + // can drive the balance negative if the org already spent the + // credited funds — that is a real reconcile signal (the org owes + // back more than it holds), not an error to swallow here. + // + // Intentionally NOT inserting a `Refund` row for org-level + // refunds: Refund.paymentId is NOT NULL and is scoped to the B2C + // `Payment` table. The TOPUP_REFUND journal transaction + // (idempotencyKey topup-refund:) is the + // authoritative record; reconcile jobs index on it. + // ORM decrement (no raw SQL); WALLET accounts carry a non-null balance. + // May go negative by design (org already spent the credited funds) — a + // real reconcile signal, handled above, not an error. + await tx.billingAccount.update({ + where: { id: topUp.billingAccountId }, + data: { walletBalance: { decrement: refundAmt } }, + }); + console.log( + `💸 Top-up refund ${refundId} booked: -${refundAmt} paise on billingAccount ${topUp.billingAccountId}`, + ); + } + return; + } + + // --- Enterprise invoice refund --- + const invoice = await tx.organizationInvoice.findFirst({ + where: { providerPaymentId }, + select: { + id: true, + organizationId: true, + invoiceNumber: true, + totalPaise: true, + status: true, + }, + }); + if (invoice) { + const mapped = mapRefundStatus(status); + if (mapped === "SUCCEEDED") { + if (invoice.status === "REFUNDED") { + console.log(`💸 Invoice ${invoice.id} already REFUNDED, skipping`); + return; + } + await tx.organizationInvoice.update({ + where: { id: invoice.id }, + data: { status: "REFUNDED" }, + }); + // #776 / PR#785 review — mint the GST credit note (Sec 34) for the + // refunded invoice. Idempotent on refundId; the invoice.status===REFUNDED + // short-circuit above also guards against a duplicate webhook. + await mintInvoiceRefundCreditNote(tx, { + invoiceId: invoice.id, + refundId, + amountPaise: amount, + reason: `Invoice ${invoice.invoiceNumber} refund`, + }); + // NOTE: Booking-level utilization reversal is keyed on + // individual Payment ids (BookingUtilization.paymentId @unique), + // not on the invoice. Invoices that roll up many bookings do + // not have a single paymentId to feed `reverseBookingUtilization` + // — a follow-up phase (after the invoice-line-item schema lands) + // will iterate over linked line-items and reverse each one + // individually. For now, the compensating WalletEntry credit + // below plus the INVOICE_REFUNDED audit log is the guaranteed + // bookkeeping; the operator runbook calls out bookings that + // may need manual reversal. + await tx.orgAuditLog + .create({ + data: { + organizationId: invoice.organizationId, + actorMembershipId: null, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_REFUNDED, + description: `Invoice ${invoice.invoiceNumber} refunded (${refundId}, ${amount} ${currency})`, + details: { + invoiceId: invoice.id, + refundId, + amount, + currency, + providerPaymentId, + }, + }, + }) + .catch((err) => + console.error( + `⚠️ Failed to write INVOICE_REFUNDED audit log:`, + err, + ), + ); + // Opportunistic: if wallet-based funding was used, credit the + // refund amount back. Swallow if no wallet flow applies. + try { + const ba = await tx.billingAccount.findFirst({ + where: { ownerOrgId: invoice.organizationId }, + select: { id: true, fundingSource: true }, + }); + if (ba && ba.fundingSource === "WALLET") { + await walletCredit(tx, { + billingAccountId: ba.id, + amountPaise: amount, + reason: "REFUND", + providerPaymentId, + notes: `Invoice ${invoice.invoiceNumber} refund (${refundId})`, + }); + } + } catch (err) { + console.warn( + `⚠️ Wallet credit for invoice refund ${refundId} skipped:`, + err, + ); + } + console.log( + `💸 Invoice refund ${refundId} booked for invoice ${invoice.id}`, + ); + } + return; + } + + // #813/#812 — the refund references a payment we can't find on ANY path. + // The common cause is ordering: `refund.created` arrived before the + // `payment.captured` that creates the Payment row. A plain return ACKs the + // event (processed=true/error=null) so it never re-runs; a throw stamps + // error=true which the sweeper skips (it only re-drives error=null) — both + // are permanent death on Razorpay (no redelivery after a 200). Instead + // DEFER: on Razorpay the dispatcher skips the mark and the sweeper re-drives + // until the payment lands (or the terminal age cap gives up). Stripe retries + // natively on a 5xx and doesn't read this return, so keep throwing there. + const deferReason = `refund-before-capture: payment not yet recorded for refund ${refundId} (paymentIntent=${paymentIntentId}, providerPaymentId=${providerPaymentId})`; + if (gateway === "RAZORPAY") { + return new DeferSignal(deferReason); + } + throw new Error(`${deferReason} — re-driving`); } // Check if refund already exists @@ -124,41 +865,43 @@ export async function handleRefundCreated( const runRefundSideEffects = async ( paymentId: string, refundStatus: string, + refundRowId: string, refundAmt?: number, originalPaymentAmt?: number, ) => { if (mapRefundStatus(refundStatus) !== "SUCCEEDED") return; + // #776 — route gateway refunds through the canonical cascade so card/app/cron + // refunds share ONE engine: earnings + funding-leg + wallet + ledger + + // booking-utilization + GST credit-note reversal, idempotent on + // `Refund.cascadedAt`. This replaces the old earnings-only `refundEarnings` + // path, which left the refund ledger posting + leg/wallet reversal undone on + // gateway refunds (a divergence from the app/cron paths). The cascade allows + // PAID→REFUNDED, so the legacy `forceRefund` override is no longer needed. try { - // Check if any refund for this payment has forceRefund in metadata - const refunds = await prisma.refund.findMany({ - where: { paymentId }, - select: { metadata: true }, - }); - const hasForceRefund = refunds.some( - (r) => - r.metadata && - typeof r.metadata === "object" && - (r.metadata as Record).forceRefund === true, - ); - - // FIX #618: Pass refund amount context so partial refunds only - // reverse a proportional share of earnings, not the full amount. - await refundEarnings(paymentId, { - forceRefund: hasForceRefund, - refundAmount: refundAmt, - paymentAmount: originalPaymentAmt, + await applyRefundCascade(tx, { + paymentId, + refundId: refundRowId, + amountPaise: refundAmt ?? originalPaymentAmt ?? 0, + reason: "Gateway refund", + initiatedByUserId: null, }); - console.log(`💰 Earnings refunded for payment ${paymentId}`); - } catch (earningsError) { - // Log but don't fail - earnings can be manually updated - // refundEarnings already guards against double-refund (checks REFUNDED status) + console.log(`💰 Refund cascade applied for payment ${paymentId}`); + } catch (cascadeError) { + // #776 / PR#785 review — do NOT swallow. The cascade is idempotent + // (Refund.cascadedAt, claimed at its start) and atomic, so rethrowing rolls + // the tx back (the claim reverts) and the gateway redelivery / cascadedAt + // backstop cron retry it — instead of committing a partial refund (e.g. + // earnings reversed but the GST credit note un-minted, with no durable retry). console.error( - `⚠️ Failed to refund earnings for payment ${paymentId}:`, - earningsError, + `⚠️ Refund cascade failed for payment ${paymentId}:`, + cascadeError, ); + throw cascadeError; } + // Referral-credit restoration is NOT part of the cascade (v2 referral + // ledger) — keep it here so refunded credit-funded bookings still restore. try { const restored = await reverseCreditsForPayment( paymentId, @@ -199,6 +942,7 @@ export async function handleRefundCreated( await runRefundSideEffects( payment.id, status, + existingRefund.id, amount, payment.amount, ); @@ -208,21 +952,30 @@ export async function handleRefundCreated( } // Create new refund record - await tx.refund.create({ + const createdRefund = await tx.refund.create({ data: { - amount, - currency, + amountPaise: amount, + // #781 §A — gateway hands back a free-form ISO code; an unsupported + // one throws here and dead-letters the event rather than booking it. + currency: toCurrencyEnum(currency), status: mapRefundStatus(status), refundId, paymentGateway: gateway, paymentId: payment.id, }, + select: { id: true }, }); console.log(`✅ Refund ${refundId} created for payment ${payment.id}`); // Run side effects for new refunds that are already SUCCEEDED - await runRefundSideEffects(payment.id, status, amount, payment.amount); + await runRefundSideEffects( + payment.id, + status, + createdRefund.id, + amount, + payment.amount, + ); // --- Novu notification (fire-and-forget) --- void notifyRefundProcessed(payment.userId, { @@ -328,8 +1081,8 @@ export async function handleDisputeCreated( // Create dispute record await tx.dispute.create({ data: { - amount, - currency, + amountPaise: amount, + currency: toCurrencyEnum(currency), reason, status: mapDisputeStatus(status), disputeId, @@ -376,100 +1129,317 @@ export async function handleDisputeUpdated( status: string, evidence: Record | null, ) { - return await prisma.$transaction(async (tx) => { - const dispute = await tx.dispute.findUnique({ - where: { disputeId }, - }); - - if (!dispute) { - console.warn(`Dispute not found: ${disputeId}`); - return; - } - - const mappedStatus = mapDisputeStatus(status); + // #785 — Serializable so SSI detects a refund racing this lost-chargeback on + // the same payment: refundPayment (also Serializable) reads disputes + writes + // a Refund row while applyOrgChargeback below reads refunds + writes the + // dispute, so an interleaving forms a dangerous rw-structure and one tx aborts + // (retried by the gateway webhook redelivery) instead of both reversing the + // org for the same money. + return await prisma.$transaction( + async (tx) => { + const dispute = await tx.dispute.findUnique({ + where: { disputeId }, + // #738-B — payment amount/TCS needed for the lost-dispute tax parity. + include: { + payment: { + select: { id: true, amount: true, gstTcsCollectedPaise: true }, + }, + }, + }); - await tx.dispute.update({ - where: { disputeId }, - data: { - status: mappedStatus, - ...(evidence && { evidence: evidence as Prisma.InputJsonValue }), - updatedAt: new Date(), - }, - }); + if (!dispute) { + console.warn(`Dispute not found: ${disputeId}`); + return; + } - console.log(`✅ Dispute ${disputeId} updated to status ${mappedStatus}`); + const mappedStatus = mapDisputeStatus(status); - // M1 FIX: Release or refund earnings based on dispute resolution - if (mappedStatus === "WON" || mappedStatus === "WARNING_CLOSED") { - // Dispute resolved in platform's favor — release held earnings back to READY - const released = await tx.consultantEarnings.updateMany({ - where: { paymentId: dispute.paymentId, status: "HELD" }, - data: { status: "READY" }, - }); - if (released.count > 0) { - console.log(`🔓 ${released.count} earnings released — dispute ${disputeId} won`); + // #776 — skip a redelivered no-op so the resolution side effects below (earnings + // flips, applyOrgChargeback) don't re-run on a webhook retry. + if (dispute.status === mappedStatus) { + console.log(`Dispute ${disputeId} already ${mappedStatus} — no-op`); + return; } - } else if (mappedStatus === "LOST" || mappedStatus === "CHARGE_REFUNDED") { - // Dispute lost — mark held earnings as REFUNDED, accounting for partial refunds - const heldEarnings = await tx.consultantEarnings.findMany({ - where: { paymentId: dispute.paymentId, status: "HELD" }, - select: { - id: true, - consultantShare: true, - refundedShareAmount: true, - consultantProfileId: true, + // #776 — reject illegal transitions, most importantly re-driving a TERMINAL + // verdict (WON/LOST/CHARGE_REFUNDED). Log + skip rather than corrupt the state + // machine on a delayed/out-of-order gateway delivery. + if (!isLegalDisputeTransition(dispute.status, mappedStatus)) { + console.warn( + `Illegal dispute transition ${dispute.status} → ${mappedStatus} for ${disputeId} — skipping`, + ); + return; + } + + await tx.dispute.update({ + where: { disputeId }, + data: { + status: mappedStatus, + ...(evidence && { evidence: evidence as Prisma.InputJsonValue }), + updatedAt: new Date(), }, }); - for (const earning of heldEarnings) { - const alreadyRefunded = earning.refundedShareAmount ?? 0; - const remainingRefundable = Math.max( - earning.consultantShare - alreadyRefunded, - 0, - ); - await tx.consultantEarnings.update({ - where: { id: earning.id }, - data: { - status: "REFUNDED", - ...(remainingRefundable > 0 - ? { refundedShareAmount: { increment: remainingRefundable } } - : {}), + console.log(`✅ Dispute ${disputeId} updated to status ${mappedStatus}`); + + // M1 FIX: Release or refund earnings based on dispute resolution + if (mappedStatus === "WON" || mappedStatus === "WARNING_CLOSED") { + // Dispute resolved in platform's favor — release held earnings back to READY + const released = await tx.consultantEarnings.updateMany({ + where: { paymentId: dispute.paymentId, status: "HELD" }, + data: { status: "READY" }, + }); + if (released.count > 0) { + console.log( + `🔓 ${released.count} earnings released — dispute ${disputeId} won`, + ); + } + } else if ( + mappedStatus === "LOST" || + mappedStatus === "CHARGE_REFUNDED" + ) { + // Dispute lost — mark held earnings as REFUNDED, accounting for partial refunds + const heldEarnings = await tx.consultantEarnings.findMany({ + where: { paymentId: dispute.paymentId, status: "HELD" }, + select: { + id: true, + consultantSharePaise: true, + refundedShareAmount: true, + consultantProfileId: true, + payoutId: true, }, }); + for (const earning of heldEarnings) { + const alreadyRefunded = earning.refundedShareAmount ?? 0; + const remainingRefundable = Math.max( + earning.consultantSharePaise - alreadyRefunded, + 0, + ); - if (remainingRefundable > 0) { - await tx.consultantProfile.update({ - where: { id: earning.consultantProfileId }, - data: { pendingRevenue: { decrement: remainingRefundable } }, + await tx.consultantEarnings.update({ + where: { id: earning.id }, + data: { + status: "REFUNDED", + ...(remainingRefundable > 0 + ? { refundedShareAmount: { increment: remainingRefundable } } + : {}), + }, }); + + // #738-B — statutory parity with the refund path: withholding that + // was deposited against a now-charged-back sale must net out of the + // next quarter's return. The shared helper's dedup cap prevents a + // double reversal when an app refund preceded the chargeback. + if (earning.payoutId) { + await recordTdsReversal(tx, { + payoutId: earning.payoutId, + consultantProfileId: earning.consultantProfileId, + earningsId: earning.id, + refundAmountPaise: dispute.amountPaise, + paymentAmountPaise: dispute.payment.amount, + }); + } + + console.log( + `💸 Earnings ${earning.id} refunded (${remainingRefundable} paise) — dispute ${disputeId} lost`, + ); } - console.log(`💸 Earnings ${earning.id} refunded (${remainingRefundable} paise) — dispute ${disputeId} lost`); - } - } - // --- Novu notification for resolved disputes (fire-and-forget) --- - const resolvedStatuses = [ - "WON", - "LOST", - "CHARGE_REFUNDED", - "WARNING_CLOSED", - ]; - if (resolvedStatuses.includes(mappedStatus)) { - const disputePayment = await tx.payment.findUnique({ - where: { id: dispute.paymentId }, - }); + // #776 §C — org-funded chargeback money-path. When the disputed booking + // was org-funded, the funder (the org) bears the chargeback, not the + // platform: debit the org wallet, falling back to an ORG_RECEIVABLE the + // dunning flow pursues if the wallet can't cover it. + const disputedPayment = await tx.payment.findUnique({ + where: { id: dispute.paymentId }, + select: { + id: true, + organizationId: true, + billingAccountId: true, + amount: true, + }, + }); + if (disputedPayment?.organizationId) { + await applyOrgChargeback(tx, { + paymentId: disputedPayment.id, + organizationId: disputedPayment.organizationId, + billingAccountId: disputedPayment.billingAccountId, + amountPaise: dispute.amountPaise, + disputeId, + }); + } - if (disputePayment) { - void notifyDisputeResolved([disputePayment.userId], { - disputeId, - amount: dispute.amount, - currency: dispute.currency, - reason: dispute.reason || undefined, - status: mappedStatus, - dashboardUrl: `${getAppUrl()}/dashboard`, + // #738-B — GST parity with the refund path: a lost chargeback reverses + // the sale, so the issued invoice needs a Sec 34 credit note exactly + // like a refund would. Idempotent on CreditNote.disputeId; no-op for + // non-invoiced (B2C card) payments. + await mintRefundCreditNote(tx, { + paymentId: dispute.paymentId, + disputeId: dispute.id, + amountPaise: dispute.amountPaise, + reason: `chargeback lost (dispute ${disputeId})`, + }); + + // #738-B — TCS u/s 52 parity: if collection ever stamped this payment + // (flag-gated, schema-live), the chargeback must net it out of the + // next GSTR-8. Inert while gstTcsCollectedPaise stays null. + if ((dispute.payment.gstTcsCollectedPaise ?? 0) > 0) { + const tcsReverse = Math.floor( + (dispute.payment.gstTcsCollectedPaise! * dispute.amountPaise) / + dispute.payment.amount, + ); + if (tcsReverse > 0) { + await tx.gstTcsAdjustment.create({ + data: { + paymentId: dispute.paymentId, + amountPaise: -tcsReverse, + reason: `chargeback lost (dispute ${disputeId})`, + }, + }); + } + } + } + + // --- Novu notification for resolved disputes (fire-and-forget) --- + const resolvedStatuses = [ + "WON", + "LOST", + "CHARGE_REFUNDED", + "WARNING_CLOSED", + ]; + if (resolvedStatuses.includes(mappedStatus)) { + const disputePayment = await tx.payment.findUnique({ + where: { id: dispute.paymentId }, }); + + if (disputePayment) { + void notifyDisputeResolved([disputePayment.userId], { + disputeId, + amount: dispute.amountPaise, + currency: dispute.currency, + reason: dispute.reason || undefined, + status: mappedStatus, + dashboardUrl: `${getAppUrl()}/dashboard`, + }); + } } + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); +} + +/** + * #776 §C — settle a lost chargeback on an org-funded booking. The org funded + * the booking, so the org bears the chargeback: debit its wallet. If the wallet + * can't cover it (or there's no wallet), record the amount as an ORG_RECEIVABLE + * the dunning flow pursues. Either way the platform's CASH (pulled by the bank) + * is balanced by the org side. Idempotent on `chargeback:`. + */ +async function applyOrgChargeback( + tx: Tx, + params: { + paymentId: string; + organizationId: string; + billingAccountId: string | null; + amountPaise: number; + disputeId: string; + }, +): Promise { + const { organizationId, billingAccountId, amountPaise, disputeId } = params; + if (amountPaise <= 0) return; + + // Idempotent on the chargeback key. `walletDebit` is NOT keyed on the dispute, + // but the ledger post below is — so a webhook retry would re-debit the wallet + // while posting the ledger only once (drift + double-charge). If the + // chargeback ledger txn already exists, this is a replay: skip all mutations. + const alreadyPosted = await tx.ledgerTransaction.findUnique({ + where: { idempotencyKey: `chargeback:${disputeId}` }, + select: { id: true }, + }); + if (alreadyPosted) return; + + // #785 — net against money already reversed by an app refund on this payment. + // A refund and a lost chargeback are two routes to the same "customer got the + // money back"; without this the org is debited twice (refund: reverses the + // funding AND chargeback: debits again). Settle only the un-reversed + // remainder so the disputed amount hits the org's books exactly once. + // Net only against SUCCEEDED refunds: a PENDING refund hasn't moved money and + // may yet FAIL — netting against it would leave the org permanently + // under-debited (the idempotent chargeback post never recomputes). App refunds + // commit straight to SUCCEEDED, so this loses nothing for them; the concurrent + // mid-flight refund case is handled by the Serializable guard in + // handleDisputeUpdated. + const priorRefundAgg = await tx.refund.aggregate({ + where: { + paymentId: params.paymentId, + status: { in: ["SUCCEEDED"] }, + }, + _sum: { amountPaise: true }, + }); + // #780 — _sum bypasses the result extension: bigint until sumPaise'd. + const settlePaise = Math.max( + 0, + amountPaise - sumPaise(priorRefundAgg._sum.amountPaise), + ); + if (settlePaise <= 0) { + console.log( + `[Webhook] Chargeback ${disputeId} fully covered by prior refund(s) — no additional org debit`, + ); + return; + } + + let recoveredFromWallet = false; + if (billingAccountId) { + try { + await walletDebit(tx, { + billingAccountId, + amountPaise: settlePaise, + reason: "ADJUSTMENT", + paymentId: params.paymentId, + notes: `Chargeback recovery: dispute ${disputeId}`, + }); + recoveredFromWallet = true; + } catch (err) { + if (!(err instanceof WalletInsufficientFundsError)) throw err; + // Insufficient balance — fall through to the receivable path. } + } + + // Balanced counter-post: the bank pulled CASH; recover it from the funder. + await postLedgerTxn(tx, { + idempotencyKey: `chargeback:${disputeId}`, + kind: "REFUND", + paymentId: params.paymentId, + postings: [ + { + account: recoveredFromWallet + ? { kind: "WALLET", organizationId } + : { kind: "ORG_RECEIVABLE", organizationId }, + direction: "DEBIT", + amountPaise: settlePaise, + }, + { + account: { kind: "CASH" }, + direction: "CREDIT", + amountPaise: settlePaise, + }, + ], + }); + + await tx.orgAuditLog.create({ + data: { + organizationId, + actorMembershipId: null, + category: "INVOICE", + action: AUDIT_ACTIONS.INVOICE.INVOICE_REFUNDED, + description: `Chargeback ${disputeId} settled: ${amountPaise} paise ${ + recoveredFromWallet ? "debited from wallet" : "booked as receivable" + }`, + details: { + disputeId, + paymentId: params.paymentId, + amountPaise, + recoveredFromWallet, + } as Prisma.InputJsonValue, + }, }); } @@ -641,7 +1611,20 @@ export async function markWebhookEventProcessed( // ============================================================================ /** - * Handle RazorpayX payout webhook events + * Handle RazorpayX payout webhook events. + * + * A1+A8 dispatcher: try the OrganizationPayout reconciler first (look up + * by `gatewayPayoutId`). On hit, route to the org-payout state machine + * (`markOrgPayoutCompleted` / `markOrgPayoutFailed` / `markOrgPayoutReversed`) + * which already handles audit log + earnings release + Novu fire. + * + * On miss (no matching OrganizationPayout), fall through to the + * consultant-payout path (`handlePayoutWebhook`). The consultant path + * already soft-skips orphan IDs and returns 200 to prevent gateway + * retry storms. + * + * Idempotency: the per-payout helpers themselves only progress rows in + * the expected source state — duplicate webhook deliveries are a no-op. */ export async function handleRazorpayPayoutWebhook( eventType: string, @@ -649,9 +1632,69 @@ export async function handleRazorpayPayoutWebhook( id: string; status: string; failure_reason?: string; + utr?: string; }, ): Promise { - // Map RazorpayX status to our internal status + // First: is this an OrganizationPayout? Look up by gatewayPayoutId. + // Imported lazily to avoid a circular import (org-payout-service -> + // org-workflows -> ... -> webhooks/utils when it grows). + const { default: prismaClient } = await import("@/lib/prisma"); + const orgPayout = await prismaClient.organizationPayout.findUnique({ + where: { gatewayPayoutId: payoutData.id }, + select: { id: true, status: true, organizationId: true }, + }); + + if (orgPayout) { + const { + markOrgPayoutCompleted, + markOrgPayoutFailed, + markOrgPayoutReversed, + } = await import("@/lib/payments/payouts"); + + switch (eventType) { + case "payout.processed": { + // Persist the bank UTR before flipping to COMPLETED so the + // notification + audit log have the canonical reference. + if (payoutData.utr) { + await prismaClient.organizationPayout.update({ + where: { id: orgPayout.id }, + data: { gatewayUtr: payoutData.utr }, + }); + } + await markOrgPayoutCompleted(orgPayout.id); + break; + } + case "payout.failed": + case "payout.rejected": { + await markOrgPayoutFailed( + orgPayout.id, + payoutData.failure_reason ?? "RazorpayX failure", + ); + break; + } + case "payout.reversed": { + await markOrgPayoutReversed( + orgPayout.id, + payoutData.failure_reason ?? "RazorpayX reversal", + ); + break; + } + // queued / initiated / pending / cancelled — informational only; + // the row already sits in PROCESSING and we wait for the terminal + // event. No state change here. + default: + console.log( + `[orgPayoutWebhook] non-terminal event ${eventType} for ${orgPayout.id} — no-op`, + ); + } + console.log( + `✅ Org payout ${orgPayout.id} (gateway=${payoutData.id}) webhook ${eventType} processed`, + ); + return; + } + + // Fall through to the consultant-payout path. Map RazorpayX status to + // our internal enum. const statusMap: Record< string, "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED" | "CANCELLED" @@ -667,6 +1710,26 @@ export async function handleRazorpayPayoutWebhook( const status = statusMap[payoutData.status] || "PENDING"; + // #813/#812 — a `payout.reversed` for an ALREADY-COMPLETED consultant payout + // must post the inverse journal + re-open earnings, mirroring the org branch. + // Attempt the reversal first; it no-ops via its COMPLETED claim if the payout + // hasn't settled yet, in which case we fall through to the FAILED mapping + // (handlePayoutWebhook only claims non-terminal rows, so no double-handling). + if (eventType === "payout.reversed") { + const { markConsultantPayoutReversed } = + await import("@/lib/payments/payouts"); + const { wasNoOp } = await markConsultantPayoutReversed( + payoutData.id, + payoutData.failure_reason ?? "RazorpayX reversal", + ); + if (!wasNoOp) { + console.log( + `✅ RazorpayX consultant payout ${payoutData.id} reversed after completion`, + ); + return; + } + } + await handlePayoutWebhook( PaymentGateway.RAZORPAY, payoutData.id, @@ -675,7 +1738,7 @@ export async function handleRazorpayPayoutWebhook( ); console.log( - `✅ RazorpayX payout ${payoutData.id} webhook processed: ${status}`, + `✅ RazorpayX consultant payout ${payoutData.id} webhook processed: ${status}`, ); } @@ -715,4 +1778,3 @@ export async function handleStripePayoutWebhook( console.log(`✅ Stripe payout ${payoutData.id} webhook processed: ${status}`); } - diff --git a/app/api/webhooks/xflow/route.ts b/app/api/webhooks/xflow/route.ts index ccd75bd33..b2b39fa08 100644 --- a/app/api/webhooks/xflow/route.ts +++ b/app/api/webhooks/xflow/route.ts @@ -1,15 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -import crypto from "crypto"; -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { Prisma, PaymentStatus, RequestStatus } from "@prisma/client"; -import { isDbHealthy } from "@/app/api/webhooks/utils"; +import { + isDbHealthy, + verifyHmacWebhookSignature, +} from "@/app/api/webhooks/utils"; export async function POST(req: NextRequest) { try { - const body = await req.text(); - const signature = req.headers.get("x-xflow-signature"); - - if (!process.env.XFLOW_WEBHOOK_SECRET) { + // #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" }, @@ -17,20 +18,21 @@ export async function POST(req: NextRequest) { ); } - // Verify webhook signature for XFlow - if (signature) { - const expectedSignature = crypto - .createHmac("sha256", process.env.XFLOW_WEBHOOK_SECRET) - .update(body) - .digest("hex"); - - if (signature !== expectedSignature) { - console.error("XFlow webhook signature verification failed"); - return NextResponse.json( - { error: "Invalid signature" }, - { status: 400 }, - ); - } + // #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 @@ -245,9 +247,16 @@ async function handleXFlowPaymentFailure(paymentId: string) { // Helper function to create appointment from payment record async function createAppointmentFromPayment( - tx: Prisma.TransactionClient, + tx: Tx, payment: { id: string; userId: string; [key: string]: unknown }, - metadata: { type?: string; planId?: string; eventId?: string; slotIds?: string; title?: string; description?: string }, + 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; @@ -381,7 +390,7 @@ async function createAppointmentFromPayment( } // Helper function to confirm existing appointment -async function confirmExistingAppointment(tx: Prisma.TransactionClient, appointmentId: string) { +async function confirmExistingAppointment(tx: Tx, appointmentId: string) { // Make slots non-tentative await tx.slotOfAppointment.updateMany({ where: { appointmentId }, @@ -429,7 +438,7 @@ async function confirmExistingAppointment(tx: Prisma.TransactionClient, appointm } // Helper function to cleanup failed payment appointments -async function cleanupFailedPaymentAppointment(tx: Prisma.TransactionClient, appointmentId: string) { +async function cleanupFailedPaymentAppointment(tx: Tx, appointmentId: string) { const appointment = await tx.appointment.findUnique({ where: { id: appointmentId }, include: { diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 7fe1be143..002b3e70b 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import { signIn, useSession } from "@/lib/auth-client"; +import { ssoSigninWithGuard } from "@/lib/sso/signin-with-toast"; import { GlobeIcon } from "@/components/auth/auth-icons"; import { SocialLoginButtons } from "@/components/auth/social-login-buttons"; import Link from "next/link"; @@ -34,6 +35,12 @@ function SignInContent() { const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [callbackUrl, setCallbackUrl] = useState(null); + const [ssoCheck, setSsoCheck] = useState<{ + enforceSSO: boolean; + organizationName: string; + ssoBody: { providerId: string; domain: string; callbackURL: string }; + } | null>(null); + const [ssoChecking, setSsoChecking] = useState(false); useEffect(() => { const url = searchParams.get("callbackUrl"); @@ -75,6 +82,138 @@ function SignInContent() { ); } + const handleEmailBlur = async () => { + if (!email || !email.includes("@")) return; + setSsoChecking(true); + try { + const res = await fetch(`/api/auth/sso/domain-check?email=${encodeURIComponent(email)}`); + if (res.ok) { + const data = await res.json(); + // Why: the domain-check route returns `providerMisconfigured: true` + // when the stored SAML cert fails parse. Surface a friendly toast + // and leave `ssoCheck` null so the user falls back to credentials + // (which they may also have for legacy reasons). Without this + // branch, clicking "Sign in with SSO" would crash BetterAuth and + // present a blank 500. + if (data.enforceSSO && data.providerMisconfigured) { + toast({ + title: "Single sign-on is misconfigured", + description: + "Your SSO provider's certificate is invalid. Contact your IT admin to re-paste the X.509 PEM.", + variant: "destructive", + }); + setSsoCheck(null); + } else { + setSsoCheck(data.enforceSSO ? { + enforceSSO: true, + organizationName: data.organizationName, + ssoBody: data.ssoBody, + } : null); + } + } + } catch { + // ignore — fall through to normal login + } finally { + setSsoChecking(false); + } + }; + + const handleSSOSignIn = async () => { + if (!ssoCheck) return; + // Use the guarded wrapper around signIn.sso() so the call goes + // through the ssoClient plugin (OIDC PKCE verifier persists before + // the IdP redirect) AND so failure modes surface as toasts instead + // of silent dead-ends. See `lib/sso/signin-with-toast.ts` for the + // three failure modes this guards: BetterAuth's resolve-with-error + // shape, 500-with-empty-body crashes, and no-redirect-after-2s. + // Audit Phase B.1. + const result = await ssoSigninWithGuard({ + providerId: ssoCheck.ssoBody.providerId, + domain: ssoCheck.ssoBody.domain, + callbackURL: ssoCheck.ssoBody.callbackURL, + }); + if (!result.ok && result.errorMessage) { + toast({ + title: "SSO sign-in failed", + description: result.errorMessage, + variant: "destructive", + }); + } + }; + + // Manual SSO trigger for IT admins testing their setup before enforcement + // is turned on. Same domain-check logic as the email blur handler, but + // immediately fires the redirect if a provider is found. + const handleManualSSOClick = async () => { + if (!email || !email.includes("@")) { + toast({ + title: "Enter your work email first", + description: "Type your corporate email address above, then try again.", + }); + return; + } + setSsoChecking(true); + try { + const res = await fetch(`/api/auth/sso/domain-check?email=${encodeURIComponent(email)}`); + if (!res.ok) throw new Error("check failed"); + const data = await res.json(); + // Same misconfigured-cert short-circuit as the blur handler — see + // its comment above for the failure mode this guards against. + if (data.enforceSSO && data.providerMisconfigured) { + toast({ + title: "Single sign-on is misconfigured", + description: + "Your SSO provider's certificate is invalid. Contact your IT admin to re-paste the X.509 PEM.", + variant: "destructive", + }); + return; + } + if (data.enforceSSO) { + setSsoCheck({ enforceSSO: true, organizationName: data.organizationName, ssoBody: data.ssoBody }); + const result = await ssoSigninWithGuard({ + providerId: data.ssoBody.providerId, + domain: data.ssoBody.domain, + callbackURL: data.ssoBody.callbackURL, + }); + if (!result.ok && result.errorMessage) { + toast({ + title: "SSO sign-in failed", + description: result.errorMessage, + variant: "destructive", + }); + } + } else { + toast({ + title: "No SSO provider found", + description: "No corporate SSO is configured for this email domain. Contact your IT admin.", + variant: "destructive", + }); + } + } catch { + toast({ + title: "SSO check failed", + description: "Could not verify SSO for this domain. Please try again.", + variant: "destructive", + }); + } finally { + setSsoChecking(false); + } + }; + + const friendlyAuthError = (raw: string | undefined): string => { + if (!raw) return "Invalid email or password."; + const lower = raw.toLowerCase(); + if (lower.includes("email") && (lower.includes("invalid") || lower.includes("required"))) + return "Please enter a valid email address."; + if (lower.includes("password") && (lower.includes("too small") || lower.includes(">=") || lower.includes("required"))) + return "Please enter your password."; + if (lower.includes("invalid") && lower.includes("credentials")) + return "Invalid email or password."; + if (lower.includes("not found") || lower.includes("no user")) + return "No account found with this email. Check the address or sign up."; + return raw.replace(/\[body\.\w+\]\s*/g, "").trim() || "Invalid email or password."; + }; + const handleEmailSignIn = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); @@ -89,7 +228,7 @@ function SignInContent() { if (error) { toast({ title: "Sign In Failed", - description: error.message || "Invalid email or password.", + description: friendlyAuthError(error.message), variant: "destructive", }); } else if (data) { @@ -143,6 +282,13 @@ function SignInContent() {

Sign in to your account

+ {searchParams.get("sso_required") === "1" && ( +
+

+ Your organization requires SSO sign-in. +

+
+ )}

Enter your email and password below to sign in.

@@ -158,52 +304,72 @@ function SignInContent() { autoCorrect="off" value={email} onChange={(e) => setEmail(e.target.value)} + onBlur={handleEmailBlur} required - disabled={isLoading} + disabled={isLoading || ssoChecking} /> -
-
- - - Forgot password? - + {!ssoCheck?.enforceSSO && ( +
+
+ + + Forgot password? + +
+ setPassword(e.target.value)} + required + disabled={isLoading} + />
- setPassword(e.target.value)} - required + )} + {ssoCheck?.enforceSSO ? ( + + ) : ( +
- + > + {isLoading ? "Signing In..." : "Sign In with Email"} + + )} -
-
-
-
-
- - OR CONTINUE WITH - -
-
- + {!ssoCheck?.enforceSSO && ( + <> +
+
+
+
+
+ + OR CONTINUE WITH + +
+
+ + + )}

Don't have an account?{" "} (null); + const [ssoChecking, setSsoChecking] = useState(false); + + // Build onboarding URL with optional callbackUrl passthrough (for org invite flow) + const onboardingUrl = callbackUrl + ? `/form/onboarding?callbackUrl=${encodeURIComponent(callbackUrl)}` + : "/form/onboarding"; // Redirect authenticated users based on onboarding status useEffect(() => { if (!isPending && session?.user) { if (session.user.onboardingCompleted) { - router.push("/dashboard"); + // If there's a callbackUrl (e.g., from an invite link), honor it + if (callbackUrl?.startsWith("/") && !callbackUrl.startsWith("//")) { + router.push(callbackUrl); + } else { + router.push("/dashboard"); + } } else { - router.push("/form/onboarding"); + router.push(onboardingUrl); } } - }, [session, isPending, router]); + }, [session, isPending, router, callbackUrl, onboardingUrl]); // Show loading while checking session status (fallback for when middleware doesn't catch) if (isPending) { @@ -70,6 +88,66 @@ function SignUpContent() { ); } + const handleEmailBlur = async () => { + if (!email || !email.includes("@")) return; + setSsoChecking(true); + try { + const res = await fetch(`/api/auth/sso/domain-check?email=${encodeURIComponent(email)}`); + if (res.ok) { + const data = await res.json(); + setSsoCheck(data.enforceSSO ? { + enforceSSO: true, + organizationName: data.organizationName, + ssoBody: data.ssoBody, + } : null); + } + } catch { + // ignore — fall through to normal signup + } finally { + setSsoChecking(false); + } + }; + + /** + * Translate BetterAuth's developer-facing validation errors into + * user-friendly messages. Raw errors look like: + * "[body.email] Invalid email address; [body.password] Too small: ..." + */ + const handleSSOSignIn = async () => { + if (!ssoCheck) return; + // Use the guarded wrapper around signIn.sso() so SSO failures + // (resolve-with-error, 500-with-empty-body, no-redirect-after-2s) + // surface as a destructive toast instead of a silent dead-end on + // the signup form. See `lib/sso/signin-with-toast.ts` + audit B.1. + const result = await ssoSigninWithGuard({ + providerId: ssoCheck.ssoBody.providerId, + domain: ssoCheck.ssoBody.domain, + callbackURL: ssoCheck.ssoBody.callbackURL, + }); + if (!result.ok && result.errorMessage) { + toast({ + title: "SSO sign-in failed", + description: result.errorMessage, + variant: "destructive", + }); + } + }; + + const friendlyAuthError = (raw: string | undefined): string => { + if (!raw) return "An unexpected error occurred. Please try again."; + const lower = raw.toLowerCase(); + const issues: string[] = []; + if (lower.includes("email") && (lower.includes("invalid") || lower.includes("required"))) + issues.push("Please enter a valid email address."); + if (lower.includes("password") && (lower.includes("too small") || lower.includes(">=") || lower.includes("required"))) + issues.push("Password must be at least 8 characters."); + if (lower.includes("already") || lower.includes("exists")) + return "An account with this email already exists. Try signing in instead."; + if (issues.length > 0) return issues.join(" "); + // Strip "[body.field]" prefixes for anything we didn't catch + return raw.replace(/\[body\.\w+\]\s*/g, "").trim() || "An unexpected error occurred."; + }; + const handleSignUp = async (e: React.FormEvent) => { e.preventDefault(); if (password !== confirmPassword) { @@ -89,7 +167,7 @@ function SignUpContent() { if (error) { toast({ title: "Sign Up Failed", - description: error.message || "An unexpected error occurred.", + description: friendlyAuthError(error.message), variant: "destructive", }); } else if (data) { @@ -109,7 +187,7 @@ function SignUpContent() { title: "Account Created Successfully!", description: "Redirecting to onboarding...", }); - router.push("/form/onboarding"); + router.push(onboardingUrl); } } catch (error: unknown) { console.error("Sign up error:", error); @@ -186,35 +264,40 @@ function SignUpContent() { autoCorrect="off" value={email} onChange={(e) => setEmail(e.target.value)} + onBlur={handleEmailBlur} required - disabled={isLoading} - /> -

-
- - setPassword(e.target.value)} - required - disabled={isLoading} - /> -
-
- - setConfirmPassword(e.target.value)} - required - disabled={isLoading} + disabled={isLoading || ssoChecking} />
- {!referralCode && ( + {!ssoCheck?.enforceSSO && ( + <> +
+ + setPassword(e.target.value)} + required + disabled={isLoading} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + disabled={isLoading} + /> +
+ + )} + {!referralCode && !ssoCheck?.enforceSSO && (
)} - {referralCode && ( + {referralCode && !ssoCheck?.enforceSSO && (

Referral code{" "} @@ -236,31 +319,53 @@ function SignUpContent() {

)} - + {!ssoCheck?.enforceSSO && ( + + )} -
-
-
-
-
- - OR CONTINUE WITH - + {ssoCheck?.enforceSSO && ( +
+

+ Your organization requires SSO sign-in. Use the button below to authenticate. +

+
-
+ )} + + {!ssoCheck?.enforceSSO && ( + <> +
+
+
+
+
+ + OR CONTINUE WITH + +
+
- + + + )}

Already have an account?{" "} diff --git a/app/checkout/components/IncludedWeeklyCalls.tsx b/app/checkout/components/IncludedWeeklyCalls.tsx deleted file mode 100644 index 731bad928..000000000 --- a/app/checkout/components/IncludedWeeklyCalls.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; - -import React, { useMemo } from "react"; -import { getWeeklyCallCount } from "@/utils/subscription"; - -function addOneMonth(date: Date): Date { - const d = new Date(date); - const month = d.getMonth(); - d.setMonth(month + 1); - // Handle cases where adding a month overflows (e.g., Jan 31 -> Mar 3) - if (d.getMonth() === (month + 2) % 12) { - d.setDate(0); // move to last day of previous month - } - return d; -} - -type IncludedWeeklyCallsProps = Readonly<{ - startDate?: Date | string; - endDate?: Date | string; - className?: string; - minWeeks?: number; // optional UI guardrail - maxWeeks?: number; // optional UI guardrail -}>; - -export default function IncludedWeeklyCalls({ - startDate, - endDate, - className, - minWeeks = 1, - maxWeeks = 6, -}: IncludedWeeklyCallsProps) { - const { calls, error, warnings } = useMemo(() => { - // Resolve dates - const resolvedStart = startDate ? new Date(startDate) : new Date(); - const resolvedEnd = endDate - ? new Date(endDate) - : addOneMonth(resolvedStart); - - // Basic validity checks - if (isNaN(resolvedStart.getTime()) || isNaN(resolvedEnd.getTime())) { - return { - calls: 0, - error: "Invalid start or end date.", - warnings: [] as string[], - }; - } - - if (resolvedEnd < resolvedStart) { - return { - calls: 0, - error: "End date cannot be earlier than start date.", - warnings: [] as string[], - }; - } - - let calls: number; - try { - calls = getWeeklyCallCount(resolvedStart, resolvedEnd); - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Unable to compute weekly calls."; - return { - calls: 0, - error: message, - warnings: [] as string[], - }; - } - - const warnings: string[] = []; - if (calls < minWeeks) { - warnings.push( - `Selected range is too short. At least ${minWeeks} week(s) expected.`, - ); - } - if (calls > maxWeeks) { - warnings.push( - `Selected range is quite long. Typically ${maxWeeks} week(s) max for a monthly plan.`, - ); - } - - return { calls, error: "", warnings }; - }, [startDate, endDate, minWeeks, maxWeeks]); - - if (error) { - return ( -

-

{error}

-
- ); - } - - return ( -
-

- Included weekly calls:{" "} - {calls} -

-

- Weeks counted from the Sunday of the start week to the Saturday of the - end week. -

- {warnings.length > 0 && ( -
    - {warnings.map((w) => ( -
  • {w}
  • - ))} -
- )} -
- ); -} diff --git a/app/checkout/components/LemonSqueezyCheckout.tsx b/app/checkout/components/LemonSqueezyCheckout.tsx deleted file mode 100644 index 25cc74f8d..000000000 --- a/app/checkout/components/LemonSqueezyCheckout.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; -import { CheckoutInput, checkoutResponseSchema } from "@/schemas/checkout"; - -interface LemonSqueezyCheckoutProps { - input: CheckoutInput; - onSuccess: () => void; - onError: (error: string) => void; -} - -// Lemon Squeezy checkout configuration -interface LemonSqueezyEventData { - message?: string; -} - -declare global { - interface Window { - createLemonSqueezy: () => { - Setup: (options: { - eventHandler: (event: { event: string; data?: LemonSqueezyEventData }) => void; - }) => void; - Url: { - Open: (checkoutUrl: string) => void; - }; - }; - } -} - -const LemonSqueezyCheckout: React.FC = ({ - input, - onSuccess, - onError, -}) => { - const [isProcessing, setIsProcessing] = useState(false); - const { toast } = useToast(); - - const loadLemonSqueezyScript = (): Promise => { - return new Promise((resolve, reject) => { - // Check if Lemon Squeezy is already loaded - if (typeof window.createLemonSqueezy === "function") { - resolve(true); - return; - } - - const script = document.createElement("script"); - script.src = "https://app.lemonsqueezy.com/js/lemon.js"; - script.onload = () => resolve(true); - script.onerror = () => - reject(new Error("Failed to load Lemon Squeezy SDK")); - document.body.appendChild(script); - }); - }; - - const handleLemonSqueezyCheckout = async () => { - try { - setIsProcessing(true); - - // Load Lemon Squeezy SDK - await loadLemonSqueezyScript(); - - // Make API call to create checkout session - const response = await fetch("/api/checkout", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...input, - paymentGateway: "LEMON_SQUEEZY", - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.message || "Failed to create checkout session", - ); - } - - const data = await response.json(); - const validatedData = checkoutResponseSchema.parse(data); - - if (validatedData.success && validatedData.checkoutUrl) { - // Initialize Lemon Squeezy - const LemonSqueezy = window.createLemonSqueezy(); - - LemonSqueezy.Setup({ - eventHandler: (event) => { - if (event.event === "Checkout.Success") { - setIsProcessing(false); - onSuccess(); - toast({ - title: "Payment Successful", - description: "Your payment has been processed successfully!", - }); - } else if (event.event === "Checkout.Closed") { - setIsProcessing(false); - // Don't show error for user-initiated close - } else if (event.event === "Checkout.Error") { - setIsProcessing(false); - const errorMessage = event.data?.message || "Payment failed"; - onError(errorMessage); - toast({ - title: "Payment Failed", - description: errorMessage, - variant: "destructive", - }); - } - }, - }); - - // Open checkout overlay - LemonSqueezy.Url.Open(validatedData.checkoutUrl); - } else { - throw new Error("No checkout URL received from server"); - } - } catch (error) { - setIsProcessing(false); - console.error("Lemon Squeezy checkout error:", error); - - let errorMessage = "Payment processing failed"; - if (error instanceof Error) { - if (error.message.includes("Network")) { - errorMessage = - "Network error. Please check your connection and try again."; - } else if (error.message.includes("Failed to load")) { - errorMessage = "Failed to load payment processor. Please try again."; - } else if (error.message.includes("not implemented")) { - errorMessage = - "Lemon Squeezy payments are not available yet. Please try another payment method."; - } else { - errorMessage = error.message; - } - } - - onError(errorMessage); - toast({ - title: "Payment Error", - description: errorMessage, - variant: "destructive", - }); - } - }; - - const handleSkipPayment = () => { - toast({ - title: "Payment Skipped", - description: "Payment skipped for development mode", - }); - onSuccess(); - }; - - // Development mode skip payment - if ( - process.env.NODE_ENV === "development" && - process.env.NEXT_PUBLIC_SKIP_PAYMENT === "true" - ) { - return ( - - ); - } - - return ( - - ); -}; - -export default LemonSqueezyCheckout; diff --git a/app/checkout/components/OrgPayerSelector.tsx b/app/checkout/components/OrgPayerSelector.tsx new file mode 100644 index 000000000..7f4666b8b --- /dev/null +++ b/app/checkout/components/OrgPayerSelector.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useSession } from "@/lib/auth-client"; +import { useQuery } from "@tanstack/react-query"; +import Image from "next/image"; +import { Building2, CreditCard, AlertTriangle, Ban } from "lucide-react"; +import type { CoveredPlanType } from "@prisma/client"; + +interface OveragePreview { + applicable: boolean; + programName: string | null; + marginalPaise: number; + willExceedCap: boolean; + willBlock: boolean; + chargeTo: "MEMBER" | "ORG" | null; +} + +interface OrgPayerSelectorProps { + selectedOrganizationId: string | null; + onSelect: (organizationId: string | null) => void; + /** + * #777 §C — when provided, the selector previews whether billing this plan to + * the selected org will breach its cap and warns BEFORE pay. Optional so + * existing callers without plan context keep working unchanged. + */ + planType?: CoveredPlanType; + planId?: string; +} + +const inr = (paise: number) => `₹${(paise / 100).toLocaleString("en-IN")}`; + +/** #777 §C — pre-checkout overage warning for the selected sponsoring org. */ +function OverageWarning({ + organizationId, + organizationName, + planType, + planId, +}: { + organizationId: string; + organizationName: string; + planType: CoveredPlanType; + planId: string; +}) { + const { data } = useQuery({ + queryKey: ["overage-preview", organizationId, planType, planId], + queryFn: async () => { + const res = await fetch( + `/api/organizations/${organizationId}/checkout/overage-preview?planType=${planType}&planId=${planId}`, + ); + if (!res.ok) throw new Error("preview failed"); + return res.json(); + }, + staleTime: 30_000, + retry: false, + }); + + if (!data?.applicable) return null; + + if (data.willBlock) { + return ( +
+ + + This booking exceeds {organizationName}'s covered allocation and + can't be billed to the organization. Pay with your card, or ask + your admin to raise the program cap. + +
+ ); + } + + if (data.willExceedCap && data.marginalPaise > 0) { + const who = + data.chargeTo === "MEMBER" + ? `you'll be charged ${inr(data.marginalPaise)}` + : `${inr(data.marginalPaise)} will be billed to ${organizationName}`; + return ( +
+ + + This exceeds your covered allocation — {who} as an overage charge. + +
+ ); + } + + return null; +} + +/** + * Payer selector for checkout pages. Shows "Pay personally" vs "Bill to + * [org name]" when the user has org memberships. Self-hides for users + * with no org affiliations (B2C users). + * + * Each org option renders a funding-source-aware subtitle so the learner + * knows what picking that org actually costs them before they confirm: + * - PERSONAL → "You pay — the org is tagged for reporting only" + * - WALLET → "Credits: ₹X remaining" + * - INVOICE → "Added to the org's monthly invoice" + * - LICENSE → "Free — covered by the org's enterprise license" + * + * The membership shape comes straight from lib/auth.ts customSession; + * there are no `as` casts here — the Session type already carries the + * narrowed FundingSource via z.infer on the Prisma enum. + */ +export function OrgPayerSelector({ + selectedOrganizationId, + onSelect, + planType, + planId, +}: OrgPayerSelectorProps) { + const { data: session } = useSession(); + const memberships = session?.user?.organizationMemberships ?? []; + + // Only render the selector when the user has at least one org that can + // actually sponsor (canSponsor=true). A pure HOST membership wouldn't + // let the learner book through the org anyway, so showing it here + // would be misleading. + const sponsoringMemberships = memberships.filter((m) => m.canSponsor); + if (sponsoringMemberships.length === 0) return null; + + const selectedMembership = sponsoringMemberships.find( + (m) => m.organizationId === selectedOrganizationId, + ); + + return ( +
+

Who is paying?

+ + {/* Personal payment option */} + + + {/* Org payment options */} + {sponsoringMemberships.map((m) => { + const isSelected = selectedOrganizationId === m.organizationId; + const subtitle = renderSubtitle(m); + return ( + + ); + })} + + {/* #777 §C — pre-checkout overage warning for the selected org. */} + {selectedOrganizationId && + selectedMembership && + planType && + planId && ( + + )} + + {selectedOrganizationId && ( +

+ Referral credits cannot be used for org-funded bookings. +

+ )} +
+ ); +} + +/** + * Derive the cost-aware subtitle from the membership payload. Returns a + * ReactNode because wallet + "no funding source" cases want coloured + * numbers, while the rest are plain text. + */ +function renderSubtitle(m: { + fundingSource: import("@prisma/client").FundingSource | null; + walletBalance: number | null; +}): React.ReactNode { + switch (m.fundingSource) { + case "WALLET": { + const paise = m.walletBalance ?? 0; + return ( + + Credits: ₹{(paise / 100).toLocaleString("en-IN")} remaining + + ); + } + case "INVOICE": + return ( + Added to org's monthly invoice + ); + case "LICENSE": + return ( + + Free — covered by enterprise license + + ); + case "PERSONAL": + return ( + You pay — org receives the report + ); + case null: + // No billing account attached — org was set up without one, or it + // was deleted. Default to a neutral label; server-side will reject + // the org-funded checkout on validation. + return Organization billing; + } +} diff --git a/app/checkout/components/RazorpayCheckout.tsx b/app/checkout/components/RazorpayCheckout.tsx index 62b278cd2..f8d94cccc 100644 --- a/app/checkout/components/RazorpayCheckout.tsx +++ b/app/checkout/components/RazorpayCheckout.tsx @@ -5,6 +5,7 @@ import { useToast } from "@/hooks/use-toast"; import { loadScript } from "../plans/utils"; import { CheckoutInput } from "@/schemas/checkout"; import { useState } from "react"; +import { mintClientIdempotencyKey } from "@/app/checkout/plans/utils"; interface RazorpayPaymentResponse { razorpay_payment_id: string; @@ -75,6 +76,10 @@ export default function RazorpayCheckout({ }: RazorpayCheckoutProps) { const { toast } = useToast(); const [isProcessing, setIsProcessing] = useState(false); + // #828 — stable per-mount; the server dedupes retries on this key. + // useState's lazy initializer runs once, unlike a useRef(arg) expression + // which would mint a key every render. + const [idempotencyKey] = useState(mintClientIdempotencyKey); const handleCheckout = async () => { setIsProcessing(true); @@ -98,7 +103,10 @@ export default function RazorpayCheckout({ headers: { "Content-Type": "application/json", }, - body: JSON.stringify(checkoutData), + body: JSON.stringify({ + ...checkoutData, + clientIdempotencyKey: idempotencyKey, + }), }); if (!response.ok) { diff --git a/app/checkout/components/StripeCheckout.tsx b/app/checkout/components/StripeCheckout.tsx index 1006e2900..cd3c8af86 100644 --- a/app/checkout/components/StripeCheckout.tsx +++ b/app/checkout/components/StripeCheckout.tsx @@ -5,6 +5,7 @@ import { useToast } from "@/hooks/use-toast"; import { CheckoutInput, checkoutResponseSchema } from "@/schemas/checkout"; import { loadStripe } from "@stripe/stripe-js"; import { useState } from "react"; +import { mintClientIdempotencyKey } from "@/app/checkout/plans/utils"; // Initialize Stripe with publishable key const stripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY; @@ -37,6 +38,10 @@ export default function StripeCheckout({ }: StripeCheckoutProps) { const { toast } = useToast(); const [isProcessing, setIsProcessing] = useState(false); + // #828 — stable per-mount; the server dedupes retries on this key. + // useState's lazy initializer runs once, unlike a useRef(arg) expression + // which would mint a key every render. + const [idempotencyKey] = useState(mintClientIdempotencyKey); const handleCheckout = async () => { setIsProcessing(true); @@ -75,7 +80,10 @@ export default function StripeCheckout({ headers: { "Content-Type": "application/json", }, - body: JSON.stringify(checkoutData), + body: JSON.stringify({ + ...checkoutData, + clientIdempotencyKey: idempotencyKey, + }), }); console.log("Response status:", response.status); diff --git a/app/checkout/components/XFlowCheckout.tsx b/app/checkout/components/XFlowCheckout.tsx deleted file mode 100644 index 1f18010dd..000000000 --- a/app/checkout/components/XFlowCheckout.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; -import { CheckoutInput, checkoutResponseSchema } from "@/schemas/checkout"; - -interface XFlowCheckoutProps { - input: CheckoutInput; - onSuccess: () => void; - onError: (error: string) => void; -} - -// XFlow payment gateway configuration for African markets -interface XFlowCallbackResponse { - status: string; - message?: string; -} - -declare global { - interface Window { - XFlow?: { - checkout: (options: { - public_key: string; - amount: number; - currency: string; - email: string; - callback: (response: XFlowCallbackResponse) => void; - onClose: () => void; - }) => void; - }; - } -} - -const XFlowCheckout: React.FC = ({ - input, - onSuccess, - onError, -}) => { - const [isProcessing, setIsProcessing] = useState(false); - const { toast } = useToast(); - - // Load XFlow script dynamically - const loadXFlowScript = (): Promise => { - return new Promise((resolve, reject) => { - // Check if XFlow is already loaded - if (typeof window.XFlow === "object") { - resolve(true); - return; - } - - const script = document.createElement("script"); - script.src = "https://js.xflow.com/v1/xflow.js"; // Example URL - adjust based on actual XFlow SDK - script.async = true; - script.onload = () => resolve(true); - script.onerror = () => reject(new Error("Failed to load XFlow SDK")); - document.head.appendChild(script); - }); - }; - - const handleXFlowPayment = async () => { - setIsProcessing(true); - - try { - // Development mode skip payment - if ( - process.env.NODE_ENV === "development" && - process.env.NEXT_PUBLIC_SKIP_PAYMENT === "true" - ) { - toast({ - title: "Development Mode", - description: "Payment skipped in development mode", - }); - onSuccess(); - return; - } - - // Make checkout request to backend - const response = await fetch("/api/checkout", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...input, - gateway: "XFLOW", - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || "Checkout request failed"); - } - - const data = await response.json(); - const validatedData = checkoutResponseSchema.parse(data); - - if (validatedData.success && validatedData.checkoutUrl) { - // Redirect to XFlow checkout page - window.location.href = validatedData.checkoutUrl; - } else if (validatedData.success && validatedData.clientSecret) { - // Handle embedded XFlow checkout if supported - await loadXFlowScript(); - - if (window.XFlow) { - window.XFlow.checkout({ - public_key: process.env.NEXT_PUBLIC_XFLOW_PUBLIC_KEY || "", - amount: validatedData.amount || 0, - currency: validatedData.currency || "NGN", // Default to Nigerian Naira for XFlow - email: "", // Will be provided by backend in actual implementation - callback: (response) => { - if (response.status === "success") { - toast({ - title: "Payment Successful", - description: "Your payment has been processed successfully", - }); - onSuccess(); - } else { - throw new Error(response.message || "Payment failed"); - } - }, - onClose: () => { - setIsProcessing(false); - }, - }); - } else { - throw new Error("XFlow SDK not available"); - } - } else { - const errorMessage = !validatedData.success - ? validatedData.error - : "Failed to create checkout session"; - throw new Error(errorMessage); - } - } catch (error) { - console.error("XFlow checkout error:", error); - - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred during checkout"; - - toast({ - title: "Payment Error", - description: errorMessage, - variant: "destructive", - }); - - onError(errorMessage); - } finally { - setIsProcessing(false); - } - }; - - return ( - - ); -}; - -export default XFlowCheckout; diff --git a/app/checkout/plans/class/[planId]/page.tsx b/app/checkout/plans/class/[planId]/page.tsx index 72da3c405..0495b2806 100644 --- a/app/checkout/plans/class/[planId]/page.tsx +++ b/app/checkout/plans/class/[planId]/page.tsx @@ -9,7 +9,11 @@ import { Switch } from "@/components/ui/switch"; import { useMaintenanceGuard } from "@/hooks/useMaintenanceGuard"; import { useToast } from "@/hooks/use-toast"; import { fetchReviews } from "@/lib/user"; -import { SearchParams, searchParamsSchema, createCheckoutData } from "@/schemas/checkout"; +import { + SearchParams, + searchParamsSchema, + createCheckoutData, +} from "@/schemas/checkout"; import { PaymentGateway } from "@prisma/client"; import { CreditCard as CreditCardIcon } from "lucide-react"; import { CompanyLogo } from "@/components/ui/company-logo"; @@ -26,6 +30,7 @@ import { import { calculatePricing, formatPercentage } from "../../math"; import { useCurrency } from "@/hooks/useCurrency"; import type { AppliedDiscount } from "@/types/checkout"; +import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; import { useCheckoutTaxContext } from "../../useCheckoutTaxContext"; import type { @@ -43,7 +48,9 @@ import type { User, } from "@prisma/client"; -export type CheckoutClassPlanData = ClassPlan & { +// price arrives as number: extended client + JSON serialization (#780) +export type CheckoutClassPlanData = Omit & { + price: number; consultantProfile: | (ConsultantProfile & { user: User & { @@ -102,6 +109,9 @@ export default function ClassCheckoutPage({ const [isApplyingDiscount, setIsApplyingDiscount] = useState(false); const [discountError, setDiscountError] = useState(null); const [useReferralCredits, setUseReferralCredits] = useState(false); + const [selectedOrganizationId, setSelectedOrganizationId] = useState< + string | null + >(null); const [availableCredits, setAvailableCredits] = useState(0); const [isLoadingCredits, setIsLoadingCredits] = useState(true); @@ -191,7 +201,10 @@ export default function ClassCheckoutPage({ }, []); const handleApiError = useMemo(() => createHandleApiError(toast), [toast]); - const handleCheckoutSuccess = useMemo(() => createHandleCheckoutSuccess(toast, "CLASS"), [toast]); + const handleCheckoutSuccess = useMemo( + () => createHandleCheckoutSuccess(toast, "CLASS"), + [toast], + ); const stripeHandlers = createStripeCheckoutHandlers(toast); const razorpayHandlers = createRazorpayCheckoutHandlers(toast); @@ -244,7 +257,10 @@ export default function ClassCheckoutPage({ displayCurrency: currency, paymentGateway: gateway, fromWaitlist, - useReferralCredits, + useReferralCredits: selectedOrganizationId + ? false + : useReferralCredits, + organizationId: selectedOrganizationId ?? undefined, }); await handleUnifiedCheckout( @@ -303,6 +319,7 @@ export default function ClassCheckoutPage({ toast, appliedDiscount, useReferralCredits, + selectedOrganizationId, validatedSearchParams, currency, availableClassId, @@ -569,6 +586,16 @@ export default function ClassCheckoutPage({
+ { + setSelectedOrganizationId(id); + if (id) setUseReferralCredits(false); + }} + /> +
Discount Codes
@@ -787,7 +814,10 @@ export default function ClassCheckoutPage({ paymentGateway: "RAZORPAY", discountCode: appliedDiscount?.code, displayCurrency: currency, - useReferralCredits, + useReferralCredits: selectedOrganizationId + ? false + : useReferralCredits, + organizationId: selectedOrganizationId ?? undefined, })} onPaymentSuccess={razorpayHandlers.onPaymentSuccess} onPaymentError={razorpayHandlers.onPaymentError} @@ -802,7 +832,10 @@ export default function ClassCheckoutPage({ paymentGateway: "STRIPE", discountCode: appliedDiscount?.code, displayCurrency: currency, - useReferralCredits, + useReferralCredits: selectedOrganizationId + ? false + : useReferralCredits, + organizationId: selectedOrganizationId ?? undefined, })} onPaymentSuccess={stripeHandlers.onPaymentSuccess} onPaymentError={stripeHandlers.onPaymentError} @@ -813,7 +846,9 @@ export default function ClassCheckoutPage({
+ { + setSelectedOrganizationId(id); + // Disable referral credits when org is selected + if (id) setUseReferralCredits(false); + }} + /> +
Discount Codes
@@ -783,8 +847,18 @@ export default function ConsultationCheckoutPage({
Total
-
{formatPrice(pricing.total)}
+
+ {isLicenseCovered + ? formatPrice(0) + : formatPrice(pricing.total)} +
+ {isLicenseCovered && ( +

+ Session value {formatPrice(pricing.total)} — covered by + enterprise license +

+ )}
@@ -828,20 +902,28 @@ export default function ConsultationCheckoutPage({
{gateway.isActive ? (
- {validatedSearchParams && gateway.gateway === "RAZORPAY" ? ( + {validatedSearchParams && + gateway.gateway === "RAZORPAY" ? ( { + onPaymentError={(error: { + description?: string; + code?: string; + reason?: string; + message?: string; + }) => { toast({ title: "Payment Failed", description: @@ -864,22 +951,32 @@ export default function ConsultationCheckoutPage({ }); }} /> - ) : validatedSearchParams && gateway.gateway === "STRIPE" ? ( + ) : validatedSearchParams && + gateway.gateway === "STRIPE" ? ( { + onPaymentSuccess={(response: { + message?: string; + }) => { toast({ title: "Payment Successful", description: @@ -889,7 +986,10 @@ export default function ConsultationCheckoutPage({ window.location.href = "/dashboard"; }} disabled={isMaintenanceBlocked} - onPaymentError={(error: { message?: string; description?: string }) => { + onPaymentError={(error: { + message?: string; + description?: string; + }) => { toast({ title: "Payment Failed", description: @@ -906,7 +1006,9 @@ export default function ConsultationCheckoutPage({
+ { + setSelectedOrganizationId(id); + if (id) setUseReferralCredits(false); + }} + /> +
Discount Codes
@@ -798,18 +830,23 @@ export default function SubscriptionCheckoutPage({ {gateway.isActive ? (
{validatedSearchParams?.schedulingPeriodStartsAt && - validatedSearchParams?.schedulingPeriodEndsAt && - gateway.gateway === "RAZORPAY" ? ( + validatedSearchParams?.schedulingPeriodEndsAt && + gateway.gateway === "RAZORPAY" ? ( handleCheckout(gateway.gateway, true)} - disabled={isCheckoutProcessing || isMaintenanceBlocked} + disabled={ + isCheckoutProcessing || isMaintenanceBlocked + } > {isCheckoutProcessing && processingGateway === `${gateway.gateway}-mock` ? ( diff --git a/app/checkout/plans/utils.ts b/app/checkout/plans/utils.ts index 28e351d1d..e13a522c1 100644 --- a/app/checkout/plans/utils.ts +++ b/app/checkout/plans/utils.ts @@ -39,17 +39,33 @@ export function createHandleApiError( }; } + +// #828 — one key per logical checkout attempt (stable across double-clicks +// and network retries within a mount; a fresh mount = a fresh attempt). The +// server CASes on Payment.clientIdempotencyKey and replays the original +// response instead of minting a duplicate order. +export function mintClientIdempotencyKey(): string { + return globalThis.crypto?.randomUUID + ? `ck_${globalThis.crypto.randomUUID().replace(/-/g, "")}` + : `ck_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + // Common API request logic for checkout export async function makeCheckoutRequest( checkoutData: CheckoutInput, isMockPayment: boolean = false, + clientIdempotencyKey?: string, ): Promise { return fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ ...checkoutData, isMockPayment }), + body: JSON.stringify({ + ...checkoutData, + isMockPayment, + ...(clientIdempotencyKey && { clientIdempotencyKey }), + }), }); } diff --git a/app/checkout/plans/webinar/[planId]/page.tsx b/app/checkout/plans/webinar/[planId]/page.tsx index f1133416d..63b4dc826 100644 --- a/app/checkout/plans/webinar/[planId]/page.tsx +++ b/app/checkout/plans/webinar/[planId]/page.tsx @@ -31,6 +31,7 @@ import { calculatePricing, formatPercentage } from "../../math"; import { useCurrency } from "@/hooks/useCurrency"; import { useCheckoutTaxContext } from "../../useCheckoutTaxContext"; import type { AppliedDiscount } from "@/types/checkout"; +import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; import type { Appointment, @@ -46,8 +47,10 @@ import type { WebinarPlan, } from "@prisma/client"; -// Define a type for the fetched WebinarPlan data -export type CheckoutWebinarPlanData = WebinarPlan & { +// Define a type for the fetched WebinarPlan data. +// price arrives as number: extended client + JSON serialization (#780) +export type CheckoutWebinarPlanData = Omit & { + price: number; consultantProfile: | (ConsultantProfile & { user: User & { @@ -108,6 +111,9 @@ export default function WebinarCheckoutPage({ const [isApplyingDiscount, setIsApplyingDiscount] = useState(false); const [discountError, setDiscountError] = useState(null); const [useReferralCredits, setUseReferralCredits] = useState(false); + const [selectedOrganizationId, setSelectedOrganizationId] = useState< + string | null + >(null); const [availableCredits, setAvailableCredits] = useState(0); const [isLoadingCredits, setIsLoadingCredits] = useState(true); @@ -190,7 +196,10 @@ export default function WebinarCheckoutPage({ // Create utility functions using the toast instance const handleApiError = useMemo(() => createHandleApiError(toast), [toast]); - const handleCheckoutSuccess = useMemo(() => createHandleCheckoutSuccess(toast, "WEBINAR"), [toast]); + const handleCheckoutSuccess = useMemo( + () => createHandleCheckoutSuccess(toast, "WEBINAR"), + [toast], + ); const stripeHandlers = createStripeCheckoutHandlers(toast); const razorpayHandlers = createRazorpayCheckoutHandlers(toast); @@ -254,7 +263,10 @@ export default function WebinarCheckoutPage({ paymentGateway: gateway, displayCurrency: currency, fromWaitlist, - useReferralCredits, + useReferralCredits: selectedOrganizationId + ? false + : useReferralCredits, + organizationId: selectedOrganizationId ?? undefined, }); // Handle unified checkout flow using the utility @@ -315,6 +327,7 @@ export default function WebinarCheckoutPage({ toast, appliedDiscount, useReferralCredits, + selectedOrganizationId, validatedSearchParams, currency, ], @@ -417,9 +430,7 @@ export default function WebinarCheckoutPage({ ].endsAt, ); if (firstSlotEnd.getTime() < Date.now()) { - setError( - "This webinar session has already ended. Please go back.", - ); + setError("This webinar session has already ended. Please go back."); } } }; @@ -590,6 +601,16 @@ export default function WebinarCheckoutPage({
+ { + setSelectedOrganizationId(id); + if (id) setUseReferralCredits(false); + }} + /> +
Discount Codes
@@ -786,7 +807,8 @@ export default function WebinarCheckoutPage({
{gateway.isActive ? (
- {validatedSearchParams && gateway.gateway === "RAZORPAY" ? ( + {validatedSearchParams && + gateway.gateway === "RAZORPAY" ? ( - ) : validatedSearchParams && gateway.gateway === "STRIPE" ? ( + ) : validatedSearchParams && + gateway.gateway === "STRIPE" ? ( handleCheckout(gateway.gateway, true)} - disabled={isCheckoutProcessing || isMaintenanceBlocked} + disabled={ + isCheckoutProcessing || isMaintenanceBlocked + } > {isCheckoutProcessing && processingGateway === `${gateway.gateway}-mock` ? ( diff --git a/app/dashboard/admin/layout.tsx b/app/dashboard/admin/layout.tsx index 23c66c5af..bdf924d21 100644 --- a/app/dashboard/admin/layout.tsx +++ b/app/dashboard/admin/layout.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import NovuProvider from "@/providers/NovuProvider"; import { NotificationInbox } from "@/components/notifications/NotificationInbox"; +import { OrganizationSwitcher } from "@/components/dashboard/OrganizationSwitcher"; import { useNovuSubscriberSync } from "@/hooks/useNovuSubscriberSync"; import { CollapsibleSidebar, @@ -21,6 +22,7 @@ import { AlertTriangle, BadgeCheck, BarChart3, + Building2, CreditCard, Home, ListChecks, @@ -50,6 +52,7 @@ const sidebarItems: CollapsibleSidebarItem[] = [ { name: "Payouts", icon: Wallet, path: "payouts" }, { name: "Invoices", icon: Receipt, path: "invoices" }, { name: "Analytics", icon: BarChart3, path: "analytics" }, + { name: "Organizations", icon: Building2, path: "organizations" }, { name: "Users", icon: Users, path: "users" }, { name: "Waitlists", icon: ListChecks, path: "waitlists" }, { name: "System Jobs", icon: Play, path: "system-jobs" }, @@ -278,6 +281,7 @@ export default function AdminLayout({ children }: Readonly) { {/* Main Content */}
+
diff --git a/app/dashboard/admin/organizations/page.tsx b/app/dashboard/admin/organizations/page.tsx new file mode 100644 index 000000000..d55286f04 --- /dev/null +++ b/app/dashboard/admin/organizations/page.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Building2, + CheckCircle, + Clock, + Loader2, + PauseCircle, + PlayCircle, + Search, + XCircle, +} from "lucide-react"; +import type { OrgStatus } from "@prisma/client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface OrgListItem { + id: string; + name: string; + slug: string; + status: OrgStatus; + canSponsor: boolean; + canHost: boolean; + billingEmail: string; + createdAt: string; + billingAccount: { id: string; fundingSource: string } | null; + _count: { memberships: number; contracts: number }; +} + +type VerifyAction = "VERIFY" | "SUSPEND" | "REACTIVATE" | "DEACTIVATE"; + +// --------------------------------------------------------------------------- +// API layer +// --------------------------------------------------------------------------- + +async function fetchOrganizations(params: { + status?: string; + search?: string; + page?: number; +}): Promise<{ data: OrgListItem[]; pagination: { total: number; page: number; pages: number } }> { + const url = new URL("/api/admin/organizations", window.location.origin); + if (params.status && params.status !== "ALL") url.searchParams.set("status", params.status); + if (params.search) url.searchParams.set("search", params.search); + if (params.page) url.searchParams.set("page", String(params.page)); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error("Failed to load organizations"); + return res.json(); +} + +async function verifyOrg( + orgId: string, + action: VerifyAction, + reason?: string, +): Promise<{ organization: OrgListItem }> { + const res = await fetch(`/api/admin/organizations/${orgId}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, reason }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error((json as { error?: string }).error ?? "Action failed"); + } + return json; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fmtDate(iso: string) { + return new Date(iso).toLocaleDateString("en-IN", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +const STATUS_BADGE: Record = { + PENDING_VERIFICATION: "bg-amber-50 text-amber-800 border-amber-300", + ACTIVE: "bg-green-50 text-green-800 border-green-300", + SUSPENDED: "bg-red-50 text-red-700 border-red-300", + DEACTIVATED: "bg-zinc-100 text-zinc-600 border-zinc-300", +}; + +const STATUS_ICON: Record = { + PENDING_VERIFICATION: Clock, + ACTIVE: CheckCircle, + SUSPENDED: PauseCircle, + DEACTIVATED: XCircle, +}; + +function capabilityLabel(canSponsor: boolean, canHost: boolean) { + if (canSponsor && canHost) return "Hybrid"; + if (canSponsor) return "Sponsor"; + if (canHost) return "Host"; + return "—"; +} + +// Actions available from each status +const ALLOWED_ACTIONS: Record = { + PENDING_VERIFICATION: ["VERIFY", "DEACTIVATE"], + ACTIVE: ["SUSPEND", "DEACTIVATE"], + SUSPENDED: ["REACTIVATE", "DEACTIVATE"], + DEACTIVATED: [], +}; + +const ACTION_LABELS: Record = { + VERIFY: "Verify", + SUSPEND: "Suspend", + REACTIVATE: "Reactivate", + DEACTIVATE: "Deactivate", +}; + +const ACTION_VARIANT: Record = { + VERIFY: "bg-green-600 hover:bg-green-700 text-white", + SUSPEND: "bg-amber-600 hover:bg-amber-700 text-white", + REACTIVATE: "bg-blue-600 hover:bg-blue-700 text-white", + DEACTIVATE: "bg-red-600 hover:bg-red-700 text-white", +}; + +// --------------------------------------------------------------------------- +// Confirm dialog +// --------------------------------------------------------------------------- + +function ActionDialog({ + org, + action, + onClose, + onConfirm, + isPending, + error, +}: { + org: OrgListItem; + action: VerifyAction; + onClose: () => void; + onConfirm: (reason: string) => void; + isPending: boolean; + error: string | null; +}) { + const [reason, setReason] = useState(""); + + const descriptions: Record = { + VERIFY: + "This will mark the organization as ACTIVE. Members can be invited and money can move immediately.", + SUSPEND: + "Invitations and payments will be paused until the organization is reactivated. Existing memberships are preserved.", + REACTIVATE: + "The organization will return to ACTIVE status. All previously gated actions unlock immediately.", + DEACTIVATE: + "This is permanent and cannot be reversed from this endpoint. Use only for organizations that will never operate again.", + }; + + return ( + !v && onClose()}> + + + + {ACTION_LABELS[action]} “{org.name}”? + + {descriptions[action]} + +
+ +