* 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>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import { eventBus } from '@/lib/events'
|
||
import { ensureInitialized } from '@/lib/init'
|
||
import { renderToBuffer } from '@react-pdf/renderer'
|
||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||
import { getEmailService } from '@/lib/email/service'
|
||
import {
|
||
generateInvoiceEmailHtml,
|
||
generateInvoiceEmailText,
|
||
generateInvoiceEmailSubject,
|
||
} from '@/lib/email/invoice-templates'
|
||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||
|
||
ensureInitialized()
|
||
|
||
export const POST = withRouteContext(
|
||
'invoice.send',
|
||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||
const { id } = await params
|
||
const { user, supabase, companyId, log, requestId } = ctx
|
||
const opLog = log.child({ invoiceId: id })
|
||
|
||
// The sandbox must never deliver a real email to a real customer — block
|
||
// the entire send pipeline (PDF render + Resend send + status flip).
|
||
const blocked = await guardSandbox(supabase, companyId)
|
||
if (blocked) return blocked
|
||
|
||
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.email_send)
|
||
if (capBlocked) return capBlocked
|
||
|
||
const emailService = getEmailService()
|
||
if (!emailService.isConfigured()) {
|
||
return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId })
|
||
}
|
||
|
||
const { data: invoice, error: invoiceError } = await supabase
|
||
.from('invoices')
|
||
.select(`
|
||
*,
|
||
customer:customers(*),
|
||
items:invoice_items(*)
|
||
`)
|
||
.eq('id', id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (invoiceError || !invoice) {
|
||
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
|
||
}
|
||
|
||
// A cancelled invoice keeps its F-series number for compliance with ML 17
|
||
// kap 24§ but is not a valid faktura — sending it would silently
|
||
// re-activate it (the .update({ status: 'sent' }) below has no status
|
||
// guard) and could deliver a "MAKULERAD" PDF as if it were live.
|
||
if (invoice.status === 'cancelled') {
|
||
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId })
|
||
}
|
||
|
||
const customer = invoice.customer as Customer
|
||
if (!customer.email) {
|
||
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
|
||
requestId,
|
||
details: { customerId: customer.id },
|
||
})
|
||
}
|
||
|
||
const { data: company, error: companyError } = await supabase
|
||
.from('company_settings')
|
||
.select('*')
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (companyError || !company) {
|
||
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId })
|
||
}
|
||
|
||
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
|
||
|
||
let originalInvoiceNumber: string | undefined
|
||
if (invoice.credited_invoice_id) {
|
||
const { data: originalInvoice } = await supabase
|
||
.from('invoices')
|
||
.select('invoice_number')
|
||
.eq('id', invoice.credited_invoice_id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (originalInvoice) {
|
||
originalInvoiceNumber = originalInvoice.invoice_number
|
||
}
|
||
}
|
||
|
||
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
|
||
// number. If the row is already numbered (retry path), skip — we'd just
|
||
// render twice for no gain.
|
||
const isFreshAllocation = !invoice.invoice_number
|
||
if (isFreshAllocation) {
|
||
try {
|
||
const preflight = await prepareInvoicePdfRender(company as CompanySettings)
|
||
await renderToBuffer(
|
||
InvoicePDF({
|
||
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
|
||
customer,
|
||
items,
|
||
company: preflight.company,
|
||
originalInvoiceNumber,
|
||
branding: preflight.branding,
|
||
}),
|
||
)
|
||
} catch (err) {
|
||
opLog.error('preflight PDF render failed before invoice number assignment', err as Error)
|
||
return errorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', opLog, { requestId })
|
||
}
|
||
}
|
||
|
||
// Allocate the F-series number. Idempotent — retries reuse the same number.
|
||
try {
|
||
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
|
||
} catch (err) {
|
||
opLog.error('failed to assign invoice number on send', err as Error)
|
||
return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId })
|
||
}
|
||
|
||
// Final render with the assigned number — this is the buffer attached to
|
||
// the email and later archived as underlag. Override status to 'sent' on
|
||
// the in-memory copy: the DB flip happens after email delivery (line
|
||
// ~185), but if we render with the stale 'draft' status the customer
|
||
// receives a PDF stamped "UTKAST – inte en giltig faktura".
|
||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||
company as CompanySettings,
|
||
)
|
||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
|
||
const pdfBuffer = await renderToBuffer(
|
||
InvoicePDF({
|
||
invoice: renderableInvoice,
|
||
customer,
|
||
items,
|
||
company: renderCompany,
|
||
originalInvoiceNumber,
|
||
branding,
|
||
swishQrDataUrl,
|
||
}),
|
||
)
|
||
|
||
const emailData = {
|
||
invoice: invoice as Invoice,
|
||
customer,
|
||
company: company as CompanySettings,
|
||
}
|
||
|
||
const isCreditNote = !!invoice.credited_invoice_id
|
||
const docType = invoice.document_type || 'invoice'
|
||
let filename: string
|
||
if (isCreditNote) {
|
||
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
||
} else if (docType === 'proforma') {
|
||
filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
||
} else if (docType === 'delivery_note') {
|
||
filename = `foljesedel-${invoice.invoice_number}.pdf`
|
||
} else {
|
||
filename = `faktura-${invoice.invoice_number}.pdf`
|
||
}
|
||
|
||
const ccAddress = company.email || user.email
|
||
const result = await emailService.sendEmail({
|
||
to: customer.email,
|
||
cc: ccAddress,
|
||
subject: generateInvoiceEmailSubject(emailData),
|
||
html: generateInvoiceEmailHtml(emailData),
|
||
text: generateInvoiceEmailText(emailData),
|
||
replyTo: company.email || undefined,
|
||
fromName: company.company_name,
|
||
attachments: [
|
||
{
|
||
filename,
|
||
content: pdfBuffer,
|
||
contentType: 'application/pdf',
|
||
},
|
||
],
|
||
})
|
||
|
||
if (!result.success) {
|
||
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
|
||
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
|
||
requestId,
|
||
details: { providerError: result.error },
|
||
})
|
||
}
|
||
|
||
// From here on the invoice has reached the customer. Failures in the
|
||
// follow-up steps degrade the response to PARTIAL — the user gets a
|
||
// success toast with a sub-warning, and the audit trail records exactly
|
||
// which sub-step broke.
|
||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||
|
||
{
|
||
const { error: updateError } = await supabase
|
||
.from('invoices')
|
||
.update({ status: 'sent' })
|
||
.eq('id', id)
|
||
.eq('company_id', companyId)
|
||
|
||
if (updateError) {
|
||
opLog.warn('failed to update invoice status to sent', updateError)
|
||
partialFailures.push({ step: 'status_update', reason: updateError.message })
|
||
}
|
||
}
|
||
|
||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
|
||
let createdJournalEntryId: string | undefined
|
||
|
||
if (isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
|
||
try {
|
||
const journalEntry = await createInvoiceJournalEntry(
|
||
supabase,
|
||
companyId!,
|
||
user.id,
|
||
invoice as Invoice,
|
||
(company as CompanySettings).entity_type,
|
||
)
|
||
if (journalEntry) {
|
||
createdJournalEntryId = journalEntry.id
|
||
await supabase
|
||
.from('invoices')
|
||
.update({ journal_entry_id: journalEntry.id })
|
||
.eq('id', id)
|
||
|
||
// Periodiserade lines: create their schedules + catch-up
|
||
// dissolutions now that the revenue entry exists. Failures degrade
|
||
// to PARTIAL — the entry is committed and must not be rolled back.
|
||
const accrual = await createSchedulesForCustomerInvoice(
|
||
supabase,
|
||
companyId!,
|
||
user.id,
|
||
invoice as Invoice,
|
||
items,
|
||
journalEntry.id,
|
||
(company as CompanySettings).entity_type,
|
||
)
|
||
if (accrual.failed > 0) {
|
||
partialFailures.push({
|
||
step: 'accrual_schedules',
|
||
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
|
||
})
|
||
}
|
||
}
|
||
} catch (err) {
|
||
opLog.error('failed to create invoice journal entry on send', err as Error)
|
||
partialFailures.push({
|
||
step: 'journal_entry',
|
||
reason: err instanceof Error ? err.message : 'unknown',
|
||
})
|
||
}
|
||
}
|
||
|
||
if (isRealInvoice) {
|
||
try {
|
||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||
await uploadDocument(supabase, user.id, companyId!, {
|
||
name: filename,
|
||
buffer: pdfArrayBuffer,
|
||
type: 'application/pdf',
|
||
}, {
|
||
upload_source: 'system',
|
||
journal_entry_id: createdJournalEntryId,
|
||
})
|
||
} catch (err) {
|
||
opLog.error('failed to store invoice PDF as underlag', err as Error)
|
||
partialFailures.push({
|
||
step: 'pdf_archive',
|
||
reason: err instanceof Error ? err.message : 'unknown',
|
||
})
|
||
}
|
||
}
|
||
|
||
await eventBus.emit({
|
||
type: 'invoice.sent',
|
||
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
|
||
})
|
||
|
||
if (partialFailures.length > 0) {
|
||
opLog.warn('invoice sent with partial follow-up failures', {
|
||
errorCode: 'INVOICE_SEND_PARTIAL',
|
||
failures: partialFailures,
|
||
})
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||
messageId: result.messageId,
|
||
...(partialFailures.length > 0
|
||
? { partial: true, partial_failures: partialFailures }
|
||
: {}),
|
||
})
|
||
},
|
||
{ requireWrite: true },
|
||
)
|