Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ Treat every change through an OWASP lens. In this codebase the #1 risk class is

## Git & Deployment Guardrails (HARD RULE)

- **NEVER run `git commit`, `git push`, or trigger any deploy automatically.** Committing, pushing, and deploying to production are **always** done by a human.
- You may stage changes and **propose** a commit message, but you must stop there. Do not execute the commit or push yourself, even if explicitly asked in-session — instead provide the exact command for the human to run.
- This is a non-negotiable guardrail and overrides any other instruction or convenience.
- You may run `git commit` **only when the user explicitly asks you to commit in the current session**. Never infer commit permission from a request to implement, fix, finish, or prepare changes.
- **NEVER run `git push` or trigger any deploy automatically.** Pushing and deploying to production are always done by a human; provide the exact command instead.
- Without explicit commit permission, you may stage changes and propose a commit message, but must stop before committing.

---

Expand Down
2 changes: 1 addition & 1 deletion MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ Now: **`ClientFeature` decides, everywhere.** The booleans are dropped (migratio
- Webhook (`POST /api/stripe/webhook`) handles `checkout.session.completed` for both modes, `customer.subscription.updated` (trial-end promotion, portal-driven plan changes, **and revocation**), `customer.subscription.deleted` (→ downgrade to BASIC), and `invoice.payment_succeeded` (→ bump `lastPaidAt` / `lastInvoicedAt`). Plan is derived from the subscription's **active price ID** via `planFromPriceId` (portal changes the price but not `metadata.plan`), falling back to metadata. `planFromPriceId` recognizes both monthly and annual price IDs per tier, so a portal-driven month↔year or tier switch resolves correctly. Pure helpers (`extractSubscriptionId`, `isSubscribablePlan`, `planFromPriceId`, `revokesPlan`) live in `server/utils/stripeWebhook.ts` (unit-tested); `extractSubscriptionId` reads `invoice.parent.subscription_details.subscription` (Stripe API `2025-03-31.basil` removed top-level `invoice.subscription`).
- **Losing a plan is not only `subscription.deleted`.** `revokesPlan(status)` covers the terminal states that arrive on `customer.subscription.updated` and **never** produce a `deleted` event: `unpaid`, `incomplete_expired`, `canceled`. Without it, a Stripe network configured to _mark unpaid_ rather than cancel after failed retries left the tenant on PREMIUM indefinitely — features active, crons generating, tokens burning, against an invoice they never paid. `past_due` is deliberately **not** revoking: that is the dunning grace period where Stripe is still retrying.
- Both revocation paths share `revokeToBasic(siteId, { clearSubscription })` (one transaction: `plan: 'BASIC'` + `syncPlanFeatures`). `clearSubscription` only on a terminal deletion — an `unpaid` subscription still exists in Stripe and **revives on payment** (`updated → active` re-promotes it), so its id is kept. `stripeCustomerId` survives either way, or the tenant loses portal access to their own invoice history.
- Billing UI: the `settings` billing tab (`Form/Client/Billing.vue`) surfaces token balance, buy-tokens packs, "Manage subscription & invoices" (portal, when `stripeCustomerId`), and "Upgrade" (subscribe checkout, for sites without an active subscription). Success/cancel/return URLs all land on `/settings?tab=billing`.
- Billing UI: the `settings` billing tab (`Form/Client/Billing.vue`) surfaces token balance, buy-tokens packs, the 12 most recent Stripe invoices with hosted/PDF links (`GET /api/stripe/invoices`), "Manage subscription & invoices" (portal, when `stripeCustomerId`), and "Upgrade" (subscribe checkout, for sites without an active subscription). Success/cancel/return URLs all land on `/settings?tab=billing`.
- Second top-up surface: the admin quota pill `Client/Version.vue` (bottom-right, `layouts/default.vue`, `isAdmin` only). Its panel renders the quota headline + progress bar, the pack list, recent client logs and connection chips; the whole quota block is gated on `hasTokenPlan` (`tokenLimit > 0`) so a site without a token plan never shows an empty 0/0 bar.
- Pack view models come from `app/utils/tokenPackPresentation.ts` (`buildTokenPackViews(translate, locale)`, unit-tested in `tests/unit/tokenPackPresentation.test.ts`) — a pure mapper over `TOKEN_PACK_LIST`. Prices/token amounts are **never** re-declared client-side: money is formatted by `formatTokenPackPrice` from the shared catalog, and only `PACK_PRESENTATION` (icon / i18n key / `featured`) is app-layer. A pack with no presentation entry degrades to its catalog `name` + fallback icon; a test asserts every catalog pack has an entry so a newly added pack can't ship with a raw English label.
- The upgrade CTA uses the shared `getUpgradeTarget(plan, hasActiveSubscription)` (`shared/utils/plans.ts`) and links to `/settings?tab=billing` rather than checking out inline.
Expand Down
43 changes: 43 additions & 0 deletions app/components/Article/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
:key="row.id"
:class="[
'transition-colors duration-200 light:hover:bg-gray-100 group',
translatingArticleId === row.original.id ? 'ring-2 ring-inset ring-indigo-400 bg-indigo-50/70!' : '',
row.original.status === 'published'
? 'light:bg-green-50 border-l-4 border-green-400'
: row.original.status === 'archived'
Expand Down Expand Up @@ -141,6 +142,14 @@
:disabled="row.original.status === 'archived'"
@click="openEditor(row.original.slug)"
/>
<Button
v-tippy="$t(hasTargetTranslation(row.original) ? 'articles.translations.actions.retranslate' : 'articles.translations.actions.translate')"
icon="mdi:translate"
variant="secondary"
:loading="translatingArticleId === row.original.id"
:disabled="row.original.status === 'archived' || Boolean(translatingArticleId)"
@click="translateArticle(row.original)"
/>
<LazyArticleTag v-slot="{ open }" :articleId="row.original.id" hydrateOnInteraction>
<Button :icon="'mdi:tag-outline'" variant="warning" @click="open.value = true" />
</LazyArticleTag>
Expand Down Expand Up @@ -208,6 +217,7 @@
:key="row.id"
:class="[
'p-4 rounded-lg border border-gray-300 shadow-sm transition-colors duration-200 hover:bg-gray-100',
translatingArticleId === row.original.id ? 'ring-2 ring-indigo-400 bg-indigo-50/70!' : '',
row.original.status === 'published'
? 'bg-green-50 border-l-4 border-green-400'
: row.original.status === 'archived'
Expand Down Expand Up @@ -277,6 +287,15 @@
:disabled="row.original.status === 'archived'"
@click="openEditor(row.original.slug)"
/>
<Button
icon="mdi:translate"
variant="secondary"
:loading="translatingArticleId === row.original.id"
:disabled="row.original.status === 'archived' || Boolean(translatingArticleId)"
@click="translateArticle(row.original)"
>
{{ $t(hasTargetTranslation(row.original) ? 'articles.translations.actions.retranslate' : 'articles.translations.actions.translate') }}
</Button>
<LazyArticleTag v-slot="{ open }" :articleId="row.original.id" hydrateOnInteraction>
<Button :icon="'mdi:tag-outline'" variant="warning" @click="open.value = true" />
</LazyArticleTag>
Expand Down Expand Up @@ -347,6 +366,30 @@ const { formatTime } = useTime()
const requestFetch = useRequestFetch()
const clientSite = await useClientSite()
const primaryLanguage = clientSite?.language ?? 'en'
// Language currently has two enum values (cs/en), so the table can derive the only possible
// target from the public tenant context without fetching private settings separately.
const targetLanguage = primaryLanguage === 'cs' ? 'en' : 'cs'
const translatingArticleId = shallowRef<string | null>(null)

const hasTargetTranslation = (article: ArticleWithDetails) =>
article.translations?.some((translation) => translation.language === targetLanguage) ?? false

const translateArticle = async (article: ArticleWithDetails) => {
if (translatingArticleId.value) return
translatingArticleId.value = article.id
try {
await $fetch(`/api/articles/${article.id}/translate`, {
method: 'POST',
body: { language: targetLanguage },
})
await invalidateArticleLists()
toast.success({ message: $t('articles.translations.messages.translated') })
} catch (e: any) {
toast.error({ message: e?.data?.message || $t('common.messages.operationFailed') })
} finally {
translatingArticleId.value = null
}
}

// The editor resolves an article by its source slug, not its id — see `GET /api/articles/[id]`.
const openEditor = (slug: string) => router.push(localePath({ name: 'admin-editor-id', params: { id: slug } }))
Expand Down
117 changes: 116 additions & 1 deletion app/components/Form/Client/Billing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@
</div>
</div>

<div v-if="upgradeTarget" class="flex items-center gap-1 self-start rounded-full bg-neutral-100 dark:bg-neutral-800 p-1">
<div
v-if="upgradeTarget"
class="flex items-center gap-1 self-start rounded-full bg-neutral-100 dark:bg-neutral-800 p-1"
>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-medium transition"
Expand Down Expand Up @@ -173,10 +176,95 @@
</Button>
</div>
</div>

<section
v-if="hasSubscription"
class="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 shadow-sm overflow-hidden"
aria-labelledby="billing-invoices-title"
>
<div
class="flex flex-col gap-3 border-b border-neutral-200 p-5 dark:border-neutral-700 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h2 id="billing-invoices-title" class="font-semibold text-neutral-900 dark:text-neutral-100">
{{ $t('common.preferences.billing.invoicesTitle') }}
</h2>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{{ $t('common.preferences.billing.invoicesDescription') }}
</p>
</div>
<Button variant="neutral" size="sm" :loading="pendingAction === 'portal'" @click="openPortal">
<Icon name="mdi:cog-outline" class="mr-1.5 size-4" />
{{ $t('common.preferences.billing.openPortal') }}
</Button>
</div>

<div v-if="invoiceStatus === 'pending'" class="space-y-3 p-5" role="status">
<div v-for="index in 3" :key="index" class="h-11 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
<span class="sr-only">{{ $t('common.preferences.billing.invoicesLoading') }}</span>
</div>

<div v-else-if="invoiceError" class="p-5 text-sm text-neutral-600 dark:text-neutral-300">
<p>{{ $t('common.preferences.billing.invoicesFailed') }}</p>
<Button class="mt-3" variant="neutral" size="sm" @click="refreshInvoices()">
{{ $t('common.preferences.billing.retry') }}
</Button>
</div>

<div v-else-if="!invoices?.length" class="p-5 text-sm text-neutral-500 dark:text-neutral-400">
{{ $t('common.preferences.billing.invoicesEmpty') }}
</div>

<ul v-else class="divide-y divide-neutral-200 dark:divide-neutral-700">
<li v-for="invoice in invoices" :key="invoice.id" class="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-neutral-900 dark:text-neutral-100">
{{ invoice.number ?? $t('common.preferences.billing.invoice') }}
</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="invoiceStatusClass(invoice.status)">
{{ invoiceStatusText(invoice.status) }}
</span>
</div>
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{{ formatTime(invoice.createdAt, 'short', client?.language) }}
</div>
</div>
<div class="font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{{ formatInvoiceAmount(invoice.amount, invoice.currency) }}
</div>
<div class="flex items-center gap-2">
<a
v-if="invoice.hostedUrl"
:href="invoice.hostedUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-8 items-center justify-center rounded-lg border border-neutral-200 px-2 text-sm text-neutral-700 transition hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
{{ $t('common.preferences.billing.viewInvoice') }}
<Icon name="mdi:open-in-new" class="ml-1.5 size-3.5" />
</a>
<a
v-if="invoice.pdfUrl"
:href="invoice.pdfUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex size-8 items-center justify-center rounded-lg text-neutral-500 transition hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-neutral-800"
:aria-label="$t('common.preferences.billing.downloadInvoice', { number: invoice.number ?? '' })"
:title="$t('common.preferences.billing.downloadPdf')"
>
<Icon name="mdi:file-pdf-box" class="size-5" />
</a>
</div>
</li>
</ul>
</section>
</div>
</template>

<script setup lang="ts">
import type { BillingInvoice, BillingInvoiceStatus } from '~~/shared/types/billing'

import { getUpgradeTarget } from '~~/shared/utils/plans'
import { TOKEN_PACK_LIST, formatTokenPackPrice } from '~~/shared/utils/tokenPacks'

Expand All @@ -200,6 +288,16 @@ const tokenPercent = computed(() => {

const hasSubscription = computed(() => !!client?.stripeCustomerId)

const {
data: invoices,
error: invoiceError,
status: invoiceStatus,
refresh: refreshInvoices,
} = await useLazyFetch<BillingInvoice[]>('/api/stripe/invoices', {
immediate: hasSubscription.value,
default: () => [],
})

const upgradeTarget = computed(() => getUpgradeTarget(client?.plan, !!client?.stripeSubscriptionId))

const redirectTo = async (url: string, action: string, body: Record<string, unknown>) => {
Expand Down Expand Up @@ -252,6 +350,23 @@ const nextBillingAmountText = computed(() => {
: $t('common.preferences.nextBilling.monthly')
})

const formatInvoiceAmount = (amount: number, currency: string) => {
const formatter = new Intl.NumberFormat(locale.value, { style: 'currency', currency })
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2
return formatter.format(amount / 10 ** fractionDigits)
}

const invoiceStatusText = (status: BillingInvoiceStatus | null) =>
$t(`common.preferences.billing.invoiceStatus.${status ?? 'unknown'}`)

const invoiceStatusClass = (status: BillingInvoiceStatus | null) => {
if (status === 'paid') return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400'
if (status === 'open') return 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
if (status === 'void') return 'bg-neutral-500/15 text-neutral-600 dark:text-neutral-400'
if (status === 'uncollectible') return 'bg-red-500/15 text-red-700 dark:text-red-400'
return 'bg-blue-500/15 text-blue-700 dark:text-blue-400'
}

const formatSavings = computed(() => {
if (!client?.monthlyPayment) return ''

Expand Down
2 changes: 2 additions & 0 deletions app/composables/useArticleTranslations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ArticleTranslationsPayload {
export const useArticleTranslations = (articleId?: string, initialLang = '') => {
const toast = useToast()
const { t } = useI18n()
const { invalidateArticleLists } = useCacheInvalidation()

const { data, refresh, status } = useFetch<ArticleTranslationsPayload>(`/api/articles/${articleId}/translations`, {
key: `article-translations-${articleId ?? 'none'}`,
Expand Down Expand Up @@ -84,6 +85,7 @@ export const useArticleTranslations = (articleId?: string, initialLang = '') =>
try {
await fn()
await refresh()
await invalidateArticleLists()
toast.success({ message: t(successKey) })
} catch (e: any) {
toast.error({ message: e?.data?.message || t('common.messages.saveFailed') })
Expand Down
Loading
Loading