@eifi1/ui-kit 0.38.1 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +6 -5
  2. package/dist/auth/auth-errors.d.ts +8 -3
  3. package/dist/auth/auth-errors.js +3 -1
  4. package/dist/auth/auth-errors.js.map +1 -1
  5. package/dist/billing/billing-labels.d.ts +25 -2
  6. package/dist/billing/billing-labels.js +11 -2
  7. package/dist/billing/billing-labels.js.map +1 -1
  8. package/dist/billing/billing-overview.d.ts +50 -1
  9. package/dist/billing/checkout-processing.d.ts +15 -3
  10. package/dist/billing/checkout-processing.js +3 -1
  11. package/dist/billing/checkout-processing.js.map +1 -1
  12. package/dist/billing/pay-page.d.ts +55 -18
  13. package/dist/billing/pay-page.js +44 -10
  14. package/dist/billing/pay-page.js.map +1 -1
  15. package/dist/billing/plan-switch.d.ts +79 -0
  16. package/dist/billing/plan-switch.js +93 -0
  17. package/dist/billing/plan-switch.js.map +1 -0
  18. package/dist/bin/eifi1-pay-page.mjs +109 -21
  19. package/dist/eslint/index.js +1 -1
  20. package/dist/i18n/german.js +13 -2
  21. package/dist/i18n/german.js.map +1 -1
  22. package/dist/i18n/locales/en.js +14 -3
  23. package/dist/i18n/locales/en.js.map +2 -2
  24. package/dist/i18n/locales/es.js +13 -2
  25. package/dist/i18n/locales/es.js.map +1 -1
  26. package/dist/i18n/locales/fr.js +13 -2
  27. package/dist/i18n/locales/fr.js.map +1 -1
  28. package/dist/i18n/locales/hu.js +13 -2
  29. package/dist/i18n/locales/hu.js.map +1 -1
  30. package/dist/i18n/locales/it.js +13 -2
  31. package/dist/i18n/locales/it.js.map +1 -1
  32. package/dist/i18n/locales/zh.js +13 -2
  33. package/dist/i18n/locales/zh.js.map +1 -1
  34. package/dist/i18n/review.js +3 -0
  35. package/dist/i18n/review.js.map +1 -1
  36. package/dist/index.d.ts +6 -1
  37. package/dist/index.js +4 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/lib/translation-review.d.ts +11 -0
  40. package/dist/lib/translation-review.js.map +1 -1
  41. package/dist/pay/pay.js +102 -40
  42. package/package.json +1 -1
  43. package/src/auth/auth-errors.ts +12 -3
  44. package/src/billing/billing-labels.ts +38 -3
  45. package/src/billing/billing-overview.ts +53 -1
  46. package/src/billing/checkout-processing.ts +17 -3
  47. package/src/billing/pay-page.ts +111 -27
  48. package/src/billing/plan-switch.tsx +201 -0
  49. package/src/i18n/german.ts +13 -1
  50. package/src/i18n/locales/es.ts +12 -1
  51. package/src/i18n/locales/fr.ts +12 -2
  52. package/src/i18n/locales/hu.ts +12 -1
  53. package/src/i18n/locales/it.ts +12 -1
  54. package/src/i18n/locales/zh.ts +12 -1
  55. package/src/i18n/review.ts +3 -0
  56. package/src/index.ts +8 -0
  57. package/src/lib/translation-review.ts +12 -0
