feat(billing): rebuild the Abonnemang page as a clean order summary (#1696)
* feat(billing): rebuild the Abonnemang page as a clean order summary The sell view is now four outcome lines (one per paid capability), one freeze-and-retain sentence, and price / first charge / cancellation as flat Fönster rows above a single CTA. The decorative skyline banner and the repeated reassurance copy are gone; each money term is stated once, where the decision is made. Copy moves from hardcoded Swedish into the settings_billing namespace (sv+en). BillingActions shrinks to the CTA; plan choice lives in the price row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(billing): sell view as one number, four short benefits, one button Second pass on the Abonnemang page: the row version still read as cluttered. The price is now the headline (display serif, interval toggle beside it, one exkl./inkl. line), the benefits are noun + gloss in a 2x2 grid, and the money terms are one sentence under the CTA. Legal text stays behind the ?. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(billing): list every paid capability, framed as the external connections PAID_CAPABILITIES has seven keys, the page listed four. Add Betalningar (stripe_payments) and Webshop (woocommerce_sync + shopify_sync) and phrase the no-subscription line as the tier model actually works: only the external connections pause, everything else stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1070,6 +1070,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-18] IBAN prefill reads only enabled, still-connected SEK cash_accounts: disconnect keeps rows (bank_connection_id nulled) and the connect picker mirrors deselected accounts, so an unfiltered read could offer a closed or third-party IBAN as invoice payee (skeptic refutation on PR #1695).
|
||||
[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] Abonnemang sell view rebuilt as a Fönster order summary (I abonnemanget: four outcome lines, one per PAID capability, plus one freeze-and-retain sentence; Pris och villkor: price with the interval toggle in the row, first charge date, cancellation as flat rows; one CTA; one Stripe line). Founder found the 07-11/07-27 version cluttered and not upgrade-inspiring: the skyline hero (h-40, decorative) ate a quarter of the 680px settings window and pushed the offer below the fold, and the same reassurance was repeated four times (attn line, under CTA, 'Så funkar det' rows). BillingHero removed (asset stays registered for onboarding); benefits rewritten from feature names to what happens for the user; every money term stated exactly once, where the decision is made. Copy moved from hardcoded Swedish into settings_billing (sv+en). Alternative considered and not built: a landing-page style offer stack (serif headline + bullets + CTA), punchier but breaks the Fönster grammar and hides the money terms in one line.
|
||||
[2026-08-18] Abonnemang sell view, second pass the same day: the Fönster row version (Pris / Första debitering / Uppsägning rows + eyebrows) still read as cluttered to the founder (five pieces of small grey text, an uppercase label per row, three price facts in one row). Replaced with the 'one number' layout: the price in the display serif with the interval toggle beside it and one exkl./inkl. line, four benefits as noun + short gloss in a 2x2 grid, one CTA, one terms sentence (first charge date, cancel any time, Stripe), one 'ingenting raderas' sentence with the legal behind '?'. Pane 633px -> 609px; word count roughly halved again. Rows and eyebrows stay for the paying/demo views where they describe state, not an offer.
|
||||
[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.
|
||||
|
||||
@@ -1,51 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsSeg } from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string; cta: string }> = {
|
||||
monthly: {
|
||||
amount: '199 kr',
|
||||
suffix: '/ mån exkl. moms',
|
||||
sub: '248,75 kr/mån inkl. moms. Faktureras månadsvis.',
|
||||
cta: 'Aktivera abonnemang: 199 kr/mån',
|
||||
},
|
||||
yearly: {
|
||||
amount: '166 kr',
|
||||
suffix: '/ mån exkl. moms',
|
||||
sub: '1 999 kr/år exkl. moms (2 498,75 kr inkl.). Du betalar för 10 månader.',
|
||||
cta: 'Aktivera årsabonnemang: 1 999 kr/år',
|
||||
},
|
||||
}
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { PLAN_PRICES } from '@/components/settings/billing-plans'
|
||||
|
||||
/**
|
||||
* Interactive billing CTA. Paying companies get the Stripe Customer Portal
|
||||
* (manage/cancel) as a quiet row action; everyone else gets a plan picker
|
||||
* (SettingsSeg) + Checkout. Both POST to a route that returns a hosted Stripe
|
||||
* URL we redirect to.
|
||||
* The billing call to action. Paying companies get the Stripe Customer
|
||||
* Portal (manage/cancel) as a quiet row action; everyone else gets the
|
||||
* Checkout button for the plan chosen in the offer rows above it. Both POST
|
||||
* to a route that returns a hosted Stripe URL we redirect to.
|
||||
*
|
||||
* `firstChargeAt`: when the checkout route will defer the first charge to the
|
||||
* trial's end (see billing/checkout), the date it lands. Shifts the CTA from
|
||||
* "pay now" to "0 kr idag": the strongest risk-reversal we can make truthfully.
|
||||
* `firstChargeDeferred`: the checkout route will defer the first charge to
|
||||
* the trial's end (see billing/checkout), so the CTA can truthfully say
|
||||
* "0 kr idag" instead of quoting a price.
|
||||
*/
|
||||
export function BillingActions({
|
||||
isPaying,
|
||||
configured,
|
||||
firstChargeAt = null,
|
||||
plan = 'yearly',
|
||||
firstChargeDeferred = false,
|
||||
}: {
|
||||
isPaying: boolean
|
||||
configured: boolean
|
||||
firstChargeAt?: string | null
|
||||
plan?: BillingPlan
|
||||
firstChargeDeferred?: boolean
|
||||
}) {
|
||||
const t = useTranslations('settings_billing')
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [plan, setPlan] = useState<BillingPlan>('yearly')
|
||||
|
||||
async function go(endpoint: string, payload?: Record<string, unknown>) {
|
||||
setLoading(true)
|
||||
@@ -60,11 +48,11 @@ export function BillingActions({
|
||||
error?: string | { message?: string }
|
||||
}
|
||||
const errorMessage = typeof data.error === 'string' ? data.error : data.error?.message
|
||||
if (!res.ok || !data.url) throw new Error(errorMessage || 'Något gick fel')
|
||||
if (!res.ok || !data.url) throw new Error(errorMessage || t('checkout_failed'))
|
||||
window.location.href = data.url
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Kunde inte öppna betalningen',
|
||||
title: t('checkout_failed'),
|
||||
description: e instanceof Error ? getUserErrorMessage(e) : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -75,7 +63,7 @@ export function BillingActions({
|
||||
if (isPaying) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => go('/api/billing/portal')} disabled={loading}>
|
||||
Hantera abonnemang
|
||||
{t('manage_cta')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -83,54 +71,26 @@ export function BillingActions({
|
||||
if (!configured) {
|
||||
return (
|
||||
<Button size="lg" disabled className="w-full sm:w-auto">
|
||||
Uppgradering öppnar snart
|
||||
{t('cta_coming_soon')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<SettingsSeg
|
||||
value={plan}
|
||||
onChange={setPlan}
|
||||
aria-label="Betalningsintervall"
|
||||
options={[
|
||||
{ value: 'monthly', label: 'Månadsvis' },
|
||||
{
|
||||
value: 'yearly',
|
||||
label: (
|
||||
<>
|
||||
Årsvis
|
||||
<span className="ml-2 text-muted-foreground">Spara 2 mån</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="tabular-nums text-foreground">{PRICE[plan].amount}</span> {PRICE[plan].suffix}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{PRICE[plan].sub}</p>
|
||||
const label = loading
|
||||
? t('cta_opening')
|
||||
: firstChargeDeferred
|
||||
? t('cta_start_deferred')
|
||||
: t('cta_start_now', { price: formatCurrency(PLAN_PRICES[plan].exVat), period: t(`period_${plan}`) })
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => go('/api/billing/checkout', { plan })}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta}
|
||||
{!loading && <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
{firstChargeAt && (
|
||||
// The one deferred-charge line that stays visible (the rest of the
|
||||
// legal/marketing copy lives behind the group-level "?").
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess
|
||||
kostar det ingenting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => go('/api/billing/checkout', { plan })}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{label}
|
||||
{!loading && <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
|
||||
/**
|
||||
* List prices in SEK, exkl. moms, mirroring the Stripe Prices behind
|
||||
* STRIPE_PRICE_MONTHLY / STRIPE_PRICE_YEARLY (tax_behavior=exclusive, 25 %
|
||||
* added at checkout for Swedish customers). Displayed on the billing page
|
||||
* only; the charge itself is whatever Stripe computes.
|
||||
*/
|
||||
export const PLAN_PRICES: Record<BillingPlan, { exVat: number; incVat: number; perMonthEquivalent: number }> = {
|
||||
monthly: { exVat: 199, incVat: 248.75, perMonthEquivalent: 199 },
|
||||
yearly: { exVat: 1999, incVat: 2498.75, perMonthEquivalent: 166 },
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Check } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
@@ -12,28 +13,19 @@ import {
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSectionHeader,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
import { ILLUSTRATIONS, illustrationSrc } from '@/components/onboarding/onboarding-illustrations'
|
||||
import { PLAN_PRICES } from '@/components/settings/billing-plans'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
|
||||
// What the paid tier unlocks (mirrors lib/entitlements PAID_CAPABILITIES).
|
||||
const INCLUDED = [
|
||||
'AI-assistent: chatt, kategorisering och dokumenttolkning',
|
||||
'Bankkoppling och automatisk synk (PSD2)',
|
||||
'Skatteverket: moms- och AGI-inlämning',
|
||||
'E-postutskick av fakturor, påminnelser och lönebesked',
|
||||
]
|
||||
|
||||
// What stays free forever (freeze-and-retain, nothing is taken away). Shown
|
||||
// as the second column of the flat feature list so the paid tier reads
|
||||
// strongest right next to it. Mirrors the old ALWAYS_FREE copy.
|
||||
const ALWAYS_FREE = [
|
||||
'Bokföring och rapporter',
|
||||
'Fakturering',
|
||||
'SIE-export',
|
||||
'Org.nr-uppslag och momsnummerkontroll',
|
||||
]
|
||||
// What the paid tier unlocks: the external connections. One item per PAID
|
||||
// capability in lib/entitlements/keys.ts (ai, bank_sync, skatteverket,
|
||||
// email_send, stripe_payments, woocommerce_sync + shopify_sync as one
|
||||
// "webshop" item). Keep in step with PAID_CAPABILITIES when a key is added.
|
||||
const UNLOCK_KEYS = ['unlock_ai', 'unlock_bank', 'unlock_skv', 'unlock_email', 'unlock_payments', 'unlock_webshop'] as const
|
||||
|
||||
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
|
||||
// trial_end floor plus clock margin). Above this, checkout collects the card
|
||||
@@ -50,49 +42,17 @@ interface BillingView {
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative masthead: the marketing site's halftone Stockholm skyline as a
|
||||
* quiet wide banner, echoing the onboarding backdrop so the paid surface
|
||||
* feels like the same product. Purely decorative (aria-hidden, empty alt),
|
||||
* no text, one gesture.
|
||||
*/
|
||||
function BillingHero() {
|
||||
const art = ILLUSTRATIONS['about-stockholm']
|
||||
function UnlockList({ className }: { className?: string }) {
|
||||
const t = useTranslations('settings_billing')
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mt-6 h-40 overflow-hidden rounded-lg border border-border bg-frame"
|
||||
>
|
||||
{/* Anchored so the skyline's waterline sits on the strip's bottom edge
|
||||
(the water reflection falls below it), spires reaching up into the
|
||||
strip: same physics as the onboarding backdrop. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={illustrationSrc('about-stockholm')}
|
||||
width={art.w}
|
||||
height={art.h}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute bottom-0 left-1/2 w-full min-w-[640px] -translate-x-1/2 translate-y-[51%] opacity-70 dark:opacity-40 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{heading}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className={cn('grid gap-x-6 gap-y-3 px-1 sm:grid-cols-2', className)}>
|
||||
{UNLOCK_KEYS.map((key) => (
|
||||
<li key={key} className="text-sm">
|
||||
<span className="font-medium">{t(key)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t(`${key}_gloss`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,12 +60,18 @@ function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
* Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
|
||||
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
|
||||
* that reads its state from GET /api/billing/status.
|
||||
*
|
||||
* The sell view is deliberately an order summary in the Fönster row grammar:
|
||||
* what you unlock, then price / first charge / how to cancel as flat rows,
|
||||
* then one CTA. The money terms are stated once each, where the decision is
|
||||
* made, instead of repeated as reassurance copy around the page.
|
||||
*/
|
||||
export function BillingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const tBilling = useTranslations('settings_billing')
|
||||
const t = useTranslations('settings_billing')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { formatDateLong } = useFormat()
|
||||
// null = the billing state is not known: still loading, or the read failed
|
||||
// (loadError). A failed GET must never render a fabricated non-paying,
|
||||
// unconfigured panel to a paying customer.
|
||||
@@ -114,6 +80,7 @@ export function BillingSettingsContent() {
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [plan, setPlan] = useState<BillingPlan>('yearly')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -180,12 +147,7 @@ export function BillingSettingsContent() {
|
||||
return () => { active = false }
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
const header = (
|
||||
<>
|
||||
<SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
<BillingHero />
|
||||
</>
|
||||
)
|
||||
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
@@ -199,12 +161,10 @@ export function BillingSettingsContent() {
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: tBilling('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
: { label: t('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${tBilling('load_failed')} ${loadError.detail}`
|
||||
: tBilling('load_failed')}
|
||||
{loadError.detail ? `${t('load_failed')} ${loadError.detail}` : t('load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,24 +184,14 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span>Demo</span>
|
||||
<SettingsRowNote>
|
||||
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
|
||||
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
|
||||
</SettingsRowNote>
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')} borderless>
|
||||
<span>{t('status_demo')}</span>
|
||||
<SettingsRowNote>{t('status_demo_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label="I abonnemanget">
|
||||
<ul className="space-y-2 px-1 pt-3">
|
||||
{INCLUDED.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SettingsGroup label={t('group_included')}>
|
||||
<UnlockList className="pt-3" />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
@@ -252,27 +202,20 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status">
|
||||
<span className="font-medium">Aktivt</span>
|
||||
<SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')}>
|
||||
<span className="font-medium">{t('status_active')}</span>
|
||||
<SettingsRowNote>{t('status_active_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Hantera" borderless>
|
||||
<SettingsRowNote>Byt kort, ändra plan eller säg upp via Stripes kundportal.</SettingsRowNote>
|
||||
<SettingsRow label={t('row_manage')} borderless>
|
||||
<SettingsRowNote>{t('manage_note')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<BillingActions isPaying configured={view.configured} />
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label="I abonnemanget">
|
||||
<ul className="space-y-2 px-1 pt-3">
|
||||
{INCLUDED.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SettingsGroup label={t('group_included')}>
|
||||
<UnlockList className="pt-3" />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
@@ -284,22 +227,31 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')} borderless>
|
||||
<span className="flex items-start gap-2">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
<span>{t('paid_just_now')}</span>
|
||||
</span>
|
||||
<SettingsRowNote>Ladda om sidan om du inte ser ändringen.</SettingsRowNote>
|
||||
<SettingsRowNote>{t('paid_just_now_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Trialing / expired → sell view.
|
||||
const { trialEndsAt, daysLeft } = view
|
||||
const deferredTo = view.chargeDeferred ? trialEndsAt : null
|
||||
// Trialing / expired → sell view: one number, four short lines, one
|
||||
// button, two quiet sentences. Everything else lives behind the "?".
|
||||
const { trialEndsAt, daysLeft, chargeDeferred } = view
|
||||
const price = PLAN_PRICES[plan]
|
||||
const priceNote =
|
||||
plan === 'yearly'
|
||||
? t('price_note_yearly', { inc: formatCurrency(price.incVat), perMonth: formatCurrency(price.perMonthEquivalent) })
|
||||
: t('price_note_monthly', { inc: formatCurrency(price.incVat) })
|
||||
const termsLine =
|
||||
chargeDeferred && trialEndsAt
|
||||
? t('terms_deferred', { date: formatDateLong(trialEndsAt) })
|
||||
: t('terms_now')
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -309,53 +261,48 @@ export function BillingSettingsContent() {
|
||||
{daysLeft !== null && (
|
||||
<AttnLine className="mt-3">
|
||||
{daysLeft > 0
|
||||
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
|
||||
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
|
||||
}`
|
||||
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
|
||||
? t('trial_ends_in', { days: daysLeft, date: trialEndsAt ? formatDateLong(trialEndsAt) : '' })
|
||||
: t('trial_ended')}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
<SettingsGroup
|
||||
label="Allt du behöver för att sköta bokföringen själv"
|
||||
help="Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe"
|
||||
>
|
||||
<div className="px-1 pt-3">
|
||||
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
|
||||
<div className="mt-8 flex flex-wrap items-start justify-between gap-x-6 gap-y-3 px-1">
|
||||
<div>
|
||||
<p className="font-display text-xl tabular-nums leading-8">
|
||||
{formatCurrency(price.exVat)}
|
||||
<span className="text-muted-foreground"> / {t(`period_${plan}`)}</span>
|
||||
</p>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">{priceNote}</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
<SettingsSeg
|
||||
value={plan}
|
||||
onChange={setPlan}
|
||||
aria-label={t('row_interval')}
|
||||
options={[
|
||||
{ value: 'monthly', label: t('interval_monthly') },
|
||||
{
|
||||
value: 'yearly',
|
||||
label: (
|
||||
<>
|
||||
{t('interval_yearly')}
|
||||
<span className="ml-2 text-muted-foreground">{t('interval_yearly_save')}</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deferredTo && (
|
||||
<SettingsGroup label="Så funkar det">
|
||||
<SettingsRow label="Idag">
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={<span className="tabular-nums">{formatDateLong(deferredTo)}</span>}>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="När som helst" borderless>
|
||||
<span>
|
||||
Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
<UnlockList className="mt-6 border-t border-border pt-6" />
|
||||
|
||||
<SettingsGroup
|
||||
label="Funktioner"
|
||||
// The 7-year retention reassurance moved behind the "?": long legal
|
||||
// copy stays out of the page flow.
|
||||
help="Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang."
|
||||
>
|
||||
<div className="grid gap-6 px-1 pt-3 sm:grid-cols-2">
|
||||
<FeatureList heading="I abonnemanget" items={INCLUDED} />
|
||||
<FeatureList heading="Alltid gratis" items={ALWAYS_FREE} />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
<div className="mt-8 border-t border-border px-1 pt-6">
|
||||
<BillingActions isPaying={false} configured={view.configured} plan={plan} firstChargeDeferred={chargeDeferred} />
|
||||
<p className="pt-3 text-xs text-muted-foreground">{termsLine}</p>
|
||||
<p className="pt-1 text-xs text-muted-foreground">
|
||||
{t('without_note')}{' '}
|
||||
<HelpPopover className="ml-1 align-middle">{t('unlock_help')}</HelpPopover>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+52
-1
@@ -2111,7 +2111,58 @@
|
||||
},
|
||||
"settings_billing": {
|
||||
"load_failed": "The subscription details could not be loaded, so your status cannot be shown right now.",
|
||||
"load_retry": "Try again"
|
||||
"load_retry": "Try again",
|
||||
"group_yours": "Your subscription",
|
||||
"group_included": "In the subscription",
|
||||
"group_terms": "Price and terms",
|
||||
"row_status": "Status",
|
||||
"row_manage": "Manage",
|
||||
"row_interval": "Billing",
|
||||
"row_price": "Price",
|
||||
"row_first_charge": "First charge",
|
||||
"row_cancel": "Cancellation",
|
||||
"unlock_ai": "The assistant",
|
||||
"unlock_bank": "Bank connection",
|
||||
"unlock_skv": "Skatteverket",
|
||||
"unlock_email": "Email sending",
|
||||
"unlock_ai_gloss": "books transactions, reads receipts, answers questions",
|
||||
"unlock_bank_gloss": "transactions arrive on their own",
|
||||
"unlock_skv_gloss": "VAT and AGI filed directly",
|
||||
"unlock_email_gloss": "invoices, reminders and payslips",
|
||||
"unlock_payments": "Payments",
|
||||
"unlock_payments_gloss": "payment links on invoices, Stripe balance as a bank feed",
|
||||
"unlock_webshop": "Webshop",
|
||||
"unlock_webshop_gloss": "orders pulled from WooCommerce and Shopify",
|
||||
"terms_deferred": "First charge {date}. Cancel any time. Payment via Stripe.",
|
||||
"terms_now": "No lock-in, cancel any time. Payment via Stripe.",
|
||||
"unlock_help": "The subscription is the external connections: the assistant, your bank, Skatteverket, email, Stripe and the webshops. Without it, bookkeeping, invoicing, reports and SIE export keep working as usual and everything stays: accounting records are kept for seven years under the Swedish Bookkeeping Act regardless of subscription. Start again later and the connections pick up where they left off.",
|
||||
"without_note": "Without a subscription only the external connections pause. Everything else stays.",
|
||||
"interval_monthly": "Monthly",
|
||||
"interval_yearly": "Yearly",
|
||||
"interval_yearly_save": "Save 2 months",
|
||||
"period_monthly": "month",
|
||||
"period_yearly": "year",
|
||||
"price_note_monthly": "excl. VAT · {inc} incl.",
|
||||
"price_note_yearly": "excl. VAT · {inc} incl. · works out to {perMonth}/month",
|
||||
"first_charge_today": "Today",
|
||||
"first_charge_deferred_note": "When the trial ends. Nothing is charged today.",
|
||||
"cancel_value": "Any time in the Stripe customer portal. No lock-in.",
|
||||
"stripe_note": "Payment is handled by Stripe. We never see your card details.",
|
||||
"trial_ends_in": "Your trial ends in {days, plural, =1 {1 day} other {# days}} ({date}).",
|
||||
"trial_ended": "Your trial has ended: the external connections are paused.",
|
||||
"status_demo": "Demo",
|
||||
"status_demo_note": "You are trying Accounted in a demo. Create a real account to activate a subscription, the assistant, bank connection and Skatteverket filing.",
|
||||
"status_active": "Active",
|
||||
"status_active_note": "You can cancel at any time.",
|
||||
"manage_note": "Change card, switch plan or cancel in Stripe.",
|
||||
"manage_cta": "Manage subscription",
|
||||
"paid_just_now": "Done! Your subscription is active and every feature unlocks within a minute.",
|
||||
"paid_just_now_note": "Reload the page if you do not see the change.",
|
||||
"checkout_failed": "Could not open the payment",
|
||||
"cta_coming_soon": "Upgrades open soon",
|
||||
"cta_opening": "Opening…",
|
||||
"cta_start_deferred": "Start the subscription: 0 kr today",
|
||||
"cta_start_now": "Start the subscription: {price}/{period}"
|
||||
},
|
||||
"settings_invoice_form": {
|
||||
"default_our_reference_label": "Default \"Our reference\"",
|
||||
|
||||
+52
-1
@@ -2111,7 +2111,58 @@
|
||||
},
|
||||
"settings_billing": {
|
||||
"load_failed": "Abonnemangsuppgifterna kunde inte läsas in, så din status kan inte visas just nu.",
|
||||
"load_retry": "Försök igen"
|
||||
"load_retry": "Försök igen",
|
||||
"group_yours": "Ditt abonnemang",
|
||||
"group_included": "I abonnemanget",
|
||||
"group_terms": "Pris och villkor",
|
||||
"row_status": "Status",
|
||||
"row_manage": "Hantera",
|
||||
"row_interval": "Betalning",
|
||||
"row_price": "Pris",
|
||||
"row_first_charge": "Första debitering",
|
||||
"row_cancel": "Uppsägning",
|
||||
"unlock_ai": "Assistenten",
|
||||
"unlock_bank": "Bankkoppling",
|
||||
"unlock_skv": "Skatteverket",
|
||||
"unlock_email": "E-postutskick",
|
||||
"unlock_ai_gloss": "konterar, läser av kvitton, svarar på frågor",
|
||||
"unlock_bank_gloss": "transaktionerna hämtas av sig själva",
|
||||
"unlock_skv_gloss": "moms och AGI skickas in direkt",
|
||||
"unlock_email_gloss": "fakturor, påminnelser och lönebesked",
|
||||
"unlock_payments": "Betalningar",
|
||||
"unlock_payments_gloss": "betallänkar på fakturorna, Stripe-saldot som bankflöde",
|
||||
"unlock_webshop": "Webshop",
|
||||
"unlock_webshop_gloss": "ordrar hämtas från WooCommerce och Shopify",
|
||||
"terms_deferred": "Första debiteringen {date}. Avsluta när du vill. Betalning via Stripe.",
|
||||
"terms_now": "Ingen bindningstid, avsluta när du vill. Betalning via Stripe.",
|
||||
"unlock_help": "Abonnemanget är de externa kopplingarna: assistenten, banken, Skatteverket, e-post, Stripe och webshoparna. Utan det fortsätter bokföring, fakturering, rapporter och SIE-export som vanligt, och allt finns kvar: räkenskapsinformation bevaras i sju år enligt bokföringslagen oavsett abonnemang. Startar du igen senare fortsätter kopplingarna där de slutade.",
|
||||
"without_note": "Utan abonnemang pausas bara de externa kopplingarna. Allt annat finns kvar.",
|
||||
"interval_monthly": "Månadsvis",
|
||||
"interval_yearly": "Årsvis",
|
||||
"interval_yearly_save": "Spara 2 mån",
|
||||
"period_monthly": "mån",
|
||||
"period_yearly": "år",
|
||||
"price_note_monthly": "exkl. moms · {inc} inkl.",
|
||||
"price_note_yearly": "exkl. moms · {inc} inkl. · motsvarar {perMonth}/mån",
|
||||
"first_charge_today": "Idag",
|
||||
"first_charge_deferred_note": "När provperioden slutar. Inget dras idag.",
|
||||
"cancel_value": "När som helst i Stripes kundportal. Ingen bindningstid.",
|
||||
"stripe_note": "Betalningen sker hos Stripe. Vi ser aldrig dina kortuppgifter.",
|
||||
"trial_ends_in": "Provperioden löper ut om {days, plural, =1 {1 dag} other {# dagar}} ({date}).",
|
||||
"trial_ended": "Provperioden har löpt ut: de externa kopplingarna är pausade.",
|
||||
"status_demo": "Demo",
|
||||
"status_demo_note": "Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera abonnemang, assistent, bankkoppling och inlämning till Skatteverket.",
|
||||
"status_active": "Aktivt",
|
||||
"status_active_note": "Du kan avsluta när som helst.",
|
||||
"manage_note": "Byt kort, ändra plan eller säg upp i Stripe.",
|
||||
"manage_cta": "Hantera abonnemang",
|
||||
"paid_just_now": "Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.",
|
||||
"paid_just_now_note": "Ladda om sidan om du inte ser ändringen.",
|
||||
"checkout_failed": "Kunde inte öppna betalningen",
|
||||
"cta_coming_soon": "Uppgradering öppnar snart",
|
||||
"cta_opening": "Öppnar…",
|
||||
"cta_start_deferred": "Starta abonnemanget: 0 kr idag",
|
||||
"cta_start_now": "Starta abonnemanget: {price}/{period}"
|
||||
},
|
||||
"settings_invoice_form": {
|
||||
"default_our_reference_label": "Standard för Vår referens",
|
||||
|
||||
Reference in New Issue
Block a user