@aglyn/aglyn 1.0.0-beta.269 → 1.0.0-beta.271

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 (121) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/account-preferences.d.ts +248 -0
  3. package/src/lib/app-utils/account-preferences.js +434 -0
  4. package/src/lib/app-utils/account-preferences.js.map +1 -0
  5. package/src/lib/app-utils/activity-labels.js +2 -0
  6. package/src/lib/app-utils/activity-labels.js.map +1 -1
  7. package/src/lib/app-utils/analytics-events.d.ts +13 -0
  8. package/src/lib/app-utils/analytics-events.js +2 -0
  9. package/src/lib/app-utils/analytics-events.js.map +1 -1
  10. package/src/lib/app-utils/append-only-history.d.ts +94 -0
  11. package/src/lib/app-utils/append-only-history.js +73 -0
  12. package/src/lib/app-utils/append-only-history.js.map +1 -0
  13. package/src/lib/app-utils/bundle-intent.d.ts +186 -0
  14. package/src/lib/app-utils/bundle-intent.js +386 -0
  15. package/src/lib/app-utils/bundle-intent.js.map +1 -0
  16. package/src/lib/app-utils/client-build.d.ts +204 -0
  17. package/src/lib/app-utils/client-build.js +237 -0
  18. package/src/lib/app-utils/client-build.js.map +1 -0
  19. package/src/lib/app-utils/console-routes.d.ts +4 -0
  20. package/src/lib/app-utils/console-routes.js +2 -0
  21. package/src/lib/app-utils/console-routes.js.map +1 -1
  22. package/src/lib/app-utils/country-options.d.ts +27 -0
  23. package/src/lib/app-utils/country-options.js +96 -0
  24. package/src/lib/app-utils/country-options.js.map +1 -0
  25. package/src/lib/app-utils/docs-help-sections.generated.js +7 -5
  26. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  27. package/src/lib/app-utils/docs-help.generated.d.ts +11 -5
  28. package/src/lib/app-utils/docs-help.generated.js +22 -2
  29. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  30. package/src/lib/app-utils/docs-index.generated.js +116 -39
  31. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  32. package/src/lib/app-utils/margin-utilization.js +6 -4
  33. package/src/lib/app-utils/margin-utilization.js.map +1 -1
  34. package/src/lib/app-utils/media-filter.d.ts +26 -0
  35. package/src/lib/app-utils/media-filter.js +32 -0
  36. package/src/lib/app-utils/media-filter.js.map +1 -1
  37. package/src/lib/app-utils/mobile-push.d.ts +8 -0
  38. package/src/lib/app-utils/mobile-push.js +4 -1
  39. package/src/lib/app-utils/mobile-push.js.map +1 -1
  40. package/src/lib/app-utils/native-app-association.d.ts +3 -2
  41. package/src/lib/app-utils/native-app-association.js +4 -2
  42. package/src/lib/app-utils/native-app-association.js.map +1 -1
  43. package/src/lib/app-utils/native-nav.d.ts +265 -0
  44. package/src/lib/app-utils/native-nav.js +741 -0
  45. package/src/lib/app-utils/native-nav.js.map +1 -0
  46. package/src/lib/app-utils/native-themes.d.ts +249 -0
  47. package/src/lib/app-utils/native-themes.js +732 -0
  48. package/src/lib/app-utils/native-themes.js.map +1 -0
  49. package/src/lib/app-utils/notifications.d.ts +1 -1
  50. package/src/lib/app-utils/notifications.js +10 -0
  51. package/src/lib/app-utils/notifications.js.map +1 -1
  52. package/src/lib/app-utils/onboarding-deep-link.d.ts +36 -2
  53. package/src/lib/app-utils/onboarding-deep-link.js +48 -5
  54. package/src/lib/app-utils/onboarding-deep-link.js.map +1 -1
  55. package/src/lib/app-utils/one-box-search.d.ts +108 -0
  56. package/src/lib/app-utils/one-box-search.js +167 -0
  57. package/src/lib/app-utils/one-box-search.js.map +1 -0
  58. package/src/lib/app-utils/operator-alerts.js +21 -0
  59. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  60. package/src/lib/app-utils/org-permissions.js +10 -61
  61. package/src/lib/app-utils/org-permissions.js.map +1 -1
  62. package/src/lib/app-utils/org-role-grants.d.ts +95 -0
  63. package/src/lib/app-utils/org-role-grants.js +193 -0
  64. package/src/lib/app-utils/org-role-grants.js.map +1 -0
  65. package/src/lib/app-utils/owner-approval-record.d.ts +102 -0
  66. package/src/lib/app-utils/owner-approval-record.js +148 -0
  67. package/src/lib/app-utils/owner-approval-record.js.map +1 -0
  68. package/src/lib/app-utils/plan-entitlements.d.ts +224 -68
  69. package/src/lib/app-utils/plan-entitlements.js +278 -61
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/release-flags.d.ts +1 -1
  72. package/src/lib/app-utils/release-flags.js +10 -0
  73. package/src/lib/app-utils/release-flags.js.map +1 -1
  74. package/src/lib/app-utils/server.d.ts +4 -0
  75. package/src/lib/app-utils/server.js +7 -0
  76. package/src/lib/app-utils/server.js.map +1 -1
  77. package/src/lib/app-utils/site-care-project.d.ts +227 -0
  78. package/src/lib/app-utils/site-care-project.js +423 -0
  79. package/src/lib/app-utils/site-care-project.js.map +1 -0
  80. package/src/lib/app-utils/site-care.d.ts +268 -0
  81. package/src/lib/app-utils/site-care.js +283 -0
  82. package/src/lib/app-utils/site-care.js.map +1 -0
  83. package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
  84. package/src/lib/app-utils/tenant-dns-targets.js +80 -0
  85. package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
  86. package/src/lib/app-utils/upgrade-proposal.d.ts +29 -4
  87. package/src/lib/app-utils/upgrade-proposal.js +60 -9
  88. package/src/lib/app-utils/upgrade-proposal.js.map +1 -1
  89. package/src/lib/foundation/definitions/org-billing.types.d.ts +54 -10
  90. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  91. package/src/lib/foundation/definitions/organization.types.d.ts +3 -1
  92. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  93. package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
  94. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  95. package/src/lib/foundation/index.d.ts +1 -1
  96. package/src/lib/foundation/index.js.map +1 -1
  97. package/src/lib/plugin-manager/feature-plugins.d.ts +86 -0
  98. package/src/lib/plugin-manager/feature-plugins.js +20 -0
  99. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  100. package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
  101. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  102. package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +19 -0
  103. package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
  104. package/src/lib/plugin-manager/plugin-domain-events.d.ts +4 -3
  105. package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -1
  106. package/src/lib/plugin-manager/plugin-domain-search.d.ts +81 -0
  107. package/src/lib/plugin-manager/plugin-domain-search.js +63 -0
  108. package/src/lib/plugin-manager/plugin-domain-search.js.map +1 -0
  109. package/src/lib/plugin-manager/plugin-entitlements.d.ts +23 -5
  110. package/src/lib/plugin-manager/plugin-entitlements.js +10 -0
  111. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  112. package/src/lib/plugin-manager/plugin-media-ingest.d.ts +25 -0
  113. package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
  114. package/src/lib/plugin-manager/plugin-search-sources.d.ts +85 -0
  115. package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
  116. package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
  117. package/src/lib/plugin-manager/plugin-services.js +6 -0
  118. package/src/lib/plugin-manager/plugin-services.js.map +1 -1
  119. package/src/lib/plugin-manager/purchase-gate-preclearance.d.ts +61 -0
  120. package/src/lib/plugin-manager/purchase-gate-preclearance.js +102 -0
  121. package/src/lib/plugin-manager/purchase-gate-preclearance.js.map +1 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * APPEND-ONLY HISTORY (AGL-3959).
