-
Notifications
You must be signed in to change notification settings - Fork 2
feat: organization guardrails and team seat billing (#581-582) #742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
d532bc4
feat: add organization guardrails schema, enforcement, and admin API
2witstudios 40dbd32
feat: add Stripe per-seat billing with tiered org plans
2witstudios c199a5d
fix: remove unused imports to pass ESLint CI checks
2witstudios 057295a
fix: use correct Stripe Invoice type assertion pattern
2witstudios 09d41ad
fix: security hardening and review fixes for org/billing system
2witstudios d5f496d
fix: cast role to OrgRole enum type for Drizzle set()
2witstudios 80f7c72
Merge remote-tracking branch 'origin/master' into ppg/orgs-billing
2witstudios cd1097a
fix: add missing checkMCPPageScope mock to task route tests
2witstudios 3e5e312
fix: address all CodeRabbit review comments on org billing PR
2witstudios 37a8d5d
fix: address second-round CodeRabbit review comments
2witstudios File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| active: true | ||
| iteration: 1 | ||
| session_id: | ||
| max_iterations: 40 | ||
| completion_promise: "PR_READY" | ||
| started_at: "2026-03-08T02:38:40Z" | ||
| --- | ||
|
|
||
| TASK: Converge the current open Pull Request to merge-ready by addressing every review comment, replying to each thread, ensuring all CI checks pass, and completing the existing implementation plan.\n\nSUCCESS CRITERIA:\n- PR has ZERO unresolved review threads/conversations.\n- Every reviewer comment has been explicitly acknowledged with a reply explaining what changed (or why no change is needed).\n- All required CI checks are green (no failing or pending required checks).\n- The original plan (the one this PR is based on) is fully completed: all planned tasks are implemented, and any plan checklist/tracker is updated to show completion.\n- Repo validations pass locally where applicable (tests/build/lint/typecheck for this repo), or CI equivalents are confirmed green.\n- PR description is up to date (summarize what changed + how to validate), and no TODO/FIXME markers remain related to review feedback.\n\nPROCESS (repeat until success):\n1) Discover the PR context: identify the PR number/link, read the PR description, commits, files changed, and the plan/tracker the PR references.\n2) Collect all feedback: list every review comment + thread, label each as (a) code change required, (b) question/clarification, (c) optional suggestion, (d) out-of-scope.\n3) Pick the smallest actionable item (ONE thread at a time): implement the minimal change that resolves it.\n4) Run the fastest relevant local validation (targeted tests/lint/typecheck/build). If not available, rely on CI but still do best-effort local checks.\n5) Commit with a clear message referencing the thread/topic. Push.\n6) Reply in the PR thread describing exactly what you changed and where (files/lines), and mark the thread resolved if appropriate.\n7) Re-check CI status. If failing, fix the failure, push, and update any relevant PR replies.\n8) Repeat until all threads are resolved AND all required checks are green AND the plan is complete.\n\nCOMMUNICATION RULES:\n- Always reply politely and concretely. If disagreeing, explain why and propose an alternative.\n- If a comment requires a product/architecture decision that you cannot infer from context, ask a single concise question in the PR and create a TODO note, then continue with other threads.\n\nESCAPE HATCH:\n- After 25 iterations, if not complete, output <promise>BLOCKED</promise> and include: (1) remaining unresolved threads with links/quotes, (2) latest CI failures with logs summary, (3) what you tried, (4) the minimal questions needed from a human to proceed. /aidd\n\nOUTPUT: Only output <promise>PR_READY</promise> when ALL success criteria are met. |
47 changes: 47 additions & 0 deletions
47
apps/web/src/app/api/orgs/[orgId]/billing/invoices/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { db, eq, organizations } from '@pagespace/db'; | ||
| import { withOrgAdminAuth, type OrgRouteContext } from '@/lib/orgs/org-auth'; | ||
| import { stripe } from '@/lib/stripe'; | ||
|
|
||
| // GET /api/orgs/[orgId]/billing/invoices - List org invoices | ||
| export const GET = withOrgAdminAuth<OrgRouteContext>(async (_user, request, _context, orgId) => { | ||
| const { searchParams } = new URL(request.url); | ||
| const parsedLimit = parseInt(searchParams.get('limit') ?? '10'); | ||
| const limit = Math.min(Math.max(Number.isNaN(parsedLimit) ? 10 : parsedLimit, 1), 100); | ||
| const rawStartingAfter = searchParams.get('starting_after'); | ||
| const startingAfter = rawStartingAfter?.trim() || undefined; | ||
|
|
||
| const [org] = await db | ||
| .select({ stripeCustomerId: organizations.stripeCustomerId }) | ||
| .from(organizations) | ||
| .where(eq(organizations.id, orgId)) | ||
| .limit(1); | ||
|
|
||
| if (!org?.stripeCustomerId) { | ||
| return Response.json({ invoices: [], hasMore: false }); | ||
| } | ||
|
|
||
| const invoices = await stripe.invoices.list({ | ||
| customer: org.stripeCustomerId, | ||
| limit, | ||
| starting_after: startingAfter, | ||
| }); | ||
|
|
||
| const mapped = invoices.data.map((inv) => ({ | ||
| id: inv.id, | ||
| number: inv.number, | ||
| status: inv.status, | ||
| amountDue: inv.amount_due, | ||
| amountPaid: inv.amount_paid, | ||
| currency: inv.currency, | ||
| created: inv.created ? new Date(inv.created * 1000).toISOString() : null, | ||
| periodStart: inv.period_start ? new Date(inv.period_start * 1000).toISOString() : null, | ||
| periodEnd: inv.period_end ? new Date(inv.period_end * 1000).toISOString() : null, | ||
| hostedInvoiceUrl: inv.hosted_invoice_url, | ||
| pdfUrl: inv.invoice_pdf, | ||
| })); | ||
|
|
||
| return Response.json({ | ||
| invoices: mapped, | ||
| hasMore: invoices.has_more, | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { db, eq, organizations, orgSubscriptions } from '@pagespace/db'; | ||
| import { withOrgAdminAuth, withOrgOwnerAuth, type OrgRouteContext } from '@/lib/orgs/org-auth'; | ||
| import { getOrgBillingOverview, getActiveOrgSubscription } from '@/lib/orgs/seat-manager'; | ||
| import { stripe, type Stripe } from '@/lib/stripe'; | ||
| import { stripeConfig } from '@/lib/stripe-config'; | ||
| import { getOrgMemberCount } from '@/lib/orgs/guardrails'; | ||
|
|
||
| const ALLOWED_ORG_PRICE_IDS = new Set([ | ||
| stripeConfig.priceIds.pro, | ||
| stripeConfig.priceIds.business, | ||
| ]); | ||
|
|
||
| // GET /api/orgs/[orgId]/billing - Get billing overview | ||
| export const GET = withOrgAdminAuth<OrgRouteContext>(async (_user, _request, _context, orgId) => { | ||
| const overview = await getOrgBillingOverview(orgId); | ||
| if (!overview) { | ||
| return Response.json({ error: 'Organization not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| return Response.json(overview); | ||
| }); | ||
|
|
||
| // POST /api/orgs/[orgId]/billing - Create or update subscription | ||
| export const POST = withOrgOwnerAuth<OrgRouteContext>(async (user, request, _context, orgId) => { | ||
| let body: Record<string, unknown>; | ||
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return Response.json({ error: 'Invalid JSON body' }, { status: 400 }); | ||
| } | ||
|
|
||
| const { priceId } = body as { priceId?: string }; | ||
|
|
||
| if (!priceId || typeof priceId !== 'string') { | ||
| return Response.json({ error: 'priceId is required' }, { status: 400 }); | ||
| } | ||
|
2witstudios marked this conversation as resolved.
|
||
|
|
||
| if (!ALLOWED_ORG_PRICE_IDS.has(priceId)) { | ||
| return Response.json({ error: 'Invalid price ID for organization plans' }, { status: 400 }); | ||
| } | ||
|
|
||
| // Prevent duplicate subscription creation | ||
| const existingSub = await getActiveOrgSubscription(orgId); | ||
| if (existingSub) { | ||
| return Response.json( | ||
| { error: 'Organization already has an active subscription. Use the update endpoint instead.' }, | ||
| { status: 409 } | ||
| ); | ||
| } | ||
|
|
||
| const [org] = await db | ||
| .select() | ||
| .from(organizations) | ||
| .where(eq(organizations.id, orgId)) | ||
| .limit(1); | ||
|
|
||
| if (!org) { | ||
| return Response.json({ error: 'Organization not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| const memberCount = await getOrgMemberCount(orgId); | ||
| let customerId = org.stripeCustomerId; | ||
|
|
||
| // Create Stripe customer for org if needed | ||
| if (!customerId) { | ||
| const customer = await stripe.customers.create({ | ||
| name: org.name, | ||
| email: org.billingEmail ?? undefined, | ||
| metadata: { orgId, type: 'organization' }, | ||
| }); | ||
| customerId = customer.id; | ||
|
|
||
| await db | ||
| .update(organizations) | ||
| .set({ stripeCustomerId: customerId }) | ||
| .where(eq(organizations.id, orgId)); | ||
| } | ||
|
|
||
| // Create subscription with per-seat quantity | ||
| const subscription = await stripe.subscriptions.create({ | ||
| customer: customerId, | ||
| items: [{ | ||
| price: priceId, | ||
| quantity: Math.max(memberCount, 1), | ||
| }], | ||
| payment_behavior: 'default_incomplete', | ||
| payment_settings: { save_default_payment_method: 'on_subscription' }, | ||
| metadata: { orgId, type: 'organization' }, | ||
| expand: ['latest_invoice.confirmation_secret'], | ||
| }); | ||
|
|
||
| // Get the period from subscription items (Stripe API 2025+ pattern) | ||
| const subscriptionItem = subscription.items.data[0]; | ||
| const currentPeriodStart = subscriptionItem?.current_period_start | ||
| ? new Date(subscriptionItem.current_period_start * 1000) | ||
| : new Date(); | ||
| const currentPeriodEnd = subscriptionItem?.current_period_end | ||
| ? new Date(subscriptionItem.current_period_end * 1000) | ||
| : new Date(); | ||
|
|
||
| await db.insert(orgSubscriptions).values({ | ||
| orgId, | ||
| stripeSubscriptionId: subscription.id, | ||
| stripePriceId: priceId, | ||
| status: subscription.status, | ||
| quantity: Math.max(memberCount, 1), | ||
| currentPeriodStart, | ||
| currentPeriodEnd, | ||
| }); | ||
|
2witstudios marked this conversation as resolved.
|
||
|
|
||
| const invoice = subscription.latest_invoice as Stripe.Invoice & { | ||
| confirmation_secret?: { client_secret: string; type: string }; | ||
| }; | ||
|
|
||
| const clientSecret = invoice?.confirmation_secret?.client_secret ?? null; | ||
|
|
||
| return Response.json({ | ||
| subscriptionId: subscription.id, | ||
| clientSecret, | ||
| status: subscription.status, | ||
| }, { status: 201 }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { withOrgAdminAuth, type OrgRouteContext } from '@/lib/orgs/org-auth'; | ||
| import { updateSeatCount, getActiveOrgSubscription } from '@/lib/orgs/seat-manager'; | ||
| import { getOrgMemberCount } from '@/lib/orgs/guardrails'; | ||
|
|
||
| // GET /api/orgs/[orgId]/billing/seats - Get seat info | ||
| export const GET = withOrgAdminAuth<OrgRouteContext>(async (_user, _request, _context, orgId) => { | ||
| const subscription = await getActiveOrgSubscription(orgId); | ||
| const memberCount = await getOrgMemberCount(orgId); | ||
|
|
||
| return Response.json({ | ||
| currentSeats: subscription?.quantity ?? memberCount, | ||
| activeMembers: memberCount, | ||
| hasSubscription: !!subscription, | ||
| gracePeriodEnd: subscription?.gracePeriodEnd ?? null, | ||
| }); | ||
| }); | ||
|
|
||
| // PUT /api/orgs/[orgId]/billing/seats - Manually update seat count | ||
| export const PUT = withOrgAdminAuth<OrgRouteContext>(async (_user, request, _context, orgId) => { | ||
| let body: Record<string, unknown>; | ||
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return Response.json({ error: 'Invalid JSON body' }, { status: 400 }); | ||
| } | ||
|
|
||
| const { quantity } = body as { quantity?: number }; | ||
|
|
||
| if (typeof quantity !== 'number' || quantity < 1) { | ||
| return Response.json({ error: 'quantity must be a positive number' }, { status: 400 }); | ||
| } | ||
|
|
||
| const result = await updateSeatCount(orgId, quantity); | ||
|
|
||
| if (!result.success) { | ||
| return Response.json({ error: result.error }, { status: 400 }); | ||
| } | ||
|
|
||
| return Response.json({ | ||
| success: true, | ||
| newQuantity: result.newQuantity, | ||
| prorated: result.prorated, | ||
| }); | ||
| }); |
66 changes: 66 additions & 0 deletions
66
apps/web/src/app/api/orgs/[orgId]/members/[userId]/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { db, eq, and, orgMembers } from '@pagespace/db'; | ||
| import { withOrgAdminAuth, type OrgMemberRouteContext } from '@/lib/orgs/org-auth'; | ||
| import { isOrgOwner } from '@/lib/orgs/guardrails'; | ||
| import { adjustSeatsForMemberRemove } from '@/lib/orgs/seat-manager'; | ||
|
|
||
| // PATCH /api/orgs/[orgId]/members/[userId] - Update member role | ||
| export const PATCH = withOrgAdminAuth<OrgMemberRouteContext>(async (_user, request, context, orgId) => { | ||
| const { userId } = await context.params; | ||
|
|
||
| let body: Record<string, unknown>; | ||
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return Response.json({ error: 'Invalid JSON body' }, { status: 400 }); | ||
| } | ||
|
|
||
| const { role } = body as { role?: string }; | ||
|
|
||
| if (!role || !['ADMIN', 'MEMBER'].includes(role)) { | ||
| return Response.json({ error: 'Role must be ADMIN or MEMBER' }, { status: 400 }); | ||
| } | ||
|
|
||
| // Cannot change the owner's role | ||
| const ownerCheck = await isOrgOwner(userId, orgId); | ||
| if (ownerCheck) { | ||
| return Response.json({ error: 'Cannot change the organization owner\'s role' }, { status: 403 }); | ||
| } | ||
|
|
||
| const [updated] = await db | ||
| .update(orgMembers) | ||
| .set({ role: role as 'ADMIN' | 'MEMBER' }) | ||
| .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId))) | ||
| .returning(); | ||
|
|
||
| if (!updated) { | ||
| return Response.json({ error: 'Member not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| return Response.json(updated); | ||
| }); | ||
|
|
||
| // DELETE /api/orgs/[orgId]/members/[userId] - Remove member | ||
| export const DELETE = withOrgAdminAuth<OrgMemberRouteContext>(async (_user, _request, context, orgId) => { | ||
| const { userId } = await context.params; | ||
|
|
||
| // Cannot remove the owner | ||
| const ownerCheck = await isOrgOwner(userId, orgId); | ||
| if (ownerCheck) { | ||
| return Response.json({ error: 'Cannot remove the organization owner' }, { status: 403 }); | ||
| } | ||
|
|
||
| const [deleted] = await db | ||
| .delete(orgMembers) | ||
| .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId))) | ||
| .returning(); | ||
|
|
||
| if (!deleted) { | ||
| return Response.json({ error: 'Member not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| // Adjust seat count (grace period before billing decrease) | ||
| // Non-blocking: member removal succeeds even if billing update fails | ||
| const seatResult = await adjustSeatsForMemberRemove(orgId); | ||
|
|
||
|
2witstudios marked this conversation as resolved.
|
||
| return Response.json({ success: true, ...(seatResult.success ? {} : { billingWarning: 'Seat adjustment pending' }) }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.