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
@@ -1420,7 +1420,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-31] SKV broker hardening (two skeptic refutations on PR #1757, same classes as the bank fixes): the token route's code exchange now requires a verified connector state (signature, key, svc 'skv') plus an existing pending row before spending Arcim's client secret, and a concurrently consumed state withholds the tokens with 409 (SKV has no revoke endpoint; the pair expires unused); refresh requires the presented token's hash to match an ACTIVE ledger row under the presenting key (it was an open refresh oracle for any leaked token); the metering redaction gains a 10+-digit rule because personnummer/orgnr/redovisare12 in SKV data-proxy paths slipped the EB-tuned thresholds and rested in cleartext; authorize-url adopts the countHeldConnections reservation re-count; all SKV base URLs are https-only (loopback excepted). SELF-HOSTING.md's connector section collapsed from three contradictory copies (accreted across the stack merges) to one.
|
||||
[2026-08-31] lib/connect/instance/upstreams.ts reuses lib/entitlements/own-credentials.ts instead of duplicating the own-credentials checks: the seam was forward-ported into the entitlement partition during the stack's bottom-up merge (skeptic refutation on PR #1747), and two copies of "what counts as own credentials" would eventually disagree, splitting the gate from the routing. upstreams.ts re-exports the two functions for its instance-side callers.
|
||||
[2026-08-31] UpgradeNote is self-host-aware (operator-skeptic refutation on PR #1758): on a self-host every UpgradeNote surface is by definition a connector capability (local capabilities are always on), so the component centrally swaps the hosted subscription copy + /settings/billing link for the connector-key note, mirroring CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV; the SKV connect button tooltip branches the same way. Fixed centrally rather than per panel so the EB/SKV/AGI/reports surfaces can never drift. SOVEREIGN.md updated to the merged reality (infra live, keys not sold, wiring pending). Standing rule reaffirmed: no connector key is issued before the instance-side wiring (PR6b) lands, or a paying customer sees granted capabilities with clients that still call the upstreams directly and fail on missing env credentials.
|
||||
[2026-09-01] multi_user seat gate enforced app-side via a NEW gated RPC (resolve_active_company_gated) instead of editing resolve_active_company/current_active_company_id: the zero-arg RPC and the RLS twin also run on self-hosted DBs where the paywall must never bite, and the app picks the gated overload only when isMultiUserEnforced(). RLS convergence rides the existing used_fallback write-back. company_capability_config deliberately does not apply to multi_user (no expiry to hang the 20-day grace on).
|
||||
[2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices).
|
||||
[2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches.
|
||||
[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback).
|
||||
[2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there.
|
||||
[2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches).
|
||||
|
||||
@@ -12,6 +12,10 @@ import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
|
||||
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
|
||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
|
||||
import MultiUserGraceBanner from '@/components/billing/MultiUserGraceBanner'
|
||||
import { resolveDormantCompanyIds } from '@/lib/company/active-company'
|
||||
import { getMultiUserState } from '@/lib/entitlements/multi-user'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
import { CompanyProvider, type ByraTeamRef } from '@/contexts/CompanyContext'
|
||||
import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed'
|
||||
@@ -408,6 +412,62 @@ export default async function DashboardLayout({
|
||||
|
||||
const isSandbox = settings?.is_sandbox === true
|
||||
|
||||
// Multi-user seat gate, switcher side: which of the user's OTHER companies
|
||||
// are frozen for them (non-owner membership, multi_user lapsed past grace).
|
||||
// Zero queries for the common owner-of-everything user; the grants read
|
||||
// runs only when a non-owner membership exists.
|
||||
const dormantCompanyIds = await resolveDormantCompanyIds(
|
||||
supabase,
|
||||
(allMemberships || [])
|
||||
.filter((m) => m.companies)
|
||||
.map((m) => ({
|
||||
company_id: m.company_id,
|
||||
role: m.role as string,
|
||||
companies: {
|
||||
team_id: ((m.companies as { team_id?: string | null } | null)?.team_id ?? null),
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
// The entitlements-derived multiUser state is computed from the grant rows
|
||||
// the CALLER can see, and RLS hides team-scoped grants from users outside
|
||||
// the team (byrå clients): re-verify any non-entitled answer through the
|
||||
// SECURITY DEFINER state RPC before acting on it. One extra round trip only
|
||||
// in the rare non-entitled case.
|
||||
const activeMultiUser =
|
||||
entitlements.multiUser.state === 'entitled'
|
||||
? entitlements.multiUser
|
||||
: await getMultiUserState(supabase, companyId)
|
||||
|
||||
// Grace countdown banner data: only while the ACTIVE company is in its
|
||||
// 20-day window AND actually has affected people (>= 1 non-owner member).
|
||||
// Service client because other members' emails are not readable through
|
||||
// the caller's RLS (same reason as GET /api/company/members).
|
||||
let graceBanner: { graceEndsAt: string; affectedEmails: string[]; isAffectedUser: boolean } | null =
|
||||
null
|
||||
if (!isSandbox && activeMultiUser.state === 'grace' && activeMultiUser.graceEndsAt) {
|
||||
const serviceClient = await createServiceClient()
|
||||
const { data: memberRows } = await serviceClient
|
||||
.from('company_members')
|
||||
.select('user_id, role')
|
||||
.eq('company_id', companyId)
|
||||
const affected = (memberRows || []).filter((m) => m.role !== 'owner')
|
||||
if (affected.length > 0) {
|
||||
const { data: affectedProfiles } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.in('id', affected.map((a) => a.user_id))
|
||||
const emailById = new Map((affectedProfiles || []).map((p) => [p.id, p.email as string | null]))
|
||||
graceBanner = {
|
||||
graceEndsAt: activeMultiUser.graceEndsAt,
|
||||
affectedEmails: affected
|
||||
.map((a) => emailById.get(a.user_id))
|
||||
.filter((e): e is string => !!e),
|
||||
isAffectedUser: affected.some((a) => a.user_id === user.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client-driven UI preferences (sidebar collapse + fold state). Read here
|
||||
// so the shell renders at the right width on first paint; the nav toggles
|
||||
// flip the data attribute client-side and persist via /api/user/ui-state.
|
||||
@@ -458,6 +518,8 @@ export default async function DashboardLayout({
|
||||
trialEndsAt: entitlements.trialEndsAt,
|
||||
entitlementState: entitlements.entitlementState,
|
||||
trialExpiredAt: entitlements.trialExpiredAt,
|
||||
multiUser: activeMultiUser,
|
||||
lockedCompanyIds: [...dormantCompanyIds],
|
||||
}
|
||||
|
||||
// Signpost gate (WL-01): a company is opened ONLY on its home domain. When
|
||||
@@ -511,6 +573,14 @@ export default async function DashboardLayout({
|
||||
Hoppa till innehåll
|
||||
</a>
|
||||
{isSandbox && <SandboxBanner />}
|
||||
{graceBanner && (
|
||||
<MultiUserGraceBanner
|
||||
graceEndsAt={graceBanner.graceEndsAt}
|
||||
affectedEmails={graceBanner.affectedEmails}
|
||||
isAffectedUser={graceBanner.isAffectedUser}
|
||||
companyName={displayName}
|
||||
/>
|
||||
)}
|
||||
<DashboardNav
|
||||
companyName={settings?.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
|
||||
@@ -29,6 +29,14 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
// Multi-user seat gate: mocked so the queued table mock's enqueue order stays
|
||||
// untouched for the pre-existing tests; the gate's own behavior is covered in
|
||||
// lib/entitlements/__tests__/multi-user.test.ts.
|
||||
const getMultiUserStateMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/entitlements/multi-user', () => ({
|
||||
getMultiUserState: (...args: unknown[]) => getMultiUserStateMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
@@ -87,6 +95,7 @@ beforeEach(() => {
|
||||
error: null,
|
||||
})
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'entitled', graceEndsAt: null })
|
||||
isConfiguredMock.mockReturnValue(true)
|
||||
sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' })
|
||||
brandSenderMock.getSenderForCompany.mockResolvedValue({
|
||||
@@ -133,6 +142,38 @@ describe('POST /api/company/members/invite', () => {
|
||||
expect(body.error).toBe('Behörighet saknas.')
|
||||
})
|
||||
|
||||
it('blocks invites with 403 + upsell when multi_user is frozen', async () => {
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
|
||||
enqueue({ data: { role: 'owner' } }) // caller membership
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: string
|
||||
capability_blocked: boolean
|
||||
capability: string
|
||||
}>(await post({ email: 'x@y.se' }))
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error).toBe('Bjud in fler personer med betald plan.')
|
||||
expect(body.capability_blocked).toBe(true)
|
||||
expect(body.capability).toBe('multi_user')
|
||||
expect(sendEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still allows invites during the post-lapse grace window', async () => {
|
||||
getMultiUserStateMock.mockResolvedValue({
|
||||
state: 'grace',
|
||||
graceEndsAt: new Date(Date.now() + 5 * 86_400_000).toISOString(),
|
||||
})
|
||||
enqueue({ data: { role: 'owner' } }) // caller membership
|
||||
enqueue({ data: [] }) // existing members
|
||||
enqueue({ data: null }) // existing invite
|
||||
enqueue({ data: { name: 'Acme AB' } }) // company name
|
||||
enqueue({ data: null }) // insert invitation
|
||||
|
||||
const { status } = await parseJsonResponse(await post({ email: 'x@y.se' }))
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects an invalid email with 400', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const { status } = await parseJsonResponse(await post({ email: 'not-an-email' }))
|
||||
|
||||
@@ -6,6 +6,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { getMultiUserState } from '@/lib/entitlements/multi-user'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender'
|
||||
import {
|
||||
@@ -70,6 +72,24 @@ export const POST = withRouteContext(
|
||||
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Multi-user seat gate: inviting more people requires the multi_user
|
||||
// capability (paid plan or trial). A company in its post-lapse grace
|
||||
// window may still invite (its people were promised 20 undisturbed
|
||||
// days); only the frozen state blocks. Same envelope shape as
|
||||
// capabilityBlockedResponse so the UI upsells consistently.
|
||||
const multiUserAccess = await getMultiUserState(serviceClient, companyId)
|
||||
if (multiUserAccess.state === 'frozen') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Bjud in fler personer med betald plan.',
|
||||
error_en: 'Invite more people with a paid plan.',
|
||||
capability_blocked: true,
|
||||
capability: CAPABILITY.multi_user,
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const validation = await validateBody(request, InviteSchema, {
|
||||
log,
|
||||
operation: 'company_members.invite',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getMultiUserState } from '@/lib/entitlements/multi-user'
|
||||
|
||||
/**
|
||||
* GET /api/company/members
|
||||
@@ -60,6 +61,13 @@ export const GET = withRouteContext('company_members.list', async (_request, ctx
|
||||
const currentMember = members?.find((m) => m.user_id === user.id)
|
||||
const canInvite = currentMember?.role === 'owner' || currentMember?.role === 'admin'
|
||||
|
||||
// Multi-user seat gate: when the company is frozen (no multi_user grant
|
||||
// active or in grace), the invite POST answers 403, so the UI swaps the
|
||||
// invite form for the paid-plan upsell instead of a dead form.
|
||||
const multiUserAccess = canInvite
|
||||
? await getMultiUserState(serviceClient, companyId)
|
||||
: null
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
members: (members || []).map((m) => ({
|
||||
@@ -73,6 +81,7 @@ export const GET = withRouteContext('company_members.list', async (_request, ctx
|
||||
})),
|
||||
invitations: invitations || [],
|
||||
canInvite,
|
||||
inviteRequiresUpgrade: multiUserAccess?.state === 'frozen',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { runMultiUserGraceReminders } from '@/lib/notifications/multi-user-grace'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/notifications/multi-user-grace/cron, daily 07:00 UTC.
|
||||
*
|
||||
* Mails company owners when the multi_user grace window opens (their extra
|
||||
* members will pause in 20 days) and again on its last day. Windowed on the
|
||||
* grant expiry timestamps so a once-daily run sends each mail exactly once;
|
||||
* see lib/notifications/multi-user-grace.ts.
|
||||
*/
|
||||
export const GET = withCronContext('cron.multi_user_grace', async (_request, ctx) => {
|
||||
const supabase = createServiceClient()
|
||||
const summary = await runMultiUserGraceReminders(supabase, new Date())
|
||||
|
||||
ctx.log.info('multi-user grace reminder summary', { ...summary })
|
||||
|
||||
return NextResponse.json({ success: true, ...summary })
|
||||
})
|
||||
|
||||
export const POST = GET
|
||||
|
||||
/** A grants scan plus a handful of emails. */
|
||||
export const maxDuration = 300
|
||||
@@ -0,0 +1,77 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { PausedSignOutButton } from './sign-out-button'
|
||||
|
||||
/**
|
||||
* Multi-user seat gate: the landing for a user whose EVERY membership is
|
||||
* frozen (non-owner in companies whose multi_user entitlement lapsed past
|
||||
* its 20-day grace). Middleware routes here instead of onboarding, so a
|
||||
* locked-out colleague is told what happened and who can fix it rather than
|
||||
* being walked into creating a pointless company.
|
||||
*
|
||||
* Self-healing on purpose: the page re-runs the gated resolution first, so
|
||||
* the moment an owner pays, a reload lands the user straight back in the
|
||||
* company. Nothing here is stateful.
|
||||
*/
|
||||
export default async function PausedPage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
// Anything accessible after all (an owner upgraded, or the user was made
|
||||
// owner somewhere) leaves this page immediately.
|
||||
const companyId = await getActiveCompanyId(supabase, user.id).catch(() => null)
|
||||
if (companyId) redirect('/')
|
||||
|
||||
const { data: memberships } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies:company_id(name, archived_at)')
|
||||
.eq('user_id', user.id)
|
||||
type Row = { company_id: string; companies: { name: string; archived_at: string | null } | null }
|
||||
const rows = ((memberships ?? []) as unknown as Row[]).filter(
|
||||
(m) => m.companies && m.companies.archived_at === null,
|
||||
)
|
||||
|
||||
// Current display names (company_settings.company_name; companies.name is
|
||||
// frozen at creation). RLS scopes the read to the user's own companies.
|
||||
const { data: settingsNames } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_id, company_name')
|
||||
.in('company_id', rows.map((m) => m.company_id))
|
||||
const nameByCompany = new Map(
|
||||
(settingsNames ?? []).map((s) => [s.company_id, s.company_name as string | null]),
|
||||
)
|
||||
const companyNames = rows.map(
|
||||
(m) => nameByCompany.get(m.company_id) || m.companies?.name || '',
|
||||
)
|
||||
|
||||
// No memberships at all means this page is the wrong destination.
|
||||
if (companyNames.length === 0) redirect('/')
|
||||
|
||||
const t = await getTranslations('paused')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-frame px-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-secondary">
|
||||
<Lock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{companyNames.length === 1
|
||||
? t('body_single', { companyName: companyNames[0] })
|
||||
: t('body_multiple', { companyNames: companyNames.join(', ') })}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('body_action')}</p>
|
||||
<div className="mt-6">
|
||||
<PausedSignOutButton label={t('sign_out')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/** Sign-out affordance for the paused page: back to login as another user. */
|
||||
export function PausedSignOutButton({ label }: { label: string }) {
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSignOut() {
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={handleSignOut}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
/**
|
||||
* Multi-user grace countdown: shown to EVERYONE in a company whose
|
||||
* multi_user entitlement has lapsed but is still inside its 20-day grace
|
||||
* window, and only when the company actually has affected people (at least
|
||||
* one non-owner member). The affected member reads their own version; the
|
||||
* owner (and everyone else) reads which accounts pause and when.
|
||||
*
|
||||
* Server-gated: the dashboard layout renders this only in the grace state
|
||||
* with a non-empty affected list, so the component itself only formats.
|
||||
* Same chrome treatment as SandboxBanner: environment notice on secondary,
|
||||
* never a warning fill (status colors are data, not chrome).
|
||||
*/
|
||||
export function MultiUserGraceBanner({
|
||||
graceEndsAt,
|
||||
affectedEmails,
|
||||
isAffectedUser,
|
||||
companyName,
|
||||
}: {
|
||||
graceEndsAt: string
|
||||
affectedEmails: string[]
|
||||
/** True when the signed-in user is one of the accounts that will pause. */
|
||||
isAffectedUser: boolean
|
||||
companyName: string
|
||||
}) {
|
||||
const t = useTranslations('multi_user')
|
||||
|
||||
// Computed in an effect so server and client markup agree at hydration;
|
||||
// an hourly tick keeps a long-lived tab honest (same pattern as
|
||||
// SubscriptionTouchpoint).
|
||||
const [daysLeft, setDaysLeft] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const msLeft = new Date(graceEndsAt).getTime() - Date.now()
|
||||
setDaysLeft(Math.max(0, Math.ceil(msLeft / 86_400_000)))
|
||||
}
|
||||
update()
|
||||
const id = setInterval(update, 3_600_000)
|
||||
return () => clearInterval(id)
|
||||
}, [graceEndsAt])
|
||||
|
||||
if (daysLeft === null) return null
|
||||
|
||||
const message = isAffectedUser
|
||||
? t('banner_affected', { companyName, days: daysLeft })
|
||||
: t('banner_owner', { emails: affectedEmails.join(', '), days: daysLeft })
|
||||
|
||||
return (
|
||||
<div className="relative z-50 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-border bg-secondary px-4 py-2 text-sm text-secondary-foreground">
|
||||
<span className="text-center text-xs font-medium sm:text-sm">{message}</span>
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="shrink-0 rounded-full bg-foreground/10 px-3 py-0.5 text-xs font-semibold transition-colors hover:bg-foreground/15"
|
||||
>
|
||||
{t('banner_cta')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MultiUserGraceBanner
|
||||
@@ -8,10 +8,10 @@ import { cn } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { performCompanySwitch } from '@/lib/company/switch-client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
|
||||
import { Check, ChevronsUpDown, Plus, Loader2, Lock } from 'lucide-react'
|
||||
|
||||
export default function CompanySwitcher() {
|
||||
const { company, companies, isSandbox, foreignCompanies = [] } = useCompany()
|
||||
const { company, companies, isSandbox, foreignCompanies = [], lockedCompanyIds = [] } = useCompany()
|
||||
const t = useTranslations('company_switcher')
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -87,7 +87,13 @@ export default function CompanySwitcher() {
|
||||
if (result?.error) {
|
||||
setIsPending(false)
|
||||
toast({
|
||||
title: t(result.error === 'not_member' ? 'error_no_access' : 'error_switch_failed'),
|
||||
title: t(
|
||||
result.error === 'not_member'
|
||||
? 'error_no_access'
|
||||
: result.error === 'company_locked'
|
||||
? 'error_locked'
|
||||
: 'error_switch_failed',
|
||||
),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
@@ -146,7 +152,23 @@ export default function CompanySwitcher() {
|
||||
)}
|
||||
|
||||
<div className="max-h-48 overflow-y-auto px-1">
|
||||
{companies.map(({ company: c, role }) => (
|
||||
{companies.map(({ company: c, role }) =>
|
||||
lockedCompanyIds.includes(c.id) ? (
|
||||
// Multi-user seat gate: frozen for this user until the
|
||||
// company pays. Shown, not hidden: the membership exists
|
||||
// and the row explains why it cannot be entered.
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 w-full px-2.5 py-2 text-left text-[13px] leading-snug text-muted-foreground/60 rounded-sm md:whitespace-nowrap"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block truncate">{c.name}</span>
|
||||
<span className="block truncate text-[10px]">{t('locked_note')}</span>
|
||||
</span>
|
||||
<Lock className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => handleSwitch(c.id)}
|
||||
@@ -174,7 +196,8 @@ export default function CompanySwitcher() {
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -81,6 +82,10 @@ export function CompanyMembersSection() {
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [canInvite, setCanInvite] = useState(false)
|
||||
// Multi-user seat gate: true when inviting requires the paid plan
|
||||
// (multi_user frozen). The form is swapped for the upsell line; the POST's
|
||||
// own 403 stays the real enforcement.
|
||||
const [inviteRequiresUpgrade, setInviteRequiresUpgrade] = useState(false)
|
||||
const [shareInvite, setShareInvite] = useState<ShareableInvite | null>(null)
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
@@ -118,6 +123,7 @@ export function CompanyMembersSection() {
|
||||
setMembers(parsed.members)
|
||||
setInvitations(parsed.invitations)
|
||||
setCanInvite(parsed.canInvite)
|
||||
setInviteRequiresUpgrade(parsed.inviteRequiresUpgrade)
|
||||
} catch {
|
||||
setMembers(null)
|
||||
setInvitations(null)
|
||||
@@ -374,8 +380,22 @@ export function CompanyMembersSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-user seat gate: no invite form without the paid plan. One
|
||||
muted sentence with the billing link, not a dead form. */}
|
||||
{canInvite && inviteRequiresUpgrade && (
|
||||
<p className="px-1 pt-3 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{t('members_invite_upgrade')}{' '}
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('members_invite_upgrade_cta')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Inline invite: the list's own last row instead of a separate card. */}
|
||||
{canInvite && (
|
||||
{canInvite && !inviteRequiresUpgrade && (
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center">
|
||||
<label htmlFor="company-invite-email" className="sr-only">
|
||||
{t('members_invite_email_label')}
|
||||
|
||||
@@ -23,13 +23,14 @@ describe('parseCompanyMembersPayload', () => {
|
||||
members: [{ id: 'm1', email: 'anna@example.se' }],
|
||||
invitations: [{ id: 'i1', email: 'bo@example.se' }],
|
||||
canInvite: true,
|
||||
inviteRequiresUpgrade: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a confirmed-empty roster (that is data, not a failure)', () => {
|
||||
expect(
|
||||
parseCompanyMembersPayload({ data: { members: [], invitations: [], canInvite: false } }),
|
||||
).toEqual({ members: [], invitations: [], canInvite: false })
|
||||
).toEqual({ members: [], invitations: [], canInvite: false, inviteRequiresUpgrade: false })
|
||||
})
|
||||
|
||||
it('rejects a 200 with no data envelope instead of fabricating an empty roster', () => {
|
||||
@@ -50,7 +51,22 @@ describe('parseCompanyMembersPayload', () => {
|
||||
// A truthy non-boolean must not unlock the invite form.
|
||||
expect(
|
||||
parseCompanyMembersPayload({ data: { members: [], invitations: [], canInvite: 'yes' } }),
|
||||
).toEqual({ members: [], invitations: [], canInvite: false })
|
||||
).toEqual({ members: [], invitations: [], canInvite: false, inviteRequiresUpgrade: false })
|
||||
})
|
||||
|
||||
it('only swaps the invite form for the upsell on an explicit boolean true', () => {
|
||||
// Multi-user seat gate: an older server without the field (or junk) keeps
|
||||
// the form; the invite POST's own 403 is the real enforcement.
|
||||
expect(
|
||||
parseCompanyMembersPayload({
|
||||
data: { members: [], invitations: [], canInvite: true, inviteRequiresUpgrade: 'yes' },
|
||||
}),
|
||||
).toEqual({ members: [], invitations: [], canInvite: true, inviteRequiresUpgrade: false })
|
||||
expect(
|
||||
parseCompanyMembersPayload({
|
||||
data: { members: [], invitations: [], canInvite: true, inviteRequiresUpgrade: true },
|
||||
}),
|
||||
).toEqual({ members: [], invitations: [], canInvite: true, inviteRequiresUpgrade: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -17,19 +17,29 @@ export interface CompanyMembersPayload<M, I> {
|
||||
members: M[]
|
||||
invitations: I[]
|
||||
canInvite: boolean
|
||||
/** Multi-user seat gate: swap the invite form for the paid-plan upsell. */
|
||||
inviteRequiresUpgrade: boolean
|
||||
}
|
||||
|
||||
export function parseCompanyMembersPayload<M, I>(body: unknown): CompanyMembersPayload<M, I> | null {
|
||||
if (typeof body !== 'object' || body === null) return null
|
||||
const data = (body as { data?: unknown }).data
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
const d = data as { members?: unknown; invitations?: unknown; canInvite?: unknown }
|
||||
const d = data as {
|
||||
members?: unknown
|
||||
invitations?: unknown
|
||||
canInvite?: unknown
|
||||
inviteRequiresUpgrade?: unknown
|
||||
}
|
||||
if (!Array.isArray(d.members) || !Array.isArray(d.invitations)) return null
|
||||
return {
|
||||
members: d.members as M[],
|
||||
invitations: d.invitations as I[],
|
||||
// Only an explicit boolean true unlocks the invite form.
|
||||
canInvite: d.canInvite === true,
|
||||
// Only an explicit true swaps the form for the upsell: an older server
|
||||
// without the field keeps the form, and the POST's own 403 still guards.
|
||||
inviteRequiresUpgrade: d.inviteRequiresUpgrade === true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useBranding } from '@/lib/branding/brand-context'
|
||||
// capability in lib/entitlements/keys.ts (ai, bank_sync, skatteverket,
|
||||
// email_send, stripe_payments, woocommerce_sync + shopify_sync as one
|
||||
// "webshop" item). Keep in step with PAID_CAPABILITIES when a key is added.
|
||||
const UNLOCK_KEYS = ['unlock_ai', 'unlock_bank', 'unlock_skv', 'unlock_email', 'unlock_payments', 'unlock_webshop'] as const
|
||||
const UNLOCK_KEYS = ['unlock_ai', 'unlock_bank', 'unlock_skv', 'unlock_email', 'unlock_payments', 'unlock_webshop', 'unlock_users'] as const
|
||||
|
||||
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
|
||||
// trial_end floor plus clock margin). Above this, checkout collects the card
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createContext, useContext } from 'react'
|
||||
import type { Company, CompanyRole, Team } from '@/types'
|
||||
import type { CapabilityKey } from '@/lib/entitlements/keys'
|
||||
import type { EntitlementState } from '@/lib/entitlements/has-capability'
|
||||
import type { MultiUserAccess } from '@/lib/entitlements/multi-user-state'
|
||||
|
||||
/** The user's byrå team membership (teams.kind = 'byra'), when any (WL-08). */
|
||||
export interface ByraTeamRef {
|
||||
@@ -62,6 +63,19 @@ interface CompanyContextValue {
|
||||
entitlementState: EntitlementState
|
||||
/** When the lapsed trial ran out; set only while entitlementState is 'trial_expired'. */
|
||||
trialExpiredAt: string | null
|
||||
/**
|
||||
* Multi-user seat state of the ACTIVE company (entitled / grace / frozen)
|
||||
* with the grace deadline. Optional: the no-company shells leave it unset;
|
||||
* consumers treat absence as entitled (UI affordance only, the server
|
||||
* enforces).
|
||||
*/
|
||||
multiUser?: MultiUserAccess
|
||||
/**
|
||||
* Companies in `companies` that are FROZEN for this user (non-owner
|
||||
* membership, multi_user lapsed past grace): the switcher renders them
|
||||
* greyed with a lock instead of hiding them. Optional; absent = none.
|
||||
*/
|
||||
lockedCompanyIds?: string[]
|
||||
}
|
||||
|
||||
const CompanyContext = createContext<CompanyContextValue | null>(null)
|
||||
|
||||
@@ -49,5 +49,6 @@
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
|
||||
0 7 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/multi-user-grace/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
|
||||
0 7 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/multi-user-grace/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
addCompanyToNextHint,
|
||||
addCompanyToTopLevelNext,
|
||||
@@ -12,6 +12,25 @@ import {
|
||||
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
// Multi-user seat gate: mocked (real logic covered in
|
||||
// lib/entitlements/__tests__/multi-user.test.ts) so the membership chain mock
|
||||
// below stays single-purpose. Default entitled; individual tests flip it.
|
||||
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),
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
getMultiUserStateMock.mockReset()
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'entitled', graceEndsAt: null })
|
||||
})
|
||||
|
||||
function membershipClient(result: { data: unknown; error: unknown }) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
@@ -133,6 +152,60 @@ describe('MCP company routing', () => {
|
||||
expect(chain.maybeSingle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a NON-OWNER membership in a frozen company (multi-user seat gate)', async () => {
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
|
||||
const { client } = membershipClient({
|
||||
data: { company_id: OTHER_COMPANY_ID, role: 'admin' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
requestedCompanyId: OTHER_COMPANY_ID,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' })
|
||||
})
|
||||
|
||||
it('lets an OWNER through without consulting the seat gate', async () => {
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
|
||||
const { client } = membershipClient({
|
||||
data: { company_id: DEFAULT_COMPANY_ID, role: 'owner' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
})
|
||||
).resolves.toMatchObject({ role: 'owner' })
|
||||
expect(getMultiUserStateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a non-owner through while the company is in its grace window', async () => {
|
||||
getMultiUserStateMock.mockResolvedValue({
|
||||
state: 'grace',
|
||||
graceEndsAt: new Date(Date.now() + 5 * 86_400_000).toISOString(),
|
||||
})
|
||||
const { client } = membershipClient({
|
||||
data: { company_id: OTHER_COMPANY_ID, role: 'member' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveMcpCompanyContext({
|
||||
supabase: client as never,
|
||||
userId: 'user-1',
|
||||
defaultCompanyId: DEFAULT_COMPANY_ID,
|
||||
requestedCompanyId: OTHER_COMPANY_ID,
|
||||
})
|
||||
).resolves.toMatchObject({ role: 'member' })
|
||||
})
|
||||
|
||||
it('rejects companies without a current non-archived membership', async () => {
|
||||
const { client } = membershipClient({ data: null, error: null })
|
||||
|
||||
|
||||
@@ -56,6 +56,18 @@ vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
return { ...actual, hasCapability: mocks.hasCapability }
|
||||
})
|
||||
|
||||
// Multi-user seat gate: entitled here, so the strict company_members-only
|
||||
// table mock above stays valid for non-owner roles (the gate would otherwise
|
||||
// read companies + capability_grants). Gate behavior is covered in
|
||||
// company-routing.test.ts and lib/entitlements/__tests__/multi-user.test.ts.
|
||||
vi.mock('@/lib/entitlements/multi-user', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/multi-user')>()
|
||||
return {
|
||||
...actual,
|
||||
getMultiUserState: vi.fn().mockResolvedValue({ state: 'entitled', graceEndsAt: null }),
|
||||
}
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function toolCall(args: Record<string, unknown>): Request {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import { getMultiUserState, isMembershipDormant } from '@/lib/entitlements/multi-user'
|
||||
import type { CompanyRole } from '@/types'
|
||||
|
||||
const UUID_PATTERN =
|
||||
@@ -144,6 +145,20 @@ export async function resolveMcpCompanyContext(args: {
|
||||
throw codedError('FORBIDDEN', 'Company membership has an unsupported role')
|
||||
}
|
||||
|
||||
// Multi-user seat gate: the MCP surface is a chokepoint like the HTTP
|
||||
// routes, so a non-owner membership in a frozen company (multi_user lapsed
|
||||
// past its 20-day grace) is refused here, before any tool touches tenant
|
||||
// data. Owners always pass; self-hosted/dev return 'entitled' outright.
|
||||
if (membership.role !== 'owner') {
|
||||
const access = await getMultiUserState(args.supabase, companyId)
|
||||
if (isMembershipDormant(membership.role, access.state)) {
|
||||
throw codedError(
|
||||
'FORBIDDEN',
|
||||
'This company is paused for your account: multiple users require a paid plan. Ask the company owner to upgrade.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
role: membership.role,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
+19
-1
@@ -1833,6 +1833,8 @@
|
||||
"members_invite_link_copy_failed": "The link could not be copied.",
|
||||
"members_invite_sent_description": "Email sent to {email}.",
|
||||
"members_invite_failed": "Could not send invitation.",
|
||||
"members_invite_upgrade": "Invite more people with a paid plan.",
|
||||
"members_invite_upgrade_cta": "Upgrade",
|
||||
"members_removed": "Member removed",
|
||||
"members_remove_failed": "Could not remove member.",
|
||||
"members_invite_revoked": "Invitation revoked",
|
||||
@@ -2241,6 +2243,8 @@
|
||||
"unlock_payments_gloss": "payment links on invoices, Stripe balance as a bank feed",
|
||||
"unlock_webshop": "Webshop",
|
||||
"unlock_webshop_gloss": "orders pulled from WooCommerce and Shopify",
|
||||
"unlock_users": "More users",
|
||||
"unlock_users_gloss": "invite colleagues, everyone can work in the company",
|
||||
"terms_deferred": "First charge {date}. Cancel any time. Payment via Stripe.",
|
||||
"terms_now": "No lock-in, cancel any time. Payment via Stripe.",
|
||||
"unlock_help": "The subscription is the external connections: the assistant, your bank, Skatteverket, email, Stripe and the webshops. Without it, bookkeeping, invoicing, reports and SIE export keep working as usual and everything stays: accounting records are kept for seven years under the Swedish Bookkeeping Act regardless of subscription. Start again later and the connections pick up where they left off.",
|
||||
@@ -5935,10 +5939,24 @@
|
||||
"add_company": "Add company",
|
||||
"error_no_access": "You don't have access to this company.",
|
||||
"error_switch_failed": "The company switch could not be saved. Please try again.",
|
||||
"error_locked": "The company is paused for your account. Ask the owner to upgrade to a paid plan.",
|
||||
"search_placeholder": "Search companies...",
|
||||
"no_results": "No matching companies",
|
||||
"managed_elsewhere": "Managed on another domain",
|
||||
"managed_via": "Managed via {domain}"
|
||||
"managed_via": "Managed via {domain}",
|
||||
"locked_note": "Paused: requires a paid plan"
|
||||
},
|
||||
"multi_user": {
|
||||
"banner_owner": "{emails} will be paused in {days, plural, =1 {1 day} other {# days}}. Upgrade to keep multiple users.",
|
||||
"banner_affected": "Your access to {companyName} will be paused in {days, plural, =1 {1 day} other {# days}} unless the company upgrades.",
|
||||
"banner_cta": "Upgrade"
|
||||
},
|
||||
"paused": {
|
||||
"title": "Your account is paused",
|
||||
"body_single": "Your account is paused in {companyName}. Only one person can work in the company without a paid plan.",
|
||||
"body_multiple": "Your account is paused in the following companies: {companyNames}. Only one person can work in each company without a paid plan.",
|
||||
"body_action": "Ask the owner to upgrade to continue.",
|
||||
"sign_out": "Sign out"
|
||||
},
|
||||
"company_tab_guard": {
|
||||
"title": "The active company was switched in another tab",
|
||||
|
||||
+19
-1
@@ -1833,6 +1833,8 @@
|
||||
"members_invite_link_copy_failed": "Länken kunde inte kopieras.",
|
||||
"members_invite_sent_description": "E-post skickad till {email}.",
|
||||
"members_invite_failed": "Kunde inte skicka inbjudan.",
|
||||
"members_invite_upgrade": "Bjud in fler personer med betald plan.",
|
||||
"members_invite_upgrade_cta": "Uppgradera",
|
||||
"members_removed": "Medlem borttagen",
|
||||
"members_remove_failed": "Kunde inte ta bort medlem.",
|
||||
"members_invite_revoked": "Inbjudan återkallad",
|
||||
@@ -2241,6 +2243,8 @@
|
||||
"unlock_payments_gloss": "betallänkar på fakturorna, Stripe-saldot som bankflöde",
|
||||
"unlock_webshop": "Webshop",
|
||||
"unlock_webshop_gloss": "ordrar hämtas från WooCommerce och Shopify",
|
||||
"unlock_users": "Fler användare",
|
||||
"unlock_users_gloss": "bjud in kollegor, alla kan arbeta i företaget",
|
||||
"terms_deferred": "Första debiteringen {date}. Avsluta när du vill. Betalning via Stripe.",
|
||||
"terms_now": "Ingen bindningstid, avsluta när du vill. Betalning via Stripe.",
|
||||
"unlock_help": "Abonnemanget är de externa kopplingarna: assistenten, banken, Skatteverket, e-post, Stripe och webshoparna. Utan det fortsätter bokföring, fakturering, rapporter och SIE-export som vanligt, och allt finns kvar: räkenskapsinformation bevaras i sju år enligt bokföringslagen oavsett abonnemang. Startar du igen senare fortsätter kopplingarna där de slutade.",
|
||||
@@ -5935,10 +5939,24 @@
|
||||
"add_company": "Lägg till företag",
|
||||
"error_no_access": "Du har inte tillgång till detta företag.",
|
||||
"error_switch_failed": "Företagsbytet kunde inte sparas. Försök igen.",
|
||||
"error_locked": "Företaget är pausat för ditt konto. Be ägaren uppgradera till betald plan.",
|
||||
"search_placeholder": "Sök företag...",
|
||||
"no_results": "Inga företag matchar",
|
||||
"managed_elsewhere": "Hanteras via annan domän",
|
||||
"managed_via": "Hanteras via {domain}"
|
||||
"managed_via": "Hanteras via {domain}",
|
||||
"locked_note": "Pausat: kräver betald plan"
|
||||
},
|
||||
"multi_user": {
|
||||
"banner_owner": "{emails} pausas om {days, plural, =1 {1 dag} other {# dagar}}. Uppgradera för att behålla fler användare.",
|
||||
"banner_affected": "Din åtkomst till {companyName} pausas om {days, plural, =1 {1 dag} other {# dagar}} om företaget inte uppgraderar.",
|
||||
"banner_cta": "Uppgradera"
|
||||
},
|
||||
"paused": {
|
||||
"title": "Ditt konto är pausat",
|
||||
"body_single": "Ditt konto är pausat i {companyName}. Endast en person kan arbeta i företaget utan betald plan.",
|
||||
"body_multiple": "Ditt konto är pausat i följande företag: {companyNames}. Endast en person kan arbeta i varje företag utan betald plan.",
|
||||
"body_action": "Be ägaren uppgradera för att fortsätta.",
|
||||
"sign_out": "Logga ut"
|
||||
},
|
||||
"company_tab_guard": {
|
||||
"title": "Aktivt företag har bytts i en annan flik",
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
-- Multi-user paywall: the `multi_user` capability (founder decision 2026-09-01).
|
||||
--
|
||||
-- Multiple people working in one company becomes a PAID capability. The rule
|
||||
-- (lib/entitlements/multi-user-state.ts is the TS twin; keep them aligned):
|
||||
--
|
||||
-- entitled : an active multi_user grant (trial/stripe/team/manual/comp).
|
||||
-- grace : the newest grant expired less than p_grace_days (20) ago.
|
||||
-- Everyone still works; the app shows a countdown banner.
|
||||
-- frozen : lapsed >= 20 days ago (or never granted). Only role = 'owner'
|
||||
-- memberships resolve; every other membership is dormant. Rows
|
||||
-- are NEVER deleted: paying reactivates everyone instantly.
|
||||
--
|
||||
-- Enforcement is derived at resolution time (no status column, no cron):
|
||||
-- resolve_active_company_gated() below is the gated twin of
|
||||
-- resolve_active_company() (20260723161000). The zero-arg function and
|
||||
-- current_active_company_id() are deliberately UNTOUCHED:
|
||||
-- - they also run on self-hosted instances, where the seat gate must never
|
||||
-- bite (multi_user is a local capability there; the app only calls the
|
||||
-- gated function when lib/entitlements isMultiUserEnforced() is true);
|
||||
-- - current_active_company_id() (RLS side) converges via the middleware
|
||||
-- write-back: when the preference points at a frozen company the gated
|
||||
-- function reports used_fallback = true, middleware persists the
|
||||
-- accessible company to user_preferences, and RLS follows. During the
|
||||
-- one-request window before the write-back the app never serves the
|
||||
-- frozen company anyway (resolution is the only source of companyId).
|
||||
--
|
||||
-- Also in this migration:
|
||||
-- - seed_trial_capability_grants() learns the eighth key ('multi_user').
|
||||
-- - Backfills so existing companies land in the right state at deploy:
|
||||
-- mid-trial -> trial grant, active Stripe subs -> stripe grant, teams
|
||||
-- with active agreements -> team grant, and every other non-archived
|
||||
-- company with more than one member -> a 'manual' grant expiring NOW,
|
||||
-- which starts their 20-day grace window at deploy time (the
|
||||
-- grandfather cohort; metadata.reason = 'multi_user_grandfather').
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. company_multi_user_ok(company, grace_days): entitled-or-in-grace check
|
||||
-- =============================================================================
|
||||
-- True while the company may have several active people: an unexpired
|
||||
-- multi_user grant (company- or team-scoped), or one that expired less than
|
||||
-- p_grace_days ago. Never granted => false. company_capability_config is
|
||||
-- deliberately NOT consulted: a config disable has no expiry to hang the
|
||||
-- grace window on, and member access must not freeze through a side channel.
|
||||
CREATE OR REPLACE FUNCTION public.company_multi_user_ok(p_company_id uuid, p_grace_days integer)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.capability_key = 'multi_user'
|
||||
AND (
|
||||
cg.company_id = p_company_id
|
||||
OR cg.team_id = (SELECT c.team_id FROM public.companies c WHERE c.id = p_company_id)
|
||||
)
|
||||
AND (
|
||||
cg.expires_at IS NULL
|
||||
OR cg.expires_at > now() - make_interval(days => GREATEST(p_grace_days, 0))
|
||||
)
|
||||
);
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_multi_user_ok(uuid, integer) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.company_multi_user_ok(uuid, integer) TO authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. resolve_active_company_gated(grace_days): the seat-gated resolution
|
||||
-- =============================================================================
|
||||
-- Semantics of resolve_active_company() (20260723161000) with one added
|
||||
-- predicate: a membership resolves only when the caller is the company's
|
||||
-- owner or the company passes company_multi_user_ok(). Keep everything else
|
||||
-- IDENTICAL to the zero-arg function (resolution order, NULL auth.uid()
|
||||
-- behavior, no writes); see its header for the invariants.
|
||||
--
|
||||
-- has_locked_membership is computed only when nothing resolved: it tells the
|
||||
-- caller "this user has companies, but every one of them is frozen for them"
|
||||
-- so middleware can route to the paused page instead of onboarding.
|
||||
CREATE OR REPLACE FUNCTION public.resolve_active_company_gated(p_grace_days integer)
|
||||
RETURNS TABLE(company_id uuid, locale text, used_fallback boolean, has_locked_membership boolean)
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH pref AS (
|
||||
SELECT up.active_company_id, up.locale
|
||||
FROM public.user_preferences up
|
||||
WHERE up.user_id = auth.uid()
|
||||
),
|
||||
validated AS (
|
||||
SELECT cm.company_id
|
||||
FROM pref p
|
||||
JOIN public.company_members cm
|
||||
ON cm.user_id = auth.uid() AND cm.company_id = p.active_company_id
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE cm.role = 'owner' OR public.company_multi_user_ok(cm.company_id, p_grace_days)
|
||||
LIMIT 1
|
||||
),
|
||||
fallback AS (
|
||||
SELECT cm.company_id
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND (cm.role = 'owner' OR public.company_multi_user_ok(cm.company_id, p_grace_days))
|
||||
ORDER BY cm.created_at ASC
|
||||
LIMIT 1
|
||||
),
|
||||
resolved AS (
|
||||
SELECT COALESCE(
|
||||
(SELECT v.company_id FROM validated v),
|
||||
(SELECT f.company_id FROM fallback f)
|
||||
) AS company_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT r.company_id FROM resolved r) AS company_id,
|
||||
(SELECT p.locale FROM pref p) AS locale,
|
||||
((SELECT v.company_id FROM validated v) IS NULL) AS used_fallback,
|
||||
CASE
|
||||
WHEN (SELECT r.company_id FROM resolved r) IS NOT NULL THEN false
|
||||
ELSE EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role <> 'owner'
|
||||
AND NOT public.company_multi_user_ok(cm.company_id, p_grace_days)
|
||||
)
|
||||
END AS has_locked_membership
|
||||
WHERE auth.uid() IS NOT NULL;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.resolve_active_company_gated(integer) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.resolve_active_company_gated(integer) TO authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Trial seeding: the eighth key
|
||||
-- =============================================================================
|
||||
-- Replaces ONLY the function body (same shape as 20260826130300): the byrå
|
||||
-- suppression stays, the VALUES list grows by 'multi_user'. Keep in step with
|
||||
-- lib/entitlements/keys.ts PAID_CAPABILITIES.
|
||||
CREATE OR REPLACE FUNCTION public.seed_trial_capability_grants()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Byrå-team companies are covered by the team's agreement (WL-10):
|
||||
-- no company-scoped trial, so no trial-expiry noise toward byrå clients.
|
||||
IF NEW.team_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM public.teams t
|
||||
WHERE t.id = NEW.team_id
|
||||
AND t.kind = 'byra'
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Full PAID set as of 20260901081417; keep this VALUES list in step with
|
||||
-- lib/entitlements/keys.ts PAID_CAPABILITIES whenever a key is added.
|
||||
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
|
||||
SELECT NEW.id, k.key, 'trial', NEW.created_at + interval '30 days'
|
||||
FROM (VALUES
|
||||
('ai'),
|
||||
('bank_sync'),
|
||||
('skatteverket'),
|
||||
('email_send'),
|
||||
('stripe_payments'),
|
||||
('woocommerce_sync'),
|
||||
('shopify_sync'),
|
||||
('multi_user')
|
||||
) AS k(key)
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Backfills (idempotent: ON CONFLICT DO NOTHING against the scope index)
|
||||
-- =============================================================================
|
||||
|
||||
-- 4a. Every trial-seeded company gets a multi_user trial row matching its
|
||||
-- trial expiry, expired trials included: "the trial always covered
|
||||
-- multi_user". Mid-trial companies stay fully entitled; long-expired ones
|
||||
-- read as frozen exactly like a never-granted company, and the recently
|
||||
-- expired land in whatever grace their trial end implies (4d then guarantees
|
||||
-- multi-member companies a full window from deploy).
|
||||
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
|
||||
SELECT cg.company_id, 'multi_user', 'trial', max(cg.expires_at)
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.source = 'trial'
|
||||
AND cg.company_id IS NOT NULL
|
||||
AND cg.expires_at IS NOT NULL
|
||||
GROUP BY cg.company_id
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
|
||||
-- 4b. Companies with a live Stripe subscription: same shape the webhook sync
|
||||
-- writes (source = 'stripe', period end + 3 days slack; see
|
||||
-- lib/stripe/subscription-sync.ts). The next webhook upserts over this.
|
||||
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at, metadata)
|
||||
SELECT cs.company_id, 'multi_user', 'stripe',
|
||||
cs.current_period_end + interval '3 days',
|
||||
jsonb_build_object('backfill', 'multi_user_launch')
|
||||
FROM public.company_subscriptions cs
|
||||
WHERE cs.status IN ('active', 'trialing', 'past_due')
|
||||
AND cs.current_period_end IS NOT NULL
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
|
||||
-- 4c. Teams holding any active grant (byrå partner agreements) extend to
|
||||
-- multi_user at team scope, so one paying byrå keeps covering all its client
|
||||
-- companies. NULL expiry wins when any of the team's active grants never
|
||||
-- expires.
|
||||
INSERT INTO public.capability_grants (team_id, capability_key, source, expires_at, metadata)
|
||||
SELECT cg.team_id, 'multi_user', 'manual',
|
||||
CASE WHEN bool_or(cg.expires_at IS NULL) THEN NULL ELSE max(cg.expires_at) END,
|
||||
jsonb_build_object('backfill', 'multi_user_launch')
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.team_id IS NOT NULL
|
||||
GROUP BY cg.team_id
|
||||
HAVING bool_or(cg.expires_at IS NULL OR cg.expires_at > now())
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
|
||||
-- 4d. Grandfather cohort: every remaining non-archived company with more
|
||||
-- than one member gets a grant expiring NOW, which puts it in grace until
|
||||
-- deploy + 20 days. Owners are mailed the freeze date out-of-band; the app
|
||||
-- shows the countdown banner from the first render after deploy.
|
||||
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at, metadata)
|
||||
SELECT c.id, 'multi_user', 'manual', now(),
|
||||
jsonb_build_object('reason', 'multi_user_grandfather')
|
||||
FROM public.companies c
|
||||
WHERE c.archived_at IS NULL
|
||||
AND (SELECT count(*) FROM public.company_members cm WHERE cm.company_id = c.id) > 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.capability_grants g
|
||||
WHERE g.capability_key = 'multi_user'
|
||||
AND (
|
||||
g.company_id = c.id
|
||||
OR (c.team_id IS NOT NULL AND g.team_id = c.team_id)
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > now())
|
||||
)
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Multi-user paywall hardening (skeptic findings on 20260901081417):
|
||||
--
|
||||
-- 1. company_multi_user_state(): state-returning SECURITY DEFINER twin of
|
||||
-- company_multi_user_ok. The capability_grants SELECT policy hides
|
||||
-- team-scoped rows from users who are not on the team (byrå clients by
|
||||
-- design, WL-08), so any app-side read through a user-scoped client
|
||||
-- misreads a team-covered company as frozen. All app-side state checks go
|
||||
-- through this function first; the raw grants read stays only as the
|
||||
-- fallback for not-yet-migrated databases.
|
||||
-- 2. Byrå teams get a standing team-scoped multi_user grant (backfill +
|
||||
-- trigger): the codebase's WL-10 assumption is that byrå client companies
|
||||
-- are entitled via the team agreement (their company-scoped trial is
|
||||
-- deliberately suppressed), so a byrå team without grant rows would
|
||||
-- freeze every consultant and client user. Billing for byrå partners is
|
||||
-- out-of-band (partner agreement), matching seed_trial_capability_grants'
|
||||
-- suppression rationale.
|
||||
-- 3. Comped/manual companies (active company-scoped comp/manual grants on
|
||||
-- the pre-existing PAID keys) extend to multi_user: a hand-comped
|
||||
-- multi-member company must not read as paying while locking out its
|
||||
-- second user.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. company_multi_user_state(company, grace_days)
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.company_multi_user_state(p_company_id uuid, p_grace_days integer)
|
||||
RETURNS TABLE(state text, grace_ends_at timestamptz)
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH grant_rows AS (
|
||||
SELECT cg.expires_at
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.capability_key = 'multi_user'
|
||||
AND (
|
||||
cg.company_id = p_company_id
|
||||
OR cg.team_id = (SELECT c.team_id FROM public.companies c WHERE c.id = p_company_id)
|
||||
)
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
COALESCE(bool_or(gr.expires_at IS NULL OR gr.expires_at > now()), false) AS entitled,
|
||||
max(gr.expires_at) AS newest_expiry
|
||||
FROM grant_rows gr
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN a.entitled THEN 'entitled'
|
||||
WHEN a.newest_expiry IS NOT NULL
|
||||
AND a.newest_expiry + make_interval(days => GREATEST(p_grace_days, 0)) > now() THEN 'grace'
|
||||
ELSE 'frozen'
|
||||
END AS state,
|
||||
CASE
|
||||
WHEN NOT a.entitled
|
||||
AND a.newest_expiry IS NOT NULL
|
||||
AND a.newest_expiry + make_interval(days => GREATEST(p_grace_days, 0)) > now()
|
||||
THEN a.newest_expiry + make_interval(days => GREATEST(p_grace_days, 0))
|
||||
ELSE NULL
|
||||
END AS grace_ends_at
|
||||
FROM agg a;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_multi_user_state(uuid, integer) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.company_multi_user_state(uuid, integer) TO authenticated, service_role;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Byrå team standing grant: backfill + trigger for future teams
|
||||
-- ============================================================================
|
||||
INSERT INTO public.capability_grants (team_id, capability_key, source, expires_at, metadata)
|
||||
SELECT t.id, 'multi_user', 'manual', NULL,
|
||||
jsonb_build_object('reason', 'byra_team_default')
|
||||
FROM public.teams t
|
||||
WHERE t.kind = 'byra'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.capability_grants g
|
||||
WHERE g.team_id = t.id
|
||||
AND g.capability_key = 'multi_user'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > now())
|
||||
)
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_byra_team_multi_user()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.kind = 'byra' THEN
|
||||
INSERT INTO public.capability_grants (team_id, capability_key, source, expires_at, metadata)
|
||||
VALUES (NEW.id, 'multi_user', 'manual', NULL, jsonb_build_object('reason', 'byra_team_default'))
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_seed_byra_team_multi_user ON public.teams;
|
||||
CREATE TRIGGER trg_seed_byra_team_multi_user
|
||||
AFTER INSERT OR UPDATE OF kind ON public.teams
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_byra_team_multi_user();
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Comped/manual companies extend to multi_user
|
||||
-- ============================================================================
|
||||
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at, metadata)
|
||||
SELECT cg.company_id, 'multi_user', 'comp',
|
||||
CASE WHEN bool_or(cg.expires_at IS NULL) THEN NULL ELSE max(cg.expires_at) END,
|
||||
jsonb_build_object('backfill', 'multi_user_launch_comp')
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.company_id IS NOT NULL
|
||||
AND cg.source IN ('comp', 'manual')
|
||||
AND cg.capability_key IN
|
||||
('ai', 'bank_sync', 'skatteverket', 'email_send', 'stripe_payments', 'woocommerce_sync', 'shopify_sync')
|
||||
GROUP BY cg.company_id
|
||||
HAVING bool_or(cg.expires_at IS NULL OR cg.expires_at > now())
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,94 @@
|
||||
-- Membership guard on the multi-user entitlement helpers (Superagent P3 on
|
||||
-- PR #2099): both SECURITY DEFINER functions took a caller-supplied company
|
||||
-- UUID and were granted to `authenticated`, so any logged-in user could probe
|
||||
-- an arbitrary company's billing state (entitled/grace/frozen + deadline)
|
||||
-- across tenants. Both now require the caller to be a member of the company
|
||||
-- when a JWT is present; service-role/definer contexts (auth.uid() IS NULL)
|
||||
-- keep unrestricted behavior for trusted server callers. The grace window is
|
||||
-- also clamped to [0, 20] days so the parameter cannot widen the probe.
|
||||
--
|
||||
-- Non-member outcomes: company_multi_user_ok returns false;
|
||||
-- company_multi_user_state returns its row with a NULL state (callers treat
|
||||
-- only 'entitled'/'grace'/'frozen' as answers). Every legitimate caller
|
||||
-- passes: the app only ever asks about companies the user is a member of,
|
||||
-- and resolve_active_company_gated only evaluates the caller's own
|
||||
-- membership rows.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.company_multi_user_ok(p_company_id uuid, p_grace_days integer)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT
|
||||
(
|
||||
auth.uid() IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id AND cm.user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.capability_key = 'multi_user'
|
||||
AND (
|
||||
cg.company_id = p_company_id
|
||||
OR cg.team_id = (SELECT c.team_id FROM public.companies c WHERE c.id = p_company_id)
|
||||
)
|
||||
AND (
|
||||
cg.expires_at IS NULL
|
||||
OR cg.expires_at > now() - make_interval(days => LEAST(GREATEST(p_grace_days, 0), 20))
|
||||
)
|
||||
);
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.company_multi_user_state(p_company_id uuid, p_grace_days integer)
|
||||
RETURNS TABLE(state text, grace_ends_at timestamptz)
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH allowed AS (
|
||||
SELECT
|
||||
auth.uid() IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id AND cm.user_id = auth.uid()
|
||||
) AS ok
|
||||
),
|
||||
grant_rows AS (
|
||||
SELECT cg.expires_at
|
||||
FROM public.capability_grants cg
|
||||
WHERE cg.capability_key = 'multi_user'
|
||||
AND (
|
||||
cg.company_id = p_company_id
|
||||
OR cg.team_id = (SELECT c.team_id FROM public.companies c WHERE c.id = p_company_id)
|
||||
)
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
COALESCE(bool_or(gr.expires_at IS NULL OR gr.expires_at > now()), false) AS entitled,
|
||||
max(gr.expires_at) AS newest_expiry
|
||||
FROM grant_rows gr
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN NOT al.ok THEN NULL
|
||||
WHEN a.entitled THEN 'entitled'
|
||||
WHEN a.newest_expiry IS NOT NULL
|
||||
AND a.newest_expiry + make_interval(days => LEAST(GREATEST(p_grace_days, 0), 20)) > now() THEN 'grace'
|
||||
ELSE 'frozen'
|
||||
END AS state,
|
||||
CASE
|
||||
WHEN al.ok
|
||||
AND NOT a.entitled
|
||||
AND a.newest_expiry IS NOT NULL
|
||||
AND a.newest_expiry + make_interval(days => LEAST(GREATEST(p_grace_days, 0), 20)) > now()
|
||||
THEN a.newest_expiry + make_interval(days => LEAST(GREATEST(p_grace_days, 0), 20))
|
||||
ELSE NULL
|
||||
END AS grace_ends_at
|
||||
FROM agg a, allowed al;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
|
||||
|
||||
/**
|
||||
* Migration 20260901081417_multi_user_paywall.sql: the multi-user seat gate.
|
||||
*
|
||||
* - seed_trial_capability_grants() seeds the eighth key ('multi_user')
|
||||
* - company_multi_user_ok(company, grace_days): entitled-or-in-grace
|
||||
* - resolve_active_company_gated(grace_days): resolution that skips
|
||||
* non-owner memberships in frozen companies and reports
|
||||
* has_locked_membership when nothing resolved because of the gate
|
||||
*
|
||||
* TS twin: lib/entitlements/multi-user-state.ts; keep semantics aligned.
|
||||
*/
|
||||
|
||||
const GRACE_DAYS = 20
|
||||
|
||||
async function setMultiUserExpiry(companyId: string, interval: string): Promise<void> {
|
||||
// Push every grant on the company into the past/future in one stroke, so a
|
||||
// freshly trial-seeded company can be placed in grace or frozen.
|
||||
await getPool().query(
|
||||
`UPDATE public.capability_grants
|
||||
SET expires_at = now() + $2::interval
|
||||
WHERE company_id = $1`,
|
||||
[companyId, interval],
|
||||
)
|
||||
}
|
||||
|
||||
async function multiUserOk(companyId: string): Promise<boolean> {
|
||||
const res = await getPool().query<{ ok: boolean }>(
|
||||
`SELECT public.company_multi_user_ok($1, $2) AS ok`,
|
||||
[companyId, GRACE_DAYS],
|
||||
)
|
||||
return res.rows[0]!.ok
|
||||
}
|
||||
|
||||
async function setActivePreference(userId: string, companyId: string | null): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
}
|
||||
|
||||
type GatedRow = {
|
||||
company_id: string | null
|
||||
locale: string | null
|
||||
used_fallback: boolean
|
||||
has_locked_membership: boolean
|
||||
}
|
||||
|
||||
async function resolveGatedAs(userId: string): Promise<GatedRow | undefined> {
|
||||
return withUserContext(userId, async (client) => {
|
||||
const res = await client.query<GatedRow>(
|
||||
`SELECT * FROM public.resolve_active_company_gated($1)`,
|
||||
[GRACE_DAYS],
|
||||
)
|
||||
return res.rows[0]
|
||||
})
|
||||
}
|
||||
|
||||
describe('multi-user paywall (pg)', () => {
|
||||
describe('trial seeding', () => {
|
||||
it('a new company is trial-seeded with multi_user among the paid keys', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const res = await getPool().query<{ capability_key: string; source: string }>(
|
||||
`SELECT capability_key, source FROM public.capability_grants
|
||||
WHERE company_id = $1 AND capability_key = 'multi_user'`,
|
||||
[companyId],
|
||||
)
|
||||
expect(res.rows).toHaveLength(1)
|
||||
expect(res.rows[0]!.source).toBe('trial')
|
||||
})
|
||||
})
|
||||
|
||||
describe('company_multi_user_ok', () => {
|
||||
it('true while the trial grant is active', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
expect(await multiUserOk(companyId)).toBe(true)
|
||||
})
|
||||
|
||||
it('true during the grace window (lapsed 5 days ago)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await setMultiUserExpiry(companyId, '-5 days')
|
||||
expect(await multiUserOk(companyId)).toBe(true)
|
||||
})
|
||||
|
||||
it('false once the lapse is older than the grace window (25 days)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await setMultiUserExpiry(companyId, '-25 days')
|
||||
expect(await multiUserOk(companyId)).toBe(false)
|
||||
})
|
||||
|
||||
it('false when the company has no multi_user grants at all', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [
|
||||
companyId,
|
||||
])
|
||||
expect(await multiUserOk(companyId)).toBe(false)
|
||||
})
|
||||
|
||||
it('a team-scoped grant covers the team companies (byrå partner shape)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const teamRes = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.teams (name, created_by) VALUES ('PG Byrå', $1) RETURNING id`,
|
||||
[userId],
|
||||
)
|
||||
const teamId = teamRes.rows[0]!.id
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await getPool().query(`UPDATE public.companies SET team_id = $2 WHERE id = $1`, [
|
||||
companyId,
|
||||
teamId,
|
||||
])
|
||||
await setMultiUserExpiry(companyId, '-25 days') // company-scoped: frozen
|
||||
await getPool().query(
|
||||
`INSERT INTO public.capability_grants (team_id, capability_key, source, expires_at)
|
||||
VALUES ($1, 'multi_user', 'manual', NULL)`,
|
||||
[teamId],
|
||||
)
|
||||
expect(await multiUserOk(companyId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('company_multi_user_state (20260901083726 hardening)', () => {
|
||||
async function stateOf(companyId: string): Promise<{ state: string; grace_ends_at: string | null }> {
|
||||
const res = await getPool().query<{ state: string; grace_ends_at: string | null }>(
|
||||
`SELECT * FROM public.company_multi_user_state($1, $2)`,
|
||||
[companyId, GRACE_DAYS],
|
||||
)
|
||||
return res.rows[0]!
|
||||
}
|
||||
|
||||
it('entitled while a grant is active, grace with the deadline after a lapse, frozen past it', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
expect((await stateOf(companyId)).state).toBe('entitled')
|
||||
|
||||
await setMultiUserExpiry(companyId, '-5 days')
|
||||
const grace = await stateOf(companyId)
|
||||
expect(grace.state).toBe('grace')
|
||||
expect(grace.grace_ends_at).not.toBeNull()
|
||||
|
||||
await setMultiUserExpiry(companyId, '-25 days')
|
||||
const frozen = await stateOf(companyId)
|
||||
expect(frozen.state).toBe('frozen')
|
||||
expect(frozen.grace_ends_at).toBeNull()
|
||||
})
|
||||
|
||||
it('frozen with no rows at all (never granted)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [
|
||||
companyId,
|
||||
])
|
||||
expect((await stateOf(companyId)).state).toBe('frozen')
|
||||
})
|
||||
|
||||
it('a byrå team is auto-granted multi_user on creation, entitling its client companies', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const teamRes = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.teams (name, created_by, kind) VALUES ('PG Byrå Auto', $1, 'byra') RETURNING id`,
|
||||
[userId],
|
||||
)
|
||||
const teamId = teamRes.rows[0]!.id
|
||||
// The trg_seed_byra_team_multi_user trigger wrote the standing grant.
|
||||
const grant = await getPool().query(
|
||||
`SELECT 1 FROM public.capability_grants
|
||||
WHERE team_id = $1 AND capability_key = 'multi_user' AND expires_at IS NULL`,
|
||||
[teamId],
|
||||
)
|
||||
expect(grant.rows).toHaveLength(1)
|
||||
|
||||
// A client company under the team is entitled with ZERO company-scoped
|
||||
// rows (byrå companies get no trial: 20260826130300).
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await getPool().query(`UPDATE public.companies SET team_id = $2 WHERE id = $1`, [
|
||||
companyId,
|
||||
teamId,
|
||||
])
|
||||
await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [
|
||||
companyId,
|
||||
])
|
||||
expect((await stateOf(companyId)).state).toBe('entitled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('membership guard on the entitlement helpers (20260901091752)', () => {
|
||||
it('a NON-MEMBER cannot read another company state (no cross-tenant probe)', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const strangerId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' })
|
||||
|
||||
await withUserContext(strangerId, async (client) => {
|
||||
const ok = await client.query<{ ok: boolean }>(
|
||||
`SELECT public.company_multi_user_ok($1, $2) AS ok`,
|
||||
[companyId, GRACE_DAYS],
|
||||
)
|
||||
expect(ok.rows[0]!.ok).toBe(false)
|
||||
|
||||
const state = await client.query<{ state: string | null; grace_ends_at: string | null }>(
|
||||
`SELECT * FROM public.company_multi_user_state($1, $2)`,
|
||||
[companyId, GRACE_DAYS],
|
||||
)
|
||||
// The trial grant is active, but a stranger must not learn that:
|
||||
// state comes back NULL, grace deadline NULL.
|
||||
expect(state.rows[0]?.state ?? null).toBeNull()
|
||||
expect(state.rows[0]?.grace_ends_at ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('a MEMBER reads their own company state normally', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const memberId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' })
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
|
||||
await withUserContext(memberId, async (client) => {
|
||||
const state = await client.query<{ state: string | null }>(
|
||||
`SELECT * FROM public.company_multi_user_state($1, $2)`,
|
||||
[companyId, GRACE_DAYS],
|
||||
)
|
||||
expect(state.rows[0]!.state).toBe('entitled')
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps the grace window: a huge p_grace_days cannot widen the probe', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' })
|
||||
await setMultiUserExpiry(companyId, '-25 days')
|
||||
// 25-day-old lapse: frozen at the clamped 20-day window even when the
|
||||
// caller asks for 1000 days.
|
||||
const res = await getPool().query<{ ok: boolean }>(
|
||||
`SELECT public.company_multi_user_ok($1, 1000) AS ok`,
|
||||
[companyId],
|
||||
)
|
||||
expect(res.rows[0]!.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve_active_company_gated', () => {
|
||||
it('an OWNER resolves their frozen company (owners are never locked out)', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' })
|
||||
await setMultiUserExpiry(companyId, '-25 days')
|
||||
await setActivePreference(ownerId, companyId)
|
||||
|
||||
const row = await resolveGatedAs(ownerId)
|
||||
expect(row?.company_id).toBe(companyId)
|
||||
expect(row?.used_fallback).toBe(false)
|
||||
expect(row?.has_locked_membership).toBe(false)
|
||||
})
|
||||
|
||||
it('a NON-OWNER with only a frozen membership resolves nothing and is reported locked', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const memberId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' })
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
await setMultiUserExpiry(companyId, '-25 days')
|
||||
await setActivePreference(memberId, companyId)
|
||||
|
||||
const row = await resolveGatedAs(memberId)
|
||||
expect(row?.company_id).toBeNull()
|
||||
expect(row?.has_locked_membership).toBe(true)
|
||||
})
|
||||
|
||||
it('a NON-OWNER still resolves during the grace window', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const memberId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' })
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'admin' })
|
||||
await setMultiUserExpiry(companyId, '-5 days')
|
||||
await setActivePreference(memberId, companyId)
|
||||
|
||||
const row = await resolveGatedAs(memberId)
|
||||
expect(row?.company_id).toBe(companyId)
|
||||
expect(row?.has_locked_membership).toBe(false)
|
||||
})
|
||||
|
||||
it('a stale preference at a frozen company falls back to an accessible one (write-back signal)', async () => {
|
||||
const ownerId = await insertAuthUser()
|
||||
const userId = await insertAuthUser()
|
||||
const frozenCo = await insertCompany({ createdBy: ownerId })
|
||||
await insertCompanyMember({ companyId: frozenCo, userId: ownerId, role: 'owner' })
|
||||
await insertCompanyMember({ companyId: frozenCo, userId, role: 'member' })
|
||||
await setMultiUserExpiry(frozenCo, '-25 days')
|
||||
const ownedCo = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId: ownedCo, userId, role: 'owner' })
|
||||
await setActivePreference(userId, frozenCo)
|
||||
|
||||
const row = await resolveGatedAs(userId)
|
||||
expect(row?.company_id).toBe(ownedCo)
|
||||
// used_fallback=true is what makes the middleware persist the
|
||||
// accessible company back to user_preferences, so RLS
|
||||
// (current_active_company_id) converges on the same answer.
|
||||
expect(row?.used_fallback).toBe(true)
|
||||
expect(row?.has_locked_membership).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -61,15 +61,16 @@ async function trialGrantKeys(companyId: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
describe('trial suppression for byrå-team companies', () => {
|
||||
it('the trial seed covers the full seven-key PAID set', () => {
|
||||
it('the trial seed covers the full eight-key PAID set', () => {
|
||||
// Guard against the seed list drifting from lib/entitlements/keys.ts:
|
||||
// if PAID_CAPABILITIES grows, the migration VALUES list (and this test)
|
||||
// must grow with it.
|
||||
// must grow with it. multi_user joined at 20260901081417.
|
||||
expect(TRIAL_KEYS).toEqual(
|
||||
[
|
||||
'ai',
|
||||
'bank_sync',
|
||||
'email_send',
|
||||
'multi_user',
|
||||
'shopify_sync',
|
||||
'skatteverket',
|
||||
'stripe_payments',
|
||||
|
||||
@@ -139,7 +139,13 @@ const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
|
||||
// 2026-08-31: +1 for lib/connect/hosted/ledger.ts countHeldConnections, whose
|
||||
// .or() filter interpolates a computed timestamp (fresh-pending quota window);
|
||||
// the columns it references (status, created_at) are literals in the string.
|
||||
const UNRESOLVED_CEILING = 391
|
||||
// 2026-09-01: +2 for the multi_user seat gate: lib/entitlements/multi-user.ts
|
||||
// getMultiUserState's .or() scope filter interpolates server-resolved UUIDs
|
||||
// (company_id/team_id, same shape as hasCapability's existing filter), and
|
||||
// lib/stripe/subscription-sync.ts scopes the cancel-time multi_user expiry
|
||||
// update with an .or() interpolating a timestamp; every column named in both
|
||||
// strings is a literal (company_id, team_id, expires_at).
|
||||
const UNRESOLVED_CEILING = 393
|
||||
|
||||
/**
|
||||
* Floor on statically resolved column references. Guards the guard: if a change
|
||||
|
||||
@@ -106,6 +106,10 @@
|
||||
"path": "/api/notifications/bookkeeping-digest/cron",
|
||||
"schedule": "45 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/notifications/multi-user-grace/cron",
|
||||
"schedule": "0 7 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/peppol/inbound/cron",
|
||||
"schedule": "*/10 * * * *"
|
||||
|
||||
Reference in New Issue
Block a user