Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/.claude/ralph-loop.local.md
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 apps/web/src/app/api/orgs/[orgId]/billing/invoices/route.ts
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,
Comment thread
2witstudios marked this conversation as resolved.
});

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,
});
});
122 changes: 122 additions & 0 deletions apps/web/src/app/api/orgs/[orgId]/billing/route.ts
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 });
}
Comment thread
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,
});
Comment thread
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 });
});
44 changes: 44 additions & 0 deletions apps/web/src/app/api/orgs/[orgId]/billing/seats/route.ts
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 apps/web/src/app/api/orgs/[orgId]/members/[userId]/route.ts
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);

Comment thread
2witstudios marked this conversation as resolved.
return Response.json({ success: true, ...(seatResult.success ? {} : { billingWarning: 'Seat adjustment pending' }) });
});
Loading