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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ca12b1855e
commit
aabddb592f
@@ -36,6 +36,19 @@ vi.mock('@supabase/supabase-js', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Multi-user seat gate: mocked so the membership stub stays single-purpose;
|
||||
// the gate's own logic is covered in lib/entitlements/__tests__/multi-user.test.ts.
|
||||
const getMultiUserStateMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/entitlements/multi-user', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/entitlements/multi-user')>(
|
||||
'@/lib/entitlements/multi-user',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
getMultiUserState: (...args: unknown[]) => getMultiUserStateMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api/idempotency', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
|
||||
'@/lib/api/idempotency',
|
||||
@@ -102,6 +115,7 @@ function companyParams(companyId: string) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'entitled', graceEndsAt: null })
|
||||
})
|
||||
|
||||
describe('withApiV1: auth', () => {
|
||||
@@ -277,6 +291,72 @@ describe('withApiV1: company membership', () => {
|
||||
const body = await res.json()
|
||||
expect(body.data.companyId).toBe('company-1')
|
||||
})
|
||||
|
||||
it('refuses a NON-OWNER membership in a frozen company with 403 (multi-user seat gate)', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['companies:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'admin' }))
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
|
||||
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'companies.get',
|
||||
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
|
||||
{ requireScope: 'companies:read' },
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('FORBIDDEN')
|
||||
expect(body.error.details.capability).toBe('multi_user')
|
||||
})
|
||||
|
||||
it('OWNERS pass the seat gate without a state read; grace passes for non-owners', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['companies:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
|
||||
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'companies.get',
|
||||
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
|
||||
{ requireScope: 'companies:read' },
|
||||
)
|
||||
const ownerRes = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
expect(ownerRes.status).toBe(200)
|
||||
expect(getMultiUserStateMock).not.toHaveBeenCalled()
|
||||
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'member' }))
|
||||
getMultiUserStateMock.mockResolvedValue({
|
||||
state: 'grace',
|
||||
graceEndsAt: new Date(Date.now() + 5 * 86_400_000).toISOString(),
|
||||
})
|
||||
const graceRes = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
expect(graceRes.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withApiV1: static (non-dynamic) route params', () => {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
|
||||
// idempotent (guarded by a module-level boolean).
|
||||
ensureInitialized()
|
||||
import { resolveRequiredScope } from '@/lib/auth/scopes'
|
||||
import { getMultiUserState, isMembershipDormant } from '@/lib/entitlements/multi-user'
|
||||
import { getEndpointByConcretePath } from './registry'
|
||||
import {
|
||||
checkIdempotencyKey,
|
||||
@@ -412,6 +413,28 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
details: { companyId },
|
||||
})
|
||||
}
|
||||
|
||||
// Multi-user seat gate: the API-key surface is a chokepoint like the
|
||||
// cookie routes and MCP. A non-owner membership in a frozen company
|
||||
// (multi_user lapsed past its 20-day grace) is refused here so an old
|
||||
// key cannot keep working the books after the freeze. Owners pass
|
||||
// without the extra read; the service client sees team-scoped grants.
|
||||
if ((membership as { role?: string }).role !== 'owner') {
|
||||
const access = await getMultiUserState(supabase, companyId)
|
||||
if (isMembershipDormant((membership as { role: string }).role, access.state)) {
|
||||
userLog.warn('multi-user seat gate refused frozen membership', { companyId, ...forensic })
|
||||
return await v1ErrorResponseFromCode('FORBIDDEN', userLog, {
|
||||
requestId,
|
||||
status: 403,
|
||||
reason: 'multi_user_frozen',
|
||||
details: {
|
||||
companyId,
|
||||
capability: 'multi_user',
|
||||
message: 'Company is paused for this account: multiple users require a paid plan. Ask the company owner to upgrade.',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Idempotency. Mandatory for state-changing methods when the route
|
||||
|
||||
@@ -6,6 +6,26 @@ vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn(async () => ({ set: mockCookieSet })),
|
||||
}))
|
||||
|
||||
// Multi-user seat gate seam. Default: NOT enforced and every membership
|
||||
// active, so the pre-existing tests keep documenting the ungated resolution
|
||||
// (the self-hosted / dev behavior, and the pre-gate semantics). The gated
|
||||
// tests at the bottom flip these; the gate's own logic is covered in
|
||||
// lib/entitlements/__tests__/multi-user.test.ts.
|
||||
const multiUserSeam = vi.hoisted(() => ({
|
||||
enforced: false,
|
||||
membershipActive: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
vi.mock('@/lib/entitlements/multi-user', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/entitlements/multi-user')>(
|
||||
'@/lib/entitlements/multi-user',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
isMultiUserEnforced: () => multiUserSeam.enforced,
|
||||
isMembershipActive: (...args: unknown[]) => multiUserSeam.membershipActive(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
|
||||
|
||||
type CapturedCall = { table: string; method: string; args: unknown[] }
|
||||
@@ -37,7 +57,7 @@ function buildSupabase(
|
||||
|
||||
function makeChain(table: string) {
|
||||
const chain: Record<string, unknown> = {}
|
||||
const methods = ['select', 'eq', 'is', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
|
||||
const methods = ['select', 'eq', 'is', 'or', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
|
||||
for (const m of methods) {
|
||||
chain[m] = (...args: unknown[]) => {
|
||||
calls.push({ table, method: m, args })
|
||||
@@ -66,6 +86,8 @@ function buildSupabase(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
multiUserSeam.enforced = false
|
||||
multiUserSeam.membershipActive.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe('setActiveCompany', () => {
|
||||
@@ -322,6 +344,78 @@ describe('getActiveCompanyId via resolve_active_company RPC', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-user seat gate', () => {
|
||||
it('setActiveCompany refuses a switch into a company frozen for the member', async () => {
|
||||
multiUserSeam.membershipActive.mockResolvedValue(false)
|
||||
const { supabase, calls } = buildSupabase({
|
||||
company_members: { single: { data: { company_id: 'company-2', role: 'member' } } },
|
||||
})
|
||||
|
||||
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
|
||||
|
||||
expect(err).toBeInstanceOf(CompanyContextError)
|
||||
expect(err.code).toBe('company_locked')
|
||||
// The preference write must never land: a persisted frozen preference
|
||||
// would silently bounce every later resolution.
|
||||
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
|
||||
expect(mockCookieSet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getActiveCompanyId calls the GATED rpc when enforcement is on', async () => {
|
||||
multiUserSeam.enforced = true
|
||||
const { supabase } = buildSupabase(
|
||||
{},
|
||||
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
|
||||
)
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company_gated', {
|
||||
p_grace_days: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('gated query fallback resolves the first ACCESSIBLE membership (dormant skipped)', async () => {
|
||||
multiUserSeam.enforced = true
|
||||
const memberships = [
|
||||
// Frozen for the user: non-owner and no grant rows will match below.
|
||||
{ company_id: 'frozen-co', role: 'member', created_at: '2026-01-01', companies: { team_id: null } },
|
||||
{ company_id: 'owned-co', role: 'owner', created_at: '2026-02-01', companies: { team_id: null } },
|
||||
]
|
||||
const { supabase } = buildSupabase(
|
||||
{
|
||||
user_preferences: { maybeSingle: { data: { active_company_id: 'frozen-co' } } },
|
||||
// The gated path awaits the list query (no maybeSingle): seed the
|
||||
// chain's `order` terminal.
|
||||
company_members: { order: { data: memberships } },
|
||||
// No multi_user grants at all -> frozen-co is frozen for the member
|
||||
// (the shared rpc mock returns zero rows for company_multi_user_state
|
||||
// too, so getMultiUserState falls through to this grants read).
|
||||
capability_grants: { or: { data: [] } },
|
||||
},
|
||||
// Zero RPC rows = NULL auth.uid() (service-role client): routes to the
|
||||
// GATED query path, unlike PGRST202 (migration absent), which fails open.
|
||||
{ data: [] },
|
||||
)
|
||||
|
||||
// Preference points at the frozen company: resolution must skip it and
|
||||
// land on the owned company instead of locking the user out.
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('owned-co')
|
||||
})
|
||||
|
||||
it('PGRST202 on the gated rpc fails OPEN via the ungated query path (deploy race)', async () => {
|
||||
multiUserSeam.enforced = true
|
||||
// Default rpcResult is PGRST202. Pre-migration there are no multi_user
|
||||
// rows, so the gated path would freeze this non-owner: the fallback must
|
||||
// be the UNGATED path and still resolve their membership.
|
||||
const { supabase } = buildSupabase({
|
||||
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
|
||||
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
||||
})
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompanyDisplayName', () => {
|
||||
it('returns company_settings.company_name and never reads companies when set', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
|
||||
@@ -35,6 +35,11 @@ export async function switchCompany(companyId: string): Promise<{ error?: string
|
||||
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' }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getMultiUserState, isMultiUserEnforced } from '@/lib/entitlements/multi-user'
|
||||
import { isMembershipDormant, MULTI_USER_GRACE_DAYS } from '@/lib/entitlements/multi-user-state'
|
||||
|
||||
/**
|
||||
* Active-company resolution with no Next.js request-scope dependency.
|
||||
@@ -21,7 +23,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
export class CompanyContextError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed'
|
||||
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed' | 'company_locked'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'CompanyContextError'
|
||||
@@ -55,7 +57,17 @@ export async function getActiveCompanyId(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const { data, error } = await supabase.rpc('resolve_active_company')
|
||||
// Multi-user seat gate (lib/entitlements/multi-user.ts): when enforced,
|
||||
// resolution must skip memberships in companies frozen for this user
|
||||
// (non-owner, multi_user lapsed past its grace window). The gated RPC is
|
||||
// the zero-arg one plus exactly that predicate; self-hosted and dev keep
|
||||
// calling the ungated function so the gate can never bite there.
|
||||
const enforced = isMultiUserEnforced()
|
||||
const { data, error } = enforced
|
||||
? await supabase.rpc('resolve_active_company_gated', {
|
||||
p_grace_days: MULTI_USER_GRACE_DAYS,
|
||||
})
|
||||
: await supabase.rpc('resolve_active_company')
|
||||
|
||||
if (error) {
|
||||
// PGRST202: function not in the schema cache (self-hosted instance not
|
||||
@@ -66,7 +78,19 @@ export async function getActiveCompanyId(
|
||||
// branch) and lib/auth/api-keys.ts call this with
|
||||
// createServiceClientNoCookies(), and must silently resolve via the query
|
||||
// path or the OAuth token flow breaks.
|
||||
if (error.code === 'PGRST202' || error.code === '42501') {
|
||||
//
|
||||
// The two fallbacks differ in seat-gate polarity ON PURPOSE:
|
||||
// PGRST202 = the gated function does not exist here, i.e. the paywall
|
||||
// migration has not reached this database (deploy race, self-host
|
||||
// mid-migration). There are no multi_user rows either, so the gated
|
||||
// query path would freeze every non-owner: fail OPEN via the ungated
|
||||
// path, matching the middleware's own PGRST202 handling.
|
||||
// 42501 / zero rows = a migrated database reached with a service-role
|
||||
// client (API keys, MCP, OAuth): the gated query path enforces there.
|
||||
if (error.code === 'PGRST202') {
|
||||
return getActiveCompanyIdViaQueriesUngated(supabase, userId)
|
||||
}
|
||||
if (error.code === '42501') {
|
||||
return getActiveCompanyIdViaQueries(supabase, userId)
|
||||
}
|
||||
throw new CompanyContextError(
|
||||
@@ -91,12 +115,113 @@ export async function getActiveCompanyId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
|
||||
* fallback for getActiveCompanyId (see the fallback conditions there).
|
||||
* Query-path resolution: the pre-RPC implementation, kept as the fallback for
|
||||
* getActiveCompanyId (see the fallback conditions there). With the seat gate
|
||||
* enforced it routes to the gated variant below, because every service-role
|
||||
* caller (API keys, MCP, OAuth token flow) lands on this path on EVERY
|
||||
* request: leaving it ungated would make the API surface a paywall bypass.
|
||||
*/
|
||||
async function getActiveCompanyIdViaQueries(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (isMultiUserEnforced()) {
|
||||
return getActiveCompanyIdViaQueriesGated(supabase, userId)
|
||||
}
|
||||
return getActiveCompanyIdViaQueriesUngated(supabase, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gated query path: same resolution order (validated preference, else first
|
||||
* membership by created_at) restricted to memberships the seat gate lets
|
||||
* through: owner role, or a company whose multi_user grant is active or
|
||||
* within its 20-day grace window. Mirrors resolve_active_company_gated().
|
||||
*
|
||||
* Fail-open on the grants read specifically: a transient capability_grants
|
||||
* failure must never lock people out of their bookkeeping. The membership
|
||||
* and preference reads keep the fail-loud behavior of the ungated path.
|
||||
*/
|
||||
async function getActiveCompanyIdViaQueriesGated(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const [prefsRes, membershipsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select('company_id, role, created_at, companies!inner(archived_at, team_id)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true }),
|
||||
])
|
||||
|
||||
const resolutionError = prefsRes.error ?? membershipsRes.error
|
||||
if (resolutionError) {
|
||||
throw new CompanyContextError(
|
||||
`Active company resolution failed: ${resolutionError.message}`,
|
||||
'resolution_failed'
|
||||
)
|
||||
}
|
||||
|
||||
type MembershipRow = {
|
||||
company_id: string
|
||||
role: string
|
||||
companies: { team_id: string | null }
|
||||
}
|
||||
const memberships = (membershipsRes.data ?? []) as unknown as MembershipRow[]
|
||||
if (memberships.length === 0) return null
|
||||
|
||||
const dormant = await resolveDormantCompanyIds(supabase, memberships)
|
||||
|
||||
const accessible = memberships.filter((m) => !dormant.has(m.company_id) || m.role === 'owner')
|
||||
const preferred = prefsRes.data?.active_company_id
|
||||
if (preferred && accessible.some((m) => m.company_id === preferred)) {
|
||||
return preferred
|
||||
}
|
||||
return accessible[0]?.company_id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these memberships' companies are dormant FOR THE MEMBER ROLE
|
||||
* (owner rows are exempt by the caller): companies whose multi_user grants
|
||||
* (company- or team-scoped) are all lapsed past the grace window. Shared by
|
||||
* the gated query fallback here and the switcher's locked-state computation
|
||||
* in the dashboard layout.
|
||||
*
|
||||
* Resolved per company through getMultiUserState (RPC-first): the
|
||||
* capability_grants SELECT policy hides team-scoped rows from users outside
|
||||
* the team, so a direct grants read through a user-scoped client would mark
|
||||
* every team-covered (byrå) company dormant. Fail-open by construction:
|
||||
* getMultiUserState answers 'entitled' on any read failure.
|
||||
*/
|
||||
export async function resolveDormantCompanyIds(
|
||||
supabase: SupabaseClient,
|
||||
memberships: readonly { company_id: string; role: string; companies: { team_id: string | null } }[]
|
||||
): Promise<Set<string>> {
|
||||
const nonOwner = memberships.filter((m) => m.role !== 'owner')
|
||||
if (nonOwner.length === 0 || !isMultiUserEnforced()) return new Set()
|
||||
|
||||
const companyIds = [...new Set(nonOwner.map((m) => m.company_id))]
|
||||
const states = await Promise.all(
|
||||
companyIds.map((companyId) => getMultiUserState(supabase, companyId))
|
||||
)
|
||||
const dormant = new Set<string>()
|
||||
companyIds.forEach((companyId, index) => {
|
||||
if (isMembershipDormant('member', states[index].state)) dormant.add(companyId)
|
||||
})
|
||||
return dormant
|
||||
}
|
||||
|
||||
/**
|
||||
* Ungated query-path resolution: the pre-RPC implementation, kept verbatim.
|
||||
*/
|
||||
async function getActiveCompanyIdViaQueriesUngated(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
// user_preferences (authoritative) + first membership, fetched in parallel:
|
||||
// the fallback query result doubles as validation when the preferred
|
||||
|
||||
+14
-1
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { EntityType } from '@/types'
|
||||
import { CompanyContextError, getActiveCompanyId } from '@/lib/company/active-company'
|
||||
import { isMembershipActive } from '@/lib/entitlements/multi-user'
|
||||
|
||||
// The resolver and its error class live in active-company.ts (no
|
||||
// `next/headers` there) so the API-key path can use them; re-exported here so
|
||||
@@ -129,7 +130,7 @@ export async function setActiveCompany(
|
||||
// Validate membership
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.select('company_id, role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
@@ -138,6 +139,18 @@ export async function setActiveCompany(
|
||||
throw new CompanyContextError('User is not a member of this company', 'not_member')
|
||||
}
|
||||
|
||||
// Multi-user seat gate: a non-owner may not switch INTO a company frozen
|
||||
// for them (multi_user lapsed past its grace window). Without this check
|
||||
// the preference write would stick and every subsequent gated resolution
|
||||
// would silently bounce them elsewhere, which reads as a broken switch.
|
||||
const seatGateOk = await isMembershipActive(supabase, companyId, membership.role)
|
||||
if (!seatGateOk) {
|
||||
throw new CompanyContextError(
|
||||
'Company is frozen for this membership: multi-user access requires a paid plan',
|
||||
'company_locked'
|
||||
)
|
||||
}
|
||||
|
||||
// Update user_preferences: this is the authoritative value RLS reads.
|
||||
// The write MUST be verified: an UPDATE filtered out by RLS affects zero
|
||||
// rows without raising an error, which previously made failed switches
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
computeMultiUserState,
|
||||
isMembershipDormant,
|
||||
MULTI_USER_GRACE_DAYS,
|
||||
} from '../multi-user-state'
|
||||
import { getMultiUserState, isMembershipActive, isMultiUserEnforced } from '../multi-user'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const COMPANY = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString()
|
||||
|
||||
type TableResult = { data: unknown; error?: unknown }
|
||||
/**
|
||||
* Per-table mock plus an rpc seam. getMultiUserState is RPC-first
|
||||
* (company_multi_user_state); the default rpc result is a TRANSIENT error so
|
||||
* the tests below exercise the grants-read fallback path unless they seed an
|
||||
* rpc answer explicitly.
|
||||
*/
|
||||
function makeSupabase(
|
||||
byTable: Record<string, TableResult>,
|
||||
rpcResult: TableResult = { data: null, error: { code: '57014', message: 'statement timeout' } },
|
||||
): SupabaseClient {
|
||||
const chainFor = (table: string) => {
|
||||
const result = byTable[table] ?? { data: null, error: null }
|
||||
const chain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
}
|
||||
return () => chain
|
||||
},
|
||||
},
|
||||
)
|
||||
return chain
|
||||
}
|
||||
return {
|
||||
from: (t: string) => chainFor(t),
|
||||
rpc: async () => ({ data: rpcResult.data ?? null, error: rpcResult.error ?? null }),
|
||||
} as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('computeMultiUserState', () => {
|
||||
const now = Date.now()
|
||||
|
||||
it('is entitled on a never-expiring grant', () => {
|
||||
expect(computeMultiUserState([{ expires_at: null }], now).state).toBe('entitled')
|
||||
})
|
||||
|
||||
it('is entitled on an unexpired grant even when an older one lapsed', () => {
|
||||
const access = computeMultiUserState(
|
||||
[{ expires_at: iso(-40 * DAY_MS) }, { expires_at: iso(60_000) }],
|
||||
now,
|
||||
)
|
||||
expect(access.state).toBe('entitled')
|
||||
expect(access.graceEndsAt).toBeNull()
|
||||
})
|
||||
|
||||
it('is in grace right after expiry, with graceEndsAt = expiry + 20 days', () => {
|
||||
const expiry = iso(-60_000)
|
||||
const access = computeMultiUserState([{ expires_at: expiry }], now)
|
||||
expect(access.state).toBe('grace')
|
||||
expect(new Date(access.graceEndsAt!).getTime()).toBe(
|
||||
new Date(expiry).getTime() + MULTI_USER_GRACE_DAYS * DAY_MS,
|
||||
)
|
||||
})
|
||||
|
||||
it('is still in grace on day 19 after the lapse', () => {
|
||||
const access = computeMultiUserState([{ expires_at: iso(-19 * DAY_MS) }], now)
|
||||
expect(access.state).toBe('grace')
|
||||
})
|
||||
|
||||
it('is frozen once the lapse is 20 full days old', () => {
|
||||
// Derive the expiry from the SAME clock the check uses: iso() reads
|
||||
// Date.now() at call time, which sits a few ms after `now` and would
|
||||
// land the boundary case back inside the grace window.
|
||||
const expiry = new Date(now - MULTI_USER_GRACE_DAYS * DAY_MS - 1000).toISOString()
|
||||
const access = computeMultiUserState([{ expires_at: expiry }], now)
|
||||
expect(access.state).toBe('frozen')
|
||||
expect(access.graceEndsAt).toBeNull()
|
||||
})
|
||||
|
||||
it('the NEWEST expiry drives the grace window (a fresh manual grant extends it)', () => {
|
||||
// Grandfather shape: trial lapsed long ago, backfill grant expired "now".
|
||||
const access = computeMultiUserState(
|
||||
[{ expires_at: iso(-56 * DAY_MS) }, { expires_at: iso(-1000) }],
|
||||
now,
|
||||
)
|
||||
expect(access.state).toBe('grace')
|
||||
})
|
||||
|
||||
it('is frozen when never granted', () => {
|
||||
expect(computeMultiUserState([], now).state).toBe('frozen')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMembershipDormant', () => {
|
||||
it('owners are never dormant, frozen company or not', () => {
|
||||
expect(isMembershipDormant('owner', 'frozen')).toBe(false)
|
||||
})
|
||||
it('non-owners are dormant only in the frozen state', () => {
|
||||
expect(isMembershipDormant('admin', 'frozen')).toBe(true)
|
||||
expect(isMembershipDormant('member', 'frozen')).toBe(true)
|
||||
expect(isMembershipDormant('viewer', 'frozen')).toBe(true)
|
||||
expect(isMembershipDormant('member', 'grace')).toBe(false)
|
||||
expect(isMembershipDormant('member', 'entitled')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMultiUserState', () => {
|
||||
it('is entitled on self-hosted without touching the DB', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
const supabase = makeSupabase({}) // would be frozen if the gate ran
|
||||
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
||||
})
|
||||
|
||||
it('is entitled under the dev bypass', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
const supabase = makeSupabase({})
|
||||
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
||||
})
|
||||
|
||||
it('FORCE_PAYWALL activates the real gate in development', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
vi.stubEnv('FORCE_PAYWALL', 'true')
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [] },
|
||||
})
|
||||
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('frozen')
|
||||
})
|
||||
|
||||
it('is frozen for a non-UUID company id', async () => {
|
||||
const supabase = makeSupabase({})
|
||||
expect((await getMultiUserState(supabase, 'not-a-uuid')).state).toBe('frozen')
|
||||
})
|
||||
|
||||
it('reads grants and derives grace from the newest expiry', async () => {
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [{ expires_at: iso(-DAY_MS) }] },
|
||||
})
|
||||
const access = await getMultiUserState(supabase, COMPANY)
|
||||
expect(access.state).toBe('grace')
|
||||
expect(access.graceEndsAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a known teamId skips the companies lookup and still resolves', async () => {
|
||||
const supabase = makeSupabase({
|
||||
// companies deliberately absent: querying it would resolve null and be harmless,
|
||||
// but the team grant below is what entitles.
|
||||
capability_grants: { data: [{ expires_at: null }] },
|
||||
})
|
||||
const access = await getMultiUserState(supabase, COMPANY, {
|
||||
teamId: '22222222-2222-4222-8222-222222222222',
|
||||
})
|
||||
expect(access.state).toBe('entitled')
|
||||
})
|
||||
|
||||
it('fails OPEN (entitled) on a grants read error', async () => {
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: null, error: { message: 'boom' } },
|
||||
})
|
||||
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
||||
})
|
||||
|
||||
it('prefers the SECURITY DEFINER state RPC over the grants read (team grants hidden by RLS)', async () => {
|
||||
// The tables would say frozen (no visible rows: the byrå-client shape);
|
||||
// the RPC sees the team grant and must win.
|
||||
const supabase = makeSupabase(
|
||||
{
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [] },
|
||||
},
|
||||
{ data: [{ state: 'entitled', grace_ends_at: null }] },
|
||||
)
|
||||
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
||||
})
|
||||
|
||||
it('passes the RPC grace deadline through', async () => {
|
||||
const graceEnd = iso(5 * DAY_MS)
|
||||
const supabase = makeSupabase({}, { data: [{ state: 'grace', grace_ends_at: graceEnd }] })
|
||||
const access = await getMultiUserState(supabase, COMPANY)
|
||||
expect(access.state).toBe('grace')
|
||||
expect(access.graceEndsAt).toBe(graceEnd)
|
||||
})
|
||||
|
||||
it('fails OPEN when the state RPC does not exist yet (deploy race, PGRST202)', async () => {
|
||||
// Pre-migration there are no multi_user rows either, so the grants
|
||||
// fallback would freeze every non-owner: PGRST202 must short-circuit to
|
||||
// entitled instead of reaching the table path.
|
||||
const supabase = makeSupabase(
|
||||
{
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [] }, // would read as frozen
|
||||
},
|
||||
{ data: null, error: { code: 'PGRST202', message: 'function not found' } },
|
||||
)
|
||||
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMembershipActive', () => {
|
||||
it('owner passes without a grants read', async () => {
|
||||
const supabase = makeSupabase({}) // would be frozen if consulted
|
||||
expect(await isMembershipActive(supabase, COMPANY, 'owner')).toBe(true)
|
||||
})
|
||||
|
||||
it('non-owner in a frozen company is inactive', async () => {
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [] },
|
||||
})
|
||||
expect(await isMembershipActive(supabase, COMPANY, 'member')).toBe(false)
|
||||
})
|
||||
|
||||
it('non-owner in grace stays active', async () => {
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [{ expires_at: iso(-DAY_MS) }] },
|
||||
})
|
||||
expect(await isMembershipActive(supabase, COMPANY, 'member')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMultiUserEnforced', () => {
|
||||
it('is enforced in the test environment (NODE_ENV=test, hosted)', () => {
|
||||
expect(isMultiUserEnforced()).toBe(true)
|
||||
})
|
||||
it('is not enforced on self-hosted', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
expect(isMultiUserEnforced()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
import { PAID_CAPABILITIES, isConnectorCapability, type CapabilityKey } from './keys'
|
||||
import { CAPABILITY, PAID_CAPABILITIES, isConnectorCapability, type CapabilityKey } from './keys'
|
||||
import { hasOwnCredentialsFor } from './own-credentials'
|
||||
import {
|
||||
computeMultiUserState,
|
||||
type MultiUserAccess,
|
||||
type MultiUserGrantRow,
|
||||
} from './multi-user-state'
|
||||
|
||||
/**
|
||||
* Entitlement gate: the single primitive behind the paywall ("non-payer loses
|
||||
@@ -377,6 +382,14 @@ export interface CompanyEntitlements {
|
||||
* entitlementState is 'trial_expired'. Drives the expired-trial notice.
|
||||
*/
|
||||
trialExpiredAt: string | null
|
||||
/**
|
||||
* Multi-user access state (entitled / grace / frozen) derived from the same
|
||||
* multi_user grant rows, with the end of the 20-day grace window while in
|
||||
* grace. Drives the countdown banner and the invite upsell; the server-side
|
||||
* dormancy enforcement recomputes it independently (lib/entitlements/
|
||||
* multi-user.ts and the resolve_active_company_gated RPC).
|
||||
*/
|
||||
multiUser: MultiUserAccess
|
||||
}
|
||||
|
||||
/** company_subscriptions.status values that count as a live subscription. */
|
||||
@@ -441,11 +454,18 @@ export async function getCompanyEntitlements(
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'paid',
|
||||
trialExpiredAt: null,
|
||||
multiUser: { state: 'entitled', graceEndsAt: null },
|
||||
}
|
||||
}
|
||||
// Fail-closed: never interpolate a non-UUID.
|
||||
if (!isUuid(companyId)) {
|
||||
return { capabilities: [], trialEndsAt: null, entitlementState: 'none', trialExpiredAt: null }
|
||||
return {
|
||||
capabilities: [],
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'none',
|
||||
trialExpiredAt: null,
|
||||
multiUser: { state: 'frozen', graceEndsAt: null },
|
||||
}
|
||||
}
|
||||
// Self-hosted: every local capability is held outright; only the connector
|
||||
// capabilities among the paid keys are read from grants (written with
|
||||
@@ -505,11 +525,18 @@ export async function getCompanyEntitlements(
|
||||
let latestTrialExpiry: string | null = null
|
||||
let hasActiveNonTrialGrant = false
|
||||
let hasActiveConnectorGrant = false
|
||||
// multi_user rows feed the derived grace/frozen state below; the rows are
|
||||
// already in this read (multi_user is a PAID key), so the state costs no
|
||||
// extra query. Self-host never reaches this (multi_user is held outright).
|
||||
const multiUserRows: MultiUserGrantRow[] = []
|
||||
for (const g of grants ?? []) {
|
||||
const row = g as { capability_key: string; expires_at: string | null; source: string | null }
|
||||
// Self-host: a trial-seeded (or any non-connector) row never unlocks a
|
||||
// connector capability; see connectorGrantsOnly().
|
||||
if (selfHosted && row.source !== 'connector') continue
|
||||
if (row.capability_key === CAPABILITY.multi_user) {
|
||||
multiUserRows.push({ expires_at: row.expires_at })
|
||||
}
|
||||
if (
|
||||
row.source === 'trial' &&
|
||||
row.expires_at &&
|
||||
@@ -536,6 +563,8 @@ export async function getCompanyEntitlements(
|
||||
trialEndsAt: null,
|
||||
entitlementState: hasActiveConnectorGrant ? 'paid' : 'none',
|
||||
trialExpiredAt: null,
|
||||
// multi_user is a local capability: a self-host is never seat-gated.
|
||||
multiUser: { state: 'entitled', graceEndsAt: null },
|
||||
}
|
||||
}
|
||||
// Paying/comped companies are not "on trial" even if the seeded trial rows
|
||||
@@ -560,11 +589,16 @@ export async function getCompanyEntitlements(
|
||||
entitlementState = 'none'
|
||||
}
|
||||
|
||||
const multiUser = computeMultiUserState(multiUserRows, now)
|
||||
|
||||
if (entitled.size === 0) {
|
||||
return { capabilities: [], trialEndsAt: null, entitlementState, trialExpiredAt }
|
||||
return { capabilities: [], trialEndsAt: null, entitlementState, trialExpiredAt, multiUser }
|
||||
}
|
||||
|
||||
// Subtract any explicitly-disabled (enablement axis).
|
||||
// Subtract any explicitly-disabled (enablement axis). multi_user is exempt
|
||||
// from the config axis by design (see lib/entitlements/multi-user-state.ts),
|
||||
// but it is also never written to company_capability_config, so the plain
|
||||
// subtraction stays correct for the capabilities list.
|
||||
for (const c of configs ?? []) {
|
||||
entitled.delete((c as { capability_key: string }).capability_key)
|
||||
}
|
||||
@@ -574,6 +608,7 @@ export async function getCompanyEntitlements(
|
||||
trialEndsAt,
|
||||
entitlementState,
|
||||
trialExpiredAt,
|
||||
multiUser,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ export const CAPABILITY = {
|
||||
woocommerce_sync: 'woocommerce_sync',
|
||||
/** Shopify store sync: orders/refunds imported as a transaction feed. */
|
||||
shopify_sync: 'shopify_sync',
|
||||
/**
|
||||
* Multiple people working in one company. Without it only the OWNER can
|
||||
* enter the company: every other membership goes dormant (never deleted)
|
||||
* after a 20-day post-lapse grace window, and new invites are blocked.
|
||||
* See lib/entitlements/multi-user.ts for the derived entitled/grace/frozen
|
||||
* state; company_capability_config does NOT apply to this key (it gates
|
||||
* member access, not a feature surface).
|
||||
*/
|
||||
multi_user: 'multi_user',
|
||||
/**
|
||||
* Invoice email from the company's own verified sending domain (Resend
|
||||
* domain per company). Opt-in: granted manually per company, NOT part of
|
||||
@@ -69,6 +78,10 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
||||
CAPABILITY.stripe_payments,
|
||||
CAPABILITY.woocommerce_sync,
|
||||
CAPABILITY.shopify_sync,
|
||||
// Founder decision (2026-09-01): multiple users per company is paid.
|
||||
// Trial-seeded and Stripe-synced like the rest; enforcement is the
|
||||
// owner-only dormancy rule in lib/entitlements/multi-user.ts.
|
||||
CAPABILITY.multi_user,
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Pure multi-user access model: no I/O, no env reads, imported by both
|
||||
* has-capability.ts (which derives the state from grant rows it already
|
||||
* fetched) and multi-user.ts (the async lookup helpers). Keep it that way:
|
||||
* this module must never import from either of them.
|
||||
*
|
||||
* The `multi_user` capability gates PEOPLE, not a feature surface, so its
|
||||
* lapse is softened by a grace window (founder decision 2026-09-01):
|
||||
*
|
||||
* entitled : an active multi_user grant (trial/stripe/team/manual/comp).
|
||||
* Everyone in the company works normally.
|
||||
* grace : the newest grant expired less than MULTI_USER_GRACE_DAYS ago.
|
||||
* Everyone still works; the countdown banner shows.
|
||||
* frozen : expired at least MULTI_USER_GRACE_DAYS ago, or never granted.
|
||||
* Only role = 'owner' memberships resolve; every other
|
||||
* membership is dormant (rows untouched: paying reactivates
|
||||
* them instantly), and new invites are blocked.
|
||||
*
|
||||
* The 20-day grace is the PERMANENT rule for every lapse (trial end,
|
||||
* subscription cancel, comp expiry), not a one-off migration affordance:
|
||||
* the grandfather backfill simply inserts a grant expiring at deploy time
|
||||
* so existing multi-member free companies enter the same window.
|
||||
*
|
||||
* company_capability_config is deliberately ignored for this key: a config
|
||||
* disable has no expiry to hang the grace window on, and freezing people
|
||||
* out of their bookkeeping must never happen through a side channel. The
|
||||
* DB twin (public.company_multi_user_ok) implements the same rule.
|
||||
*/
|
||||
|
||||
export const MULTI_USER_GRACE_DAYS = 20
|
||||
|
||||
const GRACE_MS = MULTI_USER_GRACE_DAYS * 86_400_000
|
||||
|
||||
export type MultiUserState = 'entitled' | 'grace' | 'frozen'
|
||||
|
||||
export interface MultiUserAccess {
|
||||
state: MultiUserState
|
||||
/** End of the post-lapse grace window (ISO); set only while state === 'grace'. */
|
||||
graceEndsAt: string | null
|
||||
}
|
||||
|
||||
export interface MultiUserGrantRow {
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
/** Derive the access state from the company's multi_user grant rows. */
|
||||
export function computeMultiUserState(
|
||||
rows: readonly MultiUserGrantRow[],
|
||||
now: number,
|
||||
): MultiUserAccess {
|
||||
let newestExpiryMs: number | null = null
|
||||
for (const row of rows) {
|
||||
if (row.expires_at === null) return { state: 'entitled', graceEndsAt: null }
|
||||
const expiryMs = new Date(row.expires_at).getTime()
|
||||
if (Number.isNaN(expiryMs)) continue
|
||||
if (expiryMs > now) return { state: 'entitled', graceEndsAt: null }
|
||||
if (newestExpiryMs === null || expiryMs > newestExpiryMs) newestExpiryMs = expiryMs
|
||||
}
|
||||
if (newestExpiryMs !== null && newestExpiryMs + GRACE_MS > now) {
|
||||
return { state: 'grace', graceEndsAt: new Date(newestExpiryMs + GRACE_MS).toISOString() }
|
||||
}
|
||||
return { state: 'frozen', graceEndsAt: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The dormancy rule, in one place. Owners always keep access (a company must
|
||||
* never lock out the person who can pay); everyone else needs the company to
|
||||
* be entitled or in grace. Membership `source` is deliberately NOT consulted
|
||||
* (founder decision 2026-09-01, superseding the earlier byrå exemption):
|
||||
* byrå consultants stay in through the company's or their team's grant, not
|
||||
* through a role carve-out.
|
||||
*/
|
||||
export function isMembershipDormant(role: string, state: MultiUserState): boolean {
|
||||
return state === 'frozen' && role !== 'owner'
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
import { CAPABILITY } from './keys'
|
||||
import {
|
||||
computeMultiUserState,
|
||||
isMembershipDormant,
|
||||
MULTI_USER_GRACE_DAYS,
|
||||
type MultiUserAccess,
|
||||
type MultiUserGrantRow,
|
||||
} from './multi-user-state'
|
||||
|
||||
export {
|
||||
MULTI_USER_GRACE_DAYS,
|
||||
isMembershipDormant,
|
||||
computeMultiUserState,
|
||||
type MultiUserAccess,
|
||||
type MultiUserGrantRow,
|
||||
} from './multi-user-state'
|
||||
export type { MultiUserState } from './multi-user-state'
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Whether the owner-only dormancy rule is enforced at all in this
|
||||
* environment. False on self-hosted instances (multi_user is a local
|
||||
* capability: an AGPL operator's own instance is never seat-gated) and under
|
||||
* the dev bypass; FORCE_PAYWALL flips it on in dev like every other gate.
|
||||
* Callers use this to pick the gated resolution RPC vs the plain one.
|
||||
*
|
||||
* Deliberately NOT delegated to has-capability's isBypassedFor: multi_user is
|
||||
* never a connector capability, so the logic reduces to these env reads, and
|
||||
* standing alone keeps this module import-light (it is called from the Edge
|
||||
* middleware on every request, and several test suites partially mock
|
||||
* has-capability without expecting resolution paths to pull it in).
|
||||
*/
|
||||
export function isMultiUserEnforced(): boolean {
|
||||
if (isSelfHosted()) return false
|
||||
if (process.env.FORCE_PAYWALL === 'true') return true
|
||||
const bypassed =
|
||||
process.env.NODE_ENV === 'development' || process.env.DISABLE_PAYWALL === 'true'
|
||||
return !bypassed
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a company's multi-user access state (entitled / grace / frozen)
|
||||
* from its multi_user grants, company- and team-scoped alike. Fail-open on
|
||||
* read errors: a transient grants failure must never lock people out of
|
||||
* their bookkeeping (the opposite polarity of hasCapability, which guards
|
||||
* paid external services and fails closed).
|
||||
*
|
||||
* RPC-FIRST: the company_multi_user_state() SECURITY DEFINER function is the
|
||||
* primary path, because the capability_grants SELECT policy hides
|
||||
* team-scoped rows from users who are not on the team: a byrå client company
|
||||
* read through a user-scoped client would misread as frozen when its only
|
||||
* coverage is the byrå team's grant. The raw grants read below is only the
|
||||
* fallback for a database that does not have the function yet (deploy race,
|
||||
* self-host mid-migration), where it fails toward access.
|
||||
*/
|
||||
export async function getMultiUserState(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: { teamId?: string | null } = {},
|
||||
): Promise<MultiUserAccess> {
|
||||
if (!isMultiUserEnforced()) return { state: 'entitled', graceEndsAt: null }
|
||||
if (!UUID_RE.test(companyId)) return { state: 'frozen', graceEndsAt: null }
|
||||
try {
|
||||
return await resolveMultiUserState(supabase, companyId, options)
|
||||
} catch {
|
||||
// Fail OPEN on ANY unexpected throw (a client without .rpc, a network
|
||||
// exception): a broken read must never lock people out of their books.
|
||||
return { state: 'entitled', graceEndsAt: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMultiUserState(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: { teamId?: string | null },
|
||||
): Promise<MultiUserAccess> {
|
||||
const { data: rpcData, error: rpcError } = await supabase.rpc('company_multi_user_state', {
|
||||
p_company_id: companyId,
|
||||
p_grace_days: MULTI_USER_GRACE_DAYS,
|
||||
})
|
||||
if (!rpcError) {
|
||||
const row = (Array.isArray(rpcData) ? rpcData[0] : rpcData) as
|
||||
| { state: string; grace_ends_at: string | null }
|
||||
| null
|
||||
| undefined
|
||||
if (row?.state === 'entitled' || row?.state === 'grace' || row?.state === 'frozen') {
|
||||
return { state: row.state, graceEndsAt: row.grace_ends_at ?? null }
|
||||
}
|
||||
} else if (rpcError.code === 'PGRST202') {
|
||||
// Function absent = the paywall migration (and its backfills) has not
|
||||
// reached this database yet: there are no multi_user rows to judge by, so
|
||||
// the grants fallback would freeze every non-owner. Fail OPEN for the
|
||||
// deploy-race window; the gate arms itself when the migration lands.
|
||||
return { state: 'entitled', graceEndsAt: null }
|
||||
}
|
||||
|
||||
let teamId = options.teamId
|
||||
if (teamId === undefined) {
|
||||
const { data: company, error } = await supabase
|
||||
.from('companies')
|
||||
.select('team_id')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) return { state: 'entitled', graceEndsAt: null } // fail-open
|
||||
teamId = (company as { team_id: string | null } | null)?.team_id ?? null
|
||||
}
|
||||
const validTeamId = teamId && UUID_RE.test(teamId) ? teamId : null
|
||||
|
||||
const scopeFilter = validTeamId
|
||||
? `company_id.eq.${companyId},team_id.eq.${validTeamId}`
|
||||
: `company_id.eq.${companyId}`
|
||||
const { data: grants, error: grantsError } = await supabase
|
||||
.from('capability_grants')
|
||||
.select('expires_at')
|
||||
.eq('capability_key', CAPABILITY.multi_user)
|
||||
.or(scopeFilter)
|
||||
if (grantsError) return { state: 'entitled', graceEndsAt: null } // fail-open
|
||||
|
||||
return computeMultiUserState((grants ?? []) as MultiUserGrantRow[], Date.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether THIS membership may enter the company right now: the dormancy rule
|
||||
* applied to a resolved (companyId, role) pair. Owners always pass without a
|
||||
* grants read.
|
||||
*/
|
||||
export async function isMembershipActive(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
role: string,
|
||||
options: { teamId?: string | null } = {},
|
||||
): Promise<boolean> {
|
||||
if (role === 'owner') return true
|
||||
if (!isMultiUserEnforced()) return true
|
||||
const access = await getMultiUserState(supabase, companyId, options)
|
||||
return !isMembershipDormant(role, access.state)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import {
|
||||
computeMultiUserState,
|
||||
MULTI_USER_GRACE_DAYS,
|
||||
type MultiUserGrantRow,
|
||||
} from '@/lib/entitlements/multi-user-state'
|
||||
import { isMultiUserEnforced } from '@/lib/entitlements/multi-user'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('notifications/multi-user-grace')
|
||||
|
||||
/**
|
||||
* Multi-user grace reminders: mails company OWNERS when the 20-day grace
|
||||
* window opens (their extra members will pause) and again on its last day.
|
||||
*
|
||||
* Windowing keeps the daily cron idempotent without a sent-log: with the
|
||||
* newest multi_user expiry E and grace end G = E + 20d,
|
||||
* "start" mail : E in (now - 24h, now] (the lapse happened since the
|
||||
* previous daily run)
|
||||
* "final" mail : G in (now, now + 24h] (the freeze lands before the
|
||||
* next daily run)
|
||||
* The two windows can only both hit for a sub-24h grace, which the 20-day
|
||||
* constant rules out. Companies with no non-owner members are skipped:
|
||||
* single-person companies must never hear about the seat gate.
|
||||
*
|
||||
* The launch cohort's mail is sent by hand (founder decision 2026-09-01);
|
||||
* this cron owns every FUTURE lapse: trial ends, subscription cancellations.
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
export interface MultiUserGraceSummary {
|
||||
companiesInGrace: number
|
||||
startMails: number
|
||||
finalMails: number
|
||||
errors: number
|
||||
skipped?: 'not_enforced' | 'email_not_configured'
|
||||
}
|
||||
|
||||
interface GrantRow {
|
||||
company_id: string | null
|
||||
team_id: string | null
|
||||
expires_at: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface TrackedGrant extends MultiUserGrantRow {
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export async function runMultiUserGraceReminders(
|
||||
supabase: SupabaseClient,
|
||||
now: Date,
|
||||
): Promise<MultiUserGraceSummary> {
|
||||
const summary: MultiUserGraceSummary = {
|
||||
companiesInGrace: 0,
|
||||
startMails: 0,
|
||||
finalMails: 0,
|
||||
errors: 0,
|
||||
}
|
||||
if (!isMultiUserEnforced()) {
|
||||
summary.skipped = 'not_enforced'
|
||||
return summary
|
||||
}
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
summary.skipped = 'email_not_configured'
|
||||
return summary
|
||||
}
|
||||
|
||||
// All multi_user grants in one read: the key has at most a handful of rows
|
||||
// per company, and the whole-table scan on one capability_key is what the
|
||||
// idx_capability_grants_key index exists for.
|
||||
const { data: grantRows, error: grantsError } = await supabase
|
||||
.from('capability_grants')
|
||||
.select('company_id, team_id, expires_at, metadata')
|
||||
.eq('capability_key', CAPABILITY.multi_user)
|
||||
if (grantsError) {
|
||||
throw new Error(`multi-user grace: grants read failed: ${grantsError.message}`)
|
||||
}
|
||||
const grants = (grantRows ?? []) as GrantRow[]
|
||||
|
||||
const companyGrants = new Map<string, TrackedGrant[]>()
|
||||
const teamGrants = new Map<string, TrackedGrant[]>()
|
||||
for (const g of grants) {
|
||||
if (g.company_id) {
|
||||
const list = companyGrants.get(g.company_id) ?? []
|
||||
list.push({ expires_at: g.expires_at, metadata: g.metadata })
|
||||
companyGrants.set(g.company_id, list)
|
||||
}
|
||||
if (g.team_id) {
|
||||
const list = teamGrants.get(g.team_id) ?? []
|
||||
list.push({ expires_at: g.expires_at, metadata: g.metadata })
|
||||
teamGrants.set(g.team_id, list)
|
||||
}
|
||||
}
|
||||
|
||||
if (companyGrants.size === 0 && teamGrants.size === 0) return summary
|
||||
|
||||
// Candidate companies: company-scoped grant holders PLUS every company of
|
||||
// a team that holds grants (a byrå whose team agreement lapses has client
|
||||
// companies with zero company-scoped rows, and their owners must be mailed
|
||||
// like anyone else's).
|
||||
const companies = new Map<string, { id: string; name: string; team_id: string | null }>()
|
||||
const companyIds = [...companyGrants.keys()]
|
||||
for (let i = 0; i < companyIds.length; i += 200) {
|
||||
const chunk = companyIds.slice(i, i + 200)
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name, team_id, archived_at')
|
||||
.in('id', chunk)
|
||||
.is('archived_at', null)
|
||||
if (error) throw new Error(`multi-user grace: companies read failed: ${error.message}`)
|
||||
for (const c of (data ?? []) as { id: string; name: string; team_id: string | null }[]) {
|
||||
companies.set(c.id, c)
|
||||
}
|
||||
}
|
||||
const teamIds = [...teamGrants.keys()]
|
||||
for (let i = 0; i < teamIds.length; i += 200) {
|
||||
const chunk = teamIds.slice(i, i + 200)
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name, team_id, archived_at')
|
||||
.in('team_id', chunk)
|
||||
.is('archived_at', null)
|
||||
if (error) throw new Error(`multi-user grace: team companies read failed: ${error.message}`)
|
||||
for (const c of (data ?? []) as { id: string; name: string; team_id: string | null }[]) {
|
||||
companies.set(c.id, c)
|
||||
}
|
||||
}
|
||||
|
||||
const nowMs = now.getTime()
|
||||
for (const company of companies.values()) {
|
||||
const rows = [
|
||||
...(companyGrants.get(company.id) ?? []),
|
||||
...(company.team_id ? (teamGrants.get(company.team_id) ?? []) : []),
|
||||
]
|
||||
const access = computeMultiUserState(rows, nowMs)
|
||||
if (access.state !== 'grace' || !access.graceEndsAt) continue
|
||||
summary.companiesInGrace += 1
|
||||
|
||||
const graceEndMs = new Date(access.graceEndsAt).getTime()
|
||||
const lapseMs = graceEndMs - MULTI_USER_GRACE_DAYS * DAY_MS
|
||||
// The launch cohort's start mail is sent by hand (founder decision
|
||||
// 2026-09-01): the grandfather backfill row expires at deploy time, so
|
||||
// without this check the first cron run after deploy would re-mail the
|
||||
// whole cohort. The day-19 final reminder still goes out.
|
||||
const anchorIsGrandfather = rows.some(
|
||||
(r) =>
|
||||
r.expires_at !== null &&
|
||||
new Date(r.expires_at).getTime() === lapseMs &&
|
||||
(r.metadata as { reason?: string } | null)?.reason === 'multi_user_grandfather',
|
||||
)
|
||||
const isStart = !anchorIsGrandfather && lapseMs > nowMs - DAY_MS && lapseMs <= nowMs
|
||||
const isFinal = graceEndMs > nowMs && graceEndMs <= nowMs + DAY_MS
|
||||
if (!isStart && !isFinal) continue
|
||||
|
||||
try {
|
||||
const sent = await sendGraceMail(supabase, {
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
kind: isFinal ? 'final' : 'start',
|
||||
graceEndsAt: access.graceEndsAt,
|
||||
})
|
||||
if (sent === 0) continue
|
||||
if (isFinal) summary.finalMails += sent
|
||||
else summary.startMails += sent
|
||||
} catch (err) {
|
||||
summary.errors += 1
|
||||
logger.error('multi-user grace mail failed', err as Error, { companyId: company.id })
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
async function sendGraceMail(
|
||||
supabase: SupabaseClient,
|
||||
args: { companyId: string; companyName: string; kind: 'start' | 'final'; graceEndsAt: string },
|
||||
): Promise<number> {
|
||||
// Sandbox/demo companies have no billing: never mail them.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox, company_name')
|
||||
.eq('company_id', args.companyId)
|
||||
.maybeSingle()
|
||||
if ((settings as { is_sandbox?: boolean } | null)?.is_sandbox === true) return 0
|
||||
const companyName =
|
||||
(settings as { company_name?: string | null } | null)?.company_name || args.companyName
|
||||
|
||||
const { data: members } = await supabase
|
||||
.from('company_members')
|
||||
.select('user_id, role')
|
||||
.eq('company_id', args.companyId)
|
||||
const memberRows = (members ?? []) as { user_id: string; role: string }[]
|
||||
const affected = memberRows.filter((m) => m.role !== 'owner')
|
||||
// Single-person companies never hear about the seat gate.
|
||||
if (affected.length === 0) return 0
|
||||
const owners = memberRows.filter((m) => m.role === 'owner')
|
||||
if (owners.length === 0) return 0
|
||||
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.in('id', memberRows.map((m) => m.user_id))
|
||||
const emailById = new Map(
|
||||
((profiles ?? []) as { id: string; email: string | null }[]).map((p) => [p.id, p.email]),
|
||||
)
|
||||
const ownerEmails = owners.map((o) => emailById.get(o.user_id)).filter((e): e is string => !!e)
|
||||
const affectedEmails = affected
|
||||
.map((a) => emailById.get(a.user_id))
|
||||
.filter((e): e is string => !!e)
|
||||
if (ownerEmails.length === 0) return 0
|
||||
|
||||
const sender = await getSenderForCompany(args.companyId)
|
||||
const appUrl = sender.brand ? getBaseUrlForBrand(sender.brand) : getBranding().appUrl
|
||||
const billingUrl = `${appUrl}/settings/billing`
|
||||
const freezeDate = formatDate(args.graceEndsAt)
|
||||
const affectedList = affectedEmails.join(', ')
|
||||
|
||||
const subject =
|
||||
args.kind === 'final'
|
||||
? `Imorgon pausas fler användare i ${companyName}`
|
||||
: `Fler användare i ${companyName} kräver betald plan`
|
||||
const intro =
|
||||
args.kind === 'final'
|
||||
? `Imorgon (${freezeDate}) pausas följande konton från ${companyName}: ${affectedList}.`
|
||||
: `Från och med den ${freezeDate} ingår flera användare endast i den betalda planen. Då pausas följande konton från ${companyName}: ${affectedList}.`
|
||||
const outro =
|
||||
'Ingen data försvinner och inga användare tas bort. Uppgraderar ni, nu eller senare, ' +
|
||||
'får alla tillbaka sin åtkomst direkt.'
|
||||
const body = `Hej!\n\n${intro}\n\n${outro}\n\nUppgradera här: ${billingUrl}\n`
|
||||
const html =
|
||||
`<p>Hej!</p><p>${intro}</p><p>${outro}</p>` +
|
||||
`<p><a href="${billingUrl}">Uppgradera till betald plan</a></p>`
|
||||
|
||||
const emailService = getEmailService()
|
||||
let sent = 0
|
||||
for (const to of ownerEmails) {
|
||||
const result = await emailService.sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: body,
|
||||
fromName: sender.fromName ?? undefined,
|
||||
fromAddress: sender.fromAddress ?? undefined,
|
||||
replyTo: sender.replyTo ?? undefined,
|
||||
})
|
||||
if (result.success) sent += 1
|
||||
else logger.warn('multi-user grace mail send failed', { companyId: args.companyId })
|
||||
}
|
||||
return sent
|
||||
}
|
||||
@@ -13,7 +13,7 @@ afterEach(() => vi.unstubAllEnvs())
|
||||
// can assert what applySubscriptionState wrote, without a real DB.
|
||||
interface RecordedOp {
|
||||
table: string
|
||||
op: 'upsert' | 'delete' | null
|
||||
op: 'upsert' | 'delete' | 'update' | null
|
||||
payload: unknown
|
||||
conflict: string | undefined
|
||||
filters: Array<[string, unknown]>
|
||||
@@ -36,10 +36,24 @@ function recordingSupabase() {
|
||||
calls.push(ctx)
|
||||
return chain
|
||||
},
|
||||
update(payload: unknown) {
|
||||
ctx.op = 'update'
|
||||
ctx.payload = payload
|
||||
calls.push(ctx)
|
||||
return chain
|
||||
},
|
||||
eq(col: string, val: unknown) {
|
||||
ctx.filters.push([col, val])
|
||||
return chain
|
||||
},
|
||||
neq(col: string, val: unknown) {
|
||||
ctx.filters.push([`neq:${col}`, val])
|
||||
return chain
|
||||
},
|
||||
or(filter: string) {
|
||||
ctx.filters.push(['or', filter])
|
||||
return chain
|
||||
},
|
||||
then(resolve: (v: { data: null; error: null }) => void) {
|
||||
resolve({ data: null, error: null })
|
||||
},
|
||||
@@ -115,11 +129,11 @@ describe('applySubscriptionState', () => {
|
||||
const grantUpsert = calls.find((c) => c.table === 'capability_grants')
|
||||
expect(grantUpsert?.op).toBe('upsert')
|
||||
const rows = grantUpsert?.payload as Array<{ capability_key: string; source: string }>
|
||||
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
|
||||
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'multi_user', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
|
||||
expect(rows.every((r) => r.source === 'stripe')).toBe(true)
|
||||
})
|
||||
|
||||
it('removes only the stripe grants when canceled (freeze-and-retain)', async () => {
|
||||
it('removes the stripe grants when canceled but EXPIRES multi_user (grace anchor)', async () => {
|
||||
const { supabase, calls } = recordingSupabase()
|
||||
await applySubscriptionState(supabase, {
|
||||
companyId: 'co_1',
|
||||
@@ -129,9 +143,20 @@ describe('applySubscriptionState', () => {
|
||||
plan: null,
|
||||
currentPeriodEnd: null,
|
||||
})
|
||||
const grantOp = calls.find((c) => c.table === 'capability_grants')
|
||||
expect(grantOp?.op).toBe('delete')
|
||||
expect(grantOp?.filters).toContainEqual(['company_id', 'co_1'])
|
||||
expect(grantOp?.filters).toContainEqual(['source', 'stripe'])
|
||||
const grantOps = calls.filter((c) => c.table === 'capability_grants')
|
||||
// Freeze-and-retain deletes every external-service grant, except
|
||||
// multi_user, whose 20-day grace window hangs on an EXPIRED row: a
|
||||
// deleted row would freeze the churned payer's staff instantly.
|
||||
const deleteOp = grantOps.find((c) => c.op === 'delete')
|
||||
expect(deleteOp?.filters).toContainEqual(['company_id', 'co_1'])
|
||||
expect(deleteOp?.filters).toContainEqual(['source', 'stripe'])
|
||||
expect(deleteOp?.filters).toContainEqual(['neq:capability_key', 'multi_user'])
|
||||
const updateOp = grantOps.find((c) => c.op === 'update')
|
||||
expect(updateOp?.filters).toContainEqual(['capability_key', 'multi_user'])
|
||||
const payload = updateOp?.payload as { expires_at: string }
|
||||
expect(new Date(payload.expires_at).getTime()).toBeLessThanOrEqual(Date.now())
|
||||
// Only a still-active row is expired: a re-delivered cancel event must
|
||||
// not slide the grace anchor forward (the .or filter scopes the update).
|
||||
expect(updateOp?.filters.some(([col]) => col === 'or')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type Stripe from 'stripe'
|
||||
import { PAID_CAPABILITIES } from '@/lib/entitlements/keys'
|
||||
import { CAPABILITY, PAID_CAPABILITIES } from '@/lib/entitlements/keys'
|
||||
import type { BillingPlan } from './client'
|
||||
|
||||
/**
|
||||
@@ -104,11 +104,28 @@ export async function applySubscriptionState(
|
||||
} else {
|
||||
// Freeze-and-retain: drop only the stripe grants. Trial/comp grants (if any)
|
||||
// are untouched; data and tokens are never deleted.
|
||||
//
|
||||
// multi_user is the one exception to the delete: its 20-day grace window
|
||||
// hangs on "the newest grant EXPIRED less than 20 days ago"
|
||||
// (lib/entitlements/multi-user-state.ts), so a deleted row would freeze a
|
||||
// churned payer's non-owner members instantly, with no countdown banner
|
||||
// and no owner mail. Expiring the row NOW anchors the grace exactly at
|
||||
// the cancellation, and the grace cron's start-mail window picks it up.
|
||||
// Only a still-active row is expired: a re-delivered cancel event days
|
||||
// later must not slide the grace anchor (and the mail window) forward.
|
||||
await supabase
|
||||
.from('capability_grants')
|
||||
.delete()
|
||||
.eq('company_id', state.companyId)
|
||||
.eq('source', 'stripe')
|
||||
.neq('capability_key', CAPABILITY.multi_user)
|
||||
await supabase
|
||||
.from('capability_grants')
|
||||
.update({ expires_at: new Date().toISOString() })
|
||||
.eq('company_id', state.companyId)
|
||||
.eq('source', 'stripe')
|
||||
.eq('capability_key', CAPABILITY.multi_user)
|
||||
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
-14
@@ -11,6 +11,8 @@ import {
|
||||
type ProxyTimings,
|
||||
} from '@/lib/supabase/proxy-timing'
|
||||
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
||||
import { isMultiUserEnforced } from '@/lib/entitlements/multi-user'
|
||||
import { MULTI_USER_GRACE_DAYS } from '@/lib/entitlements/multi-user-state'
|
||||
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
|
||||
import { userHasPassword } from '@/lib/auth/has-password'
|
||||
@@ -435,6 +437,7 @@ async function updateSessionInner(
|
||||
companyId: string | null
|
||||
locale: string | null
|
||||
degraded: boolean
|
||||
allLocked: boolean
|
||||
} | null = null
|
||||
const resolveCompanyOnce = async () =>
|
||||
(resolvedCompany ??= await timed(timing, 'companyMs', () =>
|
||||
@@ -485,7 +488,7 @@ async function updateSessionInner(
|
||||
|
||||
// Company context resolution
|
||||
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
||||
const { companyId, locale: dbLocale, degraded } = await resolveCompanyOnce()
|
||||
const { companyId, locale: dbLocale, degraded, allLocked } = await resolveCompanyOnce()
|
||||
|
||||
// If the cookie pointed at a company we can no longer resolve (e.g.
|
||||
// archived), clear it so the browser stops sending it. Never on degraded
|
||||
@@ -516,7 +519,10 @@ async function updateSessionInner(
|
||||
pathname.startsWith('/select-company') ||
|
||||
pathname.startsWith('/settings/account') ||
|
||||
pathname.startsWith('/api/account/') ||
|
||||
pathname.startsWith('/api/company')
|
||||
pathname.startsWith('/api/company') ||
|
||||
// Multi-user seat gate: the paused page IS the destination for a user
|
||||
// whose every membership is frozen, so it must render in that state.
|
||||
pathname.startsWith('/paused')
|
||||
|
||||
// No companies: redirect to the picker if we have BankID enrichment for
|
||||
// this user, otherwise the manual wizard. Either way, allow the escape-hatch
|
||||
@@ -565,6 +571,18 @@ async function updateSessionInner(
|
||||
return NextResponse.redirect(new URL('/byra', request.url))
|
||||
}
|
||||
|
||||
// Multi-user seat gate: memberships exist but every one is frozen for
|
||||
// this (non-owner) user. This is NOT the no-company state: sending them
|
||||
// to onboarding would walk a locked-out colleague into creating a
|
||||
// pointless company. The paused page explains and names the companies.
|
||||
// API requests pass through so routes answer JSON, not an HTML redirect.
|
||||
if (allLocked) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return supabaseResponse
|
||||
}
|
||||
return NextResponse.redirect(new URL('/paused', request.url))
|
||||
}
|
||||
|
||||
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
|
||||
// 20260506160000), it cannot live in extension_data, which is
|
||||
// company-scoped, and the user has no company yet on this path.
|
||||
@@ -965,19 +983,32 @@ async function resolveCompanyForMiddleware(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
||||
const { data, error } = await supabase.rpc('resolve_active_company')
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean; allLocked: boolean }> {
|
||||
// Multi-user seat gate: the gated RPC skips memberships frozen for this
|
||||
// user (non-owner, multi_user lapsed past its 20-day grace) and reports
|
||||
// has_locked_membership when NOTHING resolved because of that, which is
|
||||
// what routes the user to /paused instead of onboarding. Self-hosted and
|
||||
// dev call the ungated function: the gate never bites there.
|
||||
const enforced = isMultiUserEnforced()
|
||||
const { data, error } = enforced
|
||||
? await supabase.rpc('resolve_active_company_gated', {
|
||||
p_grace_days: MULTI_USER_GRACE_DAYS,
|
||||
})
|
||||
: await supabase.rpc('resolve_active_company')
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST202') {
|
||||
// Function not deployed here: use the query path.
|
||||
// Function not deployed here (self-host not migrated yet, or a deploy
|
||||
// racing the branch merge): use the ungated query path. The race
|
||||
// window fails OPEN for the seat gate on purpose: never lock people
|
||||
// out because a deploy is mid-flight.
|
||||
return resolveCompanyForMiddlewareViaQueries(supabase, userId, _request)
|
||||
}
|
||||
// Issue #1053: a FAILED call degrades (fail open), never reads as "no
|
||||
// companies". locale null is fine because the degraded flag already
|
||||
// suppresses the locale-cookie sync at the call site.
|
||||
console.error('[middleware] resolve_active_company rpc failed', error)
|
||||
return { companyId: null, locale: null, degraded: true }
|
||||
return { companyId: null, locale: null, degraded: true, allLocked: false }
|
||||
}
|
||||
|
||||
const row = Array.isArray(data) ? data[0] : data
|
||||
@@ -985,7 +1016,7 @@ async function resolveCompanyForMiddleware(
|
||||
// Zero rows = NULL auth.uid(); impossible for the cookie-auth middleware
|
||||
// client, so treat as degraded rather than redirecting to onboarding.
|
||||
console.error('[middleware] resolve_active_company returned no row for authenticated user')
|
||||
return { companyId: null, locale: null, degraded: true }
|
||||
return { companyId: null, locale: null, degraded: true, allLocked: false }
|
||||
}
|
||||
|
||||
if (row.company_id && row.used_fallback) {
|
||||
@@ -1008,6 +1039,9 @@ async function resolveCompanyForMiddleware(
|
||||
companyId: row.company_id ?? null,
|
||||
locale: row.locale ?? null,
|
||||
degraded: false,
|
||||
// Only the gated RPC carries the column; the ungated one leaves it
|
||||
// undefined, which correctly reads as false.
|
||||
allLocked: row.has_locked_membership === true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,7 +1054,7 @@ async function resolveCompanyForMiddlewareViaQueries(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean; allLocked: boolean }> {
|
||||
// 1. user_preferences (authoritative) + first membership, fetched in
|
||||
// parallel: the fallback query result doubles as validation when the
|
||||
// preferred company happens to be the first membership, which is the
|
||||
@@ -1055,12 +1089,12 @@ async function resolveCompanyForMiddlewareViaQueries(
|
||||
'[middleware] company resolution query failed',
|
||||
prefsRes.error ?? firstRes.error
|
||||
)
|
||||
return { companyId: null, locale, degraded: true }
|
||||
return { companyId: null, locale, degraded: true, allLocked: false }
|
||||
}
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
if (prefs.active_company_id === firstCompany?.company_id) {
|
||||
return { companyId: firstCompany.company_id, locale, degraded: false }
|
||||
return { companyId: firstCompany.company_id, locale, degraded: false, allLocked: false }
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
@@ -1075,14 +1109,14 @@ async function resolveCompanyForMiddlewareViaQueries(
|
||||
// first membership (wrong company for consultants): degrade instead.
|
||||
if (membershipError) {
|
||||
console.error('[middleware] company preference validation failed', membershipError)
|
||||
return { companyId: null, locale, degraded: true }
|
||||
return { companyId: null, locale, degraded: true, allLocked: false }
|
||||
}
|
||||
|
||||
if (membership) return { companyId: membership.company_id, locale, degraded: false }
|
||||
if (membership) return { companyId: membership.company_id, locale, degraded: false, allLocked: false }
|
||||
}
|
||||
|
||||
// 2. Fallback: first non-archived membership (already fetched above)
|
||||
if (!firstCompany) return { companyId: null, locale, degraded: false }
|
||||
if (!firstCompany) return { companyId: null, locale, degraded: false, allLocked: false }
|
||||
|
||||
// Write the fallback back to user_preferences so future RLS lookups
|
||||
// see the same active company without needing this fallback scan.
|
||||
@@ -1100,5 +1134,5 @@ async function resolveCompanyForMiddlewareViaQueries(
|
||||
console.error('[middleware] active company write-back failed', writeBackError)
|
||||
}
|
||||
|
||||
return { companyId: firstCompany.company_id, locale, degraded: false }
|
||||
return { companyId: firstCompany.company_id, locale, degraded: false, allLocked: false }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user