fix(entitlements): gate remaining paid-feature UI, drop stale beta pricing copy (#921)
* fix(entitlements): gate remaining paid-feature UI and drop stale beta pricing copy Second post-cutover sweep. Non-payers still hit live controls that 403 at the server gate, plus dead plan names in the inbox guide: - document-inbox guide: remove "Gratis under beta för Open-användare / Pro-planen / Se priser" (plans that no longer exist); inbox stays free, AI extraction is pitched as part of the subscription - payslip send button (RunProgressBar): disabled + tooltip without email_send; the free PDF download next to it is untouched - skattekonto "Synkronisera nu": disabled + tooltip without skatteverket - invoice post-create send-now dialog: skipped without email_send (the invoice page's SendInvoiceDialog carries the upsell) - recurring-invoice auto_send checkbox: disabled + UpgradeNote without email_send, and force-unchecked so edit mode can't PATCH it back on - AgentChat composer: replaced with UpgradeNote without ai, covering deep links to /chat/* and conversations opened before expiry - /onboarding/agent server page: hasCapability(ai) redirect to /settings/billing before the gated composer stream starts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(entitlements): address review findings on the gating sweep - AgentChat: the mount effect auto-fired the first /api/agent/invoke on a fresh start regardless of capability; now returns early without ai, and Regenerate/Correction re-invokes are guarded too (real find by review bot) - recurring auto_send upsell copy moved to i18n (auto_send_requires_subscription, sv+en) matching the rest of the dialog - disabled-button tooltips wrapped in a span (payslip send + skattekonto sync): browsers suppress title on disabled elements Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ed7a178ac6
commit
a566a42aec
@@ -27,6 +27,8 @@ import {
|
||||
Link2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type {
|
||||
SkatteverketSaldoResponse,
|
||||
SkattekontoTransactionWithSuggestion,
|
||||
@@ -60,6 +62,7 @@ interface MatchCandidate {
|
||||
|
||||
export default function SkattekontoPage() {
|
||||
const { toast } = useToast()
|
||||
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
|
||||
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
|
||||
const [tx, setTx] = useState<TransaktionerEnvelope['data'] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -305,10 +308,13 @@ export default function SkattekontoPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeading
|
||||
right={
|
||||
<Button onClick={syncNow} disabled={syncing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
|
||||
</Button>
|
||||
// The span carries the tooltip: `title` is suppressed on disabled elements.
|
||||
<span title={!hasSkvCapability ? 'Synk mot Skatteverket kräver ett abonnemang' : undefined}>
|
||||
<Button onClick={syncNow} disabled={syncing || !hasSkvCapability}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
|
||||
import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding'
|
||||
|
||||
@@ -21,6 +23,13 @@ export default async function AgentOnboardingPage() {
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
// Paywall: the build flow drives the gated composer stream (ai capability).
|
||||
// Send non-payers to billing where the assistant is pitched, instead of a
|
||||
// build sequence that 403s mid-stream.
|
||||
if (!(await hasCapability(supabase, companyId, CAPABILITY.ai))) {
|
||||
redirect('/settings/billing')
|
||||
}
|
||||
|
||||
// Everything that doesn't depend on the TIC snapshot loads in one batch:
|
||||
// the settings row (carrying the is_sandbox gate + the onboarding-form
|
||||
// data, moms_period, fiscal_year_start_month, f_skatt, city, …, that
|
||||
|
||||
@@ -15,6 +15,9 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import ApprovalCard from './ApprovalCard'
|
||||
|
||||
// Reusable chat surface: used both inside the right-hand AgentSheet and on
|
||||
@@ -123,6 +126,7 @@ export default function AgentChat({
|
||||
const firstTurnFiredRef = useRef(false)
|
||||
const conversationIdRef = useRef<string | null>(initialConversationId ?? null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? [])
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
@@ -158,6 +162,10 @@ export default function AgentChat({
|
||||
const hasResumeState = !!initialConversationId
|
||||
if (hasResumeState) return
|
||||
|
||||
// Paywall: never auto-fire the first invoke without the ai capability;
|
||||
// the composer is already replaced by the upgrade note.
|
||||
if (!hasAi) return
|
||||
|
||||
// Seed-message path: render the user's pre-baked starter in the timeline
|
||||
// and send it as the first turn's user_message (skips intent.capture +
|
||||
// promptTemplate). Empty seed runs the normal capture-driven flow.
|
||||
@@ -339,6 +347,7 @@ export default function AgentChat({
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
if (!hasAi) return
|
||||
// Re-run the last user message and let the agent produce a fresh
|
||||
// response. UI truncates back to the last user message; DB rows are
|
||||
// append-only, so the previous assistant turn stays in agent_messages
|
||||
@@ -361,6 +370,7 @@ export default function AgentChat({
|
||||
// user turn so the agent re-proposes inline: no synthetic user bubble (we
|
||||
// don't add a user row, and the turn is persisted hidden).
|
||||
function handleCorrection(correctionMessage: string) {
|
||||
if (!hasAi) return
|
||||
void startTurn({ conversationId, userMessage: correctionMessage, hidden: true })
|
||||
}
|
||||
|
||||
@@ -549,6 +559,7 @@ export default function AgentChat({
|
||||
streamingTail={streaming && i === messages.length - 1}
|
||||
showRegenerate={
|
||||
!streaming &&
|
||||
hasAi &&
|
||||
i === lastAssistantIdx &&
|
||||
m.role === 'assistant' &&
|
||||
m.text.length > 0
|
||||
@@ -566,6 +577,14 @@ export default function AgentChat({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paywall: /api/agent/invoke 403s without the ai capability. Replace
|
||||
the composer with an upsell so an already-open conversation (or a
|
||||
deep link to /chat/*) never offers an input that can't send. */}
|
||||
{!hasAi ? (
|
||||
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
|
||||
<UpgradeNote>AI-assistenten kräver ett abonnemang.</UpgradeNote>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
// padding-bottom = base 1rem + safe-area-inset-bottom on phones so
|
||||
// the iOS home indicator / Android gesture bar doesn't overlap the
|
||||
@@ -619,6 +638,7 @@ export default function AgentChat({
|
||||
Enter att skicka · Shift+Enter för ny rad
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1353,18 +1353,8 @@ function OnboardingCard({
|
||||
</div>
|
||||
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
|
||||
Underlagen samlas här (från mail eller filuppladdning) och kan
|
||||
matchas mot bankhändelser eller bokföras direkt.
|
||||
</p>
|
||||
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
|
||||
Gratis under beta för Open-användare. Ingår senare i Pro-planen.{' '}
|
||||
<a
|
||||
href="https://www.gnubok.se/priser"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Se priser →
|
||||
</a>
|
||||
matchas mot bankhändelser eller bokföras direkt. Inkorgen är alltid
|
||||
gratis; AI-tolkning av underlag ingår i abonnemanget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
|
||||
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import {
|
||||
ROT_WORK_TYPES,
|
||||
@@ -109,6 +110,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const { company } = useCompany()
|
||||
const hasEmailSend = useCapability(CAPABILITY.email_send)
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoice_editor')
|
||||
const ts = useTranslations('self_billing')
|
||||
@@ -929,7 +931,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
function handleLogoPromptClose() {
|
||||
setShowLogoPrompt(false)
|
||||
// Resume the post-create flow that was deferred by the logo prompt.
|
||||
if (selectedCustomer?.email && createdInvoiceId) {
|
||||
// The send-now dialog only emails: skipped without the email_send
|
||||
// capability (the invoice page's SendInvoiceDialog carries the upsell).
|
||||
if (selectedCustomer?.email && createdInvoiceId && hasEmailSend) {
|
||||
setShowSendPrompt(true)
|
||||
} else if (createdInvoiceId) {
|
||||
router.push(`/invoices/${createdInvoiceId}`)
|
||||
@@ -999,7 +1003,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// prompt closes, handleLogoPromptClose resumes the regular flow.
|
||||
if (hadZeroInvoices === true && !logoUrl) {
|
||||
setShowLogoPrompt(true)
|
||||
} else if (selectedCustomer?.email) {
|
||||
} else if (selectedCustomer?.email && hasEmailSend) {
|
||||
setShowSendPrompt(true)
|
||||
} else {
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -215,15 +217,18 @@ function NewRecurringScheduleForm({
|
||||
const watchCustomerId = watch('customer_id')
|
||||
const selectedCustomer = customers.find((c) => c.id === watchCustomerId)
|
||||
const customerMissingEmail = !!selectedCustomer && !selectedCustomer.email
|
||||
const hasEmailSend = useCapability(CAPABILITY.email_send)
|
||||
const autoSendBlocked = customerMissingEmail || !hasEmailSend
|
||||
|
||||
// The onValueChange guard on the customer select only fires on a manual
|
||||
// change. In edit mode a schedule can load with auto_send=true against a
|
||||
// customer who has since lost their email (customers load async, after the
|
||||
// form's defaultValues). Force auto_send off whenever the effective customer
|
||||
// has no email so a disabled-but-checked box can't PATCH auto_send=true.
|
||||
// has no email (or email sending isn't entitled) so a disabled-but-checked
|
||||
// box can't PATCH auto_send=true.
|
||||
useEffect(() => {
|
||||
if (customerMissingEmail) setValue('auto_send', false)
|
||||
}, [customerMissingEmail, setValue])
|
||||
if (autoSendBlocked) setValue('auto_send', false)
|
||||
}, [autoSendBlocked, setValue])
|
||||
const subtotalRaw = items.reduce(
|
||||
(sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0),
|
||||
0,
|
||||
@@ -371,7 +376,7 @@ function NewRecurringScheduleForm({
|
||||
id="auto_send"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={customerMissingEmail}
|
||||
disabled={autoSendBlocked}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
@@ -388,6 +393,11 @@ function NewRecurringScheduleForm({
|
||||
{t('auto_send_missing_email')}
|
||||
</p>
|
||||
)}
|
||||
{!hasEmailSend && (
|
||||
<UpgradeNote className="mt-2">
|
||||
{t('auto_send_requires_subscription')}
|
||||
</UpgradeNote>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTranslations, useLocale } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { RunDetail } from './types'
|
||||
|
||||
type StepState = 'done' | 'active' | 'upcoming'
|
||||
@@ -45,6 +47,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
|
||||
const rank = STATUS_RANK[run.status] ?? 0
|
||||
const busy = !!actionLoading
|
||||
const deliveries = run.payslip_deliveries_summary
|
||||
const hasEmailSend = useCapability(CAPABILITY.email_send)
|
||||
|
||||
function spinnerOr(icon: React.ReactNode, key: string) {
|
||||
return actionLoading === key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon
|
||||
@@ -128,19 +131,25 @@ export function RunProgressBar(props: RunProgressBarProps) {
|
||||
const activeStep = steps.find(s => s.state === 'active')
|
||||
|
||||
// Payslip send/download — shared by the mobile summary and the desktop bar.
|
||||
// Email send is a paid capability (server 403s without it); the PDF
|
||||
// download alternative right next to it stays free.
|
||||
const payslipActions = payslipsAvailable && canWrite && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={deliveries && deliveries.sent > 0 ? 'outline' : 'default'}
|
||||
onClick={props.onSendPayslips}
|
||||
disabled={busy}
|
||||
>
|
||||
{spinnerOr(<Send className="mr-2 h-4 w-4" />, 'payslips-send')}
|
||||
{deliveries && deliveries.sent > 0
|
||||
? t('action_send_payslips_again')
|
||||
: t('action_send_payslips')}
|
||||
</Button>
|
||||
{/* The span carries the tooltip: browsers suppress `title` on
|
||||
disabled elements, and hover events don't fire on them. */}
|
||||
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={deliveries && deliveries.sent > 0 ? 'outline' : 'default'}
|
||||
onClick={props.onSendPayslips}
|
||||
disabled={busy || !hasEmailSend}
|
||||
>
|
||||
{spinnerOr(<Send className="mr-2 h-4 w-4" />, 'payslips-send')}
|
||||
{deliveries && deliveries.sent > 0
|
||||
? t('action_send_payslips_again')
|
||||
: t('action_send_payslips')}
|
||||
</Button>
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={props.onDownloadPayslips} disabled={busy}>
|
||||
{spinnerOr(<FileDown className="mr-2 h-4 w-4" />, 'bulk_payslip')}
|
||||
{t('action_download_payslips')}
|
||||
|
||||
@@ -2754,6 +2754,7 @@
|
||||
"validation_min_one_row": "At least one row required",
|
||||
"send_hour_label": "Send at",
|
||||
"send_hour_hint": "Swedish time",
|
||||
"auto_send_requires_subscription": "Automatic sending requires a subscription. Invoices are still created as drafts each period.",
|
||||
"auto_send_missing_email": "The customer has no email address. Add one to the customer to enable automatic sending."
|
||||
},
|
||||
"invoice_send_dialog": {
|
||||
@@ -4546,6 +4547,7 @@
|
||||
"action_continue": "Continue",
|
||||
"action_book": "Post",
|
||||
"action_send_payslips": "Send payslips",
|
||||
"payslips_send_requires_subscription": "Emailing payslips requires a subscription. Download the PDFs for free.",
|
||||
"action_send_payslips_again": "Send again",
|
||||
"action_download_payslips": "Download all",
|
||||
"action_download_agi": "Download AGI file (XML)",
|
||||
|
||||
@@ -2754,6 +2754,7 @@
|
||||
"validation_min_one_row": "Minst en rad krävs",
|
||||
"send_hour_label": "Skicka klockan",
|
||||
"send_hour_hint": "Svensk tid",
|
||||
"auto_send_requires_subscription": "Automatiskt utskick kräver ett abonnemang. Fakturorna skapas ändå som utkast varje period.",
|
||||
"auto_send_missing_email": "Kunden saknar e-postadress. Lägg till en e-postadress på kundkortet för att kunna skicka automatiskt."
|
||||
},
|
||||
"invoice_send_dialog": {
|
||||
@@ -4546,6 +4547,7 @@
|
||||
"action_continue": "Fortsätt",
|
||||
"action_book": "Bokför",
|
||||
"action_send_payslips": "Skicka lönebesked",
|
||||
"payslips_send_requires_subscription": "Utskick av lönebesked via e-post kräver ett abonnemang. Ladda ner PDF:erna gratis.",
|
||||
"action_send_payslips_again": "Skicka igen",
|
||||
"action_download_payslips": "Ladda ner alla",
|
||||
"action_download_agi": "Ladda ner AGI-fil (XML)",
|
||||
|
||||
Reference in New Issue
Block a user