From bcf80e2eb3e30dfe63caf009904ecc90668e869a Mon Sep 17 00:00:00 2001 From: BigJohnn1 Date: Sun, 9 Aug 2026 23:15:08 +0200 Subject: [PATCH 1/8] feat(billing): add Stripe invoice history --- MAP.md | 2 +- app/components/Form/Client/Billing.vue | 117 ++++++++++++++++++++++++- i18n/locales/cs/common.json | 19 ++++ i18n/locales/en/common.json | 19 ++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/MAP.md b/MAP.md index 307b5921..9772dccf 100644 --- a/MAP.md +++ b/MAP.md @@ -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. diff --git a/app/components/Form/Client/Billing.vue b/app/components/Form/Client/Billing.vue index b8fa8770..cb500bab 100644 --- a/app/components/Form/Client/Billing.vue +++ b/app/components/Form/Client/Billing.vue @@ -122,7 +122,10 @@ -
+
+ +
+
+
+

+ {{ $t('common.preferences.billing.invoicesTitle') }} +

+

+ {{ $t('common.preferences.billing.invoicesDescription') }} +

+
+ +
+ +
+
+ {{ $t('common.preferences.billing.invoicesLoading') }} +
+ +
+

{{ $t('common.preferences.billing.invoicesFailed') }}

+ +
+ +
+ {{ $t('common.preferences.billing.invoicesEmpty') }} +
+ +
    +
  • +
    +
    + + {{ invoice.number ?? $t('common.preferences.billing.invoice') }} + + + {{ invoiceStatusText(invoice.status) }} + +
    +
    + {{ formatTime(invoice.createdAt, 'short', client?.language) }} +
    +
    +
    + {{ formatInvoiceAmount(invoice.amount, invoice.currency) }} +
    + +
  • +
+