package/README.md CHANGED
@@ -556,14 +556,14 @@ columns, strings and permissions (see the note at the top of `src/feedback/feedb
556
556
 
557
557
  <!-- BEGIN GENERATED: exports — node scripts/gen-export-inventory.mjs -->
558
558
 
559
- **1966 names from 320 modules** — 910 values and 1056 types. _Italic_ is a type-only export.
559
+ **1975 names from 321 modules** — 912 values and 1063 types. _Italic_ is a type-only export.
560
560
 
561
561
  Generated from `dist/index.d.ts` by `node scripts/gen-export-inventory.mjs`; the count
562
562
  is pinned by `src/__tests__/public-surface.test.ts`. Do not edit between the markers.
563
563
 
564
564
  | Entry point | Names |
565
565
  |---|---|
566
- | `@eifi1/ui-kit` | 1966 |
566
+ | `@eifi1/ui-kit` | 1975 |
567
567
  | `@eifi1/ui-kit/chart` | 92 |
568
568
  | `@eifi1/ui-kit/shell` | 77 |
569
569
  | `@eifi1/ui-kit/data-table` | 51 |
@@ -596,7 +596,7 @@ re-slicing of it, never a second API.
596
596
  | `lib/server-wake` | `attachServerWake`, `createServerWake`, `serverWake`, `watchReadsAnd`, `wrapFetch`, _`AxiosLikeInstance`_, _`ServerWakeAxiosConfig`_, _`ServerWakeFilter`_, _`ServerWakeOptions`_, _`ServerWakeRequest`_, _`ServerWakeStage`_, _`ServerWakeWatcher`_ |
597
597
  | `lib/session-refresh` | `attachSessionRefresh`, `createSessionRefresh`, `isSignedOutStep`, `isTransientRefreshFailure`, _`SessionRefresh`_, _`SessionRefreshOptions`_, _`SessionRefreshOutcome`_, _`SessionRenewal`_ |
598
598
  | `lib/person-name` | `formatPersonName`, `personInitials`, _`PersonName`_ |
599
- | `lib/translation-review` | `DEFAULT_TRANSLATION_REVIEW_FILTER`, `DEFAULT_TRANSLATION_REVIEW_SWIPE`, `dropReviews`, `filterTranslationRows`, `flattenStrings`, `fromApiReview`, `groupTranslationRows`, `keyInArea`, `keyInAreas`, `keyNamespace`, `mergeReviews`, `placeholderMismatch`, `placeholderTokens`, `REVIEW_STATUSES`, `reviewAreaOf`, `reviewStatus`, `reviewUndo`, `reviewWrite`, `summariseRows`, `toApiWrite`, `TRANSLATION_REVIEW_SWIPE_ACTIONS`, `translationCorrections`, `translationReviewSwipePlan`, `translationRows`, `unreviewedRows`, _`ApiTranslationReview`_, _`ApiTranslationReviewWrite`_, _`ReviewStatus`_, _`TranslationCorrection`_, _`TranslationReview`_, _`TranslationReviewFilter`_, _`TranslationReviewKey`_, _`TranslationReviewSwipeAction`_, _`TranslationReviewSwipeBinding`_, _`TranslationReviewUndo`_, _`TranslationReviewWrite`_, _`TranslationRow`_, _`TranslationRowGroup`_, _`TranslationRowsInput`_, _`TranslationSummary`_, _`TranslationVerdict`_ |
599
+ | `lib/translation-review` | `DEFAULT_TRANSLATION_REVIEW_FILTER`, `DEFAULT_TRANSLATION_REVIEW_SWIPE`, `dropReviews`, `filterTranslationRows`, `flattenStrings`, `fromApiReview`, `groupTranslationRows`, `keyInArea`, `keyInAreas`, `keyNamespace`, `mergeReviews`, `placeholderMismatch`, `placeholderTokens`, `REVIEW_STATUSES`, `reviewAreaOf`, `reviewStatus`, `reviewUndo`, `reviewWrite`, `summariseRows`, `toApiWrite`, `TRANSLATION_REVIEW_SWIPE_ACTIONS`, `translationCorrections`, `translationReviewSwipePlan`, `translationRows`, `unreviewedRows`, _`ApiTranslationReview`_, _`ApiTranslationReviewsResponse`_, _`ApiTranslationReviewWrite`_, _`ReviewStatus`_, _`TranslationCorrection`_, _`TranslationReview`_, _`TranslationReviewFilter`_, _`TranslationReviewKey`_, _`TranslationReviewSwipeAction`_, _`TranslationReviewSwipeBinding`_, _`TranslationReviewUndo`_, _`TranslationReviewWrite`_, _`TranslationRow`_, _`TranslationRowGroup`_, _`TranslationRowsInput`_, _`TranslationSummary`_, _`TranslationVerdict`_ |
600
600
  | `lib/countries` | `COUNTRY_CODES`, `countryName` |
601
601
  | `lib/iban` | `compactIban`, `formatIban`, `IBAN_LENGTHS`, `ibanCheckDigits`, `ibanProblem`, `isQrIban`, `isValidIban`, _`IbanKind`_, _`IbanProblem`_ |
602
602
  | `lib/isin` | `formatIsin`, `isinCheckDigit`, `isValidIsin` |
@@ -960,9 +960,10 @@ re-slicing of it, never a second API.
960
960
  | `billing/subscription-actions` | `SubscriptionActions`, _`SubscriptionActionsProps`_ |
961
961
  | `billing/checkout-return` | `CHECKOUT_RETURN_PARAM`, `CHECKOUT_RETURN_VALUE`, `checkoutReturnUrl`, `isCheckoutReturn`, `withoutCheckoutReturn` |
962
962
  | `billing/checkout-processing` | `checkoutFingerprint`, `checkoutLanded`, `noteCheckoutStarted`, `useCheckoutProcessing`, _`CheckoutOverview`_, _`CheckoutProcessingOptions`_ |
963
- | `billing/pay-page` | `paddleLocale`, `PAY_PAGE_LANG_PARAM`, `payPageUrl`, _`PaddleEnvironment`_ |
964
- | `billing/billing-overview` | _`BillingOverview`_, _`PortalRequest`_, _`PortalTarget`_, _`SubscriptionSource`_ |
963
+ | `billing/pay-page` | `paddleLocale`, `PAY_PAGE_APPS`, `PAY_PAGE_LANG_PARAM`, `payPageUrl`, _`PaddleEnvironment`_, _`PayPageApp`_ |
964
+ | `billing/billing-overview` | _`BillingOverview`_, _`PaidPrice`_, _`PlanSwitchPreview`_, _`PlanSwitchRequest`_, _`PortalRequest`_, _`PortalTarget`_, _`SubscriptionSource`_ |
965
965
  | `billing/plan-limit-toast` | `usePlanLimitToast`, _`PlanLimitToastOptions`_ |
966
+ | `billing/plan-switch` | `PlanSwitchConfirm`, _`PlanSwitchConfirmProps`_, _`PlanSwitchSide`_ |
966
967
 
967
968
  ### notifications
968
969
 
@@ -91,11 +91,16 @@ export type DemoErrorCode = "demo_disabled" | "demo_rate_limited" | "demo_capaci
91
91
  * - `billing_not_at_provider` — `409`: a portal request before the payer reached the
92
92
  * provider (§14.5) — a stale page; the overview's `at_provider` says it first.
93
93
  * - `billing_already_subscribed` — `409`: a checkout while a provider subscription runs
94
- * (§14.6). Plan changes go through the portal.
94
+ * (§14.6). Since 0.39 plan changes go through the plan switch (§15.2.10).
95
95
  * - `billing_plan_not_sold` — `422`: a checkout for a plan, currency or interval the
96
- * catalogue doesn't sell.
96
+ * catalogue doesn't sell — or (0.39) a switch to one.
97
+ *
98
+ * 0.39.0 (server-kit 0.12, §15.2.10): the plan switch's refusals.
99
+ * - `billing_not_subscribed` — `409`: a switch with no running provider subscription;
100
+ * the page offers the checkout instead.
101
+ * - `billing_plan_unchanged` — `409`: a switch to the price the payer pays now.
97
102
  */
98
- export type BillingErrorCode = "billing_disabled" | "billing_read_only" | "plan_limit" | "billing_not_configured" | "billing_provider_unavailable" | "billing_not_at_provider" | "billing_already_subscribed" | "billing_plan_not_sold";
103
+ export type BillingErrorCode = "billing_disabled" | "billing_read_only" | "plan_limit" | "billing_not_configured" | "billing_provider_unavailable" | "billing_not_at_provider" | "billing_already_subscribed" | "billing_plan_not_sold" | "billing_not_subscribed" | "billing_plan_unchanged";
99
104
  /** Every code the kit reads: the sign-in refusals, the account ones (0.30.0), the demo's
100
105
  * (0.31.0) and billing's (0.32.0). */
101
106
  export type KitErrorCode = AuthErrorCode | AccountErrorCode | DemoErrorCode | BillingErrorCode;
@@ -7,7 +7,9 @@ const BILLING_CODES = /* @__PURE__ */ new Set([
7
7
  "billing_provider_unavailable",
8
8
  "billing_not_at_provider",
9
9
  "billing_already_subscribed",
10
- "billing_plan_not_sold"
10
+ "billing_plan_not_sold",
11
+ "billing_not_subscribed",
12
+ "billing_plan_unchanged"
11
13
  ]);
12
14
  const CODES = /* @__PURE__ */ new Set([
13
15
  "invalid_credentials",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/auth-errors.ts"],"sourcesContent":["/**\n * The coded refusals of sign-in and sign-up (docs/auth-harmonization.md §5.2): every\n * refusal the auth routes answer carries a machine-readable `code`, and the pages\n * recognise the CODE, never the server's sentence.\n *\n * Today each app's pages print the server's `detail` string (keksdose's\n * `extractApiErrorMessage`), which is English on every locale and, on a credential\n * check, would be an account-enumeration oracle if it ever said more than one thing.\n * keksdose already keys one refusal on a code (`features/budgets/plan-limit.ts`,\n * `402 {detail, code: \"plan_budget_limit\"}`); §5.2 makes that the rule for auth. Each\n * app switches its server and its pages together (§9).\n *\n * - `invalid_credentials` — unknown address, wrong password AND deactivated account:\n * one answer, in the same time (§2.10). `401`.\n * - `registration_closed` — the sign-up gate refused the address. `403`.\n * - `email_taken` — an account with this address exists. `409`.\n * - `invitation_invalid`, `invitation_expired` — the `invite` token of a sign-up or an\n * accept (§4.4).\n * - `token_invalid` — a one-time token (reset, verification, a sign-in challenge) is\n * unknown, spent or expired.\n * - `account_inactive` — **no server answers it.** §2.10 decided that a deactivated\n * account gets `invalid_credentials`, because a separate answer after a correct\n * password would confirm a leaked one. It stays in the union only for a future server\n * step that is past the password (an admin view, a signed-in refresh) and may say so.\n * - `session_ended` — 0.34.0 (§11.2, decision 7; server-kit 0.8): every definitive\n * refusal of `POST /auth/refresh`, a `401`. No form shows it, so it has no label: the\n * kit's `attachSessionRefresh` decides by the STATUS, so an app whose server has not\n * switched yet keeps working.\n */\nexport type AuthErrorCode =\n | \"invalid_credentials\"\n | \"registration_closed\"\n | \"email_taken\"\n | \"invitation_invalid\"\n | \"invitation_expired\"\n | \"token_invalid\"\n /** 0.30.0: a link that WAS valid and ran out (verification, reset) — keksdose's step-3\n * refusals tell it from `token_invalid`, so a page can say \"expired, ask for a new one\". */\n | \"token_expired\"\n | \"account_inactive\"\n | \"session_ended\";\n\n/**\n * 0.30.0: the coded refusals of user administration and the account's own settings\n * (docs/user-admin-harmonization.md §4, §6) — server-kit 0.4.0's `AccountErrorCode`,\n * value for value. The same family of answer as the sign-in codes (`{detail, code}`,\n * plus `companies` on kastlan's `last_admin`), read by the same {@link authErrorCode}.\n *\n * - `last_admin` — the action would leave no active admin: deactivating, demoting or\n * deleting the last one (in kastlan, of a company; the answer names the `companies`).\n * - `self_action` — an admin acting on their own account where that is never allowed.\n * - `other_companies` — kastlan: the account belongs to other companies too, so a\n * company admin may not deactivate it; \"Remove from company\" instead (§9.6).\n * - `household_has_members` — keksdose's guard on a deletion request (§6.4).\n * - `confirmation_required`, `confirmation_mismatch` — the action's confirmation level\n * asked for a typed address (or a tick), and the request carried none, or another\n * address (§4.2).\n * - `password_incorrect` — the CURRENT password is wrong on a signed-in route (the email\n * change, the deletion request). `400`, not `401`: an app's client reads a 401 as an\n * ended session and signs the user out, the wrong answer to a typo. Every other one\n * is a `409`.\n */\nexport type AccountErrorCode =\n | \"last_admin\"\n | \"self_action\"\n | \"other_companies\"\n | \"household_has_members\"\n | \"confirmation_required\"\n | \"confirmation_mismatch\"\n | \"password_incorrect\";\n\n/**\n * 0.31.0: the demo's coded refusals (docs/landing-demo-harmonization.md §6.2) —\n * server-kit 0.5's `DemoErrorCode`, value for value, answered as `{detail, code}` like\n * the others and read by the same {@link authErrorCode}.\n *\n * - `demo_disabled` — `404`: the demo switch is off (it is \"not there\", not forbidden).\n * - `demo_rate_limited` — `429` with `Retry-After`: the per-IP window of demo starts.\n * - `demo_capacity` — `429`, no `Retry-After`: the cap of live demo users.\n * - `demo_not_ready` — `503`: the demo data has not been seeded yet.\n * - `demo_read_only` — `403`, model R: any write by a demo user. The app's write lock\n * already explains it, so it shows no toast (§5.4).\n * - `demo_refused` — `403`, both models: an action a demo never may — a way in or out,\n * mail, uploads, the account export (§6.4).\n */\nexport type DemoErrorCode =\n | \"demo_disabled\"\n | \"demo_rate_limited\"\n | \"demo_capacity\"\n | \"demo_not_ready\"\n | \"demo_read_only\"\n | \"demo_refused\";\n\n/**\n * 0.32.0: billing's coded refusals (docs/billing-harmonization.md §3.3, §3.4, §4, §12.3) —\n * server-kit 0.6's `BillingError` and `PlanLimitError`, value for value, answered as\n * `{detail, code}` like the others and read by the same {@link authErrorCode}.\n *\n * - `billing_disabled` — `404`: the billing switch is off (§2.9), so every billing route\n * but `GET /billing/status` is \"not there\", as a switched-off demo is.\n * - `billing_read_only` — `402`: a write by (or into the data of) a payer out of good\n * standing (§3.3, §12.1). Inside a sync reply it marks the changes that were refused\n * and not applied (§12.5). The app's write lock and banner already say why, so — like\n * `demo_read_only` — it needs no toast of its own.\n * - `plan_limit` — `402`: a create past the plan's limit (§3.4), with the extra fields\n * `dimension`, `plan`, `limit` and `used` — read them with `isPlanLimit`. A lapsed payer\n * creating gets `billing_read_only` instead, never this (§12.3).\n * - `billing_not_configured` — billing is switched on but the provider's settings are\n * missing (the secrets, a price id): the operator's mistake, not the payer's.\n *\n * 0.33.0 (server-kit 0.7, §14.2): the provider's refusals, with the `billing_` prefix of\n * the others (decision 22).\n * - `billing_provider_unavailable` — `502`: Paddle didn't answer, or refused the request.\n * Try again.\n * - `billing_not_at_provider` — `409`: a portal request before the payer reached the\n * provider (§14.5) — a stale page; the overview's `at_provider` says it first.\n * - `billing_already_subscribed` — `409`: a checkout while a provider subscription runs\n * (§14.6). Plan changes go through the portal.\n * - `billing_plan_not_sold` — `422`: a checkout for a plan, currency or interval the\n * catalogue doesn't sell.\n */\nexport type BillingErrorCode =\n | \"billing_disabled\"\n | \"billing_read_only\"\n | \"plan_limit\"\n | \"billing_not_configured\"\n | \"billing_provider_unavailable\"\n | \"billing_not_at_provider\"\n | \"billing_already_subscribed\"\n | \"billing_plan_not_sold\";\n\n/** Every code the kit reads: the sign-in refusals, the account ones (0.30.0), the demo's\n * (0.31.0) and billing's (0.32.0). */\nexport type KitErrorCode = AuthErrorCode | AccountErrorCode | DemoErrorCode | BillingErrorCode;\n\nconst BILLING_CODES: ReadonlySet<BillingErrorCode> = new Set<BillingErrorCode>([\n \"billing_disabled\",\n \"billing_read_only\",\n \"plan_limit\",\n \"billing_not_configured\",\n \"billing_provider_unavailable\",\n \"billing_not_at_provider\",\n \"billing_already_subscribed\",\n \"billing_plan_not_sold\",\n]);\n\nconst CODES: ReadonlySet<string> = new Set<KitErrorCode>([\n \"invalid_credentials\",\n \"registration_closed\",\n \"email_taken\",\n \"invitation_invalid\",\n \"invitation_expired\",\n \"token_invalid\",\n \"token_expired\",\n \"account_inactive\",\n \"session_ended\",\n \"last_admin\",\n \"self_action\",\n \"other_companies\",\n \"household_has_members\",\n \"confirmation_required\",\n \"confirmation_mismatch\",\n \"password_incorrect\",\n \"demo_disabled\",\n \"demo_rate_limited\",\n \"demo_capacity\",\n \"demo_not_ready\",\n \"demo_read_only\",\n \"demo_refused\",\n ...BILLING_CODES,\n]);\n\ntype Bag = Record<string, unknown>;\n\nconst isBag = (value: unknown): value is Bag => typeof value === \"object\" && value !== null;\n\n/** A known code, or undefined. */\nfunction known(value: unknown): KitErrorCode | undefined {\n return typeof value === \"string\" && CODES.has(value) ? (value as KitErrorCode) : undefined;\n}\n\n/** `{code}` on a parsed body, or FastAPI's `{detail: {code}}` — what an\n * `HTTPException(detail={\"code\": …})` serialises to. */\nfunction codeOfBody(body: unknown): KitErrorCode | undefined {\n if (!isBag(body)) return undefined;\n return known(body.code) ?? (isBag(body.detail) ? known(body.detail.code) : undefined);\n}\n\n/**\n * The code an error carries — an {@link AuthErrorCode}, since 0.30.0 an\n * {@link AccountErrorCode}, since 0.31.0 a {@link DemoErrorCode}, since 0.32.0 a\n * {@link BillingErrorCode} — or `undefined` when it carries none.\n *\n * Reads the parsed response body wherever the apps' HTTP clients put it, without\n * depending on any of them:\n *\n * 1. `err.response.data` — axios (keksdose's, kastlan's and Kurvenschmiede's clients;\n * keksdose's `isPlanBudgetLimitRefusal` reads the same path);\n * 2. `err.data` — ofetch's `FetchError`, and most `fetch` wrappers that parse the body;\n * 3. `err.body` — the generated OpenAPI clients' `ApiError`;\n * 4. `err` itself — a body thrown as it came (`throw await res.json()`).\n *\n * In each, `code` or FastAPI's nested `detail.code`. Last, `err.code` — but only when\n * it is one of the codes above: axios writes its OWN codes there (`ERR_NETWORK`,\n * `ECONNABORTED`, `ERR_BAD_REQUEST`), and so does Node, so an unknown value is never\n * read as a refusal. The HTTP status is not checked: the code is the contract, and the\n * status differs per refusal (401, 402, 403, 404, 409, 410).\n */\nexport function authErrorCode(err: unknown): KitErrorCode | undefined {\n if (!isBag(err)) return undefined;\n const response = err.response;\n return (\n (isBag(response) ? codeOfBody(response.data) : undefined) ??\n codeOfBody(err.data) ??\n codeOfBody(err.body) ??\n codeOfBody(err)\n );\n}\n\n/**\n * Whether `err` is a coded auth refusal — any of them, or the one named:\n *\n * ```ts\n * try { await api.register(values) }\n * catch (err) { if (isAuthError(err, \"email_taken\")) … }\n * ```\n *\n * The kit's `SignInForm` and `RegisterForm` call it on what their callbacks reject\n * with, so an app's `onSubmit` can simply let its client's error through. See\n * {@link authErrorCode} for the shapes it reads. Since 0.30.0 it knows the account codes\n * too: `isAuthError(err, \"last_admin\")`; since 0.31.0 the demo's:\n * `isAuthError(err, \"demo_read_only\")`; since 0.32.0 billing's:\n * `isAuthError(err, \"billing_read_only\")` (or {@link isBillingError}).\n */\nexport function isAuthError(err: unknown, code?: KitErrorCode): boolean {\n const found = authErrorCode(err);\n return found !== undefined && (code === undefined || found === code);\n}\n\n/**\n * Whether `err` is one of billing's coded refusals (0.32.0, docs/billing-harmonization.md\n * §10, §14.2) — any of them, or the one named. {@link isAuthError} answers the same for a\n * named code; this one also answers \"is it billing's at all\", which an app's error\n * handler asks before it decides between the write lock, a `PlanLimitNotice` and a toast.\n */\nexport function isBillingError(err: unknown, code?: BillingErrorCode): boolean {\n const found = authErrorCode(err);\n return found !== undefined && BILLING_CODES.has(found as BillingErrorCode) && (code === undefined || found === code);\n}\n\n/**\n * The parsed body that carries a code, wherever {@link authErrorCode} would find it (in\n * the same order) — the object with the `code`, so FastAPI's nested `detail` when the code\n * is there. For a reader of a refusal's EXTRA fields (`isPlanLimit`: `dimension`, `limit`,\n * `used` sit beside the code). `accept` decides which codes count; default, the known ones.\n *\n * @internal Not part of the barrel.\n */\nexport function errorBodyWithCode(\n err: unknown,\n accept: (code: unknown) => boolean = (code) => known(code) !== undefined,\n): Record<string, unknown> | undefined {\n if (!isBag(err)) return undefined;\n const response = err.response;\n for (const body of [isBag(response) ? response.data : undefined, err.data, err.body, err]) {\n if (!isBag(body)) continue;\n if (accept(body.code)) return body;\n if (isBag(body.detail) && accept(body.detail.code)) return body.detail;\n }\n return undefined;\n}\n\n/** An HTTP status as a number, or undefined. */\nfunction statusOf(value: unknown): number | undefined {\n return typeof value === \"number\" && Number.isInteger(value) ? value : undefined;\n}\n\n/**\n * The HTTP status a thrown error carries, wherever the apps' HTTP clients put it —\n * without depending on any of them:\n *\n * 1. `err.response.status` — axios (all three apps' clients);\n * 2. `err.status` — ofetch's `FetchError`, the generated OpenAPI clients' `ApiError`, and\n * a `Response` thrown as it came;\n * 3. `err.statusCode` — ofetch's alias, and Node-style errors.\n *\n * `undefined` when none is a whole number. Package-private — not re-exported from the\n * barrel; {@link isRateLimited} and the session refresh's classifier read it (DUP-11,\n * refactor audit 2026-10: the refresh carried its own copy of this probe).\n */\nexport function httpStatusOf(err: unknown): number | undefined {\n if (!isBag(err)) return undefined;\n const response = err.response;\n return (isBag(response) ? statusOf(response.status) : undefined) ?? statusOf(err.status) ?? statusOf(err.statusCode);\n}\n\n/**\n * Whether `err` is a throttled request — HTTP `429 Too Many Requests` (0.30.0, from the\n * apps' 0.29 adoption: every app answered a throttled sign-in, sign-up or reset through\n * its own `describeError`, with three wordings of one sentence).\n *\n * A throttle carries no `code` in the contract — the STATUS is the answer, set by the\n * apps' limiters (server-kit's `AuthLimiters`, keksdose's per-IP limit) — so this reads\n * the status, wherever the apps' HTTP clients put it ({@link httpStatusOf}).\n *\n * There is no `rate_limited` code: keksdose answers its 429s uncoded, with a\n * `Retry-After` header, and the status is the one signal every limiter gives.\n *\n * The kit's `SignInForm`, `RegisterForm`, `ForgotPasswordForm` and `ResetPasswordForm`\n * call it AFTER the app's `describeError` — an app with words of its own keeps them —\n * and before their generic failure, so \"Too many attempts. Try again in 30 s.\" (with\n * {@link retryAfterSeconds}) or \"… Wait a moment and try again.\" needs no app code.\n */\nexport function isRateLimited(err: unknown): boolean {\n return httpStatusOf(err) === 429;\n}\n\n/** A header's value from a `Headers` (fetch, ofetch's `response`), axios' `AxiosHeaders`\n * or a plain record — whose keys may come in any case. */\nfunction headerOf(headers: unknown, name: string): unknown {\n if (!isBag(headers)) return undefined;\n if (typeof headers.get === \"function\") {\n try {\n const value: unknown = (headers.get as (key: string) => unknown).call(headers, name);\n if (value !== undefined && value !== null) return value;\n } catch {\n // A `get` that is not a header lookup; the record below is.\n }\n }\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === name) return value;\n }\n return undefined;\n}\n\n/** `Retry-After` as seconds from `now`: delta-seconds (\"30\"), or an HTTP date\n * (\"Wed, 07 Oct 2026 10:00:00 GMT\") counted from now, never below 0 (RFC 9110 §10.2.3). */\nfunction parseRetryAfter(value: unknown, now: number): number | undefined {\n const raw = Array.isArray(value) ? (value as unknown[])[0] : value;\n if (typeof raw === \"number\") return Number.isFinite(raw) && raw >= 0 ? Math.ceil(raw) : undefined;\n if (typeof raw !== \"string\") return undefined;\n const text = raw.trim();\n if (/^\\d+$/.test(text)) return Number(text);\n // An HTTP date names its day and month (\"Wed, 07 Oct 2026 …\"). Without a letter it is\n // a malformed number (\"-5\", \"1.5\"), which `Date.parse` would happily read as a year.\n if (!/[a-z]/i.test(text)) return undefined;\n const at = Date.parse(text);\n if (Number.isNaN(at)) return undefined;\n return Math.max(0, Math.ceil((at - now) / 1000));\n}\n\n/**\n * How long a throttled request asks to wait, in whole seconds — its `Retry-After`\n * header (0.30.0, keksdose: its 429s carry no code, only the header) — or `undefined`\n * when there is none, or none that parses.\n *\n * Read where the apps' clients keep the response headers, without depending on any of\n * them: `err.response.headers` (axios' `AxiosHeaders` or a plain record; ofetch's\n * `FetchError.response`, a fetch `Headers`), then `err.headers` (a `Response` thrown as\n * it came). The value is delta-seconds, or an HTTP date turned into seconds from `now`\n * (default: the present moment) — never below 0.\n *\n * The forms hand it to their `rateLimited` label when {@link isRateLimited} is true:\n * \"Too many attempts. Try again in 30 s.\" — and, without one, \"… Wait a moment and try\n * again.\" A browser only exposes `Retry-After` to a cross-origin page when the server\n * lists it in `Access-Control-Expose-Headers`.\n */\nexport function retryAfterSeconds(err: unknown, now: number | Date = Date.now()): number | undefined {\n if (!isBag(err)) return undefined;\n const at = typeof now === \"number\" ? now : now.getTime();\n const response = err.response;\n const value =\n (isBag(response) ? headerOf(response.headers, \"retry-after\") : undefined) ?? headerOf(err.headers, \"retry-after\");\n return value === undefined ? undefined : parseRetryAfter(value, at);\n}\n\n/**\n * The English of every `rateLimited` label (0.30.0): \"Too many attempts. Try again in\n * 30 s.\" with a wait under a minute, in whole minutes from one minute up (keksdose's\n * reset throttle answers in hours, and \"3600 s\" is a number nobody reads), and \"Wait a\n * moment and try again.\" without one — or with a `0`.\n *\n * @internal The namespaces' English; not part of the barrel.\n */\nexport function englishRateLimited(seconds?: number): string {\n if (!seconds || seconds <= 0) return \"Too many attempts. Wait a moment and try again.\";\n return `Too many attempts. Try again in ${englishWait(seconds)}.`;\n}\n\n/** \"30 s\", \"2 min\" — a wait in English, rounded up. @internal */\nexport function englishWait(seconds: number): string {\n return seconds < 60 ? `${Math.ceil(seconds)} s` : `${Math.ceil(seconds / 60)} min`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/auth/auth-errors.ts"],"sourcesContent":["/**\n * The coded refusals of sign-in and sign-up (docs/auth-harmonization.md §5.2): every\n * refusal the auth routes answer carries a machine-readable `code`, and the pages\n * recognise the CODE, never the server's sentence.\n *\n * Today each app's pages print the server's `detail` string (keksdose's\n * `extractApiErrorMessage`), which is English on every locale and, on a credential\n * check, would be an account-enumeration oracle if it ever said more than one thing.\n * keksdose already keys one refusal on a code (`features/budgets/plan-limit.ts`,\n * `402 {detail, code: \"plan_budget_limit\"}`); §5.2 makes that the rule for auth. Each\n * app switches its server and its pages together (§9).\n *\n * - `invalid_credentials` — unknown address, wrong password AND deactivated account:\n * one answer, in the same time (§2.10). `401`.\n * - `registration_closed` — the sign-up gate refused the address. `403`.\n * - `email_taken` — an account with this address exists. `409`.\n * - `invitation_invalid`, `invitation_expired` — the `invite` token of a sign-up or an\n * accept (§4.4).\n * - `token_invalid` — a one-time token (reset, verification, a sign-in challenge) is\n * unknown, spent or expired.\n * - `account_inactive` — **no server answers it.** §2.10 decided that a deactivated\n * account gets `invalid_credentials`, because a separate answer after a correct\n * password would confirm a leaked one. It stays in the union only for a future server\n * step that is past the password (an admin view, a signed-in refresh) and may say so.\n * - `session_ended` — 0.34.0 (§11.2, decision 7; server-kit 0.8): every definitive\n * refusal of `POST /auth/refresh`, a `401`. No form shows it, so it has no label: the\n * kit's `attachSessionRefresh` decides by the STATUS, so an app whose server has not\n * switched yet keeps working.\n */\nexport type AuthErrorCode =\n | \"invalid_credentials\"\n | \"registration_closed\"\n | \"email_taken\"\n | \"invitation_invalid\"\n | \"invitation_expired\"\n | \"token_invalid\"\n /** 0.30.0: a link that WAS valid and ran out (verification, reset) — keksdose's step-3\n * refusals tell it from `token_invalid`, so a page can say \"expired, ask for a new one\". */\n | \"token_expired\"\n | \"account_inactive\"\n | \"session_ended\";\n\n/**\n * 0.30.0: the coded refusals of user administration and the account's own settings\n * (docs/user-admin-harmonization.md §4, §6) — server-kit 0.4.0's `AccountErrorCode`,\n * value for value. The same family of answer as the sign-in codes (`{detail, code}`,\n * plus `companies` on kastlan's `last_admin`), read by the same {@link authErrorCode}.\n *\n * - `last_admin` — the action would leave no active admin: deactivating, demoting or\n * deleting the last one (in kastlan, of a company; the answer names the `companies`).\n * - `self_action` — an admin acting on their own account where that is never allowed.\n * - `other_companies` — kastlan: the account belongs to other companies too, so a\n * company admin may not deactivate it; \"Remove from company\" instead (§9.6).\n * - `household_has_members` — keksdose's guard on a deletion request (§6.4).\n * - `confirmation_required`, `confirmation_mismatch` — the action's confirmation level\n * asked for a typed address (or a tick), and the request carried none, or another\n * address (§4.2).\n * - `password_incorrect` — the CURRENT password is wrong on a signed-in route (the email\n * change, the deletion request). `400`, not `401`: an app's client reads a 401 as an\n * ended session and signs the user out, the wrong answer to a typo. Every other one\n * is a `409`.\n */\nexport type AccountErrorCode =\n | \"last_admin\"\n | \"self_action\"\n | \"other_companies\"\n | \"household_has_members\"\n | \"confirmation_required\"\n | \"confirmation_mismatch\"\n | \"password_incorrect\";\n\n/**\n * 0.31.0: the demo's coded refusals (docs/landing-demo-harmonization.md §6.2) —\n * server-kit 0.5's `DemoErrorCode`, value for value, answered as `{detail, code}` like\n * the others and read by the same {@link authErrorCode}.\n *\n * - `demo_disabled` — `404`: the demo switch is off (it is \"not there\", not forbidden).\n * - `demo_rate_limited` — `429` with `Retry-After`: the per-IP window of demo starts.\n * - `demo_capacity` — `429`, no `Retry-After`: the cap of live demo users.\n * - `demo_not_ready` — `503`: the demo data has not been seeded yet.\n * - `demo_read_only` — `403`, model R: any write by a demo user. The app's write lock\n * already explains it, so it shows no toast (§5.4).\n * - `demo_refused` — `403`, both models: an action a demo never may — a way in or out,\n * mail, uploads, the account export (§6.4).\n */\nexport type DemoErrorCode =\n | \"demo_disabled\"\n | \"demo_rate_limited\"\n | \"demo_capacity\"\n | \"demo_not_ready\"\n | \"demo_read_only\"\n | \"demo_refused\";\n\n/**\n * 0.32.0: billing's coded refusals (docs/billing-harmonization.md §3.3, §3.4, §4, §12.3) —\n * server-kit 0.6's `BillingError` and `PlanLimitError`, value for value, answered as\n * `{detail, code}` like the others and read by the same {@link authErrorCode}.\n *\n * - `billing_disabled` — `404`: the billing switch is off (§2.9), so every billing route\n * but `GET /billing/status` is \"not there\", as a switched-off demo is.\n * - `billing_read_only` — `402`: a write by (or into the data of) a payer out of good\n * standing (§3.3, §12.1). Inside a sync reply it marks the changes that were refused\n * and not applied (§12.5). The app's write lock and banner already say why, so — like\n * `demo_read_only` — it needs no toast of its own.\n * - `plan_limit` — `402`: a create past the plan's limit (§3.4), with the extra fields\n * `dimension`, `plan`, `limit` and `used` — read them with `isPlanLimit`. A lapsed payer\n * creating gets `billing_read_only` instead, never this (§12.3).\n * - `billing_not_configured` — billing is switched on but the provider's settings are\n * missing (the secrets, a price id): the operator's mistake, not the payer's.\n *\n * 0.33.0 (server-kit 0.7, §14.2): the provider's refusals, with the `billing_` prefix of\n * the others (decision 22).\n * - `billing_provider_unavailable` — `502`: Paddle didn't answer, or refused the request.\n * Try again.\n * - `billing_not_at_provider` — `409`: a portal request before the payer reached the\n * provider (§14.5) — a stale page; the overview's `at_provider` says it first.\n * - `billing_already_subscribed` — `409`: a checkout while a provider subscription runs\n * (§14.6). Since 0.39 plan changes go through the plan switch (§15.2.10).\n * - `billing_plan_not_sold` — `422`: a checkout for a plan, currency or interval the\n * catalogue doesn't sell — or (0.39) a switch to one.\n *\n * 0.39.0 (server-kit 0.12, §15.2.10): the plan switch's refusals.\n * - `billing_not_subscribed` — `409`: a switch with no running provider subscription;\n * the page offers the checkout instead.\n * - `billing_plan_unchanged` — `409`: a switch to the price the payer pays now.\n */\nexport type BillingErrorCode =\n | \"billing_disabled\"\n | \"billing_read_only\"\n | \"plan_limit\"\n | \"billing_not_configured\"\n | \"billing_provider_unavailable\"\n | \"billing_not_at_provider\"\n | \"billing_already_subscribed\"\n | \"billing_plan_not_sold\"\n | \"billing_not_subscribed\"\n | \"billing_plan_unchanged\";\n\n/** Every code the kit reads: the sign-in refusals, the account ones (0.30.0), the demo's\n * (0.31.0) and billing's (0.32.0). */\nexport type KitErrorCode = AuthErrorCode | AccountErrorCode | DemoErrorCode | BillingErrorCode;\n\nconst BILLING_CODES: ReadonlySet<BillingErrorCode> = new Set<BillingErrorCode>([\n \"billing_disabled\",\n \"billing_read_only\",\n \"plan_limit\",\n \"billing_not_configured\",\n \"billing_provider_unavailable\",\n \"billing_not_at_provider\",\n \"billing_already_subscribed\",\n \"billing_plan_not_sold\",\n \"billing_not_subscribed\",\n \"billing_plan_unchanged\",\n]);\n\nconst CODES: ReadonlySet<string> = new Set<KitErrorCode>([\n \"invalid_credentials\",\n \"registration_closed\",\n \"email_taken\",\n \"invitation_invalid\",\n \"invitation_expired\",\n \"token_invalid\",\n \"token_expired\",\n \"account_inactive\",\n \"session_ended\",\n \"last_admin\",\n \"self_action\",\n \"other_companies\",\n \"household_has_members\",\n \"confirmation_required\",\n \"confirmation_mismatch\",\n \"password_incorrect\",\n \"demo_disabled\",\n \"demo_rate_limited\",\n \"demo_capacity\",\n \"demo_not_ready\",\n \"demo_read_only\",\n \"demo_refused\",\n ...BILLING_CODES,\n]);\n\ntype Bag = Record<string, unknown>;\n\nconst isBag = (value: unknown): value is Bag => typeof value === \"object\" && value !== null;\n\n/** A known code, or undefined. */\nfunction known(value: unknown): KitErrorCode | undefined {\n return typeof value === \"string\" && CODES.has(value) ? (value as KitErrorCode) : undefined;\n}\n\n/** `{code}` on a parsed body, or FastAPI's `{detail: {code}}` — what an\n * `HTTPException(detail={\"code\": …})` serialises to. */\nfunction codeOfBody(body: unknown): KitErrorCode | undefined {\n if (!isBag(body)) return undefined;\n return known(body.code) ?? (isBag(body.detail) ? known(body.detail.code) : undefined);\n}\n\n/**\n * The code an error carries — an {@link AuthErrorCode}, since 0.30.0 an\n * {@link AccountErrorCode}, since 0.31.0 a {@link DemoErrorCode}, since 0.32.0 a\n * {@link BillingErrorCode} — or `undefined` when it carries none.\n *\n * Reads the parsed response body wherever the apps' HTTP clients put it, without\n * depending on any of them:\n *\n * 1. `err.response.data` — axios (keksdose's, kastlan's and Kurvenschmiede's clients;\n * keksdose's `isPlanBudgetLimitRefusal` reads the same path);\n * 2. `err.data` — ofetch's `FetchError`, and most `fetch` wrappers that parse the body;\n * 3. `err.body` — the generated OpenAPI clients' `ApiError`;\n * 4. `err` itself — a body thrown as it came (`throw await res.json()`).\n *\n * In each, `code` or FastAPI's nested `detail.code`. Last, `err.code` — but only when\n * it is one of the codes above: axios writes its OWN codes there (`ERR_NETWORK`,\n * `ECONNABORTED`, `ERR_BAD_REQUEST`), and so does Node, so an unknown value is never\n * read as a refusal. The HTTP status is not checked: the code is the contract, and the\n * status differs per refusal (401, 402, 403, 404, 409, 410).\n */\nexport function authErrorCode(err: unknown): KitErrorCode | undefined {\n if (!isBag(err)) return undefined;\n const response = err.response;\n return (\n (isBag(response) ? codeOfBody(response.data) : undefined) ??\n codeOfBody(err.data) ??\n codeOfBody(err.body) ??\n codeOfBody(err)\n );\n}\n\n/**\n * Whether `err` is a coded auth refusal — any of them, or the one named:\n *\n * ```ts\n * try { await api.register(values) }\n * catch (err) { if (isAuthError(err, \"email_taken\")) … }\n * ```\n *\n * The kit's `SignInForm` and `RegisterForm` call it on what their callbacks reject\n * with, so an app's `onSubmit` can simply let its client's error through. See\n * {@link authErrorCode} for the shapes it reads. Since 0.30.0 it knows the account codes\n * too: `isAuthError(err, \"last_admin\")`; since 0.31.0 the demo's:\n * `isAuthError(err, \"demo_read_only\")`; since 0.32.0 billing's:\n * `isAuthError(err, \"billing_read_only\")` (or {@link isBillingError}).\n */\nexport function isAuthError(err: unknown, code?: KitErrorCode): boolean {\n const found = authErrorCode(err);\n return found !== undefined && (code === undefined || found === code);\n}\n\n/**\n * Whether `err` is one of billing's coded refusals (0.32.0, docs/billing-harmonization.md\n * §10, §14.2) — any of them, or the one named. {@link isAuthError} answers the same for a\n * named code; this one also answers \"is it billing's at all\", which an app's error\n * handler asks before it decides between the write lock, a `PlanLimitNotice` and a toast.\n */\nexport function isBillingError(err: unknown, code?: BillingErrorCode): boolean {\n const found = authErrorCode(err);\n return found !== undefined && BILLING_CODES.has(found as BillingErrorCode) && (code === undefined || found === code);\n}\n\n/**\n * The parsed body that carries a code, wherever {@link authErrorCode} would find it (in\n * the same order) — the object with the `code`, so FastAPI's nested `detail` when the code\n * is there. For a reader of a refusal's EXTRA fields (`isPlanLimit`: `dimension`, `limit`,\n * `used` sit beside the code). `accept` decides which codes count; default, the known ones.\n *\n * @internal Not part of the barrel.\n */\nexport function errorBodyWithCode(\n err: unknown,\n accept: (code: unknown) => boolean = (code) => known(code) !== undefined,\n): Record<string, unknown> | undefined {\n if (!isBag(err)) return undefined;\n const response = err.response;\n for (const body of [isBag(response) ? response.data : undefined, err.data, err.body, err]) {\n if (!isBag(body)) continue;\n if (accept(body.code)) return body;\n if (isBag(body.detail) && accept(body.detail.code)) return body.detail;\n }\n return undefined;\n}\n\n/** An HTTP status as a number, or undefined. */\nfunction statusOf(value: unknown): number | undefined {\n return typeof value === \"number\" && Number.isInteger(value) ? value : undefined;\n}\n\n/**\n * The HTTP status a thrown error carries, wherever the apps' HTTP clients put it —\n * without depending on any of them:\n *\n * 1. `err.response.status` — axios (all three apps' clients);\n * 2. `err.status` — ofetch's `FetchError`, the generated OpenAPI clients' `ApiError`, and\n * a `Response` thrown as it came;\n * 3. `err.statusCode` — ofetch's alias, and Node-style errors.\n *\n * `undefined` when none is a whole number. Package-private — not re-exported from the\n * barrel; {@link isRateLimited} and the session refresh's classifier read it (DUP-11,\n * refactor audit 2026-10: the refresh carried its own copy of this probe).\n */\nexport function httpStatusOf(err: unknown): number | undefined {\n if (!isBag(err)) return undefined;\n const response = err.response;\n return (isBag(response) ? statusOf(response.status) : undefined) ?? statusOf(err.status) ?? statusOf(err.statusCode);\n}\n\n/**\n * Whether `err` is a throttled request — HTTP `429 Too Many Requests` (0.30.0, from the\n * apps' 0.29 adoption: every app answered a throttled sign-in, sign-up or reset through\n * its own `describeError`, with three wordings of one sentence).\n *\n * A throttle carries no `code` in the contract — the STATUS is the answer, set by the\n * apps' limiters (server-kit's `AuthLimiters`, keksdose's per-IP limit) — so this reads\n * the status, wherever the apps' HTTP clients put it ({@link httpStatusOf}).\n *\n * There is no `rate_limited` code: keksdose answers its 429s uncoded, with a\n * `Retry-After` header, and the status is the one signal every limiter gives.\n *\n * The kit's `SignInForm`, `RegisterForm`, `ForgotPasswordForm` and `ResetPasswordForm`\n * call it AFTER the app's `describeError` — an app with words of its own keeps them —\n * and before their generic failure, so \"Too many attempts. Try again in 30 s.\" (with\n * {@link retryAfterSeconds}) or \"… Wait a moment and try again.\" needs no app code.\n */\nexport function isRateLimited(err: unknown): boolean {\n return httpStatusOf(err) === 429;\n}\n\n/** A header's value from a `Headers` (fetch, ofetch's `response`), axios' `AxiosHeaders`\n * or a plain record — whose keys may come in any case. */\nfunction headerOf(headers: unknown, name: string): unknown {\n if (!isBag(headers)) return undefined;\n if (typeof headers.get === \"function\") {\n try {\n const value: unknown = (headers.get as (key: string) => unknown).call(headers, name);\n if (value !== undefined && value !== null) return value;\n } catch {\n // A `get` that is not a header lookup; the record below is.\n }\n }\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === name) return value;\n }\n return undefined;\n}\n\n/** `Retry-After` as seconds from `now`: delta-seconds (\"30\"), or an HTTP date\n * (\"Wed, 07 Oct 2026 10:00:00 GMT\") counted from now, never below 0 (RFC 9110 §10.2.3). */\nfunction parseRetryAfter(value: unknown, now: number): number | undefined {\n const raw = Array.isArray(value) ? (value as unknown[])[0] : value;\n if (typeof raw === \"number\") return Number.isFinite(raw) && raw >= 0 ? Math.ceil(raw) : undefined;\n if (typeof raw !== \"string\") return undefined;\n const text = raw.trim();\n if (/^\\d+$/.test(text)) return Number(text);\n // An HTTP date names its day and month (\"Wed, 07 Oct 2026 …\"). Without a letter it is\n // a malformed number (\"-5\", \"1.5\"), which `Date.parse` would happily read as a year.\n if (!/[a-z]/i.test(text)) return undefined;\n const at = Date.parse(text);\n if (Number.isNaN(at)) return undefined;\n return Math.max(0, Math.ceil((at - now) / 1000));\n}\n\n/**\n * How long a throttled request asks to wait, in whole seconds — its `Retry-After`\n * header (0.30.0, keksdose: its 429s carry no code, only the header) — or `undefined`\n * when there is none, or none that parses.\n *\n * Read where the apps' clients keep the response headers, without depending on any of\n * them: `err.response.headers` (axios' `AxiosHeaders` or a plain record; ofetch's\n * `FetchError.response`, a fetch `Headers`), then `err.headers` (a `Response` thrown as\n * it came). The value is delta-seconds, or an HTTP date turned into seconds from `now`\n * (default: the present moment) — never below 0.\n *\n * The forms hand it to their `rateLimited` label when {@link isRateLimited} is true:\n * \"Too many attempts. Try again in 30 s.\" — and, without one, \"… Wait a moment and try\n * again.\" A browser only exposes `Retry-After` to a cross-origin page when the server\n * lists it in `Access-Control-Expose-Headers`.\n */\nexport function retryAfterSeconds(err: unknown, now: number | Date = Date.now()): number | undefined {\n if (!isBag(err)) return undefined;\n const at = typeof now === \"number\" ? now : now.getTime();\n const response = err.response;\n const value =\n (isBag(response) ? headerOf(response.headers, \"retry-after\") : undefined) ?? headerOf(err.headers, \"retry-after\");\n return value === undefined ? undefined : parseRetryAfter(value, at);\n}\n\n/**\n * The English of every `rateLimited` label (0.30.0): \"Too many attempts. Try again in\n * 30 s.\" with a wait under a minute, in whole minutes from one minute up (keksdose's\n * reset throttle answers in hours, and \"3600 s\" is a number nobody reads), and \"Wait a\n * moment and try again.\" without one — or with a `0`.\n *\n * @internal The namespaces' English; not part of the barrel.\n */\nexport function englishRateLimited(seconds?: number): string {\n if (!seconds || seconds <= 0) return \"Too many attempts. Wait a moment and try again.\";\n return `Too many attempts. Try again in ${englishWait(seconds)}.`;\n}\n\n/** \"30 s\", \"2 min\" — a wait in English, rounded up. @internal */\nexport function englishWait(seconds: number): string {\n return seconds < 60 ? `${Math.ceil(seconds)} s` : `${Math.ceil(seconds / 60)} min`;\n}\n"],"mappings":"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","names":[]}