18
+ *
19
+ * Work that bills — Site Care minutes and extra time, and the flat-quoted
20
+ * project invoice after it (AGL-3970) — keeps one dated row per thing that
21
+ * happened: a minute entry, a timer start, an approval, a charge, an email,
22
+ * a correction. Nothing is overwritten. A fix is a new row that says what it
23
+ * was before, what it is now, who changed it and why, so the record a person
24
+ * can be shown is the whole record.
25
+ *
26
+ * Each row carries two tellings of the same fact: `summary`, written for the
27
+ * owner in plain words (it is what their statement lists), and the
28
+ * structured `before` / `after` / `stripe` a staff member or the
29
+ * reconciliation reads. This module is the row's shape and its rules; the
30
+ * write — always a `create`, in the same batch as the change it records — is
31
+ * `@aglyn/tenant-data-admin/server/append-only-history-write`.
32
+ */ /** The longest reason or summary a row keeps. */ export const HISTORY_TEXT_MAX = 500;
33
+ /** A trimmed, bounded reason, or null when blank. */ export function cleanHistoryReason(reason) {
34
+ const text = typeof reason === 'string' ? reason.trim().slice(0, HISTORY_TEXT_MAX) : '';
35
+ return text ? text : null;
36
+ }
37
+ /**
38
+ * The reason a correction must carry, or an error to show. Every change to
39
+ * something already recorded goes through this, so "why" is never optional
40
+ * where it matters.
41
+ */ export function requireHistoryReason(reason) {
42
+ const cleaned = cleanHistoryReason(reason);
43
+ return cleaned ? {
44
+ ok: true,
45
+ reason: cleaned
46
+ } : {
47
+ ok: false,
48
+ error: 'Say why — a reason is required.'
49
+ };
50
+ }
51
+ /** A row from its parts, every field present so a reader never guesses. */ export function historyRow(input) {
52
+ var _input_by_uid, _input_by_email, _input_summary, _input_reason, _input_before, _input_after, _input_stripe, _input_subjectId;
53
+ return {
54
+ atMs: Math.floor(input.atMs),
55
+ byUid: (_input_by_uid = input.by.uid) != null ? _input_by_uid : null,
56
+ byEmail: (_input_by_email = input.by.email) != null ? _input_by_email : null,
57
+ kind: input.kind,
58
+ summary: String((_input_summary = input.summary) != null ? _input_summary : '').trim().slice(0, HISTORY_TEXT_MAX),
59
+ reason: cleanHistoryReason((_input_reason = input.reason) != null ? _input_reason : null),
60
+ before: (_input_before = input.before) != null ? _input_before : null,
61
+ after: (_input_after = input.after) != null ? _input_after : null,
62
+ stripe: (_input_stripe = input.stripe) != null ? _input_stripe : null,
63
+ subjectId: (_input_subjectId = input.subjectId) != null ? _input_subjectId : null
64
+ };
65
+ }
66
+ /** Rows oldest first, ties kept in the order they were read. */ export function sortHistoryRows(rows) {
67
+ return rows.map((row, index)=>({
68
+ row,
69
+ index
70
+ })).sort((a, b)=>a.row.atMs - b.row.atMs || a.index - b.index).map(({ row })=>row);
71
+ }
72
+
73
+ //# sourceMappingURL=append-only-history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/append-only-history.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * APPEND-ONLY HISTORY (AGL-3959).\n *\n * Work that bills — Site Care minutes and extra time, and the flat-quoted\n * project invoice after it (AGL-3970) — keeps one dated row per thing that\n * happened: a minute entry, a timer start, an approval, a charge, an email,\n * a correction. Nothing is overwritten. A fix is a new row that says what it\n * was before, what it is now, who changed it and why, so the record a person\n * can be shown is the whole record.\n *\n * Each row carries two tellings of the same fact: `summary`, written for the\n * owner in plain words (it is what their statement lists), and the\n * structured `before` / `after` / `stripe` a staff member or the\n * reconciliation reads. This module is the row's shape and its rules; the\n * write — always a `create`, in the same batch as the change it records — is\n * `@aglyn/tenant-data-admin/server/append-only-history-write`.\n */\n\n/** The longest reason or summary a row keeps. */\nexport const HISTORY_TEXT_MAX = 500\n\n/** The Stripe object a row's money movement names. */\nexport interface HistoryStripeRef {\n /** `invoiceitem`, `credit_note`, `refund`, `invoice`. */\n object: string\n id: string\n /** Whole cents the object moved, when it moved money. */\n amountCents?: number\n}\n\n/** One row. */\nexport interface AppendOnlyHistoryRow {\n /** When it happened, epoch ms. */\n atMs: number\n /** Who did it; null when nobody did (a scheduled check). */\n byUid: string | null\n byEmail: string | null\n /** What kind of thing happened, `noun.verb`: `minutes.corrected`. */\n kind: string\n /** What happened, in plain words an owner can read. */\n summary: string\n /** Why; required on every correction. */\n reason: string | null\n before: Readonly<Record<string, unknown>> | null\n after: Readonly<Record<string, unknown>> | null\n stripe: HistoryStripeRef | null\n /** The record the row belongs to, when the history is shared by many. */\n subjectId: string | null\n}\n\nexport interface HistoryRowInput {\n atMs: number\n by: { uid: string | null; email: string | null }\n kind: string\n summary: string\n reason?: string | null\n before?: Readonly<Record<string, unknown>> | null\n after?: Readonly<Record<string, unknown>> | null\n stripe?: HistoryStripeRef | null\n subjectId?: string | null\n}\n\n/** A trimmed, bounded reason, or null when blank. */\nexport function cleanHistoryReason(reason: unknown): string | null {\n const text = typeof reason === 'string' ? reason.trim().slice(0, HISTORY_TEXT_MAX) : ''\n return text ? text : null\n}\n\n/**\n * The reason a correction must carry, or an error to show. Every change to\n * something already recorded goes through this, so \"why\" is never optional\n * where it matters.\n */\nexport function requireHistoryReason(\n reason: unknown,\n): { ok: true; reason: string } | { ok: false; error: string } {\n const cleaned = cleanHistoryReason(reason)\n return cleaned ? { ok: true, reason: cleaned } : { ok: false, error: 'Say why — a reason is required.' }\n}\n\n/** A row from its parts, every field present so a reader never guesses. */\nexport function historyRow(input: HistoryRowInput): AppendOnlyHistoryRow {\n return {\n atMs: Math.floor(input.atMs),\n byUid: input.by.uid ?? null,\n byEmail: input.by.email ?? null,\n kind: input.kind,\n summary: String(input.summary ?? '').trim().slice(0, HISTORY_TEXT_MAX),\n reason: cleanHistoryReason(input.reason ?? null),\n before: input.before ?? null,\n after: input.after ?? null,\n stripe: input.stripe ?? null,\n subjectId: input.subjectId ?? null,\n }\n}\n\n/** Rows oldest first, ties kept in the order they were read. */\nexport function sortHistoryRows<Row extends Pick<AppendOnlyHistoryRow, 'atMs'>>(\n rows: readonly Row[],\n): Row[] {\n return rows\n .map((row, index) => ({ row, index }))\n .sort((a, b) => a.row.atMs - b.row.atMs || a.index - b.index)\n .map(({ row }) => row)\n}\n"],"names":["HISTORY_TEXT_MAX","cleanHistoryReason","reason","text","trim","slice","requireHistoryReason","cleaned","ok","error","historyRow","input","atMs","Math","floor","byUid","by","uid","byEmail","email","kind","summary","String","before","after","stripe","subjectId","sortHistoryRows","rows","map","row","index","sort","a","b"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,+CAA+C,GAC/C,OAAO,MAAMA,mBAAmB,IAAG;AA2CnC,mDAAmD,GACnD,OAAO,SAASC,mBAAmBC,MAAe;IAChD,MAAMC,OAAO,OAAOD,WAAW,WAAWA,OAAOE,IAAI,GAAGC,KAAK,CAAC,GAAGL,oBAAoB;IACrF,OAAOG,OAAOA,OAAO;AACvB;AAEA;;;;CAIC,GACD,OAAO,SAASG,qBACdJ,MAAe;IAEf,MAAMK,UAAUN,mBAAmBC;IACnC,OAAOK,UAAU;QAAEC,IAAI;QAAMN,QAAQK;IAAQ,IAAI;QAAEC,IAAI;QAAOC,OAAO;IAAkC;AACzG;AAEA,yEAAyE,GACzE,OAAO,SAASC,WAAWC,KAAsB;QAGtCA,eACEA,iBAEOA,gBACWA,eACnBA,eACDA,cACCA,eACGA;IAVb,OAAO;QACLC,MAAMC,KAAKC,KAAK,CAACH,MAAMC,IAAI;QAC3BG,KAAK,GAAEJ,gBAAAA,MAAMK,EAAE,CAACC,GAAG,YAAZN,gBAAgB;QACvBO,OAAO,GAAEP,kBAAAA,MAAMK,EAAE,CAACG,KAAK,YAAdR,kBAAkB;QAC3BS,MAAMT,MAAMS,IAAI;QAChBC,SAASC,QAAOX,iBAAAA,MAAMU,OAAO,YAAbV,iBAAiB,IAAIP,IAAI,GAAGC,KAAK,CAAC,GAAGL;QACrDE,QAAQD,oBAAmBU,gBAAAA,MAAMT,MAAM,YAAZS,gBAAgB;QAC3CY,MAAM,GAAEZ,gBAAAA,MAAMY,MAAM,YAAZZ,gBAAgB;QACxBa,KAAK,GAAEb,eAAAA,MAAMa,KAAK,YAAXb,eAAe;QACtBc,MAAM,GAAEd,gBAAAA,MAAMc,MAAM,YAAZd,gBAAgB;QACxBe,SAAS,GAAEf,mBAAAA,MAAMe,SAAS,YAAff,mBAAmB;IAChC;AACF;AAEA,8DAA8D,GAC9D,OAAO,SAASgB,gBACdC,IAAoB;IAEpB,OAAOA,KACJC,GAAG,CAAC,CAACC,KAAKC,QAAW,CAAA;YAAED;YAAKC;QAAM,CAAA,GAClCC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEH,GAAG,CAAClB,IAAI,GAAGsB,EAAEJ,GAAG,CAAClB,IAAI,IAAIqB,EAAEF,KAAK,GAAGG,EAAEH,KAAK,EAC3DF,GAAG,CAAC,CAAC,EAAEC,GAAG,EAAE,GAAKA;AACtB"}
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * THE ADD-ONS A PLAN SELLS, AND THE BUNDLE A LINK ASKS FOR (AGL-3955).
19
+ *
20
+ * One list of add-on kinds and one parser for the add-ons a link, an upgrade
21
+ * proposal or a staff dialog names, so the Billing card, the bundle checkout
22
+ * link, the hand-off proposal (AGL-3956) and `/api/billing/checkout` agree on
23
+ * what `addons=siteCare,ai,extraHost:2` means.
24
+ *
25
+ * The kinds are `OrgSeatAddons`' keys: what `resolvePurchasedAddons` counts,
26
+ * what the webhook mirrors from Stripe, and what `/api/billing/addons` sells.
27
+ * The prices and ceilings here are the ones `billing-addons.ts` (server)
28
+ * re-exports, so a kind the link can preselect is always a kind the
29
+ * checkout can price.
30
+ *
31
+ * A link is typed by a person and passed through hops nobody controls, so
32
+ * the parser never throws and never guesses: an unknown token is dropped and
33
+ * reported, so the page can say so in one line instead of failing.
34
+ */
35
+ import type { AiTierId, OrgPlan, OrgSeatAddons } from '../foundation/definitions/org-billing.types';
36
+ import { resolveOrgEntitlements } from './plan-entitlements';
37
+ /** An add-on kind: a key of `org.seatAddons`. */
38
+ export type AddonKind = keyof Required<OrgSeatAddons>;
39
+ /**
40
+ * Every add-on kind Billing sells, in the order the Billing card lists them.
41
+ * `/api/billing/addons`, the webhook's price map and the native add-ons
42
+ * screen all read this list.
43
+ */
44
+ export declare const ADDON_KINDS: readonly AddonKind[];
45
+ /** The org-wide 0/1 kinds: one purchase covers the workspace. */
46
+ export declare const TOGGLE_ADDON_KIND_LIST: readonly AddonKind[];
47
+ export declare function isAddonKind(value: unknown): value is AddonKind;
48
+ /**
49
+ * Monthly USD per unit for an add-on on a plan (yearly prices are ×12 of
50
+ * this); null when the plan doesn't sell the kind. Single pricing source
51
+ * of truth: `PLAN_PRICING`, the three flat add-on constants and `AI_TIERS`
52
+ * (`tier` as `addonPriceId` reads it; absent, the tier a plan-named purchase
53
+ * means, `defaultAiTierForPlan`, AGL-3965).
54
+ */
55
+ export declare function addonUnitUsd(kind: AddonKind, plan: OrgPlan, tier?: AiTierId | null): number | null;
56
+ /**
57
+ * Max purchasable quantity per kind, from a purchases-free entitlement
58
+ * resolution (plan defaults + staff overrides only) so the ceiling does not
59
+ * drift as the org buys: seat/dataset kinds stop at the plan's hard max,
60
+ * hosts/registers use flat ceilings, the Event Calendar, Aglyn AI and Site
61
+ * Care are 0/1 toggles. POS registers additionally require the `pos`
62
+ * feature, and Site Care a plan that sells it (`planSellsSiteCare`), which is
63
+ * why the plan travels with the baseline.
64
+ *
65
+ * One definition for every reader: `/api/billing/addons` asks how many the
66
+ * org may buy on its plan, a plan change asks how many of what it bought the
67
+ * new plan can deliver, and a bundle link asks how many it may preselect.
68
+ */
69
+ export declare function addonMaxForBaseline(kind: AddonKind, baseline: ReturnType<typeof resolveOrgEntitlements>, plan: OrgPlan): number;
70
+ /** The ceiling a PLAN would impose on an add-on kind, purchases excluded. */
71
+ export declare function addonMaxForPlan(kind: AddonKind, plan: OrgPlan): number;
72
+ /** Whether a plan sells an add-on at all: priced, with a ceiling above zero. */
73
+ export declare function planSellsAddon(kind: AddonKind, plan: OrgPlan): boolean;
74
+ /** Quantities by kind, absent kinds meaning none. */
75
+ export type BundleAddons = Partial<Record<AddonKind, number>>;
76
+ /**
77
+ * What a link writes for each kind. `ai` and `extraHost` are the words the
78
+ * account owner chose (AGL-3955); the rest follow the same shape.
79
+ */
80
+ export declare const BUNDLE_ADDON_TOKENS: Record<AddonKind, string>;
81
+ /** One add-on a token names, with the Aglyn AI tier when it names one. */
82
+ interface ParsedToken {
83
+ kind: AddonKind;
84
+ quantity: number;
85
+ tier?: AiTierId;
86
+ }
87
+ /**
88
+ * THE `ai:<tier>` TOKENS (AGL-3965).
89
+ *
90
+ * Aglyn AI is a tier chosen on its own, on any paid plan: `ai:boost` adds
91
+ * Aglyn AI Boost. The words are the tier ids, lower-cased as every token is,
92
+ * and resolve through `parseAiTierAddonKey`, the one spelling of the
93
+ * `ai:<tier>` key. A bare `ai` (or `ai:1`) still means "add it" and sells
94
+ * the plan's default tier (`defaultAiTierForPlan`). An unknown tier is
95
+ * dropped with the notice like any other unknown add-on, never read as
96
+ * "add whichever tier": that would sell a tier nobody chose.
97
+ */
98
+ export declare const AI_ADDON_TIER_TOKENS: Readonly<Record<string, AiTierId>>;
99
+ export declare function resolveAiAddonTier(tier: string): ParsedToken | null;
100
+ export interface ParsedBundleAddons {
101
+ addons: BundleAddons;
102
+ /** The tokens nothing understood, as they were written. */
103
+ dropped: string[];
104
+ /** The Aglyn AI tier an `ai:<tier>` token named (AGL-3965), if any. */
105
+ aiTier?: AiTierId;
106
+ }
107
+ /**
108
+ * Read the add-ons a link, a proposal or a request names.
109
+ *
110
+ * Accepts the link form (`"siteCare,ai,extraHost:2"`), a list of tokens, or a
111
+ * map of kind → quantity or flag (`{ siteCare: true, hosts: 2 }`, the shape
112
+ * the native staff screens post from their toggles). Never throws: anything
113
+ * it cannot read is reported in `dropped` and left out.
114
+ */
115
+ export declare function parseBundleAddons(input: unknown): ParsedBundleAddons;
116
+ export interface FittedBundleAddons {
117
+ addons: BundleAddons;
118
+ /** Kinds the plan does not sell, dropped. */
119
+ notSold: AddonKind[];
120
+ }
121
+ /**
122
+ * The add-ons a plan can actually carry: kinds it does not sell are dropped,
123
+ * quantities are held to its ceiling. What a link preselects on one plan is
124
+ * re-fitted when the owner picks another, so the checkout is never asked for
125
+ * something it would refuse.
126
+ */
127
+ export declare function fitBundleAddonsToPlan(addons: BundleAddons | null | undefined, plan: OrgPlan): FittedBundleAddons;
128
+ /**
129
+ * The add-ons as the link writes them: `siteCare,ai:boost,extraHost:2`.
130
+ * Aglyn AI is written with its tier when one is named (AGL-3965), as bare
131
+ * `ai` otherwise.
132
+ */
133
+ export declare function bundleAddonsParam(addons: BundleAddons | null | undefined, aiTier?: AiTierId | null): string;
134
+ /** Whether any add-on is asked for. */
135
+ export declare function hasBundleAddons(addons: BundleAddons | null | undefined): boolean;
136
+ /**
137
+ * How each kind is named to a customer, singular. Aglyn AI carries its tier
138
+ * when one is named: "Aglyn AI Boost" (AGL-3965).
139
+ */
140
+ export declare function bundleAddonLabel(kind: AddonKind, brand?: string, aiTier?: AiTierId | null): string;
141
+ /** "Site Care", "2 × Extra site", "Aglyn AI Boost". */
142
+ export declare function describeBundleAddon(kind: AddonKind, quantity: number, brand?: string, aiTier?: AiTierId | null): string;
143
+ /** The longest coupon code a link carries. Stripe codes are far shorter. */
144
+ export declare const BUNDLE_COUPON_MAX = 64;
145
+ /**
146
+ * A promotion code as a link carries it, or undefined. Only the characters
147
+ * Stripe allows in a customer-facing code; anything else is not a code
148
+ * somebody was handed, and is dropped rather than sent to Stripe.
149
+ *
150
+ * The code is only APPLIED here. Codes are minted on the staff Coupons page,
151
+ * which audits them; nothing on this path creates one.
152
+ */
153
+ export declare function normalizeBundleCoupon(raw: unknown): string | undefined;
154
+ /**
155
+ * A domain name as a bundle carries it — lower-cased, no scheme, no path —
156
+ * or undefined. Whether it is for sale and at what price is the registrar's
157
+ * answer, re-checked at purchase; this only refuses what cannot be a name.
158
+ */
159
+ export declare function normalizeBundleDomain(raw: unknown): string | undefined;
160
+ /** "$15.99". */
161
+ export declare function formatBundleCents(cents: number): string;
162
+ /**
163
+ * The domain line a bundle shows (AGL-3955): "example.com — $15.99 first
164
+ * year, renews yearly at $15.99". The price is the registrar's at the moment
165
+ * the link was minted; the purchase re-checks it.
166
+ */
167
+ export declare function bundleDomainLine(domain: {
168
+ name: string;
169
+ firstYearCents: number;
170
+ renewalCents: number;
171
+ }): string;
172
+ /**
173
+ * The bundle in one line, for an email, a dialog or a snackbar:
174
+ * "Starter with Site Care and Aglyn AI, billed yearly, code BOOTH25".
175
+ */
176
+ export declare function describeBundle(bundle: {
177
+ plan: OrgPlan;
178
+ addons?: BundleAddons | null;
179
+ interval?: 'month' | 'year' | null;
180
+ coupon?: string | null;
181
+ /** The Aglyn AI tier, named with the AI add-on (AGL-3965). */
182
+ aiTier?: AiTierId | null;
183
+ }, options?: {
184
+ brand?: string;
185
+ }): string;
186
+ export {};
@@ -0,0 +1,386 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * THE ADD-ONS A PLAN SELLS, AND THE BUNDLE A LINK ASKS FOR (AGL-3955).
18
+ *
19
+ * One list of add-on kinds and one parser for the add-ons a link, an upgrade
20
+ * proposal or a staff dialog names, so the Billing card, the bundle checkout
21
+ * link, the hand-off proposal (AGL-3956) and `/api/billing/checkout` agree on
22
+ * what `addons=siteCare,ai,extraHost:2` means.
23
+ *
24
+ * The kinds are `OrgSeatAddons`' keys: what `resolvePurchasedAddons` counts,
25
+ * what the webhook mirrors from Stripe, and what `/api/billing/addons` sells.
26
+ * The prices and ceilings here are the ones `billing-addons.ts` (server)
27
+ * re-exports, so a kind the link can preselect is always a kind the
28
+ * checkout can price.
29
+ *
30
+ * A link is typed by a person and passed through hops nobody controls, so
31
+ * the parser never throws and never guesses: an unknown token is dropped and
32
+ * reported, so the page can say so in one line instead of failing.
33
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
34
+ import { AI_TIER_IDS, AI_TIERS, aiAddonName, aiTierAddonKey, aiTierName, defaultAiTierForPlan, EVENT_CALENDAR_ADDON_MONTHLY_USD, EXTRA_HOSTS_ADDON_MAX, PLAN_LABELS, parseAiTierAddonKey, PLAN_PRICING, planSellsAiTiers, planSellsSiteCare, POS_REGISTER_ADDON_MONTHLY_USD, POS_REGISTERS_ADDON_MAX, resolveOrgEntitlements, SITE_CARE_ADDON_MONTHLY_USD, UNLIMITED } from "./plan-entitlements.js";
35
+ /**
36
+ * Every add-on kind Billing sells, in the order the Billing card lists them.
37
+ * `/api/billing/addons`, the webhook's price map and the native add-ons
38
+ * screen all read this list.
39
+ */ export const ADDON_KINDS = [
40
+ 'managers',
41
+ 'members',
42
+ 'datasets',
43
+ 'hosts',
44
+ 'posRegisters',
45
+ 'eventCalendar',
46
+ 'aiAddon',
47
+ 'siteCare'
48
+ ];
49
+ /** The org-wide 0/1 kinds: one purchase covers the workspace. */ export const TOGGLE_ADDON_KIND_LIST = [
50
+ 'eventCalendar',
51
+ 'aiAddon',
52
+ 'siteCare'
53
+ ];
54
+ export function isAddonKind(value) {
55
+ return typeof value === 'string' && ADDON_KINDS.includes(value);
56
+ }
57
+ /**
58
+ * Monthly USD per unit for an add-on on a plan (yearly prices are ×12 of
59
+ * this); null when the plan doesn't sell the kind. Single pricing source
60
+ * of truth: `PLAN_PRICING`, the three flat add-on constants and `AI_TIERS`
61
+ * (`tier` as `addonPriceId` reads it; absent, the tier a plan-named purchase
62
+ * means, `defaultAiTierForPlan`, AGL-3965).
63
+ */ export function addonUnitUsd(kind, plan, tier) {
64
+ const pricing = PLAN_PRICING[plan];
65
+ switch(kind){
66
+ case 'managers':
67
+ return pricing.extraSeatMonthlyUsd;
68
+ case 'members':
69
+ return pricing.extraCollaboratorMonthlyUsd;
70
+ case 'datasets':
71
+ return pricing.extraDatasetMonthlyUsd;
72
+ case 'hosts':
73
+ return pricing.extraHostMonthlyUsd;
74
+ case 'posRegisters':
75
+ return POS_REGISTER_ADDON_MONTHLY_USD;
76
+ case 'eventCalendar':
77
+ return EVENT_CALENDAR_ADDON_MONTHLY_USD;
78
+ case 'aiAddon':
79
+ {
80
+ const chosen = planSellsAiTiers(plan) ? tier != null ? tier : defaultAiTierForPlan(plan) : null;
81
+ return chosen ? AI_TIERS[chosen].monthlyUsd : null;
82
+ }
83
+ case 'siteCare':
84
+ return SITE_CARE_ADDON_MONTHLY_USD;
85
+ }
86
+ }
87
+ /**
88
+ * Max purchasable quantity per kind, from a purchases-free entitlement
89
+ * resolution (plan defaults + staff overrides only) so the ceiling does not
90
+ * drift as the org buys: seat/dataset kinds stop at the plan's hard max,
91
+ * hosts/registers use flat ceilings, the Event Calendar, Aglyn AI and Site
92
+ * Care are 0/1 toggles. POS registers additionally require the `pos`
93
+ * feature, and Site Care a plan that sells it (`planSellsSiteCare`), which is
94
+ * why the plan travels with the baseline.
95
+ *
96
+ * One definition for every reader: `/api/billing/addons` asks how many the
97
+ * org may buy on its plan, a plan change asks how many of what it bought the
98
+ * new plan can deliver, and a bundle link asks how many it may preselect.
99
+ */ export function addonMaxForBaseline(kind, baseline, plan) {
100
+ const bounded = (included, max)=>Number.isFinite(max) ? Math.max(0, max - included) : EXTRA_HOSTS_ADDON_MAX;
101
+ switch(kind){
102
+ case 'managers':
103
+ return bounded(baseline.managersPerOrg, baseline.maxManagersPerOrg);
104
+ case 'members':
105
+ return bounded(baseline.membersPerHost, baseline.maxMembersPerHost);
106
+ case 'datasets':
107
+ return bounded(baseline.datasetsPerOrg, baseline.maxDatasetsPerOrg);
108
+ case 'hosts':
109
+ return baseline.hostLimit === UNLIMITED ? 0 : EXTRA_HOSTS_ADDON_MAX;
110
+ case 'posRegisters':
111
+ return baseline.features.pos ? POS_REGISTERS_ADDON_MAX : 0;
112
+ case 'eventCalendar':
113
+ return 1;
114
+ case 'aiAddon':
115
+ // Org-wide like the Event Calendar: one purchase, of one tier, on any
116
+ // plan that sells tiers (AGL-3965) — not Free, not Enterprise.
117
+ return planSellsAiTiers(plan) ? 1 : 0;
118
+ case 'siteCare':
119
+ // Flat-priced, so the price map cannot say "not sold here" the way it
120
+ // does for Aglyn AI. This ceiling is the rule that keeps Free (no
121
+ // subscription) and Enterprise (quoted per deal) from buying it.
122
+ return planSellsSiteCare(plan) ? 1 : 0;
123
+ }
124
+ }
125
+ /** The ceiling a PLAN would impose on an add-on kind, purchases excluded. */ export function addonMaxForPlan(kind, plan) {
126
+ return addonMaxForBaseline(kind, resolveOrgEntitlements({
127
+ plan
128
+ }), plan);
129
+ }
130
+ /** Whether a plan sells an add-on at all: priced, with a ceiling above zero. */ export function planSellsAddon(kind, plan) {
131
+ return addonUnitUsd(kind, plan) != null && addonMaxForPlan(kind, plan) > 0;
132
+ }
133
+ /**
134
+ * What a link writes for each kind. `ai` and `extraHost` are the words the
135
+ * account owner chose (AGL-3955); the rest follow the same shape.
136
+ */ export const BUNDLE_ADDON_TOKENS = {
137
+ managers: 'extraSeat',
138
+ members: 'extraMember',
139
+ datasets: 'extraDataset',
140
+ hosts: 'extraHost',
141
+ posRegisters: 'posRegister',
142
+ eventCalendar: 'eventCalendar',
143
+ aiAddon: 'ai',
144
+ siteCare: 'siteCare'
145
+ };
146
+ /**
147
+ * Every spelling the parser accepts, lower-cased: the written token, the
148
+ * stored kind, and the plurals a person types.
149
+ */ const TOKEN_KINDS = (()=>{
150
+ const map = {};
151
+ const add = (kind, ...words)=>{
152
+ for (const word of [
153
+ kind,
154
+ BUNDLE_ADDON_TOKENS[kind],
155
+ ...words
156
+ ]){
157
+ map[word.toLowerCase()] = kind;
158
+ }
159
+ };
160
+ add('managers', 'extraSeats', 'seats', 'managerSeats');
161
+ add('members', 'extraMembers', 'collaborators', 'collaboratorSeats');
162
+ add('datasets', 'extraDatasets');
163
+ add('hosts', 'extraHosts', 'extraSite', 'extraSites', 'sites');
164
+ add('posRegisters', 'registers', 'register');
165
+ add('eventCalendar', 'events', 'calendar');
166
+ add('aiAddon', 'aglynAi');
167
+ add('siteCare', 'site-care', 'site_care', 'care');
168
+ return map;
169
+ })();
170
+ /** The most of a quantity kind a link may ask for, before the plan's ceiling. */ const MAX_LINK_QUANTITY = 999;
171
+ /**
172
+ * THE `ai:<tier>` TOKENS (AGL-3965).
173
+ *
174
+ * Aglyn AI is a tier chosen on its own, on any paid plan: `ai:boost` adds
175
+ * Aglyn AI Boost. The words are the tier ids, lower-cased as every token is,
176
+ * and resolve through `parseAiTierAddonKey`, the one spelling of the
177
+ * `ai:<tier>` key. A bare `ai` (or `ai:1`) still means "add it" and sells
178
+ * the plan's default tier (`defaultAiTierForPlan`). An unknown tier is
179
+ * dropped with the notice like any other unknown add-on, never read as
180
+ * "add whichever tier": that would sell a tier nobody chose.
181
+ */ export const AI_ADDON_TIER_TOKENS = Object.freeze(Object.fromEntries(AI_TIER_IDS.map((tier)=>[
182
+ tier.toLowerCase(),
183
+ tier
184
+ ])));
185
+ export function resolveAiAddonTier(tier) {
186
+ const word = tier.trim().toLowerCase();
187
+ if (!Object.prototype.hasOwnProperty.call(AI_ADDON_TIER_TOKENS, word)) return null;
188
+ const parsed = parseAiTierAddonKey(aiTierAddonKey(AI_ADDON_TIER_TOKENS[word]));
189
+ return parsed ? {
190
+ kind: 'aiAddon',
191
+ quantity: 1,
192
+ tier: parsed
193
+ } : null;
194
+ }
195
+ /** One token, `kind` or `kind:quantity`, or null when nothing understands it. */ function parseToken(raw) {
196
+ const token = raw.trim();
197
+ if (!token) return null;
198
+ const [name, ...rest] = token.split(':');
199
+ const kind = TOKEN_KINDS[name.trim().toLowerCase()];
200
+ if (!kind) return null;
201
+ const value = rest.join(':').trim();
202
+ if (!value) return {
203
+ kind,
204
+ quantity: 1
205
+ };
206
+ if (/^\d+$/.test(value)) {
207
+ const quantity = Math.min(MAX_LINK_QUANTITY, Number(value));
208
+ if (quantity < 1) return null;
209
+ return {
210
+ kind,
211
+ quantity: TOGGLE_ADDON_KIND_LIST.includes(kind) ? 1 : quantity
212
+ };
213
+ }
214
+ return kind === 'aiAddon' ? resolveAiAddonTier(value) : null;
215
+ }
216
+ /**
217
+ * Read the add-ons a link, a proposal or a request names.
218
+ *
219
+ * Accepts the link form (`"siteCare,ai,extraHost:2"`), a list of tokens, or a
220
+ * map of kind → quantity or flag (`{ siteCare: true, hosts: 2 }`, the shape
221
+ * the native staff screens post from their toggles). Never throws: anything
222
+ * it cannot read is reported in `dropped` and left out.
223
+ */ export function parseBundleAddons(input) {
224
+ const addons = {};
225
+ const dropped = [];
226
+ let aiTier;
227
+ const take = (parsed, raw)=>{
228
+ var _addons_parsed_kind;
229
+ if (!parsed) {
230
+ if (raw.trim()) dropped.push(raw.trim().slice(0, 40));
231
+ return;
232
+ }
233
+ addons[parsed.kind] = Math.max((_addons_parsed_kind = addons[parsed.kind]) != null ? _addons_parsed_kind : 0, parsed.quantity);
234
+ // The first tier named wins: one workspace holds one tier.
235
+ if (parsed.tier && !aiTier) aiTier = parsed.tier;
236
+ };
237
+ if (typeof input === 'string') {
238
+ for (const raw of input.split(/[,\s]+/))take(parseToken(raw), raw);
239
+ } else if (Array.isArray(input)) {
240
+ for (const raw of input){
241
+ if (typeof raw === 'string') take(parseToken(raw), raw);
242
+ }
243
+ } else if (input && typeof input === 'object') {
244
+ for (const [name, value] of Object.entries(input)){
245
+ const off = value === false || value === 0 || value === null || value === undefined || value === '' || value === 'false' || value === '0';
246
+ if (off) continue;
247
+ const quantity = value === true || value === 'true' ? '' : typeof value === 'number' || typeof value === 'string' ? String(value) : '';
248
+ take(parseToken(quantity ? `${name}:${quantity}` : name), name);
249
+ }
250
+ }
251
+ return _extends({
252
+ addons,
253
+ dropped
254
+ }, aiTier ? {
255
+ aiTier
256
+ } : {});
257
+ }
258
+ /**
259
+ * The add-ons a plan can actually carry: kinds it does not sell are dropped,
260
+ * quantities are held to its ceiling. What a link preselects on one plan is
261
+ * re-fitted when the owner picks another, so the checkout is never asked for
262
+ * something it would refuse.
263
+ */ export function fitBundleAddonsToPlan(addons, plan) {
264
+ const fitted = {};
265
+ const notSold = [];
266
+ for (const kind of ADDON_KINDS){
267
+ var _ref;
268
+ const wanted = Math.floor(Number((_ref = addons == null ? void 0 : addons[kind]) != null ? _ref : 0));
269
+ if (!(wanted > 0)) continue;
270
+ if (!planSellsAddon(kind, plan)) {
271
+ notSold.push(kind);
272
+ continue;
273
+ }
274
+ fitted[kind] = Math.min(wanted, addonMaxForPlan(kind, plan));
275
+ }
276
+ return {
277
+ addons: fitted,
278
+ notSold
279
+ };
280
+ }
281
+ /**
282
+ * The add-ons as the link writes them: `siteCare,ai:boost,extraHost:2`.
283
+ * Aglyn AI is written with its tier when one is named (AGL-3965), as bare
284
+ * `ai` otherwise.
285
+ */ export function bundleAddonsParam(addons, aiTier) {
286
+ return ADDON_KINDS.filter((kind)=>{
287
+ var _ref;
288
+ return ((_ref = addons == null ? void 0 : addons[kind]) != null ? _ref : 0) > 0;
289
+ }).map((kind)=>{
290
+ var _ref;
291
+ if (kind === 'aiAddon' && aiTier) return aiTierAddonKey(aiTier);
292
+ const quantity = (_ref = addons == null ? void 0 : addons[kind]) != null ? _ref : 0;
293
+ return quantity > 1 ? `${BUNDLE_ADDON_TOKENS[kind]}:${quantity}` : BUNDLE_ADDON_TOKENS[kind];
294
+ }).join(',');
295
+ }
296
+ /** Whether any add-on is asked for. */ export function hasBundleAddons(addons) {
297
+ return ADDON_KINDS.some((kind)=>{
298
+ var _ref;
299
+ return ((_ref = addons == null ? void 0 : addons[kind]) != null ? _ref : 0) > 0;
300
+ });
301
+ }
302
+ /**
303
+ * How each kind is named to a customer, singular. Aglyn AI carries its tier
304
+ * when one is named: "Aglyn AI Boost" (AGL-3965).
305
+ */ export function bundleAddonLabel(kind, brand, aiTier) {
306
+ switch(kind){
307
+ case 'managers':
308
+ return 'Extra manager seat';
309
+ case 'members':
310
+ return 'Extra collaborator seat';
311
+ case 'datasets':
312
+ return 'Extra dataset';
313
+ case 'hosts':
314
+ return 'Extra site';
315
+ case 'posRegisters':
316
+ return 'POS register';
317
+ case 'eventCalendar':
318
+ return 'Event Calendar';
319
+ case 'aiAddon':
320
+ return aiTier ? aiTierName(aiTier, brand) : aiAddonName(brand);
321
+ case 'siteCare':
322
+ return 'Site Care';
323
+ }
324
+ }
325
+ /** "Site Care", "2 × Extra site", "Aglyn AI Boost". */ export function describeBundleAddon(kind, quantity, brand, aiTier) {
326
+ const label = bundleAddonLabel(kind, brand, aiTier);
327
+ return quantity > 1 ? `${quantity} × ${label}` : label;
328
+ }
329
+ /** The longest coupon code a link carries. Stripe codes are far shorter. */ export const BUNDLE_COUPON_MAX = 64;
330
+ /**
331
+ * A promotion code as a link carries it, or undefined. Only the characters
332
+ * Stripe allows in a customer-facing code; anything else is not a code
333
+ * somebody was handed, and is dropped rather than sent to Stripe.
334
+ *
335
+ * The code is only APPLIED here. Codes are minted on the staff Coupons page,
336
+ * which audits them; nothing on this path creates one.
337
+ */ export function normalizeBundleCoupon(raw) {
338
+ if (typeof raw !== 'string') return undefined;
339
+ const code = raw.trim();
340
+ if (!code || code.length > BUNDLE_COUPON_MAX) return undefined;
341
+ return /^[A-Za-z0-9_-]+$/.test(code) ? code : undefined;
342
+ }
343
+ /**
344
+ * A domain name as a bundle carries it — lower-cased, no scheme, no path —
345
+ * or undefined. Whether it is for sale and at what price is the registrar's
346
+ * answer, re-checked at purchase; this only refuses what cannot be a name.
347
+ */ export function normalizeBundleDomain(raw) {
348
+ if (typeof raw !== 'string') return undefined;
349
+ const name = raw.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/\.$/, '');
350
+ if (!name || name.length > 253) return undefined;
351
+ const labels = name.split('.');
352
+ if (labels.length < 2) return undefined;
353
+ const ok = labels.every((label)=>/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
354
+ return ok && /^[a-z]{2,63}$/.test(labels[labels.length - 1]) ? name : undefined;
355
+ }
356
+ /** "$15.99". */ export function formatBundleCents(cents) {
357
+ return `$${(Math.max(0, Math.round(cents)) / 100).toFixed(2)}`;
358
+ }
359
+ /**
360
+ * The domain line a bundle shows (AGL-3955): "example.com — $15.99 first
361
+ * year, renews yearly at $15.99". The price is the registrar's at the moment
362
+ * the link was minted; the purchase re-checks it.
363
+ */ export function bundleDomainLine(domain) {
364
+ return `${domain.name} — ${formatBundleCents(domain.firstYearCents)} first year, ` + `renews yearly at ${formatBundleCents(domain.renewalCents)}`;
365
+ }
366
+ /**
367
+ * The bundle in one line, for an email, a dialog or a snackbar:
368
+ * "Starter with Site Care and Aglyn AI, billed yearly, code BOOTH25".
369
+ */ export function describeBundle(bundle, options = {}) {
370
+ var _PLAN_LABELS_bundle_plan;
371
+ const parts = ADDON_KINDS.filter((kind)=>{
372
+ var _ref;
373
+ var _bundle_addons;
374
+ return ((_ref = (_bundle_addons = bundle.addons) == null ? void 0 : _bundle_addons[kind]) != null ? _ref : 0) > 0;
375
+ }).map((kind)=>{
376
+ var _ref;
377
+ var _bundle_addons;
378
+ return describeBundleAddon(kind, (_ref = (_bundle_addons = bundle.addons) == null ? void 0 : _bundle_addons[kind]) != null ? _ref : 0, options.brand, bundle.aiTier);
379
+ });
380
+ const addons = parts.length === 0 ? '' : parts.length === 1 ? ` with ${parts[0]}` : ` with ${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
381
+ const interval = bundle.interval === 'year' ? ', billed yearly' : bundle.interval === 'month' ? ', billed monthly' : '';
382
+ const coupon = bundle.coupon ? `, code ${bundle.coupon}` : '';
383
+ return `${(_PLAN_LABELS_bundle_plan = PLAN_LABELS[bundle.plan]) != null ? _PLAN_LABELS_bundle_plan : bundle.plan}${addons}${interval}${coupon}`;
384
+ }
385
+
386
+ //# sourceMappingURL=bundle-intent.js.map