* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy Multiple people in one company becomes a paid capability (multi_user, the eighth PAID key). Derived at access time from capability_grants, no status column, no enforcement cron: - entitled: active grant (trial/stripe/team/manual/comp), everyone works - grace: newest grant expired < 20 days ago; countdown banner for everyone in companies with > 1 user; invites still allowed - frozen: only role=owner resolves; other memberships go dormant (rows untouched, paying reactivates instantly); invites 403 with paid-plan upsell Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin untouched: they also run on self-hosts, where the gate never bites), gated query fallback for service-role/API-key paths, setActiveCompany guard, MCP company-access check, invite route. Middleware routes all-frozen users to a new /paused page; the switcher greys locked companies. Migration 20260901081417 (applied to staging): trial trigger seeds multi_user, backfills for mid-trial companies, active Stripe subs, team agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20 days) for existing unpaid multi-member companies. Daily cron mails owners at grace start and last day. Strings in sv+en; pg-real + unit tests included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): multi-user seat gate hardening from skeptic review - Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting it: the 20-day grace window hangs on an expired row, so a deleted one froze churned payers' staff instantly with no banner and no mail. Other stripe grants keep the freeze-and-retain delete. - New SECURITY DEFINER company_multi_user_state() RPC (migration 20260901083726, applied to staging) and RPC-first getMultiUserState: capability_grants RLS hides team-scoped rows from non-team users, so user-client reads misread byra-covered companies as frozen (switch refusal, wrong switcher locks). - Byra-kind teams get a standing team-scoped multi_user grant (backfill + teams trigger): byra client companies have no company-scoped trial by design, so a grantless byra team would freeze every consultant and client user. - Comped/manual companies with active PAID-key grants extend to multi_user (a comped company must not read as paying while locking out user two). - /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403). - PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user rows; the gated fallback would have frozen every non-owner mid-deploy). - Grace cron: covers team-scoped lapses (byra agreement ending) and skips the start mail for the hand-mailed grandfather cohort. - Tests updated/added across all touched surfaces; pg tests for the new RPC and byra trigger; trial-suppression pg test extended to 8 keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): decouple seat-gate env check and fail open on gate read throws CI round 1 on #2099: - isMultiUserEnforced no longer imports has-capability: several route test suites partially mock that module and the vitest mock guard threw from inside the v1 seat gate, turning expected 4xx responses into 500s. multi_user is never a connector capability, so the bypass reduces to the same env reads, now inlined. - getMultiUserState wraps its resolution in a fail-open try/catch: a client without .rpc or a thrown network error must never lock users out. - no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or() scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's timestamp .or(); all columns in both strings are literals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3) company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and were granted to authenticated with a caller-supplied company UUID: any logged-in user could probe an arbitrary company's billing state and grace deadline across tenants. Migration 20260901091752 (applied to staging) requires an auth.uid() membership in the target company when a JWT is present, keeps service-role/definer contexts unrestricted, and clamps the grace window to [0, 20] days. pg tests: stranger gets false/NULL, member reads normally, oversized p_grace_days cannot widen the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
219 lines
9.1 KiB
TypeScript
219 lines
9.1 KiB
TypeScript
'use server'
|
|
|
|
import { headers } from 'next/headers'
|
|
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
|
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { createCompanyCore } from '@/lib/company/create-company'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
/**
|
|
* Switch the active company. Returns an error *code* (translated by the
|
|
* caller, same pattern as `org_number_invalid` below): 'not_member' when the
|
|
* user lacks membership, 'persist_failed' when the user_preferences write
|
|
* failed or could not be verified (#701).
|
|
*/
|
|
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return { error: 'Unauthorized' }
|
|
}
|
|
|
|
try {
|
|
await setActiveCompany(supabase, user.id, companyId)
|
|
// No revalidatePath: the client performs a hard navigation
|
|
// (window.location.assign) after this action returns, which wipes
|
|
// every React/router/fetch cache wholesale. revalidatePath would be a
|
|
// no-op and would just race with the hard reload.
|
|
return {}
|
|
} catch (err) {
|
|
console.error('[switchCompany] failed', err)
|
|
if (err instanceof CompanyContextError && err.code === 'not_member') {
|
|
return { error: 'not_member' }
|
|
}
|
|
if (err instanceof CompanyContextError && err.code === 'company_locked') {
|
|
// Multi-user seat gate: the company is frozen for this (non-owner)
|
|
// membership until someone pays. Translated by the caller.
|
|
return { error: 'company_locked' }
|
|
}
|
|
// persist_failed and anything unexpected: a retryable failure, not a
|
|
// permissions problem: don't tell the user they lack access.
|
|
return { error: 'persist_failed' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a company from onboarding wizard data.
|
|
*
|
|
* This runs on the server so that if the Next.js server is unavailable when
|
|
* the user clicks the final "Fortsätt" button, the action never reaches
|
|
* Supabase and no ghost company is created. All operations (company,
|
|
* membership, chart of accounts, settings, fiscal period, active company)
|
|
* happen sequentially; if any step after company creation fails the company
|
|
* is rolled back to avoid partial state.
|
|
*/
|
|
export async function createCompanyFromOnboarding(params: {
|
|
teamId: string
|
|
settings: Record<string, unknown>
|
|
fiscalPeriod: {
|
|
startDate: string
|
|
endDate: string
|
|
name: string
|
|
}
|
|
// Optional TIC lookup result captured during the onboarding form. When
|
|
// supplied, persisted to companies.tic_snapshot so downstream features
|
|
// (specialized accountant agent composer, MCP briefing) can read the same
|
|
// Bolagsverket-sourced data the form used. Empty for manual entry paths.
|
|
ticLookup?: CompanyLookupResult | null
|
|
}): Promise<{ companyId?: string; error?: string }> {
|
|
try {
|
|
return await createCompanyFromOnboardingImpl(params)
|
|
} catch (err) {
|
|
// Defensive top-level catch: a thrown error escapes to the client as
|
|
// an opaque Next.js server-action exception with no message in dev
|
|
// and a redacted message in prod. Logging the full error here gives
|
|
// us a server-side trace and returns a localized fallback to the UI.
|
|
console.error('[createCompanyFromOnboarding] unexpected error', err)
|
|
return { error: getErrorMessage(err, { context: 'settings' }) }
|
|
}
|
|
}
|
|
|
|
async function createCompanyFromOnboardingImpl(params: {
|
|
teamId: string
|
|
settings: Record<string, unknown>
|
|
fiscalPeriod: { startDate: string; endDate: string; name: string }
|
|
ticLookup?: CompanyLookupResult | null
|
|
}): Promise<{ companyId?: string; error?: string }> {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return { error: 'Unauthorized' }
|
|
}
|
|
|
|
const entityType = params.settings.entity_type as string | undefined
|
|
if (entityType !== 'enskild_firma' && entityType !== 'aktiebolag') {
|
|
return { error: 'Ogiltig företagsform.' }
|
|
}
|
|
|
|
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
|
|
|
|
// Creating a company under a BYRÅ team is admin-gated (WL-15): every
|
|
// created client company is +1 on the byrå's monthly invoice, so only team
|
|
// owner/admin may do it. Personal-team creation is untouched. The
|
|
// create_company_with_owner RPC enforces the same rule in the database
|
|
// (migration 20260826130400); this check exists to return a readable error
|
|
// instead of a raw 42501. A team the caller cannot read via RLS resolves
|
|
// to null kind here and falls through to the RPC's own membership check.
|
|
const { data: teamRow } = await supabase
|
|
.from('teams')
|
|
.select('kind')
|
|
.eq('id', params.teamId)
|
|
.maybeSingle()
|
|
if ((teamRow as { kind?: string } | null)?.kind === 'byra') {
|
|
const { data: teamMemberRow } = await supabase
|
|
.from('team_members')
|
|
.select('role')
|
|
.eq('team_id', params.teamId)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle()
|
|
const teamRole = (teamMemberRow as { role?: string } | null)?.role
|
|
if (teamRole !== 'owner' && teamRole !== 'admin') {
|
|
return { error: 'Endast byråns ägare och administratörer kan skapa klientbolag.' }
|
|
}
|
|
}
|
|
|
|
// Brand-host signup homing (2026-08-27): when this wizard runs on an
|
|
// invite-only brand host and the creating user is on the brand's signup
|
|
// allowlist, the company attaches to the brand's byrå team via the
|
|
// create_company_for_brand_signup RPC (which re-checks the allowlist).
|
|
// Without this the company would get the personal team and the home-domain
|
|
// rule (WL-01) would home it on the canonical domain, invisible on the
|
|
// very brand domain the user signed up on. Only the personal-team path is
|
|
// rerouted: an explicit byrå-team creation (the cockpit's new-client flow)
|
|
// already passed the byrå team and stays under the WL-15 admin gate above.
|
|
let createCompanyRow: () => PromiseLike<{ data: unknown; error: unknown }> =
|
|
() =>
|
|
supabase.rpc('create_company_with_owner', {
|
|
p_name: companyName,
|
|
p_entity_type: entityType,
|
|
p_team_id: params.teamId,
|
|
})
|
|
// When the row is created under the service role (brand-signup path below),
|
|
// rollback must also run under the service role: `companies` has RLS and no
|
|
// FOR DELETE policy, so a cookie-session rollback of a service-created
|
|
// company deletes nothing and strands a member-less orphan on the brand's
|
|
// team. Stays null on the normal path, where the session client is correct.
|
|
// Once the rollback delete lands, user_preferences.active_company_id (which
|
|
// the RPC set) auto-clears via its ON DELETE SET NULL FK, so no dangling
|
|
// active company survives.
|
|
let rollbackClient: SupabaseClient | undefined
|
|
|
|
if ((teamRow as { kind?: string } | null)?.kind !== 'byra' && user.email) {
|
|
// Dynamic imports: this file is imported by client components (through
|
|
// switch-client.ts) for its other actions, and these two modules reach
|
|
// node:crypto; a static import would drag Node builtins into the client
|
|
// graph (client-node-builtin guard). Server actions always execute
|
|
// server-side, so the dynamic import is free here.
|
|
const [{ resolveBrandByHost }, { isEmailOnBrandAllowlist }] = await Promise.all([
|
|
import('@/lib/branding/resolve'),
|
|
import('@/lib/auth/brand-signup-gate'),
|
|
])
|
|
const requestHeaders = await headers()
|
|
const host =
|
|
requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? ''
|
|
const hostBrand = host ? await resolveBrandByHost(host) : null
|
|
if (
|
|
hostBrand?.signupMode === 'invite_only' &&
|
|
(await isEmailOnBrandAllowlist(hostBrand.id, user.email))
|
|
) {
|
|
const serviceClient = createServiceClient()
|
|
rollbackClient = serviceClient
|
|
createCompanyRow = () =>
|
|
serviceClient.rpc('create_company_for_brand_signup', {
|
|
p_user_id: user.id,
|
|
p_name: companyName,
|
|
p_entity_type: entityType,
|
|
p_brand_id: hostBrand.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Steps 1-5 (company + owner via RPC, org number, TIC snapshot, chart,
|
|
// settings, fiscal period, tax deadlines, with rollback) are shared with
|
|
// the MCP and v1 creation paths: lib/company/create-company.ts.
|
|
const created = await createCompanyCore(
|
|
supabase,
|
|
{
|
|
entityType,
|
|
companyName,
|
|
orgNumber: params.settings.org_number as string | undefined,
|
|
settings: params.settings,
|
|
fiscalPeriod: params.fiscalPeriod,
|
|
ticLookup: params.ticLookup,
|
|
},
|
|
createCompanyRow,
|
|
rollbackClient,
|
|
)
|
|
if (created.error !== undefined) {
|
|
return { error: created.error }
|
|
}
|
|
const newCompanyId = created.companyId
|
|
|
|
// 6. Set as active company
|
|
try {
|
|
await setActiveCompany(supabase, user.id, newCompanyId)
|
|
} catch (err) {
|
|
// Non-fatal: the company was created successfully; the user can switch manually
|
|
console.error('[createCompanyFromOnboarding] setActiveCompany failed', err)
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return { companyId: newCompanyId }
|
|
}
|
|
|