Files
accounted/lib/company/actions.ts
T
MattssonandClaude Fable 5 aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* 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>
2026-09-01 11:29:12 +02:00

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 }
}