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:
Jakob Wennberg
2026-07-07 14:22:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ed7a178ac6
commit a566a42aec
9 changed files with 87 additions and 35 deletions
+20 -11
View File
@@ -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')}