From 65efae3c9b6b099a36e0b3086bee974aec00c70c Mon Sep 17 00:00:00 2001 From: faizur_almas Date: Tue, 27 Oct 2026 12:23:23 +0600 Subject: [PATCH 1/3] Plans: the plan and scans in Subscription, one upgrade prompt for every limit - Subscription (profile menu) shows the plan and this month's automatic scans, with "See plans". - The API's 402 upgrade_required answers become one "ss:upgrade" event (apiError in the client); UpgradeDialog shows the bigger plans with their prices from GET /v1/plan, the suggested one first, and "Email us to upgrade" when the server has a support address. - The upload dialog says how many automatic scans are left, and when none are, that the upload still works and the details are typed in. Hidden while the server reads nothing automatically. - Excel export failures go through apiError, so its limit opens the prompt. - openapi.json is the API's current contract (adds the plan routes; sign-in and reconciliation routes were already on the API). Tested in a browser against the API in dev mode: Subscription, See plans, a business space on Free (402 -> Upgrade to Business), scans left and used up, and Escape closing only the top dialog. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + app/receipts/page.tsx | 4 +- openapi.json | 618 +++++++++++++++++++++++ src/api/client.ts | 18 +- src/api/generated/index.ts | 2 +- src/api/generated/types.gen.ts | 460 ++++++++++++++++- src/api/types.ts | 2 + src/components/app-shell.tsx | 2 + src/components/plan-summary.tsx | 124 +++++ src/components/profile-menu.tsx | 9 +- src/components/receipt-upload-dialog.tsx | 3 +- src/components/upgrade-dialog.tsx | 167 ++++++ src/plan.ts | 56 ++ test/plan.test.ts | 62 +++ 14 files changed, 1490 insertions(+), 39 deletions(-) create mode 100644 src/components/plan-summary.tsx create mode 100644 src/components/upgrade-dialog.tsx create mode 100644 src/plan.ts create mode 100644 test/plan.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 646b86b..2f389e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ The web frontend is a Bangladesh-first budget and receipt tracking prototype. Th Connected mode uses the existing ScribbleScan API only through `/v1`. Response types are generated from `openapi.json` into `src/api/generated`; never edit generated files by hand. The Next.js rewrites in `next.config.ts` target `http://127.0.0.1:8000` by default and can be overridden with `SCRIBBLESCAN_API`. +Plans (`src/plan.ts`): the API answers `402 {code: "upgrade_required", feature, plan, message}` when something needs a bigger plan. `apiError()` in `src/api/client.ts` turns that into the `ss:upgrade` event, and the one `UpgradeDialog` (mounted in the app shell) shows the plans from `GET /v1/plan`. Use `apiError()` for any failed request so every limit reaches the same prompt. Until payments exist, upgrading is an email to `upgrade_contact`. + The phone pairing secret belongs only in the scan URL fragment. QR SVGs must come from `POST /v1/qr`, never from a URL containing the secret. ## Commands diff --git a/app/receipts/page.tsx b/app/receipts/page.tsx index 517dc04..3014bdd 100644 --- a/app/receipts/page.tsx +++ b/app/receipts/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; -import { request } from "@/src/api/client"; +import { apiError, request } from "@/src/api/client"; import { useDemo } from "@/src/components/demo-context"; import { dateLabel, money } from "@/src/lib/demo-data"; import { monthLabel } from "@/src/format"; @@ -30,7 +30,7 @@ export default function ReceiptsPage() { setExportError(""); try { const response = await request(`/v1/spaces/${activeSpaceId}/export?month=${month}&format=xlsx`); - if (!response.ok) throw new Error("Export failed. Try again."); + if (!response.ok) throw response.status === 402 ? await apiError(response) : new Error("Export failed. Try again."); const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); diff --git a/openapi.json b/openapi.json index 1c1e855..777dcf5 100644 --- a/openapi.json +++ b/openapi.json @@ -604,6 +604,238 @@ } } }, + "/v1/auth/email": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Email Code", + "description": "Email a sign-in code. New addresses get an account once the code is entered.", + "operationId": "email_code_v1_auth_email_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailCodeIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailCodeSent" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/auth/email/verify": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Email Code Check", + "description": "Check the code; the browser then goes to the returned URL to collect its tokens.", + "operationId": "email_code_check_v1_auth_email_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailCodeCheck" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Redirect" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/auth/provider": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Provider Start", + "description": "Where to send the browser to sign in with Google or Apple.", + "operationId": "provider_start_v1_auth_provider_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Redirect" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/auth/provider/finish": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Provider Finish", + "description": "Back from Google or Apple: complete the sign-in.", + "operationId": "provider_finish_v1_auth_provider_finish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderReturn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Redirect" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/plan": { + "get": { + "tags": [ + "account" + ], + "summary": "My Plan", + "description": "Your plan, this month's automatic scans, and what every plan includes and costs.", + "operationId": "my_plan_v1_plan_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanOut" + } + } + } + } + } + } + }, + "/v1/dev/plan": { + "post": { + "tags": [ + "dev" + ], + "summary": "Dev Plan", + "description": "Development only: switch your own plan, to try the limits.", + "operationId": "dev_plan_v1_dev_plan_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/me": { "get": { "tags": [ @@ -935,6 +1167,51 @@ } } }, + "/v1/spaces/{space_id}/reconciliation": { + "get": { + "tags": [ + "money" + ], + "summary": "Reconcile", + "description": "Suggest receipt matches for confirmed bank-statement withdrawals.", + "operationId": "reconcile_v1_spaces__space_id__reconciliation_get", + "parameters": [ + { + "name": "space_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Space Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Reconcile V1 Spaces Space Id Reconciliation Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/spaces/{space_id}/expenses": { "get": { "tags": [ @@ -1501,6 +1778,19 @@ } ], "title": "Scopes" + }, + "login_methods": { + "items": { + "type": "string", + "enum": [ + "email", + "google", + "apple" + ] + }, + "type": "array", + "title": "Login Methods", + "default": [] } }, "type": "object", @@ -1837,6 +2127,87 @@ "type": "object", "title": "DocumentPatch" }, + "EmailCodeCheck": { + "properties": { + "flow": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Flow" + }, + "code": { + "type": "string", + "maxLength": 16, + "minLength": 4, + "title": "Code" + } + }, + "type": "object", + "required": [ + "flow", + "code" + ], + "title": "EmailCodeCheck" + }, + "EmailCodeIn": { + "properties": { + "code_challenge": { + "type": "string", + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Code Challenge" + }, + "state": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "State" + }, + "email": { + "type": "string", + "maxLength": 320, + "minLength": 3, + "title": "Email" + } + }, + "type": "object", + "required": [ + "code_challenge", + "state", + "email" + ], + "title": "EmailCodeIn" + }, + "EmailCodeSent": { + "properties": { + "flow": { + "type": "string", + "title": "Flow" + }, + "email": { + "type": "string", + "title": "Email" + }, + "expires_in": { + "type": "integer", + "title": "Expires In" + }, + "resend_after": { + "type": "integer", + "title": "Resend After" + } + }, + "type": "object", + "required": [ + "flow", + "email", + "expires_in", + "resend_after" + ], + "title": "EmailCodeSent" + }, "Expenses": { "properties": { "month": { @@ -2156,6 +2527,239 @@ "title": "PairOffer", "description": "Show qr_url as a QR code. shower_key opens the offer's event stream (/v1/pair/offers/{id}/events)." }, + "PlanIn": { + "properties": { + "plan": { + "type": "string", + "enum": [ + "free", + "plus", + "business", + "accountant" + ], + "title": "Plan" + } + }, + "type": "object", + "required": [ + "plan" + ], + "title": "PlanIn" + }, + "PlanInfo": { + "properties": { + "name": { + "type": "string", + "enum": [ + "free", + "plus", + "business", + "accountant" + ], + "title": "Name" + }, + "scans_per_month": { + "type": "integer", + "title": "Scans Per Month" + }, + "business_spaces": { + "type": "integer", + "title": "Business Spaces" + }, + "budget_categories": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Budget Categories", + "description": "null: no limit" + }, + "doc_types": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Doc Types" + }, + "export_formats": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Export Formats" + }, + "price_monthly": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Price Monthly" + }, + "price_yearly": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Price Yearly" + } + }, + "type": "object", + "required": [ + "name", + "scans_per_month", + "business_spaces", + "budget_categories", + "doc_types", + "export_formats", + "price_monthly", + "price_yearly" + ], + "title": "PlanInfo", + "description": "What a plan includes, and its price in the server's currency (null for free)." + }, + "PlanOut": { + "properties": { + "plan": { + "type": "string", + "enum": [ + "free", + "plus", + "business", + "accountant" + ], + "title": "Plan" + }, + "month": { + "type": "string", + "title": "Month", + "description": "YYYY-MM, UTC" + }, + "scans_used": { + "type": "integer", + "title": "Scans Used" + }, + "scans_limit": { + "type": "integer", + "title": "Scans Limit" + }, + "automatic_reading": { + "type": "boolean", + "title": "Automatic Reading", + "description": "false until the server has an extraction model; then nothing counts" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "upgrade_contact": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Upgrade Contact", + "description": "email for upgrades, until payments are built in" + }, + "plans": { + "items": { + "$ref": "#/components/schemas/PlanInfo" + }, + "type": "array", + "title": "Plans" + } + }, + "type": "object", + "required": [ + "plan", + "month", + "scans_used", + "scans_limit", + "automatic_reading", + "currency", + "upgrade_contact", + "plans" + ], + "title": "PlanOut", + "description": "The caller's plan and this month's automatic scans, plus every plan for an upgrade prompt." + }, + "ProviderIn": { + "properties": { + "code_challenge": { + "type": "string", + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Code Challenge" + }, + "state": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "State" + }, + "provider": { + "type": "string", + "enum": [ + "google", + "apple" + ], + "title": "Provider" + } + }, + "type": "object", + "required": [ + "code_challenge", + "state", + "provider" + ], + "title": "ProviderIn" + }, + "ProviderReturn": { + "properties": { + "flow": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Flow" + }, + "intent_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Intent Id" + }, + "intent_token": { + "type": "string", + "maxLength": 4096, + "minLength": 1, + "title": "Intent Token" + } + }, + "type": "object", + "required": [ + "flow", + "intent_id", + "intent_token" + ], + "title": "ProviderReturn", + "description": "What Zitadel appended to the return URL after Google or Apple." + }, "QrIn": { "properties": { "data": { @@ -2171,6 +2775,20 @@ ], "title": "QrIn" }, + "Redirect": { + "properties": { + "url": { + "type": "string", + "title": "Url" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "Redirect", + "description": "Send the browser here next." + }, "SpaceIn": { "properties": { "name": { diff --git a/src/api/client.ts b/src/api/client.ts index d67ba4f..52e43ed 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,6 +2,7 @@ // that survive a flaky connection. import * as oidc from "../oidc"; +import { askToUpgrade, isUpgradeRequired } from "../plan"; import { store } from "../store"; import type { ClientConfig, DocumentOut } from "./types"; @@ -109,10 +110,23 @@ export async function request(path: string, opts: RequestOptions = {}, retried = /** JSON from the API, or an Error carrying its message. */ export async function api(path: string, opts: RequestOptions = {}): Promise { const r = await request(path, opts); - if (!r.ok) throw new Error(await errorText(r)); + if (!r.ok) throw await apiError(r); return (r.status === 204 ? null : await r.json()) as T; } +/** The API's message as an Error. A 402 (the plan doesn't include this) also opens the upgrade prompt. */ +export async function apiError(r: Response): Promise { + if (r.status === 402) { + try { + const { detail } = (await r.clone().json()) as { detail?: unknown }; + if (isUpgradeRequired(detail)) askToUpgrade(detail); + } catch { + /* not JSON: the message below still says what went wrong */ + } + } + return new Error(await errorText(r)); +} + export async function errorText(r: Response): Promise { const fallback = `Something went wrong (${r.status})`; try { @@ -151,7 +165,7 @@ export async function uploadFile(spaceId: string, file: File, waits = RETRY_WAIT continue; // network error: try again } if (r.ok) return (await r.json()) as DocumentOut; - if (r.status < 500) throw new Error(await errorText(r)); + if (r.status < 500) throw await apiError(r); } throw new Error("Couldn't upload. Check your connection and try again."); } diff --git a/src/api/generated/index.ts b/src/api/generated/index.ts index 14a541f..645ede2 100644 --- a/src/api/generated/index.ts +++ b/src/api/generated/index.ts @@ -1,3 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { ApproveV1PairOffersOfferIdApprovePostData, ApproveV1PairOffersOfferIdApprovePostError, ApproveV1PairOffersOfferIdApprovePostErrors, ApproveV1PairOffersOfferIdApprovePostResponse, ApproveV1PairOffersOfferIdApprovePostResponses, BodyUploadV1SpacesSpaceIdDocumentsPost, BudgetsIn, CategoriesV1CategoriesGetData, CategoriesV1CategoriesGetResponse, CategoriesV1CategoriesGetResponses, CategorySpend, Claimed, ClaimIn, ClaimV1PairClaimPostData, ClaimV1PairClaimPostError, ClaimV1PairClaimPostErrors, ClaimV1PairClaimPostResponse, ClaimV1PairClaimPostResponses, ClientConfig, ClientConfigV1ConfigGetData, ClientConfigV1ConfigGetResponse, ClientConfigV1ConfigGetResponses, ClientOptions, Collected, CollectV1PairOffersOfferIdCollectPostData, CollectV1PairOffersOfferIdCollectPostError, CollectV1PairOffersOfferIdCollectPostErrors, CollectV1PairOffersOfferIdCollectPostResponse, CollectV1PairOffersOfferIdCollectPostResponses, CreateOfferIn, CreateOfferV1PairOffersPostData, CreateOfferV1PairOffersPostError, CreateOfferV1PairOffersPostErrors, CreateOfferV1PairOffersPostResponse, CreateOfferV1PairOffersPostResponses, CreateSpaceV1SpacesPostData, CreateSpaceV1SpacesPostError, CreateSpaceV1SpacesPostErrors, CreateSpaceV1SpacesPostResponse, CreateSpaceV1SpacesPostResponses, DeleteDocumentV1DocumentsDocIdDeleteData, DeleteDocumentV1DocumentsDocIdDeleteError, DeleteDocumentV1DocumentsDocIdDeleteErrors, DeleteDocumentV1DocumentsDocIdDeleteResponse, DeleteDocumentV1DocumentsDocIdDeleteResponses, DenyV1PairOffersOfferIdDenyPostData, DenyV1PairOffersOfferIdDenyPostError, DenyV1PairOffersOfferIdDenyPostErrors, DenyV1PairOffersOfferIdDenyPostResponse, DenyV1PairOffersOfferIdDenyPostResponses, Device, DevicesV1DevicesGetData, DevicesV1DevicesGetResponse, DevicesV1DevicesGetResponses, DevicesV1PairDevicesGetData, DevicesV1PairDevicesGetResponse, DevicesV1PairDevicesGetResponses, DeviceUnlinked, DevToken, DevTokenIn, DevTokenV1DevTokenPostData, DevTokenV1DevTokenPostError, DevTokenV1DevTokenPostErrors, DevTokenV1DevTokenPostResponse, DevTokenV1DevTokenPostResponses, DocStatus, DocumentOut, DocumentPatch, EventsV1PairOffersOfferIdEventsGetData, EventsV1PairOffersOfferIdEventsGetError, EventsV1PairOffersOfferIdEventsGetErrors, EventsV1PairOffersOfferIdEventsGetResponses, Expenses, ExpensesV1SpacesSpaceIdExpensesGetData, ExpensesV1SpacesSpaceIdExpensesGetError, ExpensesV1SpacesSpaceIdExpensesGetErrors, ExpensesV1SpacesSpaceIdExpensesGetResponse, ExpensesV1SpacesSpaceIdExpensesGetResponses, ExportV1SpacesSpaceIdExportGetData, ExportV1SpacesSpaceIdExportGetError, ExportV1SpacesSpaceIdExportGetErrors, ExportV1SpacesSpaceIdExportGetResponses, GetDocumentV1DocumentsDocIdGetData, GetDocumentV1DocumentsDocIdGetError, GetDocumentV1DocumentsDocIdGetErrors, GetDocumentV1DocumentsDocIdGetResponse, GetDocumentV1DocumentsDocIdGetResponses, GetFileV1DocumentsDocIdFileGetData, GetFileV1DocumentsDocIdFileGetError, GetFileV1DocumentsDocIdFileGetErrors, GetFileV1DocumentsDocIdFileGetResponses, GrantTo, HttpValidationError, Issue, LinkedDeviceOut, ListSpacesV1SpacesGetData, ListSpacesV1SpacesGetResponse, ListSpacesV1SpacesGetResponses, Me, MeV1MeGetData, MeV1MeGetResponse, MeV1MeGetResponses, MonthTotal, NotificationOut, NotificationsOut, OfferCreated, OfferStatus, PairOffer, PairPhoneV1SpacesSpaceIdPairPostData, PairPhoneV1SpacesSpaceIdPairPostError, PairPhoneV1SpacesSpaceIdPairPostErrors, PairPhoneV1SpacesSpaceIdPairPostResponse, PairPhoneV1SpacesSpaceIdPairPostResponses, PatchDocumentV1DocumentsDocIdPatchData, PatchDocumentV1DocumentsDocIdPatchError, PatchDocumentV1DocumentsDocIdPatchErrors, PatchDocumentV1DocumentsDocIdPatchResponse, PatchDocumentV1DocumentsDocIdPatchResponses, QrIn, QrV1QrPostData, QrV1QrPostError, QrV1QrPostErrors, QrV1QrPostResponse, QrV1QrPostResponses, SchemaV1SchemasDocTypeGetData, SchemaV1SchemasDocTypeGetError, SchemaV1SchemasDocTypeGetErrors, SchemaV1SchemasDocTypeGetResponse, SchemaV1SchemasDocTypeGetResponses, SetBudgetsV1SpacesSpaceIdBudgetsPutData, SetBudgetsV1SpacesSpaceIdBudgetsPutError, SetBudgetsV1SpacesSpaceIdBudgetsPutErrors, SetBudgetsV1SpacesSpaceIdBudgetsPutResponse, SetBudgetsV1SpacesSpaceIdBudgetsPutResponses, SpaceIn, SpaceKind, SpaceOut, State, StatusV1PairOffersOfferIdGetData, StatusV1PairOffersOfferIdGetError, StatusV1PairOffersOfferIdGetErrors, StatusV1PairOffersOfferIdGetResponse, StatusV1PairOffersOfferIdGetResponses, Summary, SummaryV1SpacesSpaceIdSummaryGetData, SummaryV1SpacesSpaceIdSummaryGetError, SummaryV1SpacesSpaceIdSummaryGetErrors, SummaryV1SpacesSpaceIdSummaryGetResponse, SummaryV1SpacesSpaceIdSummaryGetResponses, UnlinkV1DevicesDeviceTokenIdDeleteData, UnlinkV1DevicesDeviceTokenIdDeleteError, UnlinkV1DevicesDeviceTokenIdDeleteErrors, UnlinkV1DevicesDeviceTokenIdDeleteResponse, UnlinkV1DevicesDeviceTokenIdDeleteResponses, UnlinkV1PairDevicesDeviceIdDeleteData, UnlinkV1PairDevicesDeviceIdDeleteError, UnlinkV1PairDevicesDeviceIdDeleteErrors, UnlinkV1PairDevicesDeviceIdDeleteResponse, UnlinkV1PairDevicesDeviceIdDeleteResponses, UploadV1SpacesSpaceIdDocumentsPostData, UploadV1SpacesSpaceIdDocumentsPostError, UploadV1SpacesSpaceIdDocumentsPostErrors, UploadV1SpacesSpaceIdDocumentsPostResponse, UploadV1SpacesSpaceIdDocumentsPostResponses, ValidationError } from './types.gen'; +export type { ApproveV1PairOffersOfferIdApprovePostData, ApproveV1PairOffersOfferIdApprovePostError, ApproveV1PairOffersOfferIdApprovePostErrors, ApproveV1PairOffersOfferIdApprovePostResponse, ApproveV1PairOffersOfferIdApprovePostResponses, BodyUploadV1SpacesSpaceIdDocumentsPost, BudgetsIn, CategoriesV1CategoriesGetData, CategoriesV1CategoriesGetResponse, CategoriesV1CategoriesGetResponses, CategorySpend, Claimed, ClaimIn, ClaimV1PairClaimPostData, ClaimV1PairClaimPostError, ClaimV1PairClaimPostErrors, ClaimV1PairClaimPostResponse, ClaimV1PairClaimPostResponses, ClientConfig, ClientConfigV1ConfigGetData, ClientConfigV1ConfigGetResponse, ClientConfigV1ConfigGetResponses, ClientOptions, Collected, CollectV1PairOffersOfferIdCollectPostData, CollectV1PairOffersOfferIdCollectPostError, CollectV1PairOffersOfferIdCollectPostErrors, CollectV1PairOffersOfferIdCollectPostResponse, CollectV1PairOffersOfferIdCollectPostResponses, CreateOfferIn, CreateOfferV1PairOffersPostData, CreateOfferV1PairOffersPostError, CreateOfferV1PairOffersPostErrors, CreateOfferV1PairOffersPostResponse, CreateOfferV1PairOffersPostResponses, CreateSpaceV1SpacesPostData, CreateSpaceV1SpacesPostError, CreateSpaceV1SpacesPostErrors, CreateSpaceV1SpacesPostResponse, CreateSpaceV1SpacesPostResponses, DeleteDocumentV1DocumentsDocIdDeleteData, DeleteDocumentV1DocumentsDocIdDeleteError, DeleteDocumentV1DocumentsDocIdDeleteErrors, DeleteDocumentV1DocumentsDocIdDeleteResponse, DeleteDocumentV1DocumentsDocIdDeleteResponses, DenyV1PairOffersOfferIdDenyPostData, DenyV1PairOffersOfferIdDenyPostError, DenyV1PairOffersOfferIdDenyPostErrors, DenyV1PairOffersOfferIdDenyPostResponse, DenyV1PairOffersOfferIdDenyPostResponses, Device, DevicesV1DevicesGetData, DevicesV1DevicesGetResponse, DevicesV1DevicesGetResponses, DevicesV1PairDevicesGetData, DevicesV1PairDevicesGetResponse, DevicesV1PairDevicesGetResponses, DeviceUnlinked, DevPlanV1DevPlanPostData, DevPlanV1DevPlanPostError, DevPlanV1DevPlanPostErrors, DevPlanV1DevPlanPostResponse, DevPlanV1DevPlanPostResponses, DevToken, DevTokenIn, DevTokenV1DevTokenPostData, DevTokenV1DevTokenPostError, DevTokenV1DevTokenPostErrors, DevTokenV1DevTokenPostResponse, DevTokenV1DevTokenPostResponses, DocStatus, DocumentOut, DocumentPatch, EmailCodeCheck, EmailCodeCheckV1AuthEmailVerifyPostData, EmailCodeCheckV1AuthEmailVerifyPostError, EmailCodeCheckV1AuthEmailVerifyPostErrors, EmailCodeCheckV1AuthEmailVerifyPostResponse, EmailCodeCheckV1AuthEmailVerifyPostResponses, EmailCodeIn, EmailCodeSent, EmailCodeV1AuthEmailPostData, EmailCodeV1AuthEmailPostError, EmailCodeV1AuthEmailPostErrors, EmailCodeV1AuthEmailPostResponse, EmailCodeV1AuthEmailPostResponses, EventsV1PairOffersOfferIdEventsGetData, EventsV1PairOffersOfferIdEventsGetError, EventsV1PairOffersOfferIdEventsGetErrors, EventsV1PairOffersOfferIdEventsGetResponses, Expenses, ExpensesV1SpacesSpaceIdExpensesGetData, ExpensesV1SpacesSpaceIdExpensesGetError, ExpensesV1SpacesSpaceIdExpensesGetErrors, ExpensesV1SpacesSpaceIdExpensesGetResponse, ExpensesV1SpacesSpaceIdExpensesGetResponses, ExportV1SpacesSpaceIdExportGetData, ExportV1SpacesSpaceIdExportGetError, ExportV1SpacesSpaceIdExportGetErrors, ExportV1SpacesSpaceIdExportGetResponses, GetDocumentV1DocumentsDocIdGetData, GetDocumentV1DocumentsDocIdGetError, GetDocumentV1DocumentsDocIdGetErrors, GetDocumentV1DocumentsDocIdGetResponse, GetDocumentV1DocumentsDocIdGetResponses, GetFileV1DocumentsDocIdFileGetData, GetFileV1DocumentsDocIdFileGetError, GetFileV1DocumentsDocIdFileGetErrors, GetFileV1DocumentsDocIdFileGetResponses, GrantTo, HttpValidationError, Issue, LinkedDeviceOut, ListNotificationsV1NotificationsGetData, ListNotificationsV1NotificationsGetResponse, ListNotificationsV1NotificationsGetResponses, ListSpacesV1SpacesGetData, ListSpacesV1SpacesGetResponse, ListSpacesV1SpacesGetResponses, MarkNotificationsReadV1NotificationsReadPostData, MarkNotificationsReadV1NotificationsReadPostResponse, MarkNotificationsReadV1NotificationsReadPostResponses, Me, MeV1MeGetData, MeV1MeGetResponse, MeV1MeGetResponses, MonthTotal, MyPlanV1PlanGetData, MyPlanV1PlanGetResponse, MyPlanV1PlanGetResponses, NotificationOut, NotificationsOut, OfferCreated, OfferStatus, PairOffer, PairPhoneV1SpacesSpaceIdPairPostData, PairPhoneV1SpacesSpaceIdPairPostError, PairPhoneV1SpacesSpaceIdPairPostErrors, PairPhoneV1SpacesSpaceIdPairPostResponse, PairPhoneV1SpacesSpaceIdPairPostResponses, PatchDocumentV1DocumentsDocIdPatchData, PatchDocumentV1DocumentsDocIdPatchError, PatchDocumentV1DocumentsDocIdPatchErrors, PatchDocumentV1DocumentsDocIdPatchResponse, PatchDocumentV1DocumentsDocIdPatchResponses, PlanIn, PlanInfo, PlanOut, ProviderFinishV1AuthProviderFinishPostData, ProviderFinishV1AuthProviderFinishPostError, ProviderFinishV1AuthProviderFinishPostErrors, ProviderFinishV1AuthProviderFinishPostResponse, ProviderFinishV1AuthProviderFinishPostResponses, ProviderIn, ProviderReturn, ProviderStartV1AuthProviderPostData, ProviderStartV1AuthProviderPostError, ProviderStartV1AuthProviderPostErrors, ProviderStartV1AuthProviderPostResponse, ProviderStartV1AuthProviderPostResponses, QrIn, QrV1QrPostData, QrV1QrPostError, QrV1QrPostErrors, QrV1QrPostResponse, QrV1QrPostResponses, ReconcileV1SpacesSpaceIdReconciliationGetData, ReconcileV1SpacesSpaceIdReconciliationGetError, ReconcileV1SpacesSpaceIdReconciliationGetErrors, ReconcileV1SpacesSpaceIdReconciliationGetResponse, ReconcileV1SpacesSpaceIdReconciliationGetResponses, Redirect, SchemaV1SchemasDocTypeGetData, SchemaV1SchemasDocTypeGetError, SchemaV1SchemasDocTypeGetErrors, SchemaV1SchemasDocTypeGetResponse, SchemaV1SchemasDocTypeGetResponses, SetBudgetsV1SpacesSpaceIdBudgetsPutData, SetBudgetsV1SpacesSpaceIdBudgetsPutError, SetBudgetsV1SpacesSpaceIdBudgetsPutErrors, SetBudgetsV1SpacesSpaceIdBudgetsPutResponse, SetBudgetsV1SpacesSpaceIdBudgetsPutResponses, SpaceIn, SpaceKind, SpaceOut, State, StatusV1PairOffersOfferIdGetData, StatusV1PairOffersOfferIdGetError, StatusV1PairOffersOfferIdGetErrors, StatusV1PairOffersOfferIdGetResponse, StatusV1PairOffersOfferIdGetResponses, Summary, SummaryV1SpacesSpaceIdSummaryGetData, SummaryV1SpacesSpaceIdSummaryGetError, SummaryV1SpacesSpaceIdSummaryGetErrors, SummaryV1SpacesSpaceIdSummaryGetResponse, SummaryV1SpacesSpaceIdSummaryGetResponses, UnlinkV1DevicesDeviceTokenIdDeleteData, UnlinkV1DevicesDeviceTokenIdDeleteError, UnlinkV1DevicesDeviceTokenIdDeleteErrors, UnlinkV1DevicesDeviceTokenIdDeleteResponse, UnlinkV1DevicesDeviceTokenIdDeleteResponses, UnlinkV1PairDevicesDeviceIdDeleteData, UnlinkV1PairDevicesDeviceIdDeleteError, UnlinkV1PairDevicesDeviceIdDeleteErrors, UnlinkV1PairDevicesDeviceIdDeleteResponse, UnlinkV1PairDevicesDeviceIdDeleteResponses, UploadV1SpacesSpaceIdDocumentsPostData, UploadV1SpacesSpaceIdDocumentsPostError, UploadV1SpacesSpaceIdDocumentsPostErrors, UploadV1SpacesSpaceIdDocumentsPostResponse, UploadV1SpacesSpaceIdDocumentsPostResponses, ValidationError } from './types.gen'; diff --git a/src/api/generated/types.gen.ts b/src/api/generated/types.gen.ts index 7d7168c..3f93c56 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -125,6 +125,10 @@ export type ClientConfig = { * Scopes */ scopes?: string | null; + /** + * Login Methods + */ + login_methods?: Array<'email' | 'google' | 'apple'>; }; /** @@ -319,6 +323,60 @@ export type DocumentPatch = { confirmed?: boolean; }; +/** + * EmailCodeCheck + */ +export type EmailCodeCheck = { + /** + * Flow + */ + flow: string; + /** + * Code + */ + code: string; +}; + +/** + * EmailCodeIn + */ +export type EmailCodeIn = { + /** + * Code Challenge + */ + code_challenge: string; + /** + * State + */ + state: string; + /** + * Email + */ + email: string; +}; + +/** + * EmailCodeSent + */ +export type EmailCodeSent = { + /** + * Flow + */ + flow: string; + /** + * Email + */ + email: string; + /** + * Expires In + */ + expires_in: number; + /** + * Resend After + */ + resend_after: number; +}; + /** * Expenses */ @@ -429,10 +487,25 @@ export type MonthTotal = { * NotificationOut */ export type NotificationOut = { + /** + * Id + */ id: string; + /** + * Title + */ title: string; + /** + * Detail + */ detail: string; + /** + * Href + */ href: string; + /** + * Unread + */ unread: boolean; }; @@ -440,7 +513,13 @@ export type NotificationOut = { * NotificationsOut */ export type NotificationsOut = { + /** + * Items + */ items: Array; + /** + * Unread Count + */ unread_count: number; }; @@ -528,6 +607,142 @@ export type PairOffer = { qr_url: string; }; +/** + * PlanIn + */ +export type PlanIn = { + /** + * Plan + */ + plan: 'free' | 'plus' | 'business' | 'accountant'; +}; + +/** + * PlanInfo + * + * What a plan includes, and its price in the server's currency (null for free). + */ +export type PlanInfo = { + /** + * Name + */ + name: 'free' | 'plus' | 'business' | 'accountant'; + /** + * Scans Per Month + */ + scans_per_month: number; + /** + * Business Spaces + */ + business_spaces: number; + /** + * Budget Categories + * + * null: no limit + */ + budget_categories: number | null; + /** + * Doc Types + */ + doc_types: Array; + /** + * Export Formats + */ + export_formats: Array; + /** + * Price Monthly + */ + price_monthly: number | null; + /** + * Price Yearly + */ + price_yearly: number | null; +}; + +/** + * PlanOut + * + * The caller's plan and this month's automatic scans, plus every plan for an upgrade prompt. + */ +export type PlanOut = { + /** + * Plan + */ + plan: 'free' | 'plus' | 'business' | 'accountant'; + /** + * Month + * + * YYYY-MM, UTC + */ + month: string; + /** + * Scans Used + */ + scans_used: number; + /** + * Scans Limit + */ + scans_limit: number; + /** + * Automatic Reading + * + * false until the server has an extraction model; then nothing counts + */ + automatic_reading: boolean; + /** + * Currency + */ + currency: string; + /** + * Upgrade Contact + * + * email for upgrades, until payments are built in + */ + upgrade_contact: string | null; + /** + * Plans + */ + plans: Array; +}; + +/** + * ProviderIn + */ +export type ProviderIn = { + /** + * Code Challenge + */ + code_challenge: string; + /** + * State + */ + state: string; + /** + * Provider + */ + provider: 'google' | 'apple'; +}; + +/** + * ProviderReturn + * + * What Zitadel appended to the return URL after Google or Apple. + */ +export type ProviderReturn = { + /** + * Flow + */ + flow: string; + /** + * Intent Id + */ + intent_id: string; + /** + * Intent Token + */ + intent_token: string; +}; + /** * QrIn */ @@ -538,6 +753,18 @@ export type QrIn = { data: string; }; +/** + * Redirect + * + * Send the browser here next. + */ +export type Redirect = { + /** + * Url + */ + url: string; +}; + /** * SpaceIn */ @@ -649,6 +876,38 @@ export type ValidationError = { }; }; +export type ListNotificationsV1NotificationsGetData = { + body?: never; + path?: never; + query?: never; + url: '/v1/notifications'; +}; + +export type ListNotificationsV1NotificationsGetResponses = { + /** + * Successful Response + */ + 200: NotificationsOut; +}; + +export type ListNotificationsV1NotificationsGetResponse = ListNotificationsV1NotificationsGetResponses[keyof ListNotificationsV1NotificationsGetResponses]; + +export type MarkNotificationsReadV1NotificationsReadPostData = { + body?: never; + path?: never; + query?: never; + url: '/v1/notifications/read'; +}; + +export type MarkNotificationsReadV1NotificationsReadPostResponses = { + /** + * Successful Response + */ + 200: NotificationsOut; +}; + +export type MarkNotificationsReadV1NotificationsReadPostResponse = MarkNotificationsReadV1NotificationsReadPostResponses[keyof MarkNotificationsReadV1NotificationsReadPostResponses]; + export type CreateOfferV1PairOffersPostData = { body: CreateOfferIn; path?: never; @@ -1031,6 +1290,147 @@ export type ClientConfigV1ConfigGetResponses = { export type ClientConfigV1ConfigGetResponse = ClientConfigV1ConfigGetResponses[keyof ClientConfigV1ConfigGetResponses]; +export type EmailCodeV1AuthEmailPostData = { + body: EmailCodeIn; + path?: never; + query?: never; + url: '/v1/auth/email'; +}; + +export type EmailCodeV1AuthEmailPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type EmailCodeV1AuthEmailPostError = EmailCodeV1AuthEmailPostErrors[keyof EmailCodeV1AuthEmailPostErrors]; + +export type EmailCodeV1AuthEmailPostResponses = { + /** + * Successful Response + */ + 200: EmailCodeSent; +}; + +export type EmailCodeV1AuthEmailPostResponse = EmailCodeV1AuthEmailPostResponses[keyof EmailCodeV1AuthEmailPostResponses]; + +export type EmailCodeCheckV1AuthEmailVerifyPostData = { + body: EmailCodeCheck; + path?: never; + query?: never; + url: '/v1/auth/email/verify'; +}; + +export type EmailCodeCheckV1AuthEmailVerifyPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type EmailCodeCheckV1AuthEmailVerifyPostError = EmailCodeCheckV1AuthEmailVerifyPostErrors[keyof EmailCodeCheckV1AuthEmailVerifyPostErrors]; + +export type EmailCodeCheckV1AuthEmailVerifyPostResponses = { + /** + * Successful Response + */ + 200: Redirect; +}; + +export type EmailCodeCheckV1AuthEmailVerifyPostResponse = EmailCodeCheckV1AuthEmailVerifyPostResponses[keyof EmailCodeCheckV1AuthEmailVerifyPostResponses]; + +export type ProviderStartV1AuthProviderPostData = { + body: ProviderIn; + path?: never; + query?: never; + url: '/v1/auth/provider'; +}; + +export type ProviderStartV1AuthProviderPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ProviderStartV1AuthProviderPostError = ProviderStartV1AuthProviderPostErrors[keyof ProviderStartV1AuthProviderPostErrors]; + +export type ProviderStartV1AuthProviderPostResponses = { + /** + * Successful Response + */ + 200: Redirect; +}; + +export type ProviderStartV1AuthProviderPostResponse = ProviderStartV1AuthProviderPostResponses[keyof ProviderStartV1AuthProviderPostResponses]; + +export type ProviderFinishV1AuthProviderFinishPostData = { + body: ProviderReturn; + path?: never; + query?: never; + url: '/v1/auth/provider/finish'; +}; + +export type ProviderFinishV1AuthProviderFinishPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ProviderFinishV1AuthProviderFinishPostError = ProviderFinishV1AuthProviderFinishPostErrors[keyof ProviderFinishV1AuthProviderFinishPostErrors]; + +export type ProviderFinishV1AuthProviderFinishPostResponses = { + /** + * Successful Response + */ + 200: Redirect; +}; + +export type ProviderFinishV1AuthProviderFinishPostResponse = ProviderFinishV1AuthProviderFinishPostResponses[keyof ProviderFinishV1AuthProviderFinishPostResponses]; + +export type MyPlanV1PlanGetData = { + body?: never; + path?: never; + query?: never; + url: '/v1/plan'; +}; + +export type MyPlanV1PlanGetResponses = { + /** + * Successful Response + */ + 200: PlanOut; +}; + +export type MyPlanV1PlanGetResponse = MyPlanV1PlanGetResponses[keyof MyPlanV1PlanGetResponses]; + +export type DevPlanV1DevPlanPostData = { + body: PlanIn; + path?: never; + query?: never; + url: '/v1/dev/plan'; +}; + +export type DevPlanV1DevPlanPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DevPlanV1DevPlanPostError = DevPlanV1DevPlanPostErrors[keyof DevPlanV1DevPlanPostErrors]; + +export type DevPlanV1DevPlanPostResponses = { + /** + * Successful Response + */ + 200: PlanOut; +}; + +export type DevPlanV1DevPlanPostResponse = DevPlanV1DevPlanPostResponses[keyof DevPlanV1DevPlanPostResponses]; + export type MeV1MeGetData = { body?: never; path?: never; @@ -1249,6 +1649,40 @@ export type GetFileV1DocumentsDocIdFileGetResponses = { 200: unknown; }; +export type ReconcileV1SpacesSpaceIdReconciliationGetData = { + body?: never; + path: { + /** + * Space Id + */ + space_id: string; + }; + query?: never; + url: '/v1/spaces/{space_id}/reconciliation'; +}; + +export type ReconcileV1SpacesSpaceIdReconciliationGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReconcileV1SpacesSpaceIdReconciliationGetError = ReconcileV1SpacesSpaceIdReconciliationGetErrors[keyof ReconcileV1SpacesSpaceIdReconciliationGetErrors]; + +export type ReconcileV1SpacesSpaceIdReconciliationGetResponses = { + /** + * Response Reconcile V1 Spaces Space Id Reconciliation Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ReconcileV1SpacesSpaceIdReconciliationGetResponse = ReconcileV1SpacesSpaceIdReconciliationGetResponses[keyof ReconcileV1SpacesSpaceIdReconciliationGetResponses]; + export type ExpensesV1SpacesSpaceIdExpensesGetData = { body?: never; path: { @@ -1470,29 +1904,3 @@ export type QrV1QrPostResponses = { }; export type QrV1QrPostResponse = QrV1QrPostResponses[keyof QrV1QrPostResponses]; - -export type ListNotificationsV1NotificationsGetData = { - body?: never; - path?: never; - query?: never; - url: '/v1/notifications'; -}; - -export type ListNotificationsV1NotificationsGetResponses = { - 200: NotificationsOut; -}; - -export type ListNotificationsV1NotificationsGetResponse = ListNotificationsV1NotificationsGetResponses[keyof ListNotificationsV1NotificationsGetResponses]; - -export type MarkNotificationsReadV1NotificationsReadPostData = { - body?: never; - path?: never; - query?: never; - url: '/v1/notifications/read'; -}; - -export type MarkNotificationsReadV1NotificationsReadPostResponses = { - 200: NotificationsOut; -}; - -export type MarkNotificationsReadV1NotificationsReadPostResponse = MarkNotificationsReadV1NotificationsReadPostResponses[keyof MarkNotificationsReadV1NotificationsReadPostResponses]; diff --git a/src/api/types.ts b/src/api/types.ts index 0a62ca9..4348dc2 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -9,6 +9,8 @@ export type { NotificationOut, NotificationsOut, PairOffer, + PlanInfo, + PlanOut, SpaceOut, Summary, } from "./generated"; diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 21eabfc..b4d3144 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -11,6 +11,7 @@ import * as oidc from "@/src/oidc"; import { store } from "@/src/store"; import { ProfileMenu, type AccountNotification } from "@/src/components/profile-menu"; import { ReceiptUploadDialog } from "@/src/components/receipt-upload-dialog"; +import { UpgradeDialog } from "@/src/components/upgrade-dialog"; type NavItem = { href?: string; label: string; icon: "spending" | "receipts" | "add" | "budgets" | "spaces" }; @@ -212,6 +213,7 @@ function AppFrame({ children }: { children: ReactNode }) { {uploadOpen ? : null} + {source === "connected" ? : null} ); } diff --git a/src/components/plan-summary.tsx b/src/components/plan-summary.tsx new file mode 100644 index 0000000..e86981f --- /dev/null +++ b/src/components/plan-summary.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/src/api/client"; +import type { PlanOut } from "@/src/api/types"; +import { askToUpgrade, PLAN_LABELS, type PlanName } from "@/src/plan"; + +const NEXT: Record = { + free: "plus", + plus: "business", + business: "accountant", + accountant: "accountant", +}; + +/** The Subscription panel: the account's plan and this month's automatic scans. */ +export function PlanSummary({ preview }: { preview: boolean }) { + const [plan, setPlan] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + if (preview) return; + api("/v1/plan") + .then(setPlan) + .catch(() => setError("Couldn't load your plan.")); + }, [preview]); + + if (preview) { + return ( +
+

Current access

+

Sample preview

+

Plans apply to signed-in accounts.

+
+ ); + } + if (error) + return ( +

+ {error} +

+ ); + if (!plan) + return ( +

+ Loading your plan… +

+ ); + + const used = Math.min(plan.scans_used, plan.scans_limit); + return ( +
+
+

Your plan

+

{PLAN_LABELS[plan.plan]}

+

+ {plan.scans_used} of {plan.scans_limit} automatic scans used this month +

+ {/* The sentence above says the numbers; the bar only shows them. */} + + +
+ ); +} + +/** In the upload dialog: how many automatic scans are left, and what happens when none are. */ +export function ScansLeft() { + const [plan, setPlan] = useState(null); + + useEffect(() => { + api("/v1/plan") + .then(setPlan) + .catch(() => setPlan(null)); // not essential: uploading works without it + }, []); + + if (!plan?.automatic_reading) return null; + const left = Math.max(0, plan.scans_limit - plan.scans_used); + if (left > 0) + return ( +

+ {left} of {plan.scans_limit} automatic scans left this month. +

+ ); + return ( +
+

+ No automatic scans left this month. You can still + upload; you'll type the details in yourself. +

+ +
+ ); +} diff --git a/src/components/profile-menu.tsx b/src/components/profile-menu.tsx index af2beb3..3f394b2 100644 --- a/src/components/profile-menu.tsx +++ b/src/components/profile-menu.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { store } from "@/src/store"; +import { PlanSummary } from "@/src/components/plan-summary"; type Theme = "dark" | "light"; type Panel = "menu" | "settings" | "appearance" | "subscription"; @@ -198,13 +199,7 @@ export function ProfileMenu({ email, preview, space, currency, notifications, on {(["dark", "light"] as const).map((option) => )}
: null} - {panel === "subscription" ?
-
-

Current access

-

{preview ? "Sample preview" : "ScribbleScan account"}

-
-

Plan and billing options will be available here.

-
: null} + {panel === "subscription" ? : null} {panel === "subscription" ?
: null} diff --git a/src/components/receipt-upload-dialog.tsx b/src/components/receipt-upload-dialog.tsx index b43b0c5..6f33cd1 100644 --- a/src/components/receipt-upload-dialog.tsx +++ b/src/components/receipt-upload-dialog.tsx @@ -6,6 +6,7 @@ import { api, clearTokens, request } from "@/src/api/client"; import type { DocSchema, DocumentOut, FieldSpec, Issue } from "@/src/api/types"; import { useDemo } from "@/src/components/demo-context"; import { nice, parseAmount, title, toDMY, toISO } from "@/src/format"; +import { ScansLeft } from "@/src/components/plan-summary"; type FieldKind = NonNullable; type FormData = Record; @@ -314,7 +315,7 @@ export function ReceiptUploadDialog({ open, onClose }: { open: boolean; onClose: - {source === "connected" ?

Uploading to {activeSpace}; extraction and review stay in this popup.

:
+ {source === "connected" ? <>

Uploading to {activeSpace}; extraction and review stay in this popup.

:

Sample account. The receipt will not be uploaded or processed in preview mode.

} diff --git a/src/components/upgrade-dialog.tsx b/src/components/upgrade-dialog.tsx new file mode 100644 index 0000000..c343116 --- /dev/null +++ b/src/components/upgrade-dialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { api } from "@/src/api/client"; +import type { PlanOut } from "@/src/api/types"; +import { money } from "@/src/format"; +import { onUpgradeAsked, PLAN_LABELS, type PlanName, planFeatures, type UpgradeRequired } from "@/src/plan"; + +// One upgrade prompt for every limit: the API's 402 answers (and "See plans" buttons) open it +// through the "ss:upgrade" event. Until payments are built in, upgrading is an email to us. + +export function UpgradeDialog() { + const [asked, setAsked] = useState(null); + const [plan, setPlan] = useState(null); + const [loadError, setLoadError] = useState(""); + const closeButton = useRef(null); + + useEffect( + () => + onUpgradeAsked((detail) => { + setAsked(detail); + setLoadError(""); + api("/v1/plan") + .then(setPlan) + .catch(() => setLoadError("Couldn't load the plans. Try again in a moment.")); + }), + [], + ); + + useEffect(() => { + if (!asked) return; + closeButton.current?.focus(); + // Capture, and stop the key there: this dialog can sit on top of the upload dialog, + // and Escape should close only the top one. + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + setAsked(null); + }; + window.addEventListener("keydown", closeOnEscape, true); + return () => window.removeEventListener("keydown", closeOnEscape, true); + }, [asked]); + + if (!asked) return null; + const order: PlanName[] = ["free", "plus", "business", "accountant"]; + const current = plan ? order.indexOf(plan.plan) : 0; + const choices = plan?.plans.filter((info) => order.indexOf(info.name) > current) ?? []; + + return ( +
+
+
+
+

+ {asked.feature === "plans" ? "Plans" : `Upgrade to ${PLAN_LABELS[asked.plan]}`} +

+

{asked.message}

+
+ +
+ +
+ {loadError ? ( +

+ {loadError} +

+ ) : null} + {!plan && !loadError ? ( +

+ Loading plans… +

+ ) : null} + {plan ? ( + <> +

+ You're on {PLAN_LABELS[plan.plan]}:{" "} + {plan.scans_used} of {plan.scans_limit} automatic scans used this month. +

+ {choices.length ? ( +
    + {choices.map((info) => { + const suggested = info.name === asked.plan; + return ( +
  • +
    +

    {PLAN_LABELS[info.name]}

    + {suggested ? ( + Suggested + ) : null} +
    + {info.price_monthly != null ? ( +

    + + {money(info.price_monthly, plan.currency)} + + / month +

    + ) : null} + {info.price_yearly != null ? ( +

    + or {money(info.price_yearly, plan.currency)} a year +

    + ) : null} +
      + {planFeatures(info).map((line) => ( +
    • {line}
    • + ))} +
    +
  • + ); + })} +
+ ) : ( +

You're on the largest plan.

+ )} +
+ {plan.upgrade_contact && choices.length ? ( + + Email us to upgrade + + ) : null} + {!plan.upgrade_contact && choices.length ? ( +

+ Upgrades open soon. Everything you've saved stays as it is. +

+ ) : null} +

+ Typing receipt details in yourself is never limited; plans only change automatic reading and + extras. +

+
+ + ) : null} +
+
+
+ ); +} diff --git a/src/plan.ts b/src/plan.ts new file mode 100644 index 0000000..68b3c7b --- /dev/null +++ b/src/plan.ts @@ -0,0 +1,56 @@ +// Plans: what the account is on, this month's automatic scans, and the upgrade prompt. +// The API answers 402 {code: "upgrade_required", feature, plan, message} whenever something +// needs a bigger plan; the client turns that into one event the upgrade dialog listens for. + +import type { PlanInfo, PlanOut } from "./api/types"; + +export type PlanName = PlanOut["plan"]; + +export const PLAN_LABELS: Record = { + free: "Free", + plus: "Plus", + business: "Business", + accountant: "Accountant", +}; + +export interface UpgradeRequired { + code: "upgrade_required"; + feature: string; + plan: PlanName; + message: string; +} + +const UPGRADE_EVENT = "ss:upgrade"; + +export function isUpgradeRequired(detail: unknown): detail is UpgradeRequired { + return (detail as UpgradeRequired | null)?.code === "upgrade_required"; +} + +/** Ask the app to show the upgrade dialog. */ +export function askToUpgrade(detail: UpgradeRequired): void { + window.dispatchEvent(new CustomEvent(UPGRADE_EVENT, { detail })); +} + +export function onUpgradeAsked(listener: (detail: UpgradeRequired) => void): () => void { + const handle = (event: Event) => listener((event as CustomEvent).detail); + window.addEventListener(UPGRADE_EVENT, handle); + return () => window.removeEventListener(UPGRADE_EVENT, handle); +} + +export const scansLeft = (plan: PlanOut) => Math.max(0, plan.scans_limit - plan.scans_used); + +/** One line per plan for the comparison: what changes as you move up. */ +export function planFeatures(info: PlanInfo): string[] { + const docs = info.doc_types.length > 1 ? "Receipts, invoices and bank statements" : "Receipts"; + return [ + `${info.scans_per_month.toLocaleString("en-IN")} automatic scans a month`, + info.business_spaces === 0 + ? "Personal space" + : `Personal + ${info.business_spaces} business space${info.business_spaces > 1 ? "s" : ""}`, + info.budget_categories == null + ? "Budgets for every category" + : `Budgets for ${info.budget_categories} categories`, + docs, + info.export_formats.includes("xlsx") ? "CSV and Excel export" : "CSV export", + ]; +} diff --git a/test/plan.test.ts b/test/plan.test.ts new file mode 100644 index 0000000..9fbe3c8 --- /dev/null +++ b/test/plan.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, session } from "../src/api/client"; +import { onUpgradeAsked, planFeatures, type UpgradeRequired } from "../src/plan"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("upgrade prompt", () => { + it("opens on a 402 and still throws the API's message", async () => { + session.token = "t"; + const detail = { + code: "upgrade_required", + feature: "export_formats", + plan: "plus", + message: "Excel export comes with Plus.", + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ detail }, { status: 402 })), + ); + const asked: UpgradeRequired[] = []; + const stop = onUpgradeAsked((d) => asked.push(d)); + + await expect(api("/v1/spaces/s/export")).rejects.toThrow("Excel export comes with Plus."); + expect(asked).toEqual([detail]); + stop(); + }); + + it("stays closed for other errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ detail: "no such space" }, { status: 404 })), + ); + const asked: UpgradeRequired[] = []; + const stop = onUpgradeAsked((d) => asked.push(d)); + await expect(api("/v1/spaces/x")).rejects.toThrow("no such space"); + expect(asked).toEqual([]); + stop(); + }); +}); + +describe("planFeatures", () => { + it("describes what a plan adds", () => { + expect( + planFeatures({ + name: "business", + scans_per_month: 500, + business_spaces: 1, + budget_categories: null, + doc_types: ["bank_statement", "invoice", "receipt"], + export_formats: ["csv", "xlsx"], + price_monthly: 499, + price_yearly: 4999, + }), + ).toEqual([ + "500 automatic scans a month", + "Personal + 1 business space", + "Budgets for every category", + "Receipts, invoices and bank statements", + "CSV and Excel export", + ]); + }); +}); From ea93f26e69391b3c8e1b51796b45b29e035ab10e Mon Sep 17 00:00:00 2001 From: faizur_almas Date: Tue, 27 Oct 2026 15:02:05 +0600 Subject: [PATCH 2/3] Plan comparison: name the document types a plan actually has The label came from how many types a plan had, so a plan with two would still promise receipts, invoices and bank statements. Co-Authored-By: Claude Opus 5.5 --- src/plan.ts | 18 +++++++++++++++++- test/plan.test.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/plan.ts b/src/plan.ts index 68b3c7b..0659ee9 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -39,9 +39,25 @@ export function onUpgradeAsked(listener: (detail: UpgradeRequired) => void): () export const scansLeft = (plan: PlanOut) => Math.max(0, plan.scans_limit - plan.scans_used); +const DOC_NAMES: Record = { + receipt: "receipts", + invoice: "invoices", + bank_statement: "bank statements", +}; + +/** "Receipts, invoices and bank statements", from the types the plan actually includes. */ +export function documentsLabel(docTypes: string[]): string { + const order = Object.keys(DOC_NAMES); + const names = [...docTypes] + .sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99)) + .map((type) => DOC_NAMES[type] ?? `${type.replaceAll("_", " ")}s`); + const text = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : (names[0] ?? ""); + return text.charAt(0).toUpperCase() + text.slice(1); +} + /** One line per plan for the comparison: what changes as you move up. */ export function planFeatures(info: PlanInfo): string[] { - const docs = info.doc_types.length > 1 ? "Receipts, invoices and bank statements" : "Receipts"; + const docs = documentsLabel(info.doc_types); return [ `${info.scans_per_month.toLocaleString("en-IN")} automatic scans a month`, info.business_spaces === 0 diff --git a/test/plan.test.ts b/test/plan.test.ts index 9fbe3c8..0a0e890 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { api, session } from "../src/api/client"; -import { onUpgradeAsked, planFeatures, type UpgradeRequired } from "../src/plan"; +import { documentsLabel, onUpgradeAsked, planFeatures, type UpgradeRequired } from "../src/plan"; afterEach(() => vi.unstubAllGlobals()); @@ -60,3 +60,13 @@ describe("planFeatures", () => { ]); }); }); + +describe("documentsLabel", () => { + it("names exactly the plan's document types, in a fixed order", () => { + expect(documentsLabel(["receipt"])).toBe("Receipts"); + expect(documentsLabel(["invoice", "receipt"])).toBe("Receipts and invoices"); + expect(documentsLabel(["bank_statement", "invoice", "receipt"])).toBe( + "Receipts, invoices and bank statements", + ); + }); +}); From 6ded0c683c419df139f6aa169a958657f074304a Mon Sep 17 00:00:00 2001 From: faizur_almas Date: Tue, 27 Oct 2026 15:48:23 +0600 Subject: [PATCH 3/3] Biome: skip the .next build output CI now runs the lint after the build (#4), so Biome was checking the generated .next folder: 15,411 findings, all in build output. Co-Authored-By: Claude Opus 5.5 --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index bfdcade..b40c846 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!src/api/generated", "!dist", "!openapi.json"] + "includes": ["**", "!.next", "!src/api/generated", "!dist", "!openapi.json"] }, "formatter": { "indentStyle": "space",