diff --git a/CLAUDE.md b/CLAUDE.md index 097b039..da85f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ Connected mode uses the existing ScribbleScan API only through `/v1`. Response t Sign-in (`app/connect/page.tsx`) is passwordless: **Continue with email** (a code, or the link in the email), **Google** and **Apple**, through `/v1/auth/*`. The page makes the PKCE verifier (`oidc.begin()`, kept in localStorage so the emailed link works in a new tab), the API runs the sign-in with Zitadel, and the browser lands on `/?code=...`, which the app shell hands to `/connect` for `oidc.finish()`. `GET /v1/config`'s `login_methods` says which choices work; empty with `auth: "oidc"` means Zitadel's hosted pages, and dev mode signs in from the email step without a code. +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 300dcf7..9750b2a 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 { MonthNav } from "@/src/components/month-nav"; import { monthLabel } from "@/src/format"; @@ -37,7 +37,8 @@ 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 0b7b125..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,13 +1167,14 @@ } } }, - "/v1/spaces/{space_id}/expenses": { + "/v1/spaces/{space_id}/reconciliation": { "get": { "tags": [ "money" ], - "summary": "Expenses", - "operationId": "expenses_v1_spaces__space_id__expenses_get", + "summary": "Reconcile", + "description": "Suggest receipt matches for confirmed bank-statement withdrawals.", + "operationId": "reconcile_v1_spaces__space_id__reconciliation_get", "parameters": [ { "name": "space_id", @@ -951,38 +1184,6 @@ "type": "string", "title": "Space Id" } - }, - { - "name": "month", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Month" - } - }, - { - "name": "category", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Category" - } } ], "responses": { @@ -991,7 +1192,9 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Expenses" + "type": "object", + "additionalProperties": true, + "title": "Response Reconcile V1 Spaces Space Id Reconciliation Get" } } } @@ -1009,14 +1212,13 @@ } } }, - "/v1/spaces/{space_id}/summary": { + "/v1/spaces/{space_id}/expenses": { "get": { "tags": [ "money" ], - "summary": "Summary", - "description": "Total and per-category spending for a month, change from last month, and budget status.", - "operationId": "summary_v1_spaces__space_id__summary_get", + "summary": "Expenses", + "operationId": "expenses_v1_spaces__space_id__expenses_get", "parameters": [ { "name": "space_id", @@ -1042,71 +1244,31 @@ ], "title": "Month" } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Summary" - } - } - } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/v1/spaces/{space_id}/budgets": { - "put": { - "tags": [ - "money" - ], - "summary": "Set Budgets", - "operationId": "set_budgets_v1_spaces__space_id__budgets_put", - "parameters": [ { - "name": "space_id", - "in": "path", - "required": true, + "name": "category", + "in": "query", + "required": false, "schema": { - "type": "string", - "title": "Space Id" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BudgetsIn" - } - } - } - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "type": "number" - }, - "title": "Response Set Budgets V1 Spaces Space Id Budgets Put" + "$ref": "#/components/schemas/Expenses" } } } @@ -1124,38 +1286,14 @@ } } }, - "/v1/categories": { - "get": { - "tags": [ - "money" - ], - "summary": "Categories", - "operationId": "categories_v1_categories_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Response Categories V1 Categories Get" - } - } - } - } - } - } - }, - "/v1/spaces/{space_id}/export": { + "/v1/spaces/{space_id}/summary": { "get": { "tags": [ "money" ], - "summary": "Export", - "operationId": "export_v1_spaces__space_id__export_get", + "summary": "Summary", + "description": "Total and per-category spending for a month, change from last month, and budget status.", + "operationId": "summary_v1_spaces__space_id__summary_get", "parameters": [ { "name": "space_id", @@ -1181,17 +1319,6 @@ ], "title": "Month" } - }, - { - "name": "format", - "in": "query", - "required": false, - "schema": { - "type": "string", - "pattern": "^(csv|xlsx)$", - "default": "csv", - "title": "Format" - } } ], "responses": { @@ -1199,7 +1326,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/Summary" + } } } }, @@ -1216,106 +1345,33 @@ } } }, - "/v1/schemas/{doc_type}": { - "get": { + "/v1/spaces/{space_id}/budgets": { + "put": { "tags": [ - "documents" + "money" ], - "summary": "Schema", - "description": "Fields for a document type, with each field's kind, so clients can build the review form.", - "operationId": "schema_v1_schemas__doc_type__get", + "summary": "Set Budgets", + "operationId": "set_budgets_v1_spaces__space_id__budgets_put", "parameters": [ { - "name": "doc_type", + "name": "space_id", "in": "path", "required": true, - "schema": { - "type": "string", - "title": "Doc Type" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Schema V1 Schemas Doc Type Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/v1/qr": { - "post": { - "tags": [ - "pairing" - ], - "summary": "Qr", - "description": "QR code as SVG. POST so the pairing secret never lands in a URL or access log.", - "operationId": "qr_v1_qr_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrIn" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "image/svg+xml": {} - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/v1/auth/email": { - "post": { - "tags": [ - "sign-in" + "schema": { + "type": "string", + "title": "Space Id" + } + } ], - "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": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailCodeIn" + "$ref": "#/components/schemas/BudgetsIn" } } - }, - "required": true + } }, "responses": { "200": { @@ -1323,7 +1379,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailCodeSent" + "type": "object", + "additionalProperties": { + "type": "number" + }, + "title": "Response Set Budgets V1 Spaces Space Id Budgets Put" } } } @@ -1341,32 +1401,82 @@ } } }, - "/v1/auth/email/verify": { - "post": { + "/v1/categories": { + "get": { "tags": [ - "sign-in" + "money" ], - "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" + "summary": "Categories", + "operationId": "categories_v1_categories_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Response Categories V1 Categories Get" + } } } + } + } + } + }, + "/v1/spaces/{space_id}/export": { + "get": { + "tags": [ + "money" + ], + "summary": "Export", + "operationId": "export_v1_spaces__space_id__export_get", + "parameters": [ + { + "name": "space_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Space Id" + } }, - "required": true - }, + { + "name": "month", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Month" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "pattern": "^(csv|xlsx)$", + "default": "csv", + "title": "Format" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Redirect" - } + "schema": {} } } }, @@ -1383,31 +1493,34 @@ } } }, - "/v1/auth/provider": { - "post": { + "/v1/schemas/{doc_type}": { + "get": { "tags": [ - "sign-in" + "documents" ], - "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" - } + "summary": "Schema", + "description": "Fields for a document type, with each field's kind, so clients can build the review form.", + "operationId": "schema_v1_schemas__doc_type__get", + "parameters": [ + { + "name": "doc_type", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Doc Type" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Redirect" + "type": "object", + "additionalProperties": true, + "title": "Response Schema V1 Schemas Doc Type Get" } } } @@ -1425,19 +1538,19 @@ } } }, - "/v1/auth/provider/finish": { + "/v1/qr": { "post": { "tags": [ - "sign-in" + "pairing" ], - "summary": "Provider Finish", - "description": "Back from Google or Apple: complete the sign-in.", - "operationId": "provider_finish_v1_auth_provider_finish_post", + "summary": "Qr", + "description": "QR code as SVG. POST so the pairing secret never lands in a URL or access log.", + "operationId": "qr_v1_qr_post", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderReturn" + "$ref": "#/components/schemas/QrIn" } } }, @@ -1447,11 +1560,7 @@ "200": { "description": "Successful Response", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Redirect" - } - } + "image/svg+xml": {} } }, "422": { @@ -2418,6 +2527,176 @@ "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": { 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 fc6e6f8..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, 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, NotificationOut, NotificationsOut, OfferCreated, OfferStatus, PairOffer, PairPhoneV1SpacesSpaceIdPairPostData, PairPhoneV1SpacesSpaceIdPairPostError, PairPhoneV1SpacesSpaceIdPairPostErrors, PairPhoneV1SpacesSpaceIdPairPostResponse, PairPhoneV1SpacesSpaceIdPairPostResponses, PatchDocumentV1DocumentsDocIdPatchData, PatchDocumentV1DocumentsDocIdPatchError, PatchDocumentV1DocumentsDocIdPatchErrors, PatchDocumentV1DocumentsDocIdPatchResponse, PatchDocumentV1DocumentsDocIdPatchResponses, ProviderFinishV1AuthProviderFinishPostData, ProviderFinishV1AuthProviderFinishPostError, ProviderFinishV1AuthProviderFinishPostErrors, ProviderFinishV1AuthProviderFinishPostResponse, ProviderFinishV1AuthProviderFinishPostResponses, ProviderIn, ProviderReturn, ProviderStartV1AuthProviderPostData, ProviderStartV1AuthProviderPostError, ProviderStartV1AuthProviderPostErrors, ProviderStartV1AuthProviderPostResponse, ProviderStartV1AuthProviderPostResponses, QrIn, QrV1QrPostData, QrV1QrPostError, QrV1QrPostErrors, QrV1QrPostResponse, QrV1QrPostResponses, 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'; +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 6db9936..3f93c56 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -607,6 +607,104 @@ 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 */ @@ -1192,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; @@ -1410,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: { @@ -1631,103 +1904,3 @@ export type QrV1QrPostResponses = { }; export type QrV1QrPostResponse = QrV1QrPostResponses[keyof QrV1QrPostResponses]; - -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]; 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 1ec610f..ef7a594 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -9,6 +9,7 @@ import type { NotificationsOut } from "@/src/api/types"; import { DemoProvider, useDemo } from "@/src/components/demo-context"; import { type AccountNotification, ProfileMenu } from "@/src/components/profile-menu"; import { ReceiptUploadDialog } from "@/src/components/receipt-upload-dialog"; +import { UpgradeDialog } from "@/src/components/upgrade-dialog"; import * as oidc from "@/src/oidc"; import { store } from "@/src/store"; @@ -318,6 +319,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 b8982a4..867cc03 100644 --- a/src/components/profile-menu.tsx +++ b/src/components/profile-menu.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; +import { PlanSummary } from "@/src/components/plan-summary"; import { store } from "@/src/store"; type Theme = "dark" | "light"; @@ -384,21 +385,7 @@ export function ProfileMenu({
) : null} - {panel === "subscription" ? ( -
-
-

- Current access -

-

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

-
-

- Plan and billing options will be available here. -

-
- ) : null} + {panel === "subscription" ? : null} {panel === "subscription" ? (
diff --git a/src/components/receipt-upload-dialog.tsx b/src/components/receipt-upload-dialog.tsx index edd2d96..d5207c7 100644 --- a/src/components/receipt-upload-dialog.tsx +++ b/src/components/receipt-upload-dialog.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { ScansLeft } from "@/src/components/plan-summary"; import { nice, parseAmount, title, toDMY, toISO } from "@/src/format"; type FieldKind = NonNullable; @@ -450,9 +451,12 @@ export function ReceiptUploadDialog({ open, onClose }: { open: boolean; onClose:
{source === "connected" ? ( -

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

+ <> +

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

+ + ) : (

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..0659ee9 --- /dev/null +++ b/src/plan.ts @@ -0,0 +1,72 @@ +// 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); + +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 = documentsLabel(info.doc_types); + 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..0a0e890 --- /dev/null +++ b/test/plan.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, session } from "../src/api/client"; +import { documentsLabel, 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", + ]); + }); +}); + +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", + ); + }); +});