From b07a4a4bca5161d43d96ce465cf90124dfd348d4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 19 Aug 2026 11:09:10 +0200 Subject: [PATCH] fix(entitlements): trial seeding grants every paid capability, not the launch four (#1698) seed_trial_capability_grants() still hardcoded ai/bank_sync/skatteverket/ email_send while PAID_CAPABILITIES grew to seven keys. Payers got all seven via the Stripe webhook; every company created since 2026-07-12 was trialing without stripe_payments (and later woocommerce_sync/shopify_sync). Redefine the trigger with the full set, backfill existing trial grants by mirroring bank_sync, and pin the pg test to PAID_CAPABILITIES so the lists cannot drift again. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../__tests__/capability-grants.pg.test.ts | 8 ++- ...70000_trial_seed_all_paid_capabilities.sql | 60 +++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260818170000_trial_seed_all_paid_capabilities.sql diff --git a/DECISIONS.md b/DECISIONS.md index 1a54d244..2272c50e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1067,5 +1067,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] Editor PDF preview (#1686) recomputes ROT/RUT server-side from the posted lines with the same helpers as build-invoice-write.ts (computeDeduction / computeInvoiceDeductionTotal, base inkl. moms at the rendered rate, invoice-doc only) and resolves the masked personnummer the same way (typed value, else an individual customer's kundkort personnummer): the client is not trusted with the deduction math, and the preview must state the same avdrag row, info box and "Att betala" as the invoice that gets created. The editor now also posts deduction_personnummer / deduction_housing_designation to the preview route, only when a line claims a deduction (same privacy rule as buildInvoiceWritePayload). Swish QR amount in the preview follows buildSwishQrDataUrl, fixed separately in #1685. [2026-08-18] Skatteverket read data is visible to every company member, no new role gate (#1673): token rows are per (user, company) but the fetched skattekonto/declaration data belongs to the company, and viewers already read `skattekonto_transactions` and the local snapshot with no role check; membership (dispatcher-resolved ctx.companyId + company-scoped SELECT policy on `skatteverket_tokens`) is the gate. Reads resolve the caller's own token first, then the most recently issued active token of any member (all rows ordered, never `.maybeSingle()`, which errored once two members had connected). Writes (moms utkast/las/submit, AGI submit/spara/las, connect/disconnect, /status) stay on the caller's own token: BankID signing is personal. [2026-08-18] AGI receipt fallback (#1597): GET /agi/status serves the signed record from agi_declarations (kvittensnummer, response_data.signeradAv/signeradTid, submitted_at) only when the agi_submission_{period} cache is absent, and the declaration-sourced record deliberately carries NO salaryRunId: the period row is UNIQUE per company+period and regenerating a correction repoints its salary_run_id at the correction run while the stored kvittens still belongs to the original, so trusting the column would render the correction as filed with a superseded receipt. Ownership rests on signeradTid/submittedAt vs the run's agi_submitted_at stamp (same value) plus updatedAt = submitted_at, which predates any later correction's XML. Cache present still wins because it is the only place the in-flight states live. Rejected: a second client fetch in AGIPanel (two sources of truth for one card) and merging both records in the route (mixes another declaration's fields into an in-flight state). +[2026-08-18] Trial seeding trigger widened to every PAID capability (20260818170000): the 2026-06-29 trigger hardcoded the four launch keys while PAID_CAPABILITIES grew to seven (stripe_payments, woocommerce_sync, shopify_sync); the Stripe webhook seeds from the constant, so payers had all seven and trialers four (prod 2026-08-18: 226/230 active trialers lacked stripe_payments). Fixed by redefining the function with all seven and mirroring existing trial bank_sync grants for the three keys; the pg test now compares the seeded set against PAID_CAPABILITIES itself so the two cannot drift silently again. Rejected: generating the VALUES list from the TS constant at build time (no codegen path into migrations exists; a test that pins them together is the cheaper guard). [2026-08-18] Invoice ROT/RUT personnummer surfaces (detail page, invoice PDF, preview PDF, editor kept-hint) switch to the payroll mask convention YYYYMMDD-XXXX (birth date visible, last four hidden), computed on read from deduction_personnummer_encrypted via lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). The browser gets the mask from GET /api/invoices/[id]/rot-rut and never both the mask and the last four (that is the full number); v1 REST and MCP keep deduction_personnummer_last4 for compatibility (an additive deduction_personnummer_masked is a possible follow-up). InvoicePDF derives the mask itself when the caller passes the stored row, so none of the 11 render call sites can silently drop the personnummer; the preview route passes an already-masked value since it only has plaintext. The separate Skattereduktion card on the invoice detail page is folded into Detaljer as plain rows (Personnummer, Fastighet, Skattereduktion status with the begäran lifecycle) per founder decision 2026-08-18: it duplicated the totals block. [2026-08-19] Keep reversal allocation metadata limited to failures before any reversal header exists: later cleanup preserves a cancelled header with the allocated voucher number, so documenting it as an unused voucher gap would be false. diff --git a/lib/entitlements/__tests__/capability-grants.pg.test.ts b/lib/entitlements/__tests__/capability-grants.pg.test.ts index 17f40e8c..0672f5c4 100644 --- a/lib/entitlements/__tests__/capability-grants.pg.test.ts +++ b/lib/entitlements/__tests__/capability-grants.pg.test.ts @@ -2,6 +2,7 @@ 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' +import { PAID_CAPABILITIES } from '../keys' // pg-real coverage for migrations 20260628140000 (capability_grants / // company_capability_config / metered_events + company_has_capability RPC + @@ -186,7 +187,7 @@ describe('capability_grants scope constraint', () => { }) }) -describe('trial grant seeding trigger (20260629120000)', () => { +describe('trial grant seeding trigger (20260629120000, widened 20260818170000)', () => { 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 }) @@ -199,7 +200,10 @@ describe('trial grant seeding trigger (20260629120000)', () => { WHERE company_id = $1 ORDER BY capability_key`, [companyId], ) - expect(rows.map((r) => r.capability_key)).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket']) + // Every PAID key, compared against the constant itself so the trigger's + // hardcoded list (20260629120000, widened 20260818170000) can never drift + // from PAID_CAPABILITIES again without this test failing. + expect(rows.map((r) => r.capability_key)).toEqual([...PAID_CAPABILITIES].sort()) 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) diff --git a/supabase/migrations/20260818170000_trial_seed_all_paid_capabilities.sql b/supabase/migrations/20260818170000_trial_seed_all_paid_capabilities.sql new file mode 100644 index 00000000..7cfc6b18 --- /dev/null +++ b/supabase/migrations/20260818170000_trial_seed_all_paid_capabilities.sql @@ -0,0 +1,60 @@ +-- Trial seeding must grant every PAID capability, not the 2026-06-29 four. +-- +-- lib/entitlements/keys.ts PAID_CAPABILITIES grew to seven keys +-- (stripe_payments 2026-07-12, woocommerce_sync 2026-08-06, shopify_sync +-- 2026-08-08). The Stripe webhook writes grants from that constant, so +-- payers have all seven; the trial trigger seed_trial_capability_grants() +-- (20260629120000) still hardcodes ('ai','bank_sync','skatteverket', +-- 'email_send'), and each key's backfill only mirrored the grants that +-- existed on its day. Every company created since is trialing without the +-- newer keys: on 2026-08-18, 226 of 230 active trialers lacked +-- stripe_payments, 146 woocommerce_sync, 131 shopify_sync. +-- +-- 1. Redefine the trigger function with the full PAID set. Keep this VALUES +-- list in step with PAID_CAPABILITIES whenever a key is added (and add a +-- backfill like part 2 for the companies already seeded). +-- 2. Backfill: mirror every existing trial bank_sync grant (same company, +-- same expiry) for the three missing keys. Idempotent via the unique +-- index; expired trials are mirrored too so state stays aligned, exactly +-- as the earlier backfills did. + +CREATE OR REPLACE FUNCTION public.seed_trial_capability_grants() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + 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') + ) AS k(key) + ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING; + RETURN NEW; +END; +$$; + +INSERT INTO public.capability_grants + (company_id, team_id, capability_key, source, granted_at, expires_at, metadata) +SELECT + g.company_id, + g.team_id, + k.key, + g.source, + g.granted_at, + g.expires_at, + jsonb_build_object( + 'backfilled_from', 'bank_sync', + 'backfill_migration', '20260818170000' + ) +FROM public.capability_grants g +CROSS JOIN (VALUES ('stripe_payments'), ('woocommerce_sync'), ('shopify_sync')) AS k(key) +WHERE g.capability_key = 'bank_sync' AND g.source = 'trial' +ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;