* feat(invoicing): opt-in invoice email from the company's own sending domain Companies holding the custom_sender_domain capability grant can register their own domain (Resend sending-only profile), publish DKIM/SPF, and once verified every invoice email (send, reminders, recurring, payment confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>" instead of the platform sender. Reply-To is unchanged. - New table company_sending_domains (RLS: members read, owner/admin write; audit trigger), types, archive-export classification. - New capability key custom_sender_domain: manually granted per company, deliberately outside PAID_CAPABILITIES (never trial-seeded, never written by the Stripe sync). Without the grant the settings section is hidden and nothing changes. - Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify), Resend domain lifecycle without orphan adoption, domain.updated handling on the delivery webhook, explicit From support in the Resend adapter. - Core resolveInvoiceSender(): verified + enabled + entitled, else the platform sender; never throws. - Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en). - Unit tests for the resolver, domain helpers, routes, From header; pg-real test for RLS and constraints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): harden sending-domain writes, sender fallback, review findings Skeptic refutations: - Tenant JWTs could insert/update company_sending_domains with status = 'verified' and an arbitrary domain through PostgREST (RLS only checked membership), then send invoice mail as that domain. New migration 20260822130000 adds a BEFORE trigger: tenants may only open a pending claim and edit sender_local_part/sender_name/enabled; domain and verification state are service-role only. claim/verify helpers now take a service-role writer for those columns; the route's RLS client still does the insert. - A company domain Resend later rejects made every invoice send fail: the Resend adapter retries once as the platform sender when an explicit company From is rejected (nothing was sent, so no double send). Review findings: - domain.updated webhook: discriminated outcome; DB errors answer 500 so Svix retries, unknown domains are acknowledged. - Display names are RFC 5322-quoted only when they carry specials. - Sender local part is a strict dot-atom (no trailing/consecutive dots), in code and in the CHECK constraint; resend_domain_id index is UNIQUE. - IME composition guard on the claim input; event bus reset in tests; settings section skips its request for non-admins. Deferred (needs a product call): persisting the effective From address in the invoice delivery log touches the hardened evidence triggers; recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a tenant could delete and re-insert its pending row under the same id with a reserved domain, and the service-role writer updated by id alone. Now: - the claim's verification-state write filters on (id, company_id, domain, resend_domain_id IS NULL) and rolls back on zero rows; - verify and the domain.updated webhook compare Resend's domain name with the row before writing verified; - resolveInvoiceSender refuses reserved platform domains and non-hostnames at send time (reserved-domain logic moved to lib/email/domain-name.ts and shared with the claim validator). pg-real: the case-insensitive uniqueness assertion now expects the domain_shape CHECK (lowercase enforced) for an uppercase variant and the unique index for a same-case duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
135 lines
6.5 KiB
TypeScript
135 lines
6.5 KiB
TypeScript
/**
|
|
* Capability keys: the single namespace behind the SaaS paywall AND the
|
|
* per-tenant modularity / marketplace vision. Each key names one gateable
|
|
* feature; a company "has" it when an unexpired capability_grant exists
|
|
* (entitlement) and it isn't explicitly disabled (enablement).
|
|
*
|
|
* These keys are a STABLE CONTRACT: grant rows, the future marketplace catalog,
|
|
* and per-tenant module toggles all reference them. Add keys; never rename one.
|
|
*/
|
|
export const CAPABILITY = {
|
|
/** AI assistant chat, onboarding composer, and document field extraction (Anthropic/Bedrock). */
|
|
ai: 'ai',
|
|
/** Bank sync / PSD2 (Enable Banking). Freeze-and-retain: tokens are NOT revoked on downgrade. */
|
|
bank_sync: 'bank_sync',
|
|
/** Skatteverket filing/sync (VAT, AGI, skattekonto) via BankID. */
|
|
skatteverket: 'skatteverket',
|
|
/** Outbound transactional email: invoices, reminders, payslips (Resend). Auth/account email is never gated. */
|
|
email_send: 'email_send',
|
|
/** Org-number lookup / enrichment (TIC). NOT gated: identity/lookup is always free. */
|
|
org_lookup: 'org_lookup',
|
|
/** EU VAT-number validation (VIES). NOT gated: identity/lookup is always free. */
|
|
vat_validation: 'vat_validation',
|
|
/** Riksbanken FX auto-fetch. NOT gated at launch (kept free); manual rate entry is always allowed. */
|
|
currency_rates: 'currency_rates',
|
|
/** Cloud backup to Google Drive. NOT gated at launch (kept free: never hold a customer's data hostage). */
|
|
cloud_backup: 'cloud_backup',
|
|
/** Migration import from other systems (Fortnox/Visma/Bokio/BL/Briox). Kept open so new payers can migrate IN. */
|
|
migration: 'migration',
|
|
/** Bolagsverket iXBRL årsredovisning filing. Reserved (extension not yet enabled). */
|
|
bolagsverket: 'bolagsverket',
|
|
/** Stripe Connect: auto payment links on invoices + payment/payout sync. */
|
|
stripe_payments: 'stripe_payments',
|
|
/** WooCommerce store sync: orders/refunds imported as a transaction feed. */
|
|
woocommerce_sync: 'woocommerce_sync',
|
|
/** Shopify store sync: orders/refunds imported as a transaction feed. */
|
|
shopify_sync: 'shopify_sync',
|
|
/**
|
|
* Invoice email from the company's own verified sending domain (Resend
|
|
* domain per company). Opt-in: granted manually per company, NOT part of
|
|
* PAID_CAPABILITIES, so it is never trial-seeded or written by the Stripe
|
|
* subscription sync. Without the grant the settings section is hidden and
|
|
* mail keeps leaving from the platform sender.
|
|
*/
|
|
custom_sender_domain: 'custom_sender_domain',
|
|
} as const
|
|
|
|
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
|
|
|
|
/**
|
|
* The set actually withheld from non-payers (manual tier) at the 2026-07-07
|
|
* cutover. Founder decision (2026-06-28): gate the high-value recurring external
|
|
* services only.
|
|
*
|
|
* KEPT FREE on purpose:
|
|
* - identity & lookup: TIC org_lookup, VIES vat_validation, BankID login:
|
|
* they aid onboarding/data quality; gating them is friction in the wrong place.
|
|
* - currency_rates (FX auto-fetch) and cloud_backup.
|
|
* Internal bookkeeping is always fully usable on the manual tier.
|
|
*
|
|
* NOTE: bank_sync and skatteverket stay PAID even though their flows use BankID
|
|
* as an auth step: what's charged for is the bank data sync and the VAT/AGI
|
|
* filing service, not the identity check.
|
|
*/
|
|
export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
|
CAPABILITY.ai,
|
|
CAPABILITY.bank_sync,
|
|
CAPABILITY.skatteverket,
|
|
CAPABILITY.email_send,
|
|
CAPABILITY.stripe_payments,
|
|
CAPABILITY.woocommerce_sync,
|
|
CAPABILITY.shopify_sync,
|
|
] as const
|
|
|
|
/**
|
|
* Paid MCP tools → required capability. The MCP/agent path is a paid chokepoint
|
|
* just like the HTTP routes, so the dispatcher gates these the same way it gates
|
|
* API-key scope (see mcp-server `tools/call`). External-service WRITE tools
|
|
* appear here: send_invoice (email) and the two Skatteverket submissions. The
|
|
* read/local SKV tools (generate_agi, vat_declaration_validate/status, agi_status)
|
|
* stay free: the §4 carve-out forbids blocking a statutory filing obligation.
|
|
*
|
|
* The document upload tools invoke AI (Bedrock document OCR via
|
|
* extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall
|
|
* the HTTP inbox upload/attach/retry paths enforce. Without these entries a
|
|
* free-tier API key could trigger paid AI extraction. bank_sync has no MCP
|
|
* tool (bank sync is cron/HTTP only).
|
|
*/
|
|
export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
|
gnubok_send_invoice: CAPABILITY.email_send,
|
|
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
|
gnubok_agi_submit: CAPABILITY.skatteverket,
|
|
// AI document OCR (Bedrock): the inbox's paid extraction, reachable via MCP.
|
|
gnubok_create_document_upload: CAPABILITY.ai,
|
|
gnubok_complete_document_upload: CAPABILITY.ai,
|
|
gnubok_upload_document: CAPABILITY.ai,
|
|
} as const
|
|
|
|
/**
|
|
* Paid pending-operation types → required capability. Keyed by
|
|
* `pending_operations.operation_type`. This is the commit-time twin of
|
|
* MCP_TOOL_CAPABILITY_MAP: it gates the actual external-service call inside
|
|
* commitPendingOperation, so an operation staged during the trial cannot be
|
|
* committed once the grant has expired, regardless of caller (MCP approve tool
|
|
* or the UI approval path). Keep the values in sync with MCP_TOOL_CAPABILITY_MAP.
|
|
*/
|
|
export const PAID_OPERATION_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
|
send_invoice: CAPABILITY.email_send,
|
|
submit_vat_declaration: CAPABILITY.skatteverket,
|
|
submit_agi: CAPABILITY.skatteverket,
|
|
} as const
|
|
|
|
/**
|
|
* Extension workspace → required capability, keyed by `sector/slug`. This is the
|
|
* page/nav twin of the API-route gates: an extension whose entire value is a
|
|
* paid service should not just 403 its writes but be hidden from the sidebar and
|
|
* blocked at the page so a non-payer never lands on a dead workspace.
|
|
*
|
|
* invoice-inbox is fully gated on `ai`: its reason to exist is the AI field
|
|
* extraction (extractInvoiceFields / gnubok_upload_document), already the paid
|
|
* chokepoint on every other surface (HTTP upload/attach/retry, the MCP tool).
|
|
* Both the sidebar item and the /e/[sector]/[slug] page read this map so the two
|
|
* surfaces can never drift apart.
|
|
*/
|
|
export const EXTENSION_REQUIRED_CAPABILITY: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
|
'general/invoice-inbox': CAPABILITY.ai,
|
|
} as const
|
|
|
|
/** Which paid capability (if any) an extension workspace requires to be usable. */
|
|
export function requiredCapabilityForExtension(
|
|
sector: string,
|
|
slug: string,
|
|
): CapabilityKey | undefined {
|
|
return EXTENSION_REQUIRED_CAPABILITY[`${sector}/${slug}`]
|
|
}
|