@@ -137,6 +137,23 @@ export interface BillingLabels {
137
137
  payFailed: string;
138
138
  /** The buyer closed the checkout without paying. */
139
139
  payBack: string;
140
+ /** 0.39 (§15.2.5): paid, for a transaction that names no app the page knows (a
141
+ * payment-method update from Paddle's e-mail) — no way back to offer. */
142
+ payDone: string;
143
+ /** The dialog's title: "Change plan". */
144
+ switchTitle: string;
145
+ /** The line naming both plans, each with its billing period: "From Pro (Monthly) to
146
+ * Business (Yearly)". */
147
+ switchFromTo: (from: string, to: string) => string;
148
+ /** What an upgrade charges at once (Paddle's preview, prorated): "Charged now". */
149
+ switchChargedNow: string;
150
+ /** What a downgrade credits — the customer's balance, which Paddle takes off the next
151
+ * charges: "Credited towards your next charges". */
152
+ switchCredited: string;
153
+ /** The next regular charge; `date` is formatted: "Next charge on 9 Nov 2026". */
154
+ switchNextCharge: (date: string) => string;
155
+ /** The confirm button: "Change plan". */
156
+ switchConfirm: string;
140
157
  /** Changes refused as `billing_read_only` inside a sync reply, kept queued and sent
141
158
  * after payment: "3 changes waiting for a plan". */
142
159
  waitingChanges: (count: number) => string;
@@ -156,11 +173,17 @@ export interface BillingLabels {
156
173
  /** `billing_provider_unavailable` (502): the provider didn't answer. Try again. */
157
174
  providerUnavailable: string;
158
175
  /** `billing_already_subscribed` (409): a second checkout while a subscription runs
159
- * (§14.6) — plan changes go through the portal. */
176
+ * (§14.6). Since 0.39 plan changes go through the app's own switch (§15.2.10), not the
177
+ * portal, and the words say so. */
160
178
  alreadySubscribed: string;
161
179
  /** `billing_plan_not_sold` (422): the catalogue doesn't sell this plan with that
162
- * period or currency. */
180
+ * period or currency — at a checkout, or (0.39) a switch to a plan no longer sold. */
163
181
  planNotSold: string;
182
+ /** 0.39 (§15.2.10): `billing_not_subscribed` (409), a plan switch with no running
183
+ * provider subscription — the page offers the checkout instead. */
184
+ notSubscribed: string;
185
+ /** 0.39: `billing_plan_unchanged` (409), a switch to the price the payer pays now. */
186
+ planUnchanged: string;
164
187
  }
165
188
  export declare const DEFAULT_BILLING_LABELS: BillingLabels;
166
189
  /** The `billing` namespace, resolved: English, then the provider, then `labels` — which
@@ -66,13 +66,22 @@ const DEFAULT_BILLING_LABELS = {
66
66
  payNothing: "There\u2019s no payment to make here. To choose a plan, go back to the app.",
67
67
  payFailed: "The checkout couldn\u2019t be opened. Please go back and try again in a moment.",
68
68
  payBack: "The checkout was closed. You can go back, or reload this page to open it again.",
69
+ payDone: "Payment complete. You can close this page.",
70
+ switchTitle: "Change plan",
71
+ switchFromTo: (from, to) => `From ${from} to ${to}`,
72
+ switchChargedNow: "Charged now",
73
+ switchCredited: "Credited towards your next charges",
74
+ switchNextCharge: (date) => `Next charge on ${date}`,
75
+ switchConfirm: "Change plan",
69
76
  waitingChanges: (count) => count === 1 ? "1 change waiting for a plan" : `${count} changes waiting for a plan`,
70
77
  discardWaiting: "Discard waiting changes",
71
78
  notConfigured: "Payments aren't set up yet. Please try again later.",
72
79
  disabled: "Subscriptions aren't available here.",
73
80
  providerUnavailable: "The payment provider can\u2019t be reached right now. Please try again in a moment.",
74
- alreadySubscribed: "You already have a subscription. To change your plan, go to \u201CPayment and invoices\u201D.",
75
- planNotSold: "This plan isn\u2019t offered for that billing period or currency. Please choose another."
81
+ alreadySubscribed: "You already have a subscription. Change your plan above.",
82
+ planNotSold: "This plan isn\u2019t offered for that billing period or currency. Please choose another.",
83
+ notSubscribed: "There\u2019s no running subscription to change. Choose a plan to subscribe.",
84
+ planUnchanged: "That\u2019s already your plan. Pick another plan or billing period."
76
85
  };
77
86
  function useBillingLabels(labels) {
78
87
  return useKitLabels("billing", DEFAULT_BILLING_LABELS, labels);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/billing-labels.ts"],"sourcesContent":["import { useKitLabels } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\n\n/**\n * The subscription's status vocabulary (docs/billing-harmonization.md §3.2) — server-kit's\n * `SubscriptionStatus`, value for value:\n *\n * - `trialing` — the 30-day trial without a card (§2.7), or a pure guest's row whose trial\n * has not started (§12.9, `trial_ends_at` empty);\n * - `active` — paid;\n * - `past_due` — a payment failed; in good standing during the provider's retries (§3.3);\n * - `canceled` — the payer cancelled; the American spelling is the wire's, the word shown\n * is \"Cancelled\";\n * - `expired` — a trial, a grant or a failed subscription ran out: read-only (§2.3);\n * - `comped` — free by the operator's grant or as a beta user (§3.2, `comped_until`).\n */\nexport type SubscriptionStatus = \"trialing\" | \"active\" | \"past_due\" | \"canceled\" | \"expired\" | \"comped\";\n\n/** Every status, in the order a lifecycle reads: trial, paid, trouble, ended, granted. */\nexport const SUBSCRIPTION_STATUSES: readonly SubscriptionStatus[] = Object.freeze([\n \"trialing\",\n \"active\",\n \"past_due\",\n \"canceled\",\n \"expired\",\n \"comped\",\n]);\n\n/**\n * The words of the subscription parts — the `billing` namespace of `<UiKitProvider labels>`\n * (docs/billing-harmonization.md §7, §10; ui-kit 0.32.0).\n *\n * WORDING (§12.24): the English group is \"Subscription\", never \"Plan\" — keksdose's \"plan\"\n * is the month's budget plan (\"One plan per month\"), and two meanings of one word on one\n * settings page is the clash the review found. \"Plan\" stays the word for WHAT is bought\n * (Free, Pro), \"subscription\" for the standing arrangement. German says \"Tarif\" for the\n * plan and \"Abonnement\" for the subscription (§10).\n *\n * Prices are GROSS (§12.17): Swiss consumer law (PBV) wants a consumer price with the\n * MWST in it, so the cards say so (`vatIncluded`).\n */\nexport interface BillingLabels {\n /** The settings group or admin section the parts live on (§7, §12.24): \"Subscription\". */\n group: string;\n\n /* ── PlanPicker / PlanCard (§7) ── */\n /** The picker's name when it has no visible `legend`: \"Plans\". */\n plans: string;\n /** The name of the billing-period switch: \"Billing period\". */\n interval: string;\n /** The switch's two options — yearly first (§12.17). */\n yearly: string;\n monthly: string;\n /** The name of the currency switch: \"Currency\". */\n currency: string;\n /** After a price: \"per year\", \"per month\". */\n perYear: string;\n perMonth: string;\n /** Under a price (§12.17): \"VAT included\". German: \"inkl. MWST\". */\n vatIncluded: string;\n /** A plan with no price, or a price of 0: \"Free\". */\n free: string;\n /** A plan with prices, but none for this currency and period. */\n notOffered: string;\n /** A limit of `null` (§3.1): \"Unlimited\". */\n unlimited: string;\n /** The accessible names of a card's two lists. */\n limits: string;\n features: string;\n /** The chip on the payer's current plan. */\n current: string;\n /** The picker's button for `plan` (the plan's display name) with no current plan —\n * during a trial, or after a plan ended: \"Choose Pro\". */\n choose: (plan: string) => string;\n /** …for a plan after the current one in the catalogue's order: \"Upgrade to Pro\". */\n upgrade: (plan: string) => string;\n /** …for a plan before it: \"Switch to Free\". */\n downgrade: (plan: string) => string;\n /** Why the button is off while the current plan is the one picked. */\n isCurrent: string;\n /** Why it is off while nothing is picked. */\n pickFirst: string;\n\n /* ── SubscriptionStatusChip (§6) ── */\n /** One word per status. */\n status: Record<SubscriptionStatus, string>;\n\n /* ── BillingBanner (§3.3, §12.6, §12.21) ── */\n /** A trial ends in `days` calendar days — 0 is today, 1 tomorrow. */\n trialEnding: (days: number) => string;\n /** A free grant (`comped_until`: a beta user's 12 months, an operator's grant) ends in\n * `days` calendar days. */\n grantEnding: (days: number) => string;\n /** The first failed payment (`past_due`), while the provider retries. */\n paymentFailed: string;\n /** Out of good standing: read-only, never locked (§2.3). */\n planEnded: string;\n /**\n * A guest in someone else's item that is read-only for billing (§12.6). It never says\n * WHY beyond \"for now\": the owner's payment status is the owner's personal data.\n * `item` is the item's own name (\"Household 2026\"), shown in quotes — or `undefined`.\n */\n guestReadOnly: (item?: string) => string;\n /** Back from checkout before the provider's webhook landed (§12.21). */\n processing: string;\n /** The action of the trial, grant and plan-ended banners, and of `PlanLimitNotice`'s\n * `upgrade` mode: \"Choose a plan\". */\n choosePlan: string;\n /** The payment-failed banner's action, to the provider's portal: \"Update payment method\". */\n updatePayment: string;\n /** The processing banner's action, when the app gives one: \"Check again\". */\n checkAgain: string;\n\n /* ── The read-only lock (§3.3, §12.6) ── */\n /** The `WriteLockProvider` reason for the payer (§3.3, verbatim). A guest's reason is\n * {@link guestReadOnly}. */\n lockReason: string;\n\n /* ── PlanLimitNotice (§3.4) ── */\n /** The notice's first line: \"Plan limit reached\". */\n limitReached: string;\n /** Mode `upgrade`: what the action does. */\n limitUpgrade: string;\n /** Mode `contact` (the operator grants plans by hand, keksdose's today). */\n limitContact: string;\n /** Mode `contact`'s action, a mail to support: \"Ask for more\". */\n askForMore: string;\n /** The meter's value: \"3 of 3\". Both figures are formatted already. */\n usage: (used: string, limit: string) => string;\n /** The subject of the `contact` mail; `dimension` is the app's name for it (\"Budgets\"). */\n contactSubject: (dimension: string) => string;\n /**\n * The figure line of the limit's toast (`usePlanLimitToast`, §14.11) and of an\n * operator's plan change over the new limit (§14.12): \"Budgets: 3 of 3\". A key of its\n * own rather than `${dimension}: ${usage}`, because French puts a space before the\n * colon. Both figures are formatted already.\n */\n limitUsageLine: (dimension: string, used: string, limit: string) => string;\n\n /* ── The provider's portal (§7, §12.26) ── */\n /** The portal: payment method and invoices (§2.8, the provider hosts both). */\n manage: string;\n /**\n * The visible cancel link on the app's subscription page (§12.26). German: §312k BGB\n * wants the button that leads to the cancellation labelled \"Verträge hier kündigen\" or\n * as unambiguously — a translator should use exactly that.\n */\n cancel: string;\n /**\n * In place of both while the payer hasn't reached the provider (the overview's\n * `at_provider` is false, §14.5): there is no customer to open a portal for yet. Also\n * the words for `409 billing_not_at_provider`, a stale page that asked anyway.\n */\n notAtProvider: string;\n\n /* ── The pay page (§14.4) ── */\n /* Built into the static page `dist/pay/` at the kit's build: it has no React and no\n provider, so an app's override never reaches it. */\n /** While the checkout opens. */\n payOpening: string;\n /** A link without a transaction, or billing switched off: nothing to pay. */\n payNothing: string;\n /** Paddle's script didn't load, or the page's configuration is wrong. */\n payFailed: string;\n /** The buyer closed the checkout without paying. */\n payBack: string;\n\n /* ── Offline (§12.5) ── */\n /** Changes refused as `billing_read_only` inside a sync reply, kept queued and sent\n * after payment: \"3 changes waiting for a plan\". */\n waitingChanges: (count: number) => string;\n /** The sign-out guard's way out for someone who will not pay. */\n discardWaiting: string;\n\n /* ── Refusals with no part of their own (0.32.1, kastlan's 0.32 report) ── */\n /**\n * `billing_not_configured`: billing is on but the provider's settings are missing — the\n * operator's mistake, so the words ask the payer to wait, not to act. For the toast or\n * alert after \"Choose a plan\" or \"Payment and invoices\" (`isBillingError(err,\n * \"billing_not_configured\")`). `billing_read_only` and `plan_limit` need none: the\n * write lock, the banner and `PlanLimitNotice` say them.\n */\n notConfigured: string;\n /** `billing_disabled`: billing is switched off on this server (§2.9), for a page or a\n * link that reached it anyway. */\n disabled: string;\n /* 0.33.0 — server-kit 0.7's refusals (§14.2). `billing_not_at_provider` is\n {@link notAtProvider}'s: a stale page that asked for the portal anyway. */\n /** `billing_provider_unavailable` (502): the provider didn't answer. Try again. */\n providerUnavailable: string;\n /** `billing_already_subscribed` (409): a second checkout while a subscription runs\n * (§14.6) — plan changes go through the portal. */\n alreadySubscribed: string;\n /** `billing_plan_not_sold` (422): the catalogue doesn't sell this plan with that\n * period or currency. */\n planNotSold: string;\n}\n\n/** \"today\", \"tomorrow\", \"in 5 days\". */\nfunction inDays(days: number): string {\n if (days <= 0) return \"today\";\n if (days === 1) return \"tomorrow\";\n return `in ${days} days`;\n}\n\nexport const DEFAULT_BILLING_LABELS: BillingLabels = {\n group: \"Subscription\",\n\n plans: \"Plans\",\n interval: \"Billing period\",\n yearly: \"Yearly\",\n monthly: \"Monthly\",\n currency: \"Currency\",\n perYear: \"per year\",\n perMonth: \"per month\",\n vatIncluded: \"VAT included\",\n free: \"Free\",\n notOffered: \"Not offered for this billing period\",\n unlimited: \"Unlimited\",\n limits: \"Limits\",\n features: \"Included\",\n current: \"Current plan\",\n choose: (plan) => `Choose ${plan}`,\n upgrade: (plan) => `Upgrade to ${plan}`,\n downgrade: (plan) => `Switch to ${plan}`,\n isCurrent: \"This is your current plan.\",\n pickFirst: \"Pick a plan first.\",\n\n status: {\n trialing: \"Trial\",\n active: \"Active\",\n past_due: \"Payment overdue\",\n canceled: \"Cancelled\",\n expired: \"Expired\",\n comped: \"Complimentary\",\n },\n\n trialEnding: (days) =>\n `Your trial ends ${inDays(days)}. After that you can still view everything, but changes need a plan.`,\n grantEnding: (days) =>\n `Your free access ends ${inDays(days)}. After that you can still view everything, but changes need a plan.`,\n paymentFailed: \"Your last payment didn’t go through. Update your payment method to keep your plan.\",\n planEnded: \"Your plan has ended. You can still view and export everything; choose a plan to make changes again.\",\n guestReadOnly: (item) =>\n item ? `“${item}” is read-only for now; its owner can lift that.` : \"This is read-only for now; its owner can lift that.\",\n processing: \"Your payment is being processed. Your plan starts as soon as it’s confirmed; this page updates by itself.\",\n choosePlan: \"Choose a plan\",\n updatePayment: \"Update payment method\",\n checkAgain: \"Check again\",\n\n lockReason: \"Your plan has ended. Choose a plan to make changes again.\",\n\n limitReached: \"Plan limit reached\",\n limitUpgrade: \"To add more, choose a plan with a higher limit.\",\n limitContact: \"To add more, ask us for a higher limit.\",\n askForMore: \"Ask for more\",\n usage: (used, limit) => `${used} of ${limit}`,\n contactSubject: (dimension) => `Plan limit: ${dimension}`,\n limitUsageLine: (dimension, used, limit) => `${dimension}: ${used} of ${limit}`,\n\n manage: \"Payment and invoices\",\n cancel: \"Cancel subscription\",\n notAtProvider: \"Nothing to manage yet. Payments and invoices appear here once you have a paid plan.\",\n\n payOpening: \"Opening the secure checkout…\",\n payNothing: \"There’s no payment to make here. To choose a plan, go back to the app.\",\n payFailed: \"The checkout couldn’t be opened. Please go back and try again in a moment.\",\n payBack: \"The checkout was closed. You can go back, or reload this page to open it again.\",\n\n waitingChanges: (count) =>\n count === 1 ? \"1 change waiting for a plan\" : `${count} changes waiting for a plan`,\n discardWaiting: \"Discard waiting changes\",\n\n notConfigured: \"Payments aren't set up yet. Please try again later.\",\n disabled: \"Subscriptions aren't available here.\",\n providerUnavailable: \"The payment provider can’t be reached right now. Please try again in a moment.\",\n alreadySubscribed: \"You already have a subscription. To change your plan, go to “Payment and invoices”.\",\n planNotSold: \"This plan isn’t offered for that billing period or currency. Please choose another.\",\n};\n\n/** The `billing` namespace, resolved: English, then the provider, then `labels` — which\n * may be partial at every depth (`{ status: { comped: \"Beta\" } }`). */\nexport function useBillingLabels(labels?: LabelOverride<BillingLabels>): BillingLabels {\n return useKitLabels(\"billing\", DEFAULT_BILLING_LABELS, labels);\n}\n"],"mappings":";AAAA,SAAS,oBAAoB;AAmBtB,MAAM,wBAAuD,OAAO,OAAO;AAAA,EAChF;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AA6KD,SAAS,OAAO,MAAsB;AACpC,MAAI,QAAQ,EAAG,QAAO;AACtB,MAAI,SAAS,EAAG,QAAO;AACvB,SAAO,MAAM,IAAI;AACnB;AAEO,MAAM,yBAAwC;AAAA,EACnD,OAAO;AAAA,EAEP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,SAAS;AAAA,EACT,UAAU;AAAA,EACV,aAAa;AAAA,EACb,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ,CAAC,SAAS,UAAU,IAAI;AAAA,EAChC,SAAS,CAAC,SAAS,cAAc,IAAI;AAAA,EACrC,WAAW,CAAC,SAAS,aAAa,IAAI;AAAA,EACtC,WAAW;AAAA,EACX,WAAW;AAAA,EAEX,QAAQ;AAAA,IACN,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,UAAU;AAAA,IACV,SAAS;AAAA,IACT,QAAQ;AAAA,EACV;AAAA,EAEA,aAAa,CAAC,SACZ,mBAAmB,OAAO,IAAI,CAAC;AAAA,EACjC,aAAa,CAAC,SACZ,yBAAyB,OAAO,IAAI,CAAC;AAAA,EACvC,eAAe;AAAA,EACf,WAAW;AAAA,EACX,eAAe,CAAC,SACd,OAAO,SAAI,IAAI,0DAAqD;AAAA,EACtE,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,YAAY;AAAA,EAEZ,YAAY;AAAA,EAEZ,cAAc;AAAA,EACd,cAAc;AAAA,EACd,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,OAAO,CAAC,MAAM,UAAU,GAAG,IAAI,OAAO,KAAK;AAAA,EAC3C,gBAAgB,CAAC,cAAc,eAAe,SAAS;AAAA,EACvD,gBAAgB,CAAC,WAAW,MAAM,UAAU,GAAG,SAAS,KAAK,IAAI,OAAO,KAAK;AAAA,EAE7E,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,eAAe;AAAA,EAEf,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,SAAS;AAAA,EAET,gBAAgB,CAAC,UACf,UAAU,IAAI,gCAAgC,GAAG,KAAK;AAAA,EACxD,gBAAgB;AAAA,EAEhB,eAAe;AAAA,EACf,UAAU;AAAA,EACV,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,aAAa;AACf;AAIO,SAAS,iBAAiB,QAAsD;AACrF,SAAO,aAAa,WAAW,wBAAwB,MAAM;AAC/D;","names":[]}
