4f0a7b1db0
* feat(entitlements): capability-grant gate substrate (paywall + modularity) Two-axis capability primitive behind the SaaS paywall and the per-tenant modularity/marketplace vision: - migration: capability_grants (entitlement axis, polymorphic company/firm scope), company_capability_config (enablement axis), metered_events (append-only), company_has_capability() RPC reusing the 20260619130100 tenant guard; SELECT-only RLS (writes service-role only, no self-grant). - lib/entitlements: hasCapability/requireCapability gate (mirrors guardSandbox, fail-closed, NEXT_PUBLIC_SELF_HOSTED bypass), capability key namespace, metering helper. - unit (11) + pg-real tests (RPC/RLS/tenant-guard incl. no-self-grant). Gate not yet wired into call sites (follow-up commit). Paid keys: ai, bank_sync, skatteverket, email_send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(entitlements): enforce capability gate at paid external-service chokepoints Wire the gate into the paid surfaces (keys: ai, email_send, bank_sync, skatteverket): - AI routes (agent invoke/composer/onboarding stream): requireCapability(ai) - Invoice send (web + v1): requireCapability(email_send) - document-extraction event handler: skip Bedrock extract if ai not entitled - enable-banking + skatteverket crons: per-company hasCapability skip in loop - colocated send-route test mocks updated (requireCapability -> null) Free per founder decision: TIC org lookup, VIES VAT validation, FX auto-fetch, cloud backup, BankID login, all internal bookkeeping. DEPLOY ORDER: fail-closed by design — do NOT deploy before trial/comp grant seeding lands, or companies without grants lose these features. Seeding + Stripe checkout/webhook are the next steps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(entitlements): seed trial + comp capability grants Makes the fail-closed gate safely deployable — nobody is locked out at cutover: - AFTER INSERT trigger on companies grants every NEW company a 30-day trial on the PAID keys (ai, bank_sync, skatteverket, email_send), on ALL creation paths (RPC/MCP/direct) — so a new signup can use onboarding AI immediately. - one-time backfill for EXISTING companies: created <=2026-06-07 -> trial ends 2026-07-07; created later -> created_at + 30 days. - permanent comp grants for Arcim/Mattsson (matched by name, no hardcoded UUIDs). - pg tests: clearGrants() for controlled resolver tests + trigger coverage. Trigger fn is SECURITY DEFINER so it writes grants regardless of caller RLS (table has no INSERT policy for authenticated — no self-grant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(entitlements): client capability visibility + billing page Non-payers get a clean upsell instead of broken/empty features: - CompanyContext gains capabilities[] + useCapability(key); resolved once server-side in the dashboard layout via getCompanyCapabilities (batched, 2 queries), all three provider branches wired. - /settings/billing upgrade page — the destination upsells point to (Stripe Payment Link via NEXT_PUBLIC_STRIPE_PAYMENT_LINK; degrades to 'coming soon' until automated checkout lands). - ChatEmptyState: non-payer sees an Uppgradera CTA (mirrors the sandbox state). - SendInvoiceDialog: email send disabled + upsell note when email_send missing (extends the existing sandbox-disable pattern). Fast-follow: chat input/FAB + document-inbox empty state + bank/skatteverket/ AI-suggest buttons + a shared capability_blocked->toast backstop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(entitlements): gate remaining paid UI surfaces with upsell (fast-follow) disable-with-upsell across the rest of the paid surfaces (keys: bank_sync, skatteverket, ai): - BankSyncNowButton: sync/reconnect disabled + note when !bank_sync (CSV/SIE stays free) - AGIPanel: AGI submit-to-Skatteverket disabled + note when !skatteverket - SkatteverketConnectPanel: BankID connect/reconnect disabled + upsell - ApprovalCard: AI re-propose (correction) gated; manual approve/reject stay free - InvoiceInboxWorkspace: upsell when extraction empty AND !ai (deterministic parse + manual entry unaffected) - AgentTrigger FAB: routes to /settings/billing when !ai (no dead chat) - settings nav: 'Abonnemang'/'Subscription' link to /settings/billing (sv/en) TaxPaymentPanel + TransactionInboxCard intentionally untouched — only local/ deterministic actions there, nothing paid+external to gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(entitlements): automated Stripe subscription checkout + webhook Self-serve revenue wired to the same capability-grant primitive: - migration: company_subscriptions (company<->Stripe link/status) + stripe_webhook_events (idempotency) - lib/stripe: getStripe singleton, plan->price mapping, subscription-sync (statusGrantsAccess / subscriptionToState / applySubscriptionState / handleStripeEvent). Active sub -> upsert source='stripe' grants for PAID keys (expiry = period_end + 3d grace); canceled/unpaid -> remove ONLY stripe grants (freeze-and-retain). - routes: POST /api/billing/checkout (hosted subscription Checkout, company_id metadata), POST /api/billing/portal (Customer Portal), POST /api/stripe/webhook (raw-body signature verify, event-id dedup; handles checkout.session.completed + customer.subscription.*) - billing page: real plan-toggle Checkout CTA / manage-subscription portal, gated on isStripeConfigured() - adds stripe@22; unit tests for sync logic Provisioning is webhook-driven (never trusts the success redirect). Needs env: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_MONTHLY, STRIPE_PRICE_YEARLY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(entitlements): validate UUIDs in capability filter + log webhook errors Addresses PR review (Superagent Security / PR Agent): - has-capability.ts: validate companyId/teamId as UUIDs before interpolating into the PostgREST .or() filter (fail-closed) — removes the latent injection vector flagged in the entitlement gate. Unit tests updated to use UUIDs. - stripe/webhook: log processing failures with event id + type before the generic 500, so a failing webhook is visible to operators. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): always-free AGI XML download for manual filing; only direct API submit is paid Per founder decision on the swedish-compliance-review finding: AGI is a mandatory statutory filing, so producing/downloading the AGI XML must never be paywalled. Adds a free 'Ladda ner AGI-fil' button (generates + downloads the XML for manual upload to Skatteverket's e-service) on all tiers; the gated 'Skicka in underlag' stays the paid convenience (direct API submission — which also requires the paid BankID connection). Upsell reworded to point to the manual path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(entitlements): harden comp-grant match after prod verification Verified Arcim/Mattsson in prod (pwxtzglxptnnvjrpixpg): the name match was case-sensitive (missed the active 'Arcim technology AB' lowercase variant) and would have granted 3 archived dupes. Now match by org_number (5595386219 / 5595719864) OR case-insensitive name, active companies only — hits exactly the 3 active comp companies, excludes archived dupes and the unrelated 'Amnäs Mattsson, Emil' enskild firma. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { getPool, withUserContext } from '../../../tests/pg/setup'
|
|
import { seedCompany, insertAuthUser, insertCompany } from '../../../tests/pg/fixtures'
|
|
|
|
// pg-real coverage for migrations 20260628140000 (capability_grants /
|
|
// company_capability_config / metered_events + company_has_capability RPC +
|
|
// RLS) and 20260629120000 (trial-grant trigger). Required by
|
|
// .claude/rules/database.md for any RPC/RLS/trigger change.
|
|
//
|
|
// NOTE: as of 20260629120000 an AFTER INSERT trigger auto-seeds a trial grant
|
|
// on the PAID keys for every new company. The resolver tests therefore call
|
|
// clearGrants() first to assert against a controlled grant state.
|
|
|
|
const future = () => new Date(Date.now() + 86_400_000).toISOString()
|
|
const past = () => new Date(Date.now() - 86_400_000).toISOString()
|
|
|
|
async function insertGrant(p: {
|
|
companyId?: string | null
|
|
teamId?: string | null
|
|
key: string
|
|
source?: string
|
|
expiresAt?: string | null
|
|
}): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.capability_grants (company_id, team_id, capability_key, source, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[p.companyId ?? null, p.teamId ?? null, p.key, p.source ?? 'manual', p.expiresAt ?? null],
|
|
)
|
|
}
|
|
|
|
// Remove the trigger-seeded trial grants so a test can assert a controlled state.
|
|
async function clearGrants(companyId: string): Promise<void> {
|
|
await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [companyId])
|
|
}
|
|
|
|
async function rpc(companyId: string, key: string): Promise<boolean> {
|
|
const { rows } = await getPool().query<{ ok: boolean }>(
|
|
`SELECT public.company_has_capability($1, $2) AS ok`,
|
|
[companyId, key],
|
|
)
|
|
return rows[0].ok
|
|
}
|
|
|
|
describe('company_has_capability (entitlement axis)', () => {
|
|
it('is false when no grant exists (fail-closed)', async () => {
|
|
const { companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
expect(await rpc(companyId, 'ai')).toBe(false)
|
|
})
|
|
|
|
it('is true for an unexpired company-scoped grant', async () => {
|
|
const { companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
await insertGrant({ companyId, key: 'ai', expiresAt: future() })
|
|
expect(await rpc(companyId, 'ai')).toBe(true)
|
|
})
|
|
|
|
it('treats a null expiry as never-expiring', async () => {
|
|
const { companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
await insertGrant({ companyId, key: 'ai', source: 'comp', expiresAt: null })
|
|
expect(await rpc(companyId, 'ai')).toBe(true)
|
|
})
|
|
|
|
it('is false once the grant has expired', async () => {
|
|
const { companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
await insertGrant({ companyId, key: 'ai', source: 'trial', expiresAt: past() })
|
|
expect(await rpc(companyId, 'ai')).toBe(false)
|
|
})
|
|
|
|
it('cascades a firm/team-scoped grant to the client company', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
const teamId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Firm', $2)`,
|
|
[teamId, userId],
|
|
)
|
|
await getPool().query(`UPDATE public.companies SET team_id = $1 WHERE id = $2`, [
|
|
teamId,
|
|
companyId,
|
|
])
|
|
await insertGrant({ teamId, key: 'skatteverket', expiresAt: future() })
|
|
expect(await rpc(companyId, 'skatteverket')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('company_has_capability (enablement axis)', () => {
|
|
it('is false when entitled but explicitly disabled', async () => {
|
|
const { companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
await insertGrant({ companyId, key: 'ai', expiresAt: null })
|
|
await getPool().query(
|
|
`INSERT INTO public.company_capability_config (company_id, capability_key, enabled)
|
|
VALUES ($1, 'ai', false)`,
|
|
[companyId],
|
|
)
|
|
expect(await rpc(companyId, 'ai')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('company_has_capability tenant guard', () => {
|
|
it('raises 42501 when a non-member asks about a company (authenticated ctx)', async () => {
|
|
const { companyId } = await seedCompany()
|
|
const outsider = await insertAuthUser()
|
|
await expect(
|
|
withUserContext(outsider, async (client) => {
|
|
await client.query(`SELECT public.company_has_capability($1, 'ai')`, [companyId])
|
|
}),
|
|
).rejects.toThrow(/unauthorized/)
|
|
})
|
|
|
|
it('lets a member resolve their own company under authenticated ctx', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ok = await withUserContext(userId, async (client) => {
|
|
const r = await client.query<{ ok: boolean }>(
|
|
`SELECT public.company_has_capability($1, 'ai') AS ok`,
|
|
[companyId],
|
|
)
|
|
return r.rows[0].ok
|
|
})
|
|
// entitled via the auto-seeded trial grant
|
|
expect(ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('capability_grants RLS', () => {
|
|
it('lets a member read their own grants but hides them from non-members', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await clearGrants(companyId)
|
|
await insertGrant({ companyId, key: 'ai', expiresAt: null })
|
|
|
|
const memberCount = await withUserContext(userId, async (client) => {
|
|
const r = await client.query(
|
|
`SELECT id FROM public.capability_grants WHERE company_id = $1`,
|
|
[companyId],
|
|
)
|
|
return r.rowCount
|
|
})
|
|
expect(memberCount).toBe(1)
|
|
|
|
const outsider = await insertAuthUser()
|
|
const outsiderCount = await withUserContext(outsider, async (client) => {
|
|
const r = await client.query(
|
|
`SELECT id FROM public.capability_grants WHERE company_id = $1`,
|
|
[companyId],
|
|
)
|
|
return r.rowCount
|
|
})
|
|
expect(outsiderCount).toBe(0)
|
|
})
|
|
|
|
it('forbids a member from self-granting an entitlement (no INSERT policy)', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await expect(
|
|
withUserContext(userId, async (client) => {
|
|
await client.query(
|
|
`INSERT INTO public.capability_grants (company_id, capability_key, source)
|
|
VALUES ($1, 'ai', 'manual')`,
|
|
[companyId],
|
|
)
|
|
}),
|
|
).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('capability_grants scope constraint', () => {
|
|
it('rejects a grant with neither company_id nor team_id', async () => {
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.capability_grants (capability_key, source) VALUES ('ai', 'manual')`,
|
|
),
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
it('rejects a grant with both company_id and team_id', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const teamId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Firm', $2)`,
|
|
[teamId, userId],
|
|
)
|
|
await expect(insertGrant({ companyId, teamId, key: 'ai' })).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('trial grant seeding trigger (20260629120000)', () => {
|
|
it('grants a new company a 30-day trial on the PAID keys at creation', async () => {
|
|
const userId = await insertAuthUser()
|
|
const companyId = await insertCompany({ createdBy: userId })
|
|
const { rows } = await getPool().query<{
|
|
capability_key: string
|
|
source: string
|
|
expires_at: string | null
|
|
}>(
|
|
`SELECT capability_key, source, expires_at FROM public.capability_grants
|
|
WHERE company_id = $1 ORDER BY capability_key`,
|
|
[companyId],
|
|
)
|
|
expect(rows.map((r) => r.capability_key)).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket'])
|
|
expect(rows.every((r) => r.source === 'trial')).toBe(true)
|
|
expect(rows.every((r) => r.expires_at !== null)).toBe(true)
|
|
expect(await rpc(companyId, 'ai')).toBe(true)
|
|
})
|
|
|
|
it('does not seed free keys (only the PAID set is granted)', async () => {
|
|
const userId = await insertAuthUser()
|
|
const companyId = await insertCompany({ createdBy: userId })
|
|
expect(await rpc(companyId, 'cloud_backup')).toBe(false)
|
|
expect(await rpc(companyId, 'org_lookup')).toBe(false)
|
|
})
|
|
})
|