Files
accounted/lib/entitlements/__tests__/capability-grants.pg.test.ts
T
Jakob Wennberg 4f0a7b1db0 feat(entitlements): per-company capability paywall — gate, trial seeding, UI upsells, Stripe checkout (#815)
* 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>
2026-06-29 15:56:19 +02:00

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