1
+ {"version":3,"sources":["../../src/billing/billing-labels.ts"],"sourcesContent":["import { useKitLabels } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\n\n/**\n * The subscription's status vocabulary (docs/billing-harmonization.md §3.2) — server-kit's\n * `SubscriptionStatus`, value for value:\n *\n * - `trialing` — the 30-day trial without a card (§2.7), or a pure guest's row whose trial\n * has not started (§12.9, `trial_ends_at` empty);\n * - `active` — paid;\n * - `past_due` — a payment failed; in good standing during the provider's retries (§3.3);\n * - `canceled` — the payer cancelled; the American spelling is the wire's, the word shown\n * is \"Cancelled\";\n * - `expired` — a trial, a grant or a failed subscription ran out: read-only (§2.3);\n * - `comped` — free by the operator's grant or as a beta user (§3.2, `comped_until`).\n */\nexport type SubscriptionStatus = \"trialing\" | \"active\" | \"past_due\" | \"canceled\" | \"expired\" | \"comped\";\n\n/** Every status, in the order a lifecycle reads: trial, paid, trouble, ended, granted. */\nexport const SUBSCRIPTION_STATUSES: readonly SubscriptionStatus[] = Object.freeze([\n \"trialing\",\n \"active\",\n \"past_due\",\n \"canceled\",\n \"expired\",\n \"comped\",\n]);\n\n/**\n * The words of the subscription parts — the `billing` namespace of `<UiKitProvider labels>`\n * (docs/billing-harmonization.md §7, §10; ui-kit 0.32.0).\n *\n * WORDING (§12.24): the English group is \"Subscription\", never \"Plan\" — keksdose's \"plan\"\n * is the month's budget plan (\"One plan per month\"), and two meanings of one word on one\n * settings page is the clash the review found. \"Plan\" stays the word for WHAT is bought\n * (Free, Pro), \"subscription\" for the standing arrangement. German says \"Tarif\" for the\n * plan and \"Abonnement\" for the subscription (§10).\n *\n * Prices are GROSS (§12.17): Swiss consumer law (PBV) wants a consumer price with the\n * MWST in it, so the cards say so (`vatIncluded`).\n */\nexport interface BillingLabels {\n /** The settings group or admin section the parts live on (§7, §12.24): \"Subscription\". */\n group: string;\n\n /* ── PlanPicker / PlanCard (§7) ── */\n /** The picker's name when it has no visible `legend`: \"Plans\". */\n plans: string;\n /** The name of the billing-period switch: \"Billing period\". */\n interval: string;\n /** The switch's two options — yearly first (§12.17). */\n yearly: string;\n monthly: string;\n /** The name of the currency switch: \"Currency\". */\n currency: string;\n /** After a price: \"per year\", \"per month\". */\n perYear: string;\n perMonth: string;\n /** Under a price (§12.17): \"VAT included\". German: \"inkl. MWST\". */\n vatIncluded: string;\n /** A plan with no price, or a price of 0: \"Free\". */\n free: string;\n /** A plan with prices, but none for this currency and period. */\n notOffered: string;\n /** A limit of `null` (§3.1): \"Unlimited\". */\n unlimited: string;\n /** The accessible names of a card's two lists. */\n limits: string;\n features: string;\n /** The chip on the payer's current plan. */\n current: string;\n /** The picker's button for `plan` (the plan's display name) with no current plan —\n * during a trial, or after a plan ended: \"Choose Pro\". */\n choose: (plan: string) => string;\n /** …for a plan after the current one in the catalogue's order: \"Upgrade to Pro\". */\n upgrade: (plan: string) => string;\n /** …for a plan before it: \"Switch to Free\". */\n downgrade: (plan: string) => string;\n /** Why the button is off while the current plan is the one picked. */\n isCurrent: string;\n /** Why it is off while nothing is picked. */\n pickFirst: string;\n\n /* ── SubscriptionStatusChip (§6) ── */\n /** One word per status. */\n status: Record<SubscriptionStatus, string>;\n\n /* ── BillingBanner (§3.3, §12.6, §12.21) ── */\n /** A trial ends in `days` calendar days — 0 is today, 1 tomorrow. */\n trialEnding: (days: number) => string;\n /** A free grant (`comped_until`: a beta user's 12 months, an operator's grant) ends in\n * `days` calendar days. */\n grantEnding: (days: number) => string;\n /** The first failed payment (`past_due`), while the provider retries. */\n paymentFailed: string;\n /** Out of good standing: read-only, never locked (§2.3). */\n planEnded: string;\n /**\n * A guest in someone else's item that is read-only for billing (§12.6). It never says\n * WHY beyond \"for now\": the owner's payment status is the owner's personal data.\n * `item` is the item's own name (\"Household 2026\"), shown in quotes — or `undefined`.\n */\n guestReadOnly: (item?: string) => string;\n /** Back from checkout before the provider's webhook landed (§12.21). */\n processing: string;\n /** The action of the trial, grant and plan-ended banners, and of `PlanLimitNotice`'s\n * `upgrade` mode: \"Choose a plan\". */\n choosePlan: string;\n /** The payment-failed banner's action, to the provider's portal: \"Update payment method\". */\n updatePayment: string;\n /** The processing banner's action, when the app gives one: \"Check again\". */\n checkAgain: string;\n\n /* ── The read-only lock (§3.3, §12.6) ── */\n /** The `WriteLockProvider` reason for the payer (§3.3, verbatim). A guest's reason is\n * {@link guestReadOnly}. */\n lockReason: string;\n\n /* ── PlanLimitNotice (§3.4) ── */\n /** The notice's first line: \"Plan limit reached\". */\n limitReached: string;\n /** Mode `upgrade`: what the action does. */\n limitUpgrade: string;\n /** Mode `contact` (the operator grants plans by hand, keksdose's today). */\n limitContact: string;\n /** Mode `contact`'s action, a mail to support: \"Ask for more\". */\n askForMore: string;\n /** The meter's value: \"3 of 3\". Both figures are formatted already. */\n usage: (used: string, limit: string) => string;\n /** The subject of the `contact` mail; `dimension` is the app's name for it (\"Budgets\"). */\n contactSubject: (dimension: string) => string;\n /**\n * The figure line of the limit's toast (`usePlanLimitToast`, §14.11) and of an\n * operator's plan change over the new limit (§14.12): \"Budgets: 3 of 3\". A key of its\n * own rather than `${dimension}: ${usage}`, because French puts a space before the\n * colon. Both figures are formatted already.\n */\n limitUsageLine: (dimension: string, used: string, limit: string) => string;\n\n /* ── The provider's portal (§7, §12.26) ── */\n /** The portal: payment method and invoices (§2.8, the provider hosts both). */\n manage: string;\n /**\n * The visible cancel link on the app's subscription page (§12.26). German: §312k BGB\n * wants the button that leads to the cancellation labelled \"Verträge hier kündigen\" or\n * as unambiguously — a translator should use exactly that.\n */\n cancel: string;\n /**\n * In place of both while the payer hasn't reached the provider (the overview's\n * `at_provider` is false, §14.5): there is no customer to open a portal for yet. Also\n * the words for `409 billing_not_at_provider`, a stale page that asked anyway.\n */\n notAtProvider: string;\n\n /* ── The pay page (§14.4) ── */\n /* Built into the static page `dist/pay/` at the kit's build: it has no React and no\n provider, so an app's override never reaches it. */\n /** While the checkout opens. */\n payOpening: string;\n /** A link without a transaction, or billing switched off: nothing to pay. */\n payNothing: string;\n /** Paddle's script didn't load, or the page's configuration is wrong. */\n payFailed: string;\n /** The buyer closed the checkout without paying. */\n payBack: string;\n /** 0.39 (§15.2.5): paid, for a transaction that names no app the page knows (a\n * payment-method update from Paddle's e-mail) — no way back to offer. */\n payDone: string;\n\n /* ── PlanSwitchConfirm (0.39, §15.2.10) ── */\n /** The dialog's title: \"Change plan\". */\n switchTitle: string;\n /** The line naming both plans, each with its billing period: \"From Pro (Monthly) to\n * Business (Yearly)\". */\n switchFromTo: (from: string, to: string) => string;\n /** What an upgrade charges at once (Paddle's preview, prorated): \"Charged now\". */\n switchChargedNow: string;\n /** What a downgrade credits — the customer's balance, which Paddle takes off the next\n * charges: \"Credited towards your next charges\". */\n switchCredited: string;\n /** The next regular charge; `date` is formatted: \"Next charge on 9 Nov 2026\". */\n switchNextCharge: (date: string) => string;\n /** The confirm button: \"Change plan\". */\n switchConfirm: string;\n\n /* ── Offline (§12.5) ── */\n /** Changes refused as `billing_read_only` inside a sync reply, kept queued and sent\n * after payment: \"3 changes waiting for a plan\". */\n waitingChanges: (count: number) => string;\n /** The sign-out guard's way out for someone who will not pay. */\n discardWaiting: string;\n\n /* ── Refusals with no part of their own (0.32.1, kastlan's 0.32 report) ── */\n /**\n * `billing_not_configured`: billing is on but the provider's settings are missing — the\n * operator's mistake, so the words ask the payer to wait, not to act. For the toast or\n * alert after \"Choose a plan\" or \"Payment and invoices\" (`isBillingError(err,\n * \"billing_not_configured\")`). `billing_read_only` and `plan_limit` need none: the\n * write lock, the banner and `PlanLimitNotice` say them.\n */\n notConfigured: string;\n /** `billing_disabled`: billing is switched off on this server (§2.9), for a page or a\n * link that reached it anyway. */\n disabled: string;\n /* 0.33.0 — server-kit 0.7's refusals (§14.2). `billing_not_at_provider` is\n {@link notAtProvider}'s: a stale page that asked for the portal anyway. */\n /** `billing_provider_unavailable` (502): the provider didn't answer. Try again. */\n providerUnavailable: string;\n /** `billing_already_subscribed` (409): a second checkout while a subscription runs\n * (§14.6). Since 0.39 plan changes go through the app's own switch (§15.2.10), not the\n * portal, and the words say so. */\n alreadySubscribed: string;\n /** `billing_plan_not_sold` (422): the catalogue doesn't sell this plan with that\n * period or currency — at a checkout, or (0.39) a switch to a plan no longer sold. */\n planNotSold: string;\n /** 0.39 (§15.2.10): `billing_not_subscribed` (409), a plan switch with no running\n * provider subscription — the page offers the checkout instead. */\n notSubscribed: string;\n /** 0.39: `billing_plan_unchanged` (409), a switch to the price the payer pays now. */\n planUnchanged: string;\n}\n\n/** \"today\", \"tomorrow\", \"in 5 days\". */\nfunction inDays(days: number): string {\n if (days <= 0) return \"today\";\n if (days === 1) return \"tomorrow\";\n return `in ${days} days`;\n}\n\nexport const DEFAULT_BILLING_LABELS: BillingLabels = {\n group: \"Subscription\",\n\n plans: \"Plans\",\n interval: \"Billing period\",\n yearly: \"Yearly\",\n monthly: \"Monthly\",\n currency: \"Currency\",\n perYear: \"per year\",\n perMonth: \"per month\",\n vatIncluded: \"VAT included\",\n free: \"Free\",\n notOffered: \"Not offered for this billing period\",\n unlimited: \"Unlimited\",\n limits: \"Limits\",\n features: \"Included\",\n current: \"Current plan\",\n choose: (plan) => `Choose ${plan}`,\n upgrade: (plan) => `Upgrade to ${plan}`,\n downgrade: (plan) => `Switch to ${plan}`,\n isCurrent: \"This is your current plan.\",\n pickFirst: \"Pick a plan first.\",\n\n status: {\n trialing: \"Trial\",\n active: \"Active\",\n past_due: \"Payment overdue\",\n canceled: \"Cancelled\",\n expired: \"Expired\",\n comped: \"Complimentary\",\n },\n\n trialEnding: (days) =>\n `Your trial ends ${inDays(days)}. After that you can still view everything, but changes need a plan.`,\n grantEnding: (days) =>\n `Your free access ends ${inDays(days)}. After that you can still view everything, but changes need a plan.`,\n paymentFailed: \"Your last payment didn’t go through. Update your payment method to keep your plan.\",\n planEnded: \"Your plan has ended. You can still view and export everything; choose a plan to make changes again.\",\n guestReadOnly: (item) =>\n item ? `“${item}” is read-only for now; its owner can lift that.` : \"This is read-only for now; its owner can lift that.\",\n processing: \"Your payment is being processed. Your plan starts as soon as it’s confirmed; this page updates by itself.\",\n choosePlan: \"Choose a plan\",\n updatePayment: \"Update payment method\",\n checkAgain: \"Check again\",\n\n lockReason: \"Your plan has ended. Choose a plan to make changes again.\",\n\n limitReached: \"Plan limit reached\",\n limitUpgrade: \"To add more, choose a plan with a higher limit.\",\n limitContact: \"To add more, ask us for a higher limit.\",\n askForMore: \"Ask for more\",\n usage: (used, limit) => `${used} of ${limit}`,\n contactSubject: (dimension) => `Plan limit: ${dimension}`,\n limitUsageLine: (dimension, used, limit) => `${dimension}: ${used} of ${limit}`,\n\n manage: \"Payment and invoices\",\n cancel: \"Cancel subscription\",\n notAtProvider: \"Nothing to manage yet. Payments and invoices appear here once you have a paid plan.\",\n\n payOpening: \"Opening the secure checkout…\",\n payNothing: \"There’s no payment to make here. To choose a plan, go back to the app.\",\n payFailed: \"The checkout couldn’t be opened. Please go back and try again in a moment.\",\n payBack: \"The checkout was closed. You can go back, or reload this page to open it again.\",\n payDone: \"Payment complete. You can close this page.\",\n\n switchTitle: \"Change plan\",\n switchFromTo: (from, to) => `From ${from} to ${to}`,\n switchChargedNow: \"Charged now\",\n switchCredited: \"Credited towards your next charges\",\n switchNextCharge: (date) => `Next charge on ${date}`,\n switchConfirm: \"Change plan\",\n\n waitingChanges: (count) =>\n count === 1 ? \"1 change waiting for a plan\" : `${count} changes waiting for a plan`,\n discardWaiting: \"Discard waiting changes\",\n\n notConfigured: \"Payments aren't set up yet. Please try again later.\",\n disabled: \"Subscriptions aren't available here.\",\n providerUnavailable: \"The payment provider can’t be reached right now. Please try again in a moment.\",\n alreadySubscribed: \"You already have a subscription. Change your plan above.\",\n planNotSold: \"This plan isn’t offered for that billing period or currency. Please choose another.\",\n notSubscribed: \"There’s no running subscription to change. Choose a plan to subscribe.\",\n planUnchanged: \"That’s already your plan. Pick another plan or billing period.\",\n};\n\n/** The `billing` namespace, resolved: English, then the provider, then `labels` — which\n * may be partial at every depth (`{ status: { comped: \"Beta\" } }`). */\nexport function useBillingLabels(labels?: LabelOverride<BillingLabels>): BillingLabels {\n return useKitLabels(\"billing\", DEFAULT_BILLING_LABELS, labels);\n}\n"],"mappings":";AAAA,SAAS,oBAAoB;AAmBtB,MAAM,wBAAuD,OAAO,OAAO;AAAA,EAChF;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAsMD,SAAS,OAAO,MAAsB;AACpC,MAAI,QAAQ,EAAG,QAAO;AACtB,MAAI,SAAS,EAAG,QAAO;AACvB,SAAO,MAAM,IAAI;AACnB;AAEO,MAAM,yBAAwC;AAAA,EACnD,OAAO;AAAA,EAEP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,SAAS;AAAA,EACT,UAAU;AAAA,EACV,aAAa;AAAA,EACb,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ,CAAC,SAAS,UAAU,IAAI;AAAA,EAChC,SAAS,CAAC,SAAS,cAAc,IAAI;AAAA,EACrC,WAAW,CAAC,SAAS,aAAa,IAAI;AAAA,EACtC,WAAW;AAAA,EACX,WAAW;AAAA,EAEX,QAAQ;AAAA,IACN,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,UAAU;AAAA,IACV,SAAS;AAAA,IACT,QAAQ;AAAA,EACV;AAAA,EAEA,aAAa,CAAC,SACZ,mBAAmB,OAAO,IAAI,CAAC;AAAA,EACjC,aAAa,CAAC,SACZ,yBAAyB,OAAO,IAAI,CAAC;AAAA,EACvC,eAAe;AAAA,EACf,WAAW;AAAA,EACX,eAAe,CAAC,SACd,OAAO,SAAI,IAAI,0DAAqD;AAAA,EACtE,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,YAAY;AAAA,EAEZ,YAAY;AAAA,EAEZ,cAAc;AAAA,EACd,cAAc;AAAA,EACd,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,OAAO,CAAC,MAAM,UAAU,GAAG,IAAI,OAAO,KAAK;AAAA,EAC3C,gBAAgB,CAAC,cAAc,eAAe,SAAS;AAAA,EACvD,gBAAgB,CAAC,WAAW,MAAM,UAAU,GAAG,SAAS,KAAK,IAAI,OAAO,KAAK;AAAA,EAE7E,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,eAAe;AAAA,EAEf,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,SAAS;AAAA,EACT,SAAS;AAAA,EAET,aAAa;AAAA,EACb,cAAc,CAAC,MAAM,OAAO,QAAQ,IAAI,OAAO,EAAE;AAAA,EACjD,kBAAkB;AAAA,EAClB,gBAAgB;AAAA,EAChB,kBAAkB,CAAC,SAAS,kBAAkB,IAAI;AAAA,EAClD,eAAe;AAAA,EAEf,gBAAgB,CAAC,UACf,UAAU,IAAI,gCAAgC,GAAG,KAAK;AAAA,EACxD,gBAAgB;AAAA,EAEhB,eAAe;AAAA,EACf,UAAU;AAAA,EACV,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,aAAa;AAAA,EACb,eAAe;AAAA,EACf,eAAe;AACjB;AAIO,SAAS,iBAAiB,QAAsD;AACrF,SAAO,aAAa,WAAW,wBAAwB,MAAM;AAC/D;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import type { DateInput } from "../lib/format.js";
2
2
  import type { SubscriptionStatus } from "./billing-labels.js";
3
- import type { BillingCurrency } from "./plan-price.js";
3
+ import type { BillingCurrency, BillingInterval } from "./plan-price.js";
4
4
  /**
5
5
  * The billing API's shapes the kit's parts read (docs/billing-harmonization.md §4,
6
6
  * §14.5) — server-kit 0.7's schemas, field for field under their wire names, for an app
@@ -34,6 +34,55 @@ export interface BillingOverview {
34
34
  * portal request would answer `409 billing_not_at_provider`.
35
35
  */
36
36
  at_provider: boolean;
37
+ /**
38
+ * 0.39 (server-kit 0.12, §15.2.10): the price the payer pays at the provider — its
39
+ * plan, period and currency — or null with none (a trial, a grant without a purchase).
40
+ * Under a grant the row keeps the grant's plan, status and source (§12.11), so this
41
+ * is what shows that a switch landed; `checkoutFingerprint` reads it.
42
+ */
43
+ paid: PaidPrice | null;
44
+ }
45
+ /** 0.39: what the payer pays at the provider (server-kit's `PaidPrice`, §15.2.10),
46
+ * read from the row's `provider_price_id`. */
47
+ export interface PaidPrice {
48
+ /** The plan's code, lowercase. */
49
+ plan: string;
50
+ interval: BillingInterval;
51
+ currency: BillingCurrency;
52
+ }
53
+ /** 0.39: `POST /billing/plan/preview` and `POST /billing/plan`'s body (server-kit's
54
+ * `PlanSwitchRequest`, §15.2.10) — the plan and period to switch to. The currency stays
55
+ * the subscription's. */
56
+ export interface PlanSwitchRequest {
57
+ plan: string;
58
+ interval: BillingInterval;
59
+ }
60
+ /**
61
+ * 0.39: what a plan switch costs — or did (server-kit's `PlanSwitchPreview`, §15.2.10):
62
+ * Paddle's preview, prorated at once, both routes' answer. `PlanSwitchConfirm` shows it
63
+ * before the payer agrees.
64
+ */
65
+ export interface PlanSwitchPreview {
66
+ /** The subscription's currency. */
67
+ currency: BillingCurrency;
68
+ /** Charged at once, in minor units: an upgrade's prorated difference — or, negative, a
69
+ * downgrade's credit to the customer's balance, which Paddle takes off the next
70
+ * charges. */
71
+ now_minor: number;
72
+ /** The next regular charge, in minor units. */
73
+ next_minor?: number | null;
74
+ /** When it is charged; null when Paddle names no date. */
75
+ next_billed_at?: string | null;
76
+ /** The plan and period switched to. */
77
+ plan: string;
78
+ interval: BillingInterval;
79
+ /** The dimensions whose usage is over the new plan's limit (§3.4: a limit gates
80
+ * creation only — the switch is allowed). */
81
+ over_limit: string[];
82
+ /** The new plan's limits; null for unlimited. */
83
+ limits: Record<string, number | null>;
84
+ /** What the payer has, per dimension. */
85
+ usage: Record<string, number>;
37
86
  }
38
87
  /**
39
88
  * Where in the provider's portal a request lands (server-kit's `PortalTarget`, §14.5):
@@ -1,4 +1,5 @@
1
1
  import type { DateInput } from "../lib/format.js";
2
+ import type { PaidPrice } from "./billing-overview.js";
2
3
  import type { BillingStanding } from "./billing-standing.js";
3
4
  /**
4
5
  * "Payment processing" after the way back from a checkout (docs/billing-harmonization.md
@@ -37,11 +38,17 @@ export type CheckoutOverview = Pick<BillingStanding, "status" | "in_good_standin
37
38
  plan: string;
38
39
  source: string;
39
40
  current_period_end?: DateInput;
41
+ /** 0.39 (§15.2.10): what the payer pays — a plan switch moves only this under a grant,
42
+ * and keeps `active` without one. Left out or null: nothing paid. */
43
+ paid?: Pick<PaidPrice, "plan" | "interval"> | null;
40
44
  };
41
45
  /**
42
- * What of the overview a completed checkout changes, as one string: the plan (read
43
- * without case, §12.16), the status, the source, the standing and the period's end (read
44
- * as an instant, so `…Z` and `…+00:00` agree).
46
+ * What of the overview a completed checkout — or plan switch — changes, as one string:
47
+ * the plan (read without case, §12.16), the status, the source, the standing, the
48
+ * period's end (read as an instant, so `…Z` and `…+00:00` agree) and, since 0.39, the
49
+ * paid plan and period (`paid`, §15.2.10). A switch keeps `active`, and under a grant
50
+ * the plan too, so only `paid` shows it landed: one storage key serves the checkout and
51
+ * the switch.
45
52
  */
46
53
  export declare function checkoutFingerprint(o: CheckoutOverview): string;
47
54
  /**
@@ -59,6 +66,11 @@ export declare function checkoutLanded(before: string | null, now: CheckoutOverv
59
66
  * `payer` is the user's or the company's id: a mark never speaks for another payer.
60
67
  * `overview` is what the page shows now; left out, the return waits for `active`. Pass
61
68
  * the same `storageKey` as the hook's.
69
+ *
70
+ * A plan switch (0.39, §15.2.10) is waited for the same way, under the same key: note
71
+ * the departure before `POST /billing/plan`, and once it answers put the return marker
72
+ * on the page's URL yourself (`checkoutReturnUrl`'s `?checkout=done`) — the row follows
73
+ * by webhook, and the fingerprint's `paid` shows it landed.
62
74
  */
63
75
  export declare function noteCheckoutStarted(payer: string | number, overview?: CheckoutOverview, opts?: {
64
76
  storageKey?: string;
@@ -12,7 +12,9 @@ function checkoutFingerprint(o) {
12
12
  o.status,
13
13
  o.source,
14
14
  String(o.in_good_standing),
15
- end ? end.toISOString() : ""
15
+ end ? end.toISOString() : "",
16
+ String(o.paid?.plan ?? "").toLowerCase(),
17
+ o.paid?.interval ?? ""
16
18
  ].join("|");
17
19
  }
18
20
  function checkoutLanded(before, now) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/checkout-processing.ts"],"sourcesContent":["import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useSyncExternalStore } from \"react\";\n\nimport { toDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { readStored, removeStored, writeStored } from \"../lib/safe-storage\";\nimport type { BillingStanding } from \"./billing-standing\";\n\n/**\n * \"Payment processing\" after the way back from a checkout (docs/billing-harmonization.md\n * §12.21, §14.4; keksdose's `features/billing/checkout-return.ts`, made router-agnostic).\n *\n * The provider tells the app about a payment by WEBHOOK, and the webhook may land after\n * the buyer is back: Cloud Run scales to zero, and a cold start can outlast the\n * provider's redirect. Without this, someone who has just paid comes back to a page that\n * still says their trial has ended. So the return is recognised (`?checkout=done`,\n * `isCheckoutReturn`), the page says \"your payment is being processed\"\n * (`BillingBanner kind=\"processing\"`), and the overview is asked again every few seconds\n * until it has moved.\n *\n * **\"Landed\" is judged against the overview at departure** ({@link checkoutFingerprint},\n * noted by {@link noteCheckoutStarted}), not by \"is it `active` yet\": a trial and a beta\n * grant can both buy, and a running free grant keeps its status whatever the provider\n * says (§12.11) — only its period end moves — so \"active\" alone would wait for ever on\n * one of them. A return with no departure on record (another tab, another device) waits\n * for `active`. kastlan's 60-second poll for `source === \"provider\"` is the case this\n * replaces: a payer who bought under a grant never matched it.\n *\n * **Bounded:** after `timeoutMs` (10 minutes) the device stops asking, and the page says\n * what the overview says, whatever that is — a checkout abandoned at the provider must\n * not leave \"processing\" up for good.\n *\n * **Remembered across the whole-page return, per payer.** The departure goes into the\n * browser's storage through the kit's safe storage (a throwing storage — Safari's\n * private mode — keeps it in memory for this page instead), under `storageKey`; a mark\n * names its payer, so it never speaks for another account on a shared device. It is a\n * browser entry on the app's origin: an app whose privacy text lists its entries names\n * it (Kurvenschmiede passes its own key).\n */\n\n/** The overview fields a completed checkout changes (wire names, `GET /billing/overview`).\n * Pass the overview as it came; the other fields are ignored. */\nexport type CheckoutOverview = Pick<BillingStanding, \"status\" | \"in_good_standing\"> & {\n plan: string;\n source: string;\n current_period_end?: DateInput;\n};\n\n/** How long a return keeps the page in \"processing\" before it trusts the overview. */\nconst PROCESSING_FOR = 10 * 60_000;\n/** How often the overview is asked meanwhile. */\nconst POLL_EVERY = 4_000;\n/** The browser entry, unless the app names its own. */\nconst STORAGE_KEY = \"eifi1-billing-checkout\";\n\n/**\n * What of the overview a completed checkout changes, as one string: the plan (read\n * without case, §12.16), the status, the source, the standing and the period's end (read\n * as an instant, so `…Z` and `…+00:00` agree).\n */\nexport function checkoutFingerprint(o: CheckoutOverview): string {\n const end = toDate(o.current_period_end ?? null);\n return [\n String(o.plan ?? \"\").toLowerCase(),\n o.status,\n o.source,\n String(o.in_good_standing),\n end ? end.toISOString() : \"\",\n ].join(\"|\");\n}\n\n/**\n * Whether the overview `now` shows the checkout: it differs from the fingerprint taken\n * at departure (`before`), or — with no departure on record — it is `active`.\n */\nexport function checkoutLanded(before: string | null, now: CheckoutOverview): boolean {\n return before === null ? now.status === \"active\" : checkoutFingerprint(now) !== before;\n}\n\n/* ── The store: one per storage key, shared by every mounted instance ─────── */\n\ninterface PendingCheckout {\n /** Whose checkout: a user's or a company's id, as a string. */\n payer: string;\n /** The overview's fingerprint at departure, or null when it was not known. */\n before: string | null;\n /** When the buyer came back; null while they are (or were) at the provider. */\n returnedAt: number | null;\n}\n\n/** One mounted hook: what the shared timers call, kept current on every render. */\ninterface Instance {\n payer: string | null;\n refetch: () => unknown;\n onLanded: (() => void) | undefined;\n timeoutMs: number;\n pollMs: number;\n}\n\ninterface CheckoutStore {\n key: string;\n pending: PendingCheckout | null;\n listeners: Set<() => void>;\n /** The mounted instances, in mount order: the last is the latest. */\n instances: Instance[];\n poll: ReturnType<typeof setInterval> | null;\n pollEvery: number;\n expiry: ReturnType<typeof setTimeout> | null;\n expiryAt: number | null;\n /** An instance consumed the marker in this commit; the others leave it be. */\n consuming: boolean;\n}\n\n/** Module-level, so the banner's instance and the subscription page's share one state,\n * one poll and one landing (keksdose mounts the hook twice, §14.4). */\nconst stores = new Map<string, CheckoutStore>();\n\nfunction readPending(key: string): PendingCheckout | null {\n const raw = readStored(key);\n if (raw === null) return null;\n try {\n const value = JSON.parse(raw) as Partial<PendingCheckout> | null;\n if (\n value &&\n typeof value.payer === \"string\" &&\n (value.before === null || typeof value.before === \"string\") &&\n (value.returnedAt === null || typeof value.returnedAt === \"number\")\n ) {\n return { payer: value.payer, before: value.before, returnedAt: value.returnedAt };\n }\n } catch {\n // Not ours, or damaged: as if nothing was noted.\n }\n return null;\n}\n\n\nfunction storeOf(key: string): CheckoutStore {\n let store = stores.get(key);\n if (!store) {\n store = {\n key,\n pending: readPending(key),\n listeners: new Set(),\n instances: [],\n poll: null,\n pollEvery: 0,\n expiry: null,\n expiryAt: null,\n consuming: false,\n };\n stores.set(key, store);\n }\n return store;\n}\n\nfunction setPending(store: CheckoutStore, next: PendingCheckout | null): void {\n store.pending = next;\n if (next) writeStored(store.key, JSON.stringify(next));\n else removeStored(store.key);\n for (const listener of store.listeners) listener();\n sync(store);\n}\n\n/** The latest mounted instance's refetch, failures swallowed: a poll that fails is\n * simply asked again at the next tick. */\nfunction refetchLatest(store: CheckoutStore): void {\n const latest = store.instances.at(-1);\n if (!latest) return;\n try {\n const result = latest.refetch() as PromiseLike<unknown> | undefined;\n if (result && typeof result.then === \"function\") result.then(undefined, () => {});\n } catch {\n // as above\n }\n}\n\n/** Arms or stops the store's two timers — the bound and the poll — for its state. */\nfunction sync(store: CheckoutStore): void {\n const pending = store.pending;\n const latest = store.instances.at(-1);\n const returnedAt = pending?.returnedAt ?? null;\n\n if (returnedAt === null) {\n if (store.expiry) clearTimeout(store.expiry);\n store.expiry = null;\n store.expiryAt = null;\n } else {\n const at = returnedAt + (latest?.timeoutMs ?? PROCESSING_FOR);\n if (at <= Date.now()) {\n setPending(store, null);\n return;\n }\n if (store.expiryAt !== at) {\n if (store.expiry) clearTimeout(store.expiry);\n store.expiryAt = at;\n store.expiry = setTimeout(() => {\n store.expiry = null;\n store.expiryAt = null;\n if (store.pending === pending) setPending(store, null);\n }, at - Date.now());\n }\n }\n\n // One poll for every instance, while a mounted one is the mark's payer.\n const watching = returnedAt !== null && store.instances.some((i) => i.payer === pending!.payer);\n const every = latest?.pollMs ?? POLL_EVERY;\n if (!watching) {\n if (store.poll) clearInterval(store.poll);\n store.poll = null;\n } else if (!store.poll || store.pollEvery !== every) {\n if (store.poll) clearInterval(store.poll);\n store.pollEvery = every;\n store.poll = setInterval(() => refetchLatest(store), every);\n }\n}\n\n/** A mounted instance joins the store's timers; the latest is the one they call. */\nfunction register(store: CheckoutStore, instance: Instance): () => void {\n store.instances.push(instance);\n return () => {\n store.instances = store.instances.filter((i) => i !== instance);\n sync(store);\n };\n}\n\n/**\n * Whether this instance is the one to consume a return marker: the first in a commit is.\n * Another instance in the same commit sees the same marker and leaves it be; the claim\n * lapses once the commit's effects have run.\n */\nfunction claimReturn(store: CheckoutStore): boolean {\n if (store.consuming) return false;\n store.consuming = true;\n void Promise.resolve().then(() => {\n store.consuming = false;\n });\n return true;\n}\n\n/**\n * Note the departure for a checkout — just before the page leaves for the provider (or\n * for the pay page, `payPageUrl(url, locale)`):\n *\n * noteCheckoutStarted(user.id, overview.data);\n * window.location.assign(payPageUrl(url, i18n.language));\n *\n * `payer` is the user's or the company's id: a mark never speaks for another payer.\n * `overview` is what the page shows now; left out, the return waits for `active`. Pass\n * the same `storageKey` as the hook's.\n */\nexport function noteCheckoutStarted(\n payer: string | number,\n overview?: CheckoutOverview,\n opts: { storageKey?: string } = {},\n): void {\n const store = storeOf(opts.storageKey ?? STORAGE_KEY);\n setPending(store, {\n payer: String(payer),\n before: overview ? checkoutFingerprint(overview) : null,\n returnedAt: null,\n });\n}\n\nexport interface CheckoutProcessingOptions {\n /** The signed-in payer — the user's or the company's id; null while unknown (the\n * marker then waits in the URL). */\n payer: string | number | null;\n /** The page was reached as the way back: `isCheckoutReturn(location.search)`. */\n returned: boolean;\n /** Drop the marker, with replace — `setSearchParams((p) => withoutCheckoutReturn(p),\n * { replace: true })`. Called once per return, by the first instance that sees it. */\n onConsumed: () => void;\n /** The overview query's data, judged here against the departure. */\n overview: CheckoutOverview | undefined;\n /** The overview query's refetch: the hook polls with it — one timer for every\n * instance, with the latest mounted one's — and \"Check again\" calls it. */\n refetch: () => unknown;\n /** Once per landing: refresh what a new standing changes — the plans, the items whose\n * `locked` may have lifted. The overview itself is already fresh. */\n onLanded?: () => void;\n /** How long a return keeps \"processing\". Default 600 000 (10 minutes). */\n timeoutMs?: number;\n /** How often the overview is asked meanwhile. Default 4 000. */\n pollMs?: number;\n /** The browser entry. Default `\"eifi1-billing-checkout\"`. */\n storageKey?: string;\n}\n\n/**\n * Whether this payer is back from a checkout whose payment the overview has not shown\n * yet — and the \"Check again\" for the banner that says so:\n *\n * const [params, setParams] = useSearchParams();\n * const overview = useQuery(billingOverviewQuery);\n * const { processing, checkAgain } = useCheckoutProcessing({\n * payer: user?.id ?? null,\n * returned: isCheckoutReturn(params),\n * onConsumed: () => setParams((p) => withoutCheckoutReturn(p), { replace: true }),\n * overview: overview.data,\n * refetch: overview.refetch,\n * onLanded: () => void qc.invalidateQueries({ queryKey: [\"budgets\"] }),\n * });\n * processing && <BillingBanner kind=\"processing\" onAction={checkAgain} />\n *\n * The kit has no router: the app says whether the URL returned and how to drop the\n * marker. Mount it wherever the return lands and the banner shows — every instance with\n * the same `storageKey` shares one state: the first that sees the marker consumes it,\n * one timer polls, and `onLanded` runs once. The app's `refetchOnWindowFocus` doesn't\n * matter.\n */\nexport function useCheckoutProcessing(options: CheckoutProcessingOptions): {\n processing: boolean;\n checkAgain: () => void;\n} {\n const {\n payer,\n returned,\n onConsumed,\n overview,\n refetch,\n onLanded,\n timeoutMs = PROCESSING_FOR,\n pollMs = POLL_EVERY,\n storageKey = STORAGE_KEY,\n } = options;\n const payerKey = payer === null || payer === undefined ? null : String(payer);\n const store = storeOf(storageKey);\n const subscribe = useCallback(\n (notify: () => void) => {\n store.listeners.add(notify);\n return () => {\n store.listeners.delete(notify);\n };\n },\n [store],\n );\n const snapshot = () => store.pending;\n const pending = useSyncExternalStore(subscribe, snapshot, snapshot);\n const processing = payerKey !== null && pending?.payer === payerKey && pending.returnedAt !== null;\n\n // This instance, as the shared timers see it. Registered before paint, so a mark that\n // ran out while the page was closed is dropped before \"processing\" can flash.\n const instanceRef = useRef<Instance>({ payer: payerKey, refetch, onLanded, timeoutMs, pollMs });\n useLayoutEffect(() => register(store, instanceRef.current), [store]);\n useLayoutEffect(() => {\n Object.assign(instanceRef.current, { payer: payerKey, refetch, onLanded, timeoutMs, pollMs });\n sync(store);\n });\n\n // The return: note it, then drop the marker so a reload does not count it twice.\n const consumed = useEffectEvent(() => onConsumed());\n useEffect(() => {\n if (!returned || payerKey === null || !claimReturn(store)) return;\n const before = store.pending?.payer === payerKey ? store.pending.before : null;\n setPending(store, { payer: payerKey, before, returnedAt: Date.now() });\n consumed();\n }, [returned, payerKey, store]);\n\n // Caught up: the overview has moved since the departure. Judged against the store as\n // it is NOW, so a second instance in the same commit finds the mark gone.\n useEffect(() => {\n if (!processing || !overview) return;\n const live = store.pending;\n if (!live || live.payer !== payerKey || live.returnedAt === null) return;\n if (!checkoutLanded(live.before, overview)) return;\n setPending(store, null);\n const latest = [...store.instances].reverse().find((i) => i.onLanded);\n latest?.onLanded?.();\n }, [processing, overview, payerKey, store]);\n\n const checkAgain = useCallback(() => {\n try {\n const result = instanceRef.current.refetch() as PromiseLike<unknown> | undefined;\n if (result && typeof result.then === \"function\") result.then(undefined, () => {});\n } catch {\n // The banner stays; the next poll asks again.\n }\n }, []);\n\n return { processing, checkAgain };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/billing/checkout-processing.ts"],"sourcesContent":["import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useSyncExternalStore } from \"react\";\n\nimport { toDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { readStored, removeStored, writeStored } from \"../lib/safe-storage\";\nimport type { PaidPrice } from \"./billing-overview\";\nimport type { BillingStanding } from \"./billing-standing\";\n\n/**\n * \"Payment processing\" after the way back from a checkout (docs/billing-harmonization.md\n * §12.21, §14.4; keksdose's `features/billing/checkout-return.ts`, made router-agnostic).\n *\n * The provider tells the app about a payment by WEBHOOK, and the webhook may land after\n * the buyer is back: Cloud Run scales to zero, and a cold start can outlast the\n * provider's redirect. Without this, someone who has just paid comes back to a page that\n * still says their trial has ended. So the return is recognised (`?checkout=done`,\n * `isCheckoutReturn`), the page says \"your payment is being processed\"\n * (`BillingBanner kind=\"processing\"`), and the overview is asked again every few seconds\n * until it has moved.\n *\n * **\"Landed\" is judged against the overview at departure** ({@link checkoutFingerprint},\n * noted by {@link noteCheckoutStarted}), not by \"is it `active` yet\": a trial and a beta\n * grant can both buy, and a running free grant keeps its status whatever the provider\n * says (§12.11) — only its period end moves — so \"active\" alone would wait for ever on\n * one of them. A return with no departure on record (another tab, another device) waits\n * for `active`. kastlan's 60-second poll for `source === \"provider\"` is the case this\n * replaces: a payer who bought under a grant never matched it.\n *\n * **Bounded:** after `timeoutMs` (10 minutes) the device stops asking, and the page says\n * what the overview says, whatever that is — a checkout abandoned at the provider must\n * not leave \"processing\" up for good.\n *\n * **Remembered across the whole-page return, per payer.** The departure goes into the\n * browser's storage through the kit's safe storage (a throwing storage — Safari's\n * private mode — keeps it in memory for this page instead), under `storageKey`; a mark\n * names its payer, so it never speaks for another account on a shared device. It is a\n * browser entry on the app's origin: an app whose privacy text lists its entries names\n * it (Kurvenschmiede passes its own key).\n */\n\n/** The overview fields a completed checkout changes (wire names, `GET /billing/overview`).\n * Pass the overview as it came; the other fields are ignored. */\nexport type CheckoutOverview = Pick<BillingStanding, \"status\" | \"in_good_standing\"> & {\n plan: string;\n source: string;\n current_period_end?: DateInput;\n /** 0.39 (§15.2.10): what the payer pays — a plan switch moves only this under a grant,\n * and keeps `active` without one. Left out or null: nothing paid. */\n paid?: Pick<PaidPrice, \"plan\" | \"interval\"> | null;\n};\n\n/** How long a return keeps the page in \"processing\" before it trusts the overview. */\nconst PROCESSING_FOR = 10 * 60_000;\n/** How often the overview is asked meanwhile. */\nconst POLL_EVERY = 4_000;\n/** The browser entry, unless the app names its own. */\nconst STORAGE_KEY = \"eifi1-billing-checkout\";\n\n/**\n * What of the overview a completed checkout — or plan switch — changes, as one string:\n * the plan (read without case, §12.16), the status, the source, the standing, the\n * period's end (read as an instant, so `…Z` and `…+00:00` agree) and, since 0.39, the\n * paid plan and period (`paid`, §15.2.10). A switch keeps `active`, and under a grant\n * the plan too, so only `paid` shows it landed: one storage key serves the checkout and\n * the switch.\n */\nexport function checkoutFingerprint(o: CheckoutOverview): string {\n const end = toDate(o.current_period_end ?? null);\n return [\n String(o.plan ?? \"\").toLowerCase(),\n o.status,\n o.source,\n String(o.in_good_standing),\n end ? end.toISOString() : \"\",\n String(o.paid?.plan ?? \"\").toLowerCase(),\n o.paid?.interval ?? \"\",\n ].join(\"|\");\n}\n\n/**\n * Whether the overview `now` shows the checkout: it differs from the fingerprint taken\n * at departure (`before`), or — with no departure on record — it is `active`.\n */\nexport function checkoutLanded(before: string | null, now: CheckoutOverview): boolean {\n return before === null ? now.status === \"active\" : checkoutFingerprint(now) !== before;\n}\n\n/* ── The store: one per storage key, shared by every mounted instance ─────── */\n\ninterface PendingCheckout {\n /** Whose checkout: a user's or a company's id, as a string. */\n payer: string;\n /** The overview's fingerprint at departure, or null when it was not known. */\n before: string | null;\n /** When the buyer came back; null while they are (or were) at the provider. */\n returnedAt: number | null;\n}\n\n/** One mounted hook: what the shared timers call, kept current on every render. */\ninterface Instance {\n payer: string | null;\n refetch: () => unknown;\n onLanded: (() => void) | undefined;\n timeoutMs: number;\n pollMs: number;\n}\n\ninterface CheckoutStore {\n key: string;\n pending: PendingCheckout | null;\n listeners: Set<() => void>;\n /** The mounted instances, in mount order: the last is the latest. */\n instances: Instance[];\n poll: ReturnType<typeof setInterval> | null;\n pollEvery: number;\n expiry: ReturnType<typeof setTimeout> | null;\n expiryAt: number | null;\n /** An instance consumed the marker in this commit; the others leave it be. */\n consuming: boolean;\n}\n\n/** Module-level, so the banner's instance and the subscription page's share one state,\n * one poll and one landing (keksdose mounts the hook twice, §14.4). */\nconst stores = new Map<string, CheckoutStore>();\n\nfunction readPending(key: string): PendingCheckout | null {\n const raw = readStored(key);\n if (raw === null) return null;\n try {\n const value = JSON.parse(raw) as Partial<PendingCheckout> | null;\n if (\n value &&\n typeof value.payer === \"string\" &&\n (value.before === null || typeof value.before === \"string\") &&\n (value.returnedAt === null || typeof value.returnedAt === \"number\")\n ) {\n return { payer: value.payer, before: value.before, returnedAt: value.returnedAt };\n }\n } catch {\n // Not ours, or damaged: as if nothing was noted.\n }\n return null;\n}\n\n\nfunction storeOf(key: string): CheckoutStore {\n let store = stores.get(key);\n if (!store) {\n store = {\n key,\n pending: readPending(key),\n listeners: new Set(),\n instances: [],\n poll: null,\n pollEvery: 0,\n expiry: null,\n expiryAt: null,\n consuming: false,\n };\n stores.set(key, store);\n }\n return store;\n}\n\nfunction setPending(store: CheckoutStore, next: PendingCheckout | null): void {\n store.pending = next;\n if (next) writeStored(store.key, JSON.stringify(next));\n else removeStored(store.key);\n for (const listener of store.listeners) listener();\n sync(store);\n}\n\n/** The latest mounted instance's refetch, failures swallowed: a poll that fails is\n * simply asked again at the next tick. */\nfunction refetchLatest(store: CheckoutStore): void {\n const latest = store.instances.at(-1);\n if (!latest) return;\n try {\n const result = latest.refetch() as PromiseLike<unknown> | undefined;\n if (result && typeof result.then === \"function\") result.then(undefined, () => {});\n } catch {\n // as above\n }\n}\n\n/** Arms or stops the store's two timers — the bound and the poll — for its state. */\nfunction sync(store: CheckoutStore): void {\n const pending = store.pending;\n const latest = store.instances.at(-1);\n const returnedAt = pending?.returnedAt ?? null;\n\n if (returnedAt === null) {\n if (store.expiry) clearTimeout(store.expiry);\n store.expiry = null;\n store.expiryAt = null;\n } else {\n const at = returnedAt + (latest?.timeoutMs ?? PROCESSING_FOR);\n if (at <= Date.now()) {\n setPending(store, null);\n return;\n }\n if (store.expiryAt !== at) {\n if (store.expiry) clearTimeout(store.expiry);\n store.expiryAt = at;\n store.expiry = setTimeout(() => {\n store.expiry = null;\n store.expiryAt = null;\n if (store.pending === pending) setPending(store, null);\n }, at - Date.now());\n }\n }\n\n // One poll for every instance, while a mounted one is the mark's payer.\n const watching = returnedAt !== null && store.instances.some((i) => i.payer === pending!.payer);\n const every = latest?.pollMs ?? POLL_EVERY;\n if (!watching) {\n if (store.poll) clearInterval(store.poll);\n store.poll = null;\n } else if (!store.poll || store.pollEvery !== every) {\n if (store.poll) clearInterval(store.poll);\n store.pollEvery = every;\n store.poll = setInterval(() => refetchLatest(store), every);\n }\n}\n\n/** A mounted instance joins the store's timers; the latest is the one they call. */\nfunction register(store: CheckoutStore, instance: Instance): () => void {\n store.instances.push(instance);\n return () => {\n store.instances = store.instances.filter((i) => i !== instance);\n sync(store);\n };\n}\n\n/**\n * Whether this instance is the one to consume a return marker: the first in a commit is.\n * Another instance in the same commit sees the same marker and leaves it be; the claim\n * lapses once the commit's effects have run.\n */\nfunction claimReturn(store: CheckoutStore): boolean {\n if (store.consuming) return false;\n store.consuming = true;\n void Promise.resolve().then(() => {\n store.consuming = false;\n });\n return true;\n}\n\n/**\n * Note the departure for a checkout — just before the page leaves for the provider (or\n * for the pay page, `payPageUrl(url, locale)`):\n *\n * noteCheckoutStarted(user.id, overview.data);\n * window.location.assign(payPageUrl(url, i18n.language));\n *\n * `payer` is the user's or the company's id: a mark never speaks for another payer.\n * `overview` is what the page shows now; left out, the return waits for `active`. Pass\n * the same `storageKey` as the hook's.\n *\n * A plan switch (0.39, §15.2.10) is waited for the same way, under the same key: note\n * the departure before `POST /billing/plan`, and once it answers put the return marker\n * on the page's URL yourself (`checkoutReturnUrl`'s `?checkout=done`) — the row follows\n * by webhook, and the fingerprint's `paid` shows it landed.\n */\nexport function noteCheckoutStarted(\n payer: string | number,\n overview?: CheckoutOverview,\n opts: { storageKey?: string } = {},\n): void {\n const store = storeOf(opts.storageKey ?? STORAGE_KEY);\n setPending(store, {\n payer: String(payer),\n before: overview ? checkoutFingerprint(overview) : null,\n returnedAt: null,\n });\n}\n\nexport interface CheckoutProcessingOptions {\n /** The signed-in payer — the user's or the company's id; null while unknown (the\n * marker then waits in the URL). */\n payer: string | number | null;\n /** The page was reached as the way back: `isCheckoutReturn(location.search)`. */\n returned: boolean;\n /** Drop the marker, with replace — `setSearchParams((p) => withoutCheckoutReturn(p),\n * { replace: true })`. Called once per return, by the first instance that sees it. */\n onConsumed: () => void;\n /** The overview query's data, judged here against the departure. */\n overview: CheckoutOverview | undefined;\n /** The overview query's refetch: the hook polls with it — one timer for every\n * instance, with the latest mounted one's — and \"Check again\" calls it. */\n refetch: () => unknown;\n /** Once per landing: refresh what a new standing changes — the plans, the items whose\n * `locked` may have lifted. The overview itself is already fresh. */\n onLanded?: () => void;\n /** How long a return keeps \"processing\". Default 600 000 (10 minutes). */\n timeoutMs?: number;\n /** How often the overview is asked meanwhile. Default 4 000. */\n pollMs?: number;\n /** The browser entry. Default `\"eifi1-billing-checkout\"`. */\n storageKey?: string;\n}\n\n/**\n * Whether this payer is back from a checkout whose payment the overview has not shown\n * yet — and the \"Check again\" for the banner that says so:\n *\n * const [params, setParams] = useSearchParams();\n * const overview = useQuery(billingOverviewQuery);\n * const { processing, checkAgain } = useCheckoutProcessing({\n * payer: user?.id ?? null,\n * returned: isCheckoutReturn(params),\n * onConsumed: () => setParams((p) => withoutCheckoutReturn(p), { replace: true }),\n * overview: overview.data,\n * refetch: overview.refetch,\n * onLanded: () => void qc.invalidateQueries({ queryKey: [\"budgets\"] }),\n * });\n * processing && <BillingBanner kind=\"processing\" onAction={checkAgain} />\n *\n * The kit has no router: the app says whether the URL returned and how to drop the\n * marker. Mount it wherever the return lands and the banner shows — every instance with\n * the same `storageKey` shares one state: the first that sees the marker consumes it,\n * one timer polls, and `onLanded` runs once. The app's `refetchOnWindowFocus` doesn't\n * matter.\n */\nexport function useCheckoutProcessing(options: CheckoutProcessingOptions): {\n processing: boolean;\n checkAgain: () => void;\n} {\n const {\n payer,\n returned,\n onConsumed,\n overview,\n refetch,\n onLanded,\n timeoutMs = PROCESSING_FOR,\n pollMs = POLL_EVERY,\n storageKey = STORAGE_KEY,\n } = options;\n const payerKey = payer === null || payer === undefined ? null : String(payer);\n const store = storeOf(storageKey);\n const subscribe = useCallback(\n (notify: () => void) => {\n store.listeners.add(notify);\n return () => {\n store.listeners.delete(notify);\n };\n },\n [store],\n );\n const snapshot = () => store.pending;\n const pending = useSyncExternalStore(subscribe, snapshot, snapshot);\n const processing = payerKey !== null && pending?.payer === payerKey && pending.returnedAt !== null;\n\n // This instance, as the shared timers see it. Registered before paint, so a mark that\n // ran out while the page was closed is dropped before \"processing\" can flash.\n const instanceRef = useRef<Instance>({ payer: payerKey, refetch, onLanded, timeoutMs, pollMs });\n useLayoutEffect(() => register(store, instanceRef.current), [store]);\n useLayoutEffect(() => {\n Object.assign(instanceRef.current, { payer: payerKey, refetch, onLanded, timeoutMs, pollMs });\n sync(store);\n });\n\n // The return: note it, then drop the marker so a reload does not count it twice.\n const consumed = useEffectEvent(() => onConsumed());\n useEffect(() => {\n if (!returned || payerKey === null || !claimReturn(store)) return;\n const before = store.pending?.payer === payerKey ? store.pending.before : null;\n setPending(store, { payer: payerKey, before, returnedAt: Date.now() });\n consumed();\n }, [returned, payerKey, store]);\n\n // Caught up: the overview has moved since the departure. Judged against the store as\n // it is NOW, so a second instance in the same commit finds the mark gone.\n useEffect(() => {\n if (!processing || !overview) return;\n const live = store.pending;\n if (!live || live.payer !== payerKey || live.returnedAt === null) return;\n if (!checkoutLanded(live.before, overview)) return;\n setPending(store, null);\n const latest = [...store.instances].reverse().find((i) => i.onLanded);\n latest?.onLanded?.();\n }, [processing, overview, payerKey, store]);\n\n const checkAgain = useCallback(() => {\n try {\n const result = instanceRef.current.refetch() as PromiseLike<unknown> | undefined;\n if (result && typeof result.then === \"function\") result.then(undefined, () => {});\n } catch {\n // The banner stays; the next poll asks again.\n }\n }, []);\n\n return { processing, checkAgain };\n}\n"],"mappings":"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","names":[]}