@aglyn/plugins-ai 1.0.0-beta.219 → 1.0.0-beta.221
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.
- package/package.json +18 -18
- package/src/lib/billing/ai-month-meter.d.ts +51 -0
- package/src/lib/billing/ai-month-meter.js +89 -0
- package/src/lib/billing/ai-month-meter.js.map +1 -0
- package/src/lib/billing/ai-overage-close.d.ts +11 -33
- package/src/lib/billing/ai-overage-close.js +4 -3
- package/src/lib/billing/ai-overage-close.js.map +1 -1
- package/src/lib/billing/ai-overage-cutover.d.ts +28 -12
- package/src/lib/billing/ai-overage-cutover.js +1 -28
- package/src/lib/billing/ai-overage-cutover.js.map +1 -1
- package/src/lib/billing/ai-overage-ledger.js +1 -1
- package/src/lib/billing/ai-overage-ledger.js.map +1 -1
- package/src/lib/billing/ai-overage-standing.js +1 -1
- package/src/lib/billing/ai-overage-standing.js.map +1 -1
- package/src/lib/components/ai-allotment-editor.component.js +1 -1
- package/src/lib/components/ai-allotment-editor.component.js.map +1 -1
- package/src/lib/components/ai-assist-provider.component.js +3 -2
- package/src/lib/components/ai-assist-provider.component.js.map +1 -1
- package/src/lib/components/ai-automation-zones.d.ts +63 -0
- package/src/lib/components/ai-automation-zones.js +31 -0
- package/src/lib/components/ai-automation-zones.js.map +1 -0
- package/src/lib/components/ai-describe-automation.component.d.ts +1 -1
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-describe-button.component.d.ts +6 -3
- package/src/lib/components/ai-describe-button.component.js +4 -1
- package/src/lib/components/ai-describe-button.component.js.map +1 -1
- package/src/lib/components/ai-explain-automation.component.d.ts +1 -1
- package/src/lib/components/ai-explain-automation.component.js.map +1 -1
- package/src/lib/components/billing-ai-allotments.component.js +1 -1
- package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
- package/src/lib/components/billing-assist-overage-card.component.js +1 -1
- package/src/lib/components/billing-assist-overage-card.component.js.map +1 -1
- package/src/lib/constants.d.ts +5 -0
- package/src/lib/constants.js +4 -0
- package/src/lib/constants.js.map +1 -1
- package/src/lib/declarations.js +2 -0
- package/src/lib/declarations.js.map +1 -1
- package/src/lib/declarations.server.js +24 -10
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/insights/ai-dataset-catalog.d.ts +49 -0
- package/src/lib/insights/ai-dataset-catalog.js +57 -0
- package/src/lib/insights/ai-dataset-catalog.js.map +1 -0
- package/src/lib/insights/ai-figure-readers.d.ts +6 -39
- package/src/lib/insights/ai-figure-readers.js +2 -391
- package/src/lib/insights/ai-figure-readers.js.map +1 -1
- package/src/lib/insights/ai-insight-digest.d.ts +10 -1
- package/src/lib/insights/ai-insight-digest.js +16 -4
- package/src/lib/insights/ai-insight-digest.js.map +1 -1
- package/src/lib/insights/ai-insight-readers.d.ts +1 -1
- package/src/lib/insights/ai-insight-readers.js.map +1 -1
- package/src/lib/jobs/ai-job-component-step.d.ts +6 -1
- package/src/lib/jobs/ai-job-component-step.js +23 -6
- package/src/lib/jobs/ai-job-component-step.js.map +1 -1
- package/src/lib/jobs/ai-job-draft-ids.js +11 -0
- package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
- package/src/lib/jobs/ai-job-email-step.d.ts +5 -1
- package/src/lib/jobs/ai-job-email-step.js +10 -1
- package/src/lib/jobs/ai-job-email-step.js.map +1 -1
- package/src/lib/jobs/ai-job-form-step.d.ts +17 -1
- package/src/lib/jobs/ai-job-form-step.js +60 -8
- package/src/lib/jobs/ai-job-form-step.js.map +1 -1
- package/src/lib/jobs/ai-job-generation.d.ts +21 -0
- package/src/lib/jobs/ai-job-generation.js +53 -1
- package/src/lib/jobs/ai-job-generation.js.map +1 -1
- package/src/lib/jobs/ai-job-insight-step.js +1 -1
- package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
- package/src/lib/jobs/ai-job-layout-step.d.ts +15 -4
- package/src/lib/jobs/ai-job-layout-step.js +45 -12
- package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-conformance.d.ts +5 -0
- package/src/lib/jobs/ai-job-plan-conformance.js +7 -1
- package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-creations.d.ts +80 -0
- package/src/lib/jobs/ai-job-plan-creations.js +199 -0
- package/src/lib/jobs/ai-job-plan-creations.js.map +1 -0
- package/src/lib/jobs/ai-job-plan-step.d.ts +8 -2
- package/src/lib/jobs/ai-job-plan-step.js +20 -6
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +2 -0
- package/src/lib/jobs/ai-job-site-step.js +6 -6
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-template-step.d.ts +9 -6
- package/src/lib/jobs/ai-job-template-step.js +72 -15
- package/src/lib/jobs/ai-job-template-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.js +1 -1
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-workflow-records.d.ts +5 -4
- package/src/lib/jobs/ai-workflow-records.js +26 -22
- package/src/lib/jobs/ai-workflow-records.js.map +1 -1
- package/src/lib/model/ai-allotments.d.ts +16 -3
- package/src/lib/model/ai-allotments.js +34 -11
- package/src/lib/model/ai-allotments.js.map +1 -1
- package/src/lib/model/ai-job-creations.d.ts +55 -0
- package/src/lib/model/ai-job-creations.js +85 -0
- package/src/lib/model/ai-job-creations.js.map +1 -0
- package/src/lib/model/ai-page-job.d.ts +3 -2
- package/src/lib/model/ai-page-job.js +4 -3
- package/src/lib/model/ai-page-job.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +4 -2
- package/src/lib/model/ai-site-job.js +8 -4
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-template-subjects.d.ts +10 -0
- package/src/lib/model/ai-template-subjects.js +14 -0
- package/src/lib/model/ai-template-subjects.js.map +1 -1
- package/src/lib/model/ai-tokens.js +1 -1
- package/src/lib/model/ai-tokens.js.map +1 -1
- package/src/lib/model/ai-usage-by-user.js +1 -1
- package/src/lib/model/ai-usage-by-user.js.map +1 -1
- package/src/lib/providers/catalog.js +1 -1
- package/src/lib/providers/catalog.js.map +1 -1
- package/src/lib/providers/model-choice.js +1 -1
- package/src/lib/providers/model-choice.js.map +1 -1
- package/src/lib/runtime/ai-device-audit.d.ts +1 -1
- package/src/lib/runtime/ai-device-audit.js +1 -1
- package/src/lib/runtime/ai-device-audit.js.map +1 -1
- package/src/lib/runtime/ai-eval-live.js +1 -1
- package/src/lib/runtime/ai-eval-live.js.map +1 -1
- package/src/lib/runtime/ai-eval.d.ts +1 -1
- package/src/lib/runtime/ai-eval.js.map +1 -1
- package/src/lib/runtime/ai-gate.js +1 -1
- package/src/lib/runtime/ai-gate.js.map +1 -1
- package/src/lib/runtime/ai-palette.d.ts +1 -1
- package/src/lib/runtime/ai-palette.generated.d.ts +1 -1
- package/src/lib/runtime/ai-palette.generated.js +1 -1
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/ai-palette.js.map +1 -1
- package/src/lib/runtime/site-inventory.js +28 -22
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-admin-org.js +1 -1
- package/src/lib/server/ai-admin-org.js.map +1 -1
- package/src/lib/server/ai-admin-orgs-spend.js +1 -1
- package/src/lib/server/ai-admin-orgs-spend.js.map +1 -1
- package/src/lib/server/ai-admin-signals.js +1 -1
- package/src/lib/server/ai-admin-signals.js.map +1 -1
- package/src/lib/server/ai-allotments.js +1 -1
- package/src/lib/server/ai-allotments.js.map +1 -1
- package/src/lib/server/ai-assist.js +2 -2
- package/src/lib/server/ai-assist.js.map +1 -1
- package/src/lib/server/assist-chat.d.ts +1 -1
- package/src/lib/server/assist-chat.js +2 -2
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/billing-credits.d.ts +5 -0
- package/src/lib/server/billing-credits.js +8 -2
- package/src/lib/server/billing-credits.js.map +1 -1
- package/src/lib/server/billing-overage.js +2 -2
- package/src/lib/server/billing-overage.js.map +1 -1
- package/src/lib/testing/stand-in-dataset-index.d.ts +20 -0
- package/src/lib/testing/stand-in-dataset-index.js +80 -0
- package/src/lib/testing/stand-in-dataset-index.js.map +1 -0
- package/src/lib/usage/ai-allotment-alerts.d.ts +10 -7
- package/src/lib/usage/ai-allotment-alerts.js +31 -14
- package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
- package/src/lib/usage/ai-quota-checks.d.ts +37 -0
- package/src/lib/usage/ai-quota-checks.js +69 -0
- package/src/lib/usage/ai-quota-checks.js.map +1 -0
- package/src/lib/usage/ai-usage-alerts.js +22 -10
- package/src/lib/usage/ai-usage-alerts.js.map +1 -1
- package/src/lib/usage/ai-usage-by-user.js +1 -1
- package/src/lib/usage/ai-usage-by-user.js.map +1 -1
- package/src/lib/usage/assist-ceiling.d.ts +2 -2
- package/src/lib/usage/assist-ceiling.js +4 -4
- package/src/lib/usage/assist-ceiling.js.map +1 -1
- package/src/lib/usage/assist-credits.d.ts +494 -0
- package/src/lib/usage/assist-credits.js +524 -0
- package/src/lib/usage/assist-credits.js.map +1 -0
- package/src/lib/usage/assist-free-taste.d.ts +1 -1
- package/src/lib/usage/assist-free-taste.js +1 -1
- package/src/lib/usage/assist-free-taste.js.map +1 -1
- package/src/lib/usage/assist-signal-mining.js +1 -1
- package/src/lib/usage/assist-signal-mining.js.map +1 -1
- package/src/lib/usage/assist-spend-guards.d.ts +153 -0
- package/src/lib/usage/assist-spend-guards.js +182 -0
- package/src/lib/usage/assist-spend-guards.js.map +1 -0
- package/src/lib/usage/assist-usage.d.ts +1 -1
- package/src/lib/usage/assist-usage.js +1 -1
- package/src/lib/usage/assist-usage.js.map +1 -1
- package/src/lib/usage/staff-org-ai.js +1 -1
- package/src/lib/usage/staff-org-ai.js.map +1 -1
- package/src/lib/usage-axes.d.ts +10 -0
- package/src/lib/usage-axes.js +40 -2
- package/src/lib/usage-axes.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/billing-credits.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\nimport { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n publicAssistCredits,\n resolveAssistBudgetUsd,\n} from '
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/billing-credits.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\nimport { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n publicAssistCredits,\n assistBandRefuses,\n resolveAssistBudgetUsd,\n} from '../usage/assist-credits'\nimport { isUncappedPlanComp } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n// lockdown-423: exempt — a READ-ONLY billing surface that writes nothing,\n// same posture as billing/usage-budget beside it. AGL-1501 keeps\n// billing-locked sessions alive so members can reach Billing, and a\n// locked org needs its usage readouts more than an unlocked one does.\n\n/**\n * This month's Aglyn Assist credit standing for one org — the odometer beside\n * the band on Billing → Usage.\n *\n * ## Why a route at all\n *\n * Every other meter on that page reads its counter straight from Firestore in\n * the browser. This one cannot: `orgs/{id}/assistUsage` is deliberately absent\n * from the rules file and therefore default-deny for every client, because the\n * assist subcollections sit beside the exchange prose and are reached only\n * through the Admin SDK. A client read would fail silently and the meter would\n * sit at \"not yet metered\" forever — a surface that looks like a readout and\n * is not one, which is worse than no meter at all.\n *\n * ## Why it answers in CREDITS\n *\n * The stored figure is `estCostUsd`: our provider bill for the month at the\n * serving model's list rates. Publishing it would put our model choice and our\n * margin on a billing page, and it would move under the customer every time\n * the model is swapped. `publicAssistCredits` is the one conversion, and no\n * dollar figure crosses this boundary.\n *\n * `credits: null` is the honest answer for an org that has no assist band at\n * all. Since AGL-3203 no plan ROW bands at zero, so what reaches this is a\n * per-org override that writes one. It is not \"0 of 0\"; there is no band to\n * be a fraction of, and the page renders no meter rather than an empty one.\n *\n * An UNCAPPED staff comp (AGL-3049) has no band either, but it has a meter:\n * the workspace draws credits with no limit to draw them against. It answers\n * `credits: { used, limit: null, remaining: null }` and `unlimited: true`.\n * `null` is the band's absence on the wire — never `Infinity`, which\n * `JSON.stringify` would turn into a `null` nobody chose — and the flag says\n * which absence it is, so a reader that turns `null` into a number cannot\n * mistake an unlimited workspace for one with a band of zero.\n *\n * `stopsAtBand` says what happens at the band — the reservation's own\n * verdict (`assistBandRefuses`): `true` where it refuses, `false` where it\n * sells past at the plan's rate — so a surface that warns about the band\n * says the same thing the gate does without resolving the plan itself.\n *\n * Membership alone, not `billing.manage`: this is a capacity readout of the\n * kind every other meter on the page shows to anyone who can see the page, and\n * it carries no money. The spend view that DOES carry money is\n * `/api/billing/usage-budget`, which gates on `billing.manage`.\n */\nasync function handler(request: Request): Promise<Response> {\n const { method, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n const orgId = String((query as Record<string, unknown>)?.orgId ?? '')\n if (!orgId) return Response.json({ error: 'Bad request' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const isStaff = decoded['staff'] === true\n const actor = await resolveOrgMembership(decoded.uid, orgId)\n if (!isStaff && !actor?.member) {\n return Response.json({ error: 'Not a member' }, { status: 403 })\n }\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return Response.json({ error: 'Unknown organization' }, { status: 404 })\n }\n const org = orgSnapshot.data() as never\n const budgetUsd = resolveAssistBudgetUsd(org)\n const unlimited = budgetUsd === null && isUncappedPlanComp(org)\n if (budgetUsd === null && !unlimited) return Response.json({ credits: null })\n const month = new Date().toISOString().slice(0, 7)\n const usage = await orgRef.collection('assistUsage').doc(month).get()\n const costUsd = Number(usage.get('estCostUsd') ?? 0)\n return Response.json({\n credits: publicAssistCredits(\n Number.isFinite(costUsd) && costUsd > 0 ? costUsd : 0,\n budgetUsd,\n ),\n stopsAtBand: assistBandRefuses(org),\n ...(unlimited ? { unlimited: true } : {}),\n })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993). Null\n // for anything else, so a real failure keeps the answer below.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[assist-credits] read failed', orgId, error)\n // The meter's \"not yet metered\" state, not a zero — a failed read must\n // not render as \"you have used none of your credits\".\n return Response.json({ error: 'Assist credits unavailable' }, { status: 500 })\n }\n}\n\nexport const GET = handler\n"],"names":["pluginRequestFromWeb","publicAssistCredits","assistBandRefuses","resolveAssistBudgetUsd","isUncappedPlanComp","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","resolveOrgMembership","invalidIdTokenResponse","handler","request","headers","method","rawHeaders","query","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","orgId","String","usage","decoded","app","auth","verifyIdToken","email_verified","isStaff","actor","uid","member","orgRef","firestore","collection","doc","orgSnapshot","get","exists","org","data","budgetUsd","unlimited","credits","month","Date","toISOString","costUsd","Number","isFinite","stopsAtBand","unauthenticated","console","GET"],"mappings":"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"}
|
|
@@ -15,10 +15,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
18
|
-
import { ASSIST_HARD_CAP_CONTROL_LABEL, ASSIST_OVERAGE_CAP_CONTROL_LABEL, ASSIST_OVERAGE_CAP_MAX_USD, ASSIST_OVERAGE_CAP_MIN_USD, resolveAssistCreditBudget, resolveAssistHardCap, resolveAssistOverageCapUsd, resolveAssistOverageRateUsdPer1k } from "
|
|
18
|
+
import { ASSIST_HARD_CAP_CONTROL_LABEL, ASSIST_OVERAGE_CAP_CONTROL_LABEL, ASSIST_OVERAGE_CAP_MAX_USD, ASSIST_OVERAGE_CAP_MIN_USD, resolveAssistCreditBudget, resolveAssistHardCap, resolveAssistOverageCapUsd, resolveAssistOverageRateUsdPer1k } from "../usage/assist-credits.js";
|
|
19
19
|
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { logAiOverageControl } from "../activity/ai-activity.js";
|
|
21
|
-
import { assistMonthOverage } from "
|
|
21
|
+
import { assistMonthOverage } from "../usage/assist-credits.js";
|
|
22
22
|
import { assistUsageMonth } from "../usage/assist-usage.js";
|
|
23
23
|
import { aiOverageBillsByInvoice } from "../billing/ai-overage-cutover.js";
|
|
24
24
|
import { aiOverageNextChargeUsd } from "../billing/ai-overage-gate.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/billing-overage.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\nimport { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n ASSIST_HARD_CAP_CONTROL_LABEL,\n ASSIST_OVERAGE_CAP_CONTROL_LABEL,\n ASSIST_OVERAGE_CAP_MAX_USD,\n ASSIST_OVERAGE_CAP_MIN_USD,\n resolveAssistCreditBudget,\n resolveAssistHardCap,\n resolveAssistOverageCapUsd,\n resolveAssistOverageRateUsdPer1k,\n} from '@aglyn/aglyn/app-utils/assist-credits'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { logAiOverageControl } from '../activity/ai-activity'\nimport { assistMonthOverage } from '@aglyn/aglyn/app-utils/assist-credits'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { aiOverageBillsByInvoice } from '../billing/ai-overage-cutover'\nimport { aiOverageNextChargeUsd } from '../billing/ai-overage-gate'\nimport {\n AI_BILLING_STANDING_DOC,\n AI_BILLING_SUBCOLLECTION,\n readAiOverageMonthLedger,\n} from '../billing/ai-overage-ledger'\nimport {\n AI_OVERAGE_THRESHOLD_USD,\n aiOverageCardOnFile,\n aiOverageEffectiveCeilingUsd,\n aiOverageStandingCeilingUsd,\n aiOverageStep,\n readAiOverageStanding,\n} from '../billing/ai-overage-standing'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n// lockdown-423: exempt — self-serve billing surface, same posture as\n// billing/storage-overage beside it. AGL-1501 keeps billing-locked sessions\n// alive precisely so members can reach Billing, and this is the control that\n// stops an org accruing assist charges, so a 423 would trap it accruing them.\n\n/**\n * Read or set the org's own assist controls: the hard-cap switch (AGL-2653)\n * and the dollar ceiling on overage (AGL-2898).\n *\n * Credits past the plan's assist band are SOLD by default, at the rate\n * `resolveAssistOverageRateUsdPer1k` answers — the plan's listed\n * `extraAssistCreditsUsdPer1k`, or the add-on's rate on a plan that carries\n * its band only with the add-on. This route holds the two\n * controls a customer has over that, and nothing else — it is not a consent\n * surface, and there is nothing here an org must do before assist works.\n *\n * `get` → whether the switch is on, the ceiling if any, the band,\n * the rate the overage bills at, and whether the plan sells\n * overage at all\n * `setHardCap` → store `hardCap: true | false`\n * `setCap` → store `capUsd: number`, or `null` to clear the ceiling\n *\n * `billing.manage`-gated, because both controls bound spend in both\n * directions: lifting either is what lets the org be invoiced further past\n * its band, and setting either is what stops a workspace's assistant.\n * Admin-SDK-only by construction — `assistOverage` is denied to every client\n * in the rules, so a member cannot lift their own ceiling or lower somebody\n * else's.\n *\n * TURNING THE SWITCH OFF AND CLEARING THE CEILING ARE ALWAYS AVAILABLE, on\n * any plan and at any usage level: an org that wants the sale back on must\n * not have to argue with a precondition. Turning the switch on, or setting a\n * ceiling, is refused, 409, on a plan that sells no overage — there the band\n * is already the wall (`assistBandRefuses`), and storing a control that\n * changes nothing would read as protection the org does not have.\n */\n/**\n * What the overage card needs to say about Aglyn's own limit (AGL-3011).\n *\n * Every figure is `null` before the cutover month. That is not a missing\n * value: it is the honest answer while overage still bills on the monthly\n * invoice, and rendering a ceiling and a \"next charge\" figure that nothing\n * enforces would be a promise the platform is not yet keeping.\n */\nasync function readAiOverageStandingForCard(\n orgRef: FirebaseFirestore.DocumentReference,\n org: Parameters<typeof aiOverageEffectiveCeilingUsd>[0],\n): Promise<{\n aglynCeilingUsd: number | null\n overageStep: number | null\n overageThresholdUsd: number | null\n overageChargedUsd: number | null\n overageChargeCount: number | null\n overageNextChargeUsd: number | null\n overagePaused: string | null\n overageCardOnFile: boolean | null\n overageBillsByInvoice: boolean\n}> {\n const now = new Date()\n const month = assistUsageMonth(now)\n const empty = {\n aglynCeilingUsd: null,\n overageStep: null,\n overageThresholdUsd: null,\n overageChargedUsd: null,\n overageChargeCount: null,\n overageNextChargeUsd: null,\n overagePaused: null,\n overageCardOnFile: null,\n overageBillsByInvoice: false,\n }\n if (!aiOverageBillsByInvoice(month)) return empty\n const [standingSnapshot, usageSnapshot] = await Promise.all([\n orgRef.collection(AI_BILLING_SUBCOLLECTION).doc(AI_BILLING_STANDING_DOC).get(),\n orgRef.collection('assistUsage').doc(month).get(),\n ])\n const standing = readAiOverageStanding(\n standingSnapshot.exists ? (standingSnapshot.data() ?? null) : null,\n )\n const ledger = readAiOverageMonthLedger(\n usageSnapshot.exists ? (usageSnapshot.data() ?? null) : null,\n )\n const priced = assistMonthOverage(org, Number(usageSnapshot.get('estCostUsd') ?? 0))\n return {\n aglynCeilingUsd: aiOverageStandingCeilingUsd(standing, now),\n overageStep: aiOverageStep(standing),\n overageThresholdUsd: AI_OVERAGE_THRESHOLD_USD,\n overageChargedUsd: ledger.paidUsd,\n overageChargeCount: ledger.charges,\n overageNextChargeUsd: aiOverageNextChargeUsd(\n priced.overageMonthlyUsd,\n ledger.invoicedUsd,\n ),\n overagePaused: standing.pause?.reason ?? null,\n overageCardOnFile: aiOverageCardOnFile(standing) ?? null,\n overageBillsByInvoice: true,\n }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const orgId = String(body?.orgId ?? '')\n const action = String(body?.action ?? '')\n if (!orgId || !['get', 'setHardCap', 'setCap'].includes(action)) {\n return Response.json({ error: 'Bad request' }, { status: 400 })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const isStaff = decoded['staff'] === true\n const actor = await resolveOrgMembership(decoded.uid, orgId)\n if (\n !isStaff &&\n !(await memberHasOrgPermission(orgId, actor?.member, 'billing.manage'))\n ) {\n return Response.json({ error: 'billing.manage required' }, { status: 403 })\n }\n\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return Response.json({ error: 'Unknown organization' }, { status: 404 })\n }\n const org = (orgSnapshot.data() ?? {}) as Record<string, unknown>\n const hardCap = resolveAssistHardCap(org as never)\n const capUsd = resolveAssistOverageCapUsd(org as never)\n const bandCredits = resolveAssistCreditBudget(org as never)\n // The rate the card quotes must be the rate the rollup bills, so it is\n // read through the one resolver every reader of the rate goes through.\n // That mattered acutely under AGL-3014, when Starter's rate lived off\n // `PLAN_PRICING` and reading the table directly quoted $0.00 on an org\n // that was really being invoiced. AGL-3203 put the rate on Starter's own\n // row and deleted the exception, so the table and the resolver now agree\n // — and this still reads the resolver, because a comp is priced at\n // nothing and only `resolvePlanPricing` knows that.\n const overageRateUsdPer1k = resolveAssistOverageRateUsdPer1k(org as never)\n const sellsOverage = bandCredits !== null && overageRateUsdPer1k !== null\n\n if (action === 'get') {\n // AGLYN'S OWN LIMIT BESIDE THE WORKSPACE'S (AGL-3011). The card shows\n // both because the LOWER of the two binds, and a card that showed only\n // the figure the workspace set would explain neither the refusal it\n // gets at Aglyn's nor the charge it sees before its own is reached.\n //\n // `null` for every figure while the platform still bills overage on\n // the monthly invoice, so the card keeps saying what is true today\n // rather than describing a mechanism that is not running.\n const overage = await readAiOverageStandingForCard(orgRef, org as never)\n return Response.json(\n {\n hardCap,\n capUsd,\n bandCredits,\n overageRateUsdPer1k,\n sellsOverage,\n ...overage,\n label: ASSIST_HARD_CAP_CONTROL_LABEL,\n capLabel: ASSIST_OVERAGE_CAP_CONTROL_LABEL,\n minCapUsd: ASSIST_OVERAGE_CAP_MIN_USD,\n maxCapUsd: ASSIST_OVERAGE_CAP_MAX_USD,\n },\n { status: 200 },\n )\n }\n\n if (action === 'setCap') {\n const raw = body?.capUsd\n // `null` clears; anything else must be a real number in a real range.\n // A string \"25\" is refused rather than coerced, the way the switch\n // refuses a string \"false\": the resolver reads the field strictly, so\n // a value that reached the document as a string would be no ceiling\n // at all while the card said one was set.\n const clearing = raw === null\n if (\n !clearing &&\n (typeof raw !== 'number' ||\n !Number.isFinite(raw) ||\n raw < ASSIST_OVERAGE_CAP_MIN_USD ||\n raw > ASSIST_OVERAGE_CAP_MAX_USD)\n ) {\n return Response.json(\n {\n error:\n `Set a monthly AI overage ceiling between ` +\n `$${ASSIST_OVERAGE_CAP_MIN_USD} and ` +\n `$${ASSIST_OVERAGE_CAP_MAX_USD.toLocaleString('en-US')}, ` +\n `or clear it.`,\n code: 'invalid_cap',\n },\n { status: 400 },\n )\n }\n if (!clearing && !sellsOverage) {\n return Response.json(\n {\n error:\n bandCredits === null\n ? 'Your plan includes no AI assist credits, so there is no ' +\n 'overage to cap. Upgrade in Billing to add AI assist.'\n : 'Your plan already stops AI assist at its included band — ' +\n 'it sells no credits past it, so there is no overage to cap.',\n code: 'not_sold',\n },\n { status: 409 },\n )\n }\n const requestedCap = clearing ? null : (raw as number)\n await orgRef.set(\n {\n assistOverage: {\n capUsd: requestedCap,\n capSetAt: FieldValue.serverTimestamp(),\n capSetBy: decoded.uid,\n },\n },\n { merge: true },\n )\n await addAdminAudit(firebaseAdmin.app().firestore(), {\n actorUid: decoded.uid,\n actorEmail: decoded.email ?? null,\n action: 'billing.assistOverage.setCap',\n target: `orgs/${orgId}`,\n before: { capUsd },\n after: { capUsd: requestedCap },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n // The customer's own feed (AGL-2929): `adminAudit` is staff-only, and\n // the workspace should see who set its ceiling. Nothing when the value\n // did not move.\n await logAiOverageControl(\n orgId,\n { uid: decoded.uid, email: decoded.email ?? null },\n { control: 'cap', before: capUsd, after: requestedCap },\n )\n return Response.json({ ok: true, capUsd: requestedCap }, { status: 200 })\n }\n\n // Strictly a boolean, the same way the resolver reads it strictly: a\n // string \"false\" arriving here would otherwise be stored as `true`.\n if (typeof body?.hardCap !== 'boolean') {\n return Response.json(\n { error: 'hardCap must be true or false', code: 'invalid_value' },\n { status: 400 },\n )\n }\n const requested = body.hardCap as boolean\n\n if (requested && !sellsOverage) {\n return Response.json(\n {\n error:\n bandCredits === null\n ? 'Your plan includes no AI assist credits, so there is no band ' +\n 'to stop at. Upgrade in Billing to add AI assist.'\n : 'Your plan already stops AI assist at its included band — ' +\n 'it sells no credits past it, so there is nothing to switch off.',\n code: 'not_sold',\n },\n { status: 409 },\n )\n }\n\n await orgRef.set(\n {\n assistOverage: {\n hardCap: requested,\n hardCapSetAt: FieldValue.serverTimestamp(),\n hardCapSetBy: decoded.uid,\n },\n },\n { merge: true },\n )\n await addAdminAudit(firebaseAdmin.app().firestore(), {\n actorUid: decoded.uid,\n actorEmail: decoded.email ?? null,\n action: 'billing.assistOverage.setHardCap',\n target: `orgs/${orgId}`,\n before: { hardCap },\n after: { hardCap: requested },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n await logAiOverageControl(\n orgId,\n { uid: decoded.uid, email: decoded.email ?? null },\n { control: 'hardCap', before: hardCap, after: requested },\n )\n\n return Response.json({ ok: true, hardCap: requested }, { status: 200 })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993). Null\n // for anything else, so a real failure keeps the answer below.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error(error)\n return Response.json(\n { error: 'Assist overage update failed' },\n { status: 500 },\n )\n }\n}\n\nexport const dynamic = 'force-dynamic'\nexport { handler as POST }\n"],"names":["pluginRequestFromWeb","ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_MAX_USD","ASSIST_OVERAGE_CAP_MIN_USD","resolveAssistCreditBudget","resolveAssistHardCap","resolveAssistOverageCapUsd","resolveAssistOverageRateUsdPer1k","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","memberHasOrgPermission","resolveOrgMembership","logAiOverageControl","assistMonthOverage","assistUsageMonth","aiOverageBillsByInvoice","aiOverageNextChargeUsd","AI_BILLING_STANDING_DOC","AI_BILLING_SUBCOLLECTION","readAiOverageMonthLedger","AI_OVERAGE_THRESHOLD_USD","aiOverageCardOnFile","aiOverageStandingCeilingUsd","aiOverageStep","readAiOverageStanding","FieldValue","invalidIdTokenResponse","addAdminAudit","readAiOverageStandingForCard","orgRef","org","standingSnapshot","usageSnapshot","standing","now","Date","month","empty","aglynCeilingUsd","overageStep","overageThresholdUsd","overageChargedUsd","overageChargeCount","overageNextChargeUsd","overagePaused","overageCardOnFile","overageBillsByInvoice","Promise","all","collection","doc","get","exists","data","ledger","priced","Number","paidUsd","charges","overageMonthlyUsd","invoicedUsd","pause","reason","handler","request","headers","method","body","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","orgId","String","action","includes","orgSnapshot","decoded","app","auth","verifyIdToken","email_verified","isStaff","actor","uid","member","firestore","hardCap","capUsd","bandCredits","overageRateUsdPer1k","sellsOverage","overage","label","capLabel","minCapUsd","maxCapUsd","raw","clearing","isFinite","toLocaleString","code","requestedCap","set","assistOverage","capSetAt","serverTimestamp","capSetBy","merge","actorUid","actorEmail","email","target","before","after","at","catch","control","ok","requested","hardCapSetAt","hardCapSetBy","unauthenticated","console","dynamic","POST"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/billing-overage.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\nimport { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n ASSIST_HARD_CAP_CONTROL_LABEL,\n ASSIST_OVERAGE_CAP_CONTROL_LABEL,\n ASSIST_OVERAGE_CAP_MAX_USD,\n ASSIST_OVERAGE_CAP_MIN_USD,\n resolveAssistCreditBudget,\n resolveAssistHardCap,\n resolveAssistOverageCapUsd,\n resolveAssistOverageRateUsdPer1k,\n} from '../usage/assist-credits'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { logAiOverageControl } from '../activity/ai-activity'\nimport { assistMonthOverage } from '../usage/assist-credits'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { aiOverageBillsByInvoice } from '../billing/ai-overage-cutover'\nimport { aiOverageNextChargeUsd } from '../billing/ai-overage-gate'\nimport {\n AI_BILLING_STANDING_DOC,\n AI_BILLING_SUBCOLLECTION,\n readAiOverageMonthLedger,\n} from '../billing/ai-overage-ledger'\nimport {\n AI_OVERAGE_THRESHOLD_USD,\n aiOverageCardOnFile,\n aiOverageEffectiveCeilingUsd,\n aiOverageStandingCeilingUsd,\n aiOverageStep,\n readAiOverageStanding,\n} from '../billing/ai-overage-standing'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n// lockdown-423: exempt — self-serve billing surface, same posture as\n// billing/storage-overage beside it. AGL-1501 keeps billing-locked sessions\n// alive precisely so members can reach Billing, and this is the control that\n// stops an org accruing assist charges, so a 423 would trap it accruing them.\n\n/**\n * Read or set the org's own assist controls: the hard-cap switch (AGL-2653)\n * and the dollar ceiling on overage (AGL-2898).\n *\n * Credits past the plan's assist band are SOLD by default, at the rate\n * `resolveAssistOverageRateUsdPer1k` answers — the plan's listed\n * `extraAssistCreditsUsdPer1k`, or the add-on's rate on a plan that carries\n * its band only with the add-on. This route holds the two\n * controls a customer has over that, and nothing else — it is not a consent\n * surface, and there is nothing here an org must do before assist works.\n *\n * `get` → whether the switch is on, the ceiling if any, the band,\n * the rate the overage bills at, and whether the plan sells\n * overage at all\n * `setHardCap` → store `hardCap: true | false`\n * `setCap` → store `capUsd: number`, or `null` to clear the ceiling\n *\n * `billing.manage`-gated, because both controls bound spend in both\n * directions: lifting either is what lets the org be invoiced further past\n * its band, and setting either is what stops a workspace's assistant.\n * Admin-SDK-only by construction — `assistOverage` is denied to every client\n * in the rules, so a member cannot lift their own ceiling or lower somebody\n * else's.\n *\n * TURNING THE SWITCH OFF AND CLEARING THE CEILING ARE ALWAYS AVAILABLE, on\n * any plan and at any usage level: an org that wants the sale back on must\n * not have to argue with a precondition. Turning the switch on, or setting a\n * ceiling, is refused, 409, on a plan that sells no overage — there the band\n * is already the wall (`assistBandRefuses`), and storing a control that\n * changes nothing would read as protection the org does not have.\n */\n/**\n * What the overage card needs to say about Aglyn's own limit (AGL-3011).\n *\n * Every figure is `null` before the cutover month. That is not a missing\n * value: it is the honest answer while overage still bills on the monthly\n * invoice, and rendering a ceiling and a \"next charge\" figure that nothing\n * enforces would be a promise the platform is not yet keeping.\n */\nasync function readAiOverageStandingForCard(\n orgRef: FirebaseFirestore.DocumentReference,\n org: Parameters<typeof aiOverageEffectiveCeilingUsd>[0],\n): Promise<{\n aglynCeilingUsd: number | null\n overageStep: number | null\n overageThresholdUsd: number | null\n overageChargedUsd: number | null\n overageChargeCount: number | null\n overageNextChargeUsd: number | null\n overagePaused: string | null\n overageCardOnFile: boolean | null\n overageBillsByInvoice: boolean\n}> {\n const now = new Date()\n const month = assistUsageMonth(now)\n const empty = {\n aglynCeilingUsd: null,\n overageStep: null,\n overageThresholdUsd: null,\n overageChargedUsd: null,\n overageChargeCount: null,\n overageNextChargeUsd: null,\n overagePaused: null,\n overageCardOnFile: null,\n overageBillsByInvoice: false,\n }\n if (!aiOverageBillsByInvoice(month)) return empty\n const [standingSnapshot, usageSnapshot] = await Promise.all([\n orgRef.collection(AI_BILLING_SUBCOLLECTION).doc(AI_BILLING_STANDING_DOC).get(),\n orgRef.collection('assistUsage').doc(month).get(),\n ])\n const standing = readAiOverageStanding(\n standingSnapshot.exists ? (standingSnapshot.data() ?? null) : null,\n )\n const ledger = readAiOverageMonthLedger(\n usageSnapshot.exists ? (usageSnapshot.data() ?? null) : null,\n )\n const priced = assistMonthOverage(org, Number(usageSnapshot.get('estCostUsd') ?? 0))\n return {\n aglynCeilingUsd: aiOverageStandingCeilingUsd(standing, now),\n overageStep: aiOverageStep(standing),\n overageThresholdUsd: AI_OVERAGE_THRESHOLD_USD,\n overageChargedUsd: ledger.paidUsd,\n overageChargeCount: ledger.charges,\n overageNextChargeUsd: aiOverageNextChargeUsd(\n priced.overageMonthlyUsd,\n ledger.invoicedUsd,\n ),\n overagePaused: standing.pause?.reason ?? null,\n overageCardOnFile: aiOverageCardOnFile(standing) ?? null,\n overageBillsByInvoice: true,\n }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const orgId = String(body?.orgId ?? '')\n const action = String(body?.action ?? '')\n if (!orgId || !['get', 'setHardCap', 'setCap'].includes(action)) {\n return Response.json({ error: 'Bad request' }, { status: 400 })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const isStaff = decoded['staff'] === true\n const actor = await resolveOrgMembership(decoded.uid, orgId)\n if (\n !isStaff &&\n !(await memberHasOrgPermission(orgId, actor?.member, 'billing.manage'))\n ) {\n return Response.json({ error: 'billing.manage required' }, { status: 403 })\n }\n\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return Response.json({ error: 'Unknown organization' }, { status: 404 })\n }\n const org = (orgSnapshot.data() ?? {}) as Record<string, unknown>\n const hardCap = resolveAssistHardCap(org as never)\n const capUsd = resolveAssistOverageCapUsd(org as never)\n const bandCredits = resolveAssistCreditBudget(org as never)\n // The rate the card quotes must be the rate the rollup bills, so it is\n // read through the one resolver every reader of the rate goes through.\n // That mattered acutely under AGL-3014, when Starter's rate lived off\n // `PLAN_PRICING` and reading the table directly quoted $0.00 on an org\n // that was really being invoiced. AGL-3203 put the rate on Starter's own\n // row and deleted the exception, so the table and the resolver now agree\n // — and this still reads the resolver, because a comp is priced at\n // nothing and only `resolvePlanPricing` knows that.\n const overageRateUsdPer1k = resolveAssistOverageRateUsdPer1k(org as never)\n const sellsOverage = bandCredits !== null && overageRateUsdPer1k !== null\n\n if (action === 'get') {\n // AGLYN'S OWN LIMIT BESIDE THE WORKSPACE'S (AGL-3011). The card shows\n // both because the LOWER of the two binds, and a card that showed only\n // the figure the workspace set would explain neither the refusal it\n // gets at Aglyn's nor the charge it sees before its own is reached.\n //\n // `null` for every figure while the platform still bills overage on\n // the monthly invoice, so the card keeps saying what is true today\n // rather than describing a mechanism that is not running.\n const overage = await readAiOverageStandingForCard(orgRef, org as never)\n return Response.json(\n {\n hardCap,\n capUsd,\n bandCredits,\n overageRateUsdPer1k,\n sellsOverage,\n ...overage,\n label: ASSIST_HARD_CAP_CONTROL_LABEL,\n capLabel: ASSIST_OVERAGE_CAP_CONTROL_LABEL,\n minCapUsd: ASSIST_OVERAGE_CAP_MIN_USD,\n maxCapUsd: ASSIST_OVERAGE_CAP_MAX_USD,\n },\n { status: 200 },\n )\n }\n\n if (action === 'setCap') {\n const raw = body?.capUsd\n // `null` clears; anything else must be a real number in a real range.\n // A string \"25\" is refused rather than coerced, the way the switch\n // refuses a string \"false\": the resolver reads the field strictly, so\n // a value that reached the document as a string would be no ceiling\n // at all while the card said one was set.\n const clearing = raw === null\n if (\n !clearing &&\n (typeof raw !== 'number' ||\n !Number.isFinite(raw) ||\n raw < ASSIST_OVERAGE_CAP_MIN_USD ||\n raw > ASSIST_OVERAGE_CAP_MAX_USD)\n ) {\n return Response.json(\n {\n error:\n `Set a monthly AI overage ceiling between ` +\n `$${ASSIST_OVERAGE_CAP_MIN_USD} and ` +\n `$${ASSIST_OVERAGE_CAP_MAX_USD.toLocaleString('en-US')}, ` +\n `or clear it.`,\n code: 'invalid_cap',\n },\n { status: 400 },\n )\n }\n if (!clearing && !sellsOverage) {\n return Response.json(\n {\n error:\n bandCredits === null\n ? 'Your plan includes no AI assist credits, so there is no ' +\n 'overage to cap. Upgrade in Billing to add AI assist.'\n : 'Your plan already stops AI assist at its included band — ' +\n 'it sells no credits past it, so there is no overage to cap.',\n code: 'not_sold',\n },\n { status: 409 },\n )\n }\n const requestedCap = clearing ? null : (raw as number)\n await orgRef.set(\n {\n assistOverage: {\n capUsd: requestedCap,\n capSetAt: FieldValue.serverTimestamp(),\n capSetBy: decoded.uid,\n },\n },\n { merge: true },\n )\n await addAdminAudit(firebaseAdmin.app().firestore(), {\n actorUid: decoded.uid,\n actorEmail: decoded.email ?? null,\n action: 'billing.assistOverage.setCap',\n target: `orgs/${orgId}`,\n before: { capUsd },\n after: { capUsd: requestedCap },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n // The customer's own feed (AGL-2929): `adminAudit` is staff-only, and\n // the workspace should see who set its ceiling. Nothing when the value\n // did not move.\n await logAiOverageControl(\n orgId,\n { uid: decoded.uid, email: decoded.email ?? null },\n { control: 'cap', before: capUsd, after: requestedCap },\n )\n return Response.json({ ok: true, capUsd: requestedCap }, { status: 200 })\n }\n\n // Strictly a boolean, the same way the resolver reads it strictly: a\n // string \"false\" arriving here would otherwise be stored as `true`.\n if (typeof body?.hardCap !== 'boolean') {\n return Response.json(\n { error: 'hardCap must be true or false', code: 'invalid_value' },\n { status: 400 },\n )\n }\n const requested = body.hardCap as boolean\n\n if (requested && !sellsOverage) {\n return Response.json(\n {\n error:\n bandCredits === null\n ? 'Your plan includes no AI assist credits, so there is no band ' +\n 'to stop at. Upgrade in Billing to add AI assist.'\n : 'Your plan already stops AI assist at its included band — ' +\n 'it sells no credits past it, so there is nothing to switch off.',\n code: 'not_sold',\n },\n { status: 409 },\n )\n }\n\n await orgRef.set(\n {\n assistOverage: {\n hardCap: requested,\n hardCapSetAt: FieldValue.serverTimestamp(),\n hardCapSetBy: decoded.uid,\n },\n },\n { merge: true },\n )\n await addAdminAudit(firebaseAdmin.app().firestore(), {\n actorUid: decoded.uid,\n actorEmail: decoded.email ?? null,\n action: 'billing.assistOverage.setHardCap',\n target: `orgs/${orgId}`,\n before: { hardCap },\n after: { hardCap: requested },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n await logAiOverageControl(\n orgId,\n { uid: decoded.uid, email: decoded.email ?? null },\n { control: 'hardCap', before: hardCap, after: requested },\n )\n\n return Response.json({ ok: true, hardCap: requested }, { status: 200 })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993). Null\n // for anything else, so a real failure keeps the answer below.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error(error)\n return Response.json(\n { error: 'Assist overage update failed' },\n { status: 500 },\n )\n }\n}\n\nexport const dynamic = 'force-dynamic'\nexport { handler as POST }\n"],"names":["pluginRequestFromWeb","ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_MAX_USD","ASSIST_OVERAGE_CAP_MIN_USD","resolveAssistCreditBudget","resolveAssistHardCap","resolveAssistOverageCapUsd","resolveAssistOverageRateUsdPer1k","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","memberHasOrgPermission","resolveOrgMembership","logAiOverageControl","assistMonthOverage","assistUsageMonth","aiOverageBillsByInvoice","aiOverageNextChargeUsd","AI_BILLING_STANDING_DOC","AI_BILLING_SUBCOLLECTION","readAiOverageMonthLedger","AI_OVERAGE_THRESHOLD_USD","aiOverageCardOnFile","aiOverageStandingCeilingUsd","aiOverageStep","readAiOverageStanding","FieldValue","invalidIdTokenResponse","addAdminAudit","readAiOverageStandingForCard","orgRef","org","standingSnapshot","usageSnapshot","standing","now","Date","month","empty","aglynCeilingUsd","overageStep","overageThresholdUsd","overageChargedUsd","overageChargeCount","overageNextChargeUsd","overagePaused","overageCardOnFile","overageBillsByInvoice","Promise","all","collection","doc","get","exists","data","ledger","priced","Number","paidUsd","charges","overageMonthlyUsd","invoicedUsd","pause","reason","handler","request","headers","method","body","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","orgId","String","action","includes","orgSnapshot","decoded","app","auth","verifyIdToken","email_verified","isStaff","actor","uid","member","firestore","hardCap","capUsd","bandCredits","overageRateUsdPer1k","sellsOverage","overage","label","capLabel","minCapUsd","maxCapUsd","raw","clearing","isFinite","toLocaleString","code","requestedCap","set","assistOverage","capSetAt","serverTimestamp","capSetBy","merge","actorUid","actorEmail","email","target","before","after","at","catch","control","ok","requested","hardCapSetAt","hardCapSetBy","unauthenticated","console","dynamic","POST"],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
/** Registers it, reading through `firestore`. */
|
|
18
|
+
export declare function standInDatasetIndex(firestore: FirebaseFirestore.Firestore): void;
|
|
19
|
+
/** Forgets it, so the next spec starts with nothing keeping datasets. */
|
|
20
|
+
export declare function removeStandInDatasetIndex(): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
*/ import { effectiveDatasetModel } from "@aglyn/aglyn/app-utils/dataset-models";
|
|
17
|
+
import { datasetDisplayName } from "@aglyn/aglyn/app-utils/datasets";
|
|
18
|
+
import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
19
|
+
import { registerPluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
20
|
+
import { unregisterPluginServices } from "@aglyn/aglyn/plugin-manager/plugin-services";
|
|
21
|
+
/**
|
|
22
|
+
* The `dataset` index the plugin that keeps the workspace's datasets
|
|
23
|
+
* publishes (AGL-3080), stood in over a spec's own Firestore double — this
|
|
24
|
+
* plugin may not load the data plugin that registers the real one. It answers
|
|
25
|
+
* in the shape the data plugin's `dataset-record-index.ts` documents: live
|
|
26
|
+
* datasets only, narrowed to a site to those shared with it, each with its
|
|
27
|
+
* fields (`{ id, name, type }`, in the dataset page's order) and its
|
|
28
|
+
* `visibleTo`. What the data plugin itself answers is held in its own spec.
|
|
29
|
+
*/ const OWNER = 'data';
|
|
30
|
+
const str = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
31
|
+
function record(id, data) {
|
|
32
|
+
if (!data || data['deletedAt'] != null) return null;
|
|
33
|
+
const model = effectiveDatasetModel(data);
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
name: datasetDisplayName(data) || id,
|
|
37
|
+
facts: {
|
|
38
|
+
fields: model.order.map((fieldId)=>{
|
|
39
|
+
var _ref;
|
|
40
|
+
var _model_fields_fieldId, _model_fields_fieldId1;
|
|
41
|
+
return {
|
|
42
|
+
id: fieldId,
|
|
43
|
+
name: str((_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId.name) || fieldId,
|
|
44
|
+
type: String((_ref = (_model_fields_fieldId1 = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId1.type) != null ? _ref : 'text')
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
visibleTo: Array.isArray(data['visibleTo']) ? data['visibleTo'] : []
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Registers it, reading through `firestore`. */ export function standInDatasetIndex(firestore) {
|
|
52
|
+
const datasets = (orgId)=>firestore.collection('orgs').doc(orgId).collection('datasets');
|
|
53
|
+
const shared = (one, hostId)=>one && (!hostId || visibleToHost(one.facts['visibleTo'], hostId)) ? one : null;
|
|
54
|
+
registerPluginRecordIndex('dataset', {
|
|
55
|
+
async list ({ orgId, hostId, limit }) {
|
|
56
|
+
if (!orgId) return {
|
|
57
|
+
records: [],
|
|
58
|
+
truncated: false
|
|
59
|
+
};
|
|
60
|
+
const snapshot = await datasets(orgId).get();
|
|
61
|
+
const records = snapshot.docs.map((doc)=>shared(record(doc.id, doc.data()), hostId)).filter((one)=>one !== null);
|
|
62
|
+
return {
|
|
63
|
+
records: records.slice(0, limit),
|
|
64
|
+
truncated: records.length > limit
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
async get ({ orgId, hostId, id }) {
|
|
68
|
+
if (!orgId) return null;
|
|
69
|
+
const snapshot = await datasets(orgId).doc(id).get();
|
|
70
|
+
return snapshot.exists ? shared(record(snapshot.id, snapshot.data()), hostId) : null;
|
|
71
|
+
}
|
|
72
|
+
}, {
|
|
73
|
+
pluginId: OWNER
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Forgets it, so the next spec starts with nothing keeping datasets. */ export function removeStandInDatasetIndex() {
|
|
77
|
+
unregisterPluginServices(OWNER);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//# sourceMappingURL=stand-in-dataset-index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/testing/stand-in-dataset-index.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\nimport { effectiveDatasetModel, type DatasetModel } from '@aglyn/aglyn/app-utils/dataset-models'\nimport { datasetDisplayName } from '@aglyn/aglyn/app-utils/datasets'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n registerPluginRecordIndex,\n type PluginIndexedRecord,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { unregisterPluginServices } from '@aglyn/aglyn/plugin-manager/plugin-services'\n\n/**\n * The `dataset` index the plugin that keeps the workspace's datasets\n * publishes (AGL-3080), stood in over a spec's own Firestore double — this\n * plugin may not load the data plugin that registers the real one. It answers\n * in the shape the data plugin's `dataset-record-index.ts` documents: live\n * datasets only, narrowed to a site to those shared with it, each with its\n * fields (`{ id, name, type }`, in the dataset page's order) and its\n * `visibleTo`. What the data plugin itself answers is held in its own spec.\n */\n\nconst OWNER = 'data'\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\nfunction record(id: string, data: Record<string, unknown> | undefined): PluginIndexedRecord | null {\n if (!data || data['deletedAt'] != null) return null\n const model = effectiveDatasetModel(data as { model?: DatasetModel; fields?: string[] })\n return {\n id,\n name: datasetDisplayName(data) || id,\n facts: {\n fields: model.order.map((fieldId) => ({\n id: fieldId,\n name: str(model.fields[fieldId]?.name) || fieldId,\n type: String(model.fields[fieldId]?.type ?? 'text'),\n })),\n visibleTo: Array.isArray(data['visibleTo']) ? data['visibleTo'] : [],\n },\n }\n}\n\n/** Registers it, reading through `firestore`. */\nexport function standInDatasetIndex(firestore: FirebaseFirestore.Firestore): void {\n const datasets = (orgId: string) => firestore.collection('orgs').doc(orgId).collection('datasets')\n const shared = (one: PluginIndexedRecord | null, hostId: string | null | undefined) =>\n one && (!hostId || visibleToHost(one.facts['visibleTo'] as string[], hostId)) ? one : null\n registerPluginRecordIndex(\n 'dataset',\n {\n async list({ orgId, hostId, limit }) {\n if (!orgId) return { records: [], truncated: false }\n const snapshot = await datasets(orgId).get()\n const records = snapshot.docs\n .map((doc) => shared(record(doc.id, doc.data()), hostId))\n .filter((one): one is PluginIndexedRecord => one !== null)\n return { records: records.slice(0, limit), truncated: records.length > limit }\n },\n async get({ orgId, hostId, id }) {\n if (!orgId) return null\n const snapshot = await datasets(orgId).doc(id).get()\n return snapshot.exists ? shared(record(snapshot.id, snapshot.data()), hostId) : null\n },\n },\n { pluginId: OWNER },\n )\n}\n\n/** Forgets it, so the next spec starts with nothing keeping datasets. */\nexport function removeStandInDatasetIndex(): void {\n unregisterPluginServices(OWNER)\n}\n"],"names":["effectiveDatasetModel","datasetDisplayName","visibleToHost","registerPluginRecordIndex","unregisterPluginServices","OWNER","str","value","trim","record","id","data","model","name","facts","fields","order","map","fieldId","type","String","visibleTo","Array","isArray","standInDatasetIndex","firestore","datasets","orgId","collection","doc","shared","one","hostId","list","limit","records","truncated","snapshot","get","docs","filter","slice","length","exists","pluginId","removeStandInDatasetIndex"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAA2B,wCAAuC;AAChG,SAASC,kBAAkB,QAAQ,kCAAiC;AACpE,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SACEC,yBAAyB,QAEpB,kDAAiD;AACxD,SAASC,wBAAwB,QAAQ,8CAA6C;AAEtF;;;;;;;;CAQC,GAED,MAAMC,QAAQ;AAEd,MAAMC,MAAM,CAACC,QAA4B,OAAOA,UAAU,WAAWA,MAAMC,IAAI,KAAK;AAEpF,SAASC,OAAOC,EAAU,EAAEC,IAAyC;IACnE,IAAI,CAACA,QAAQA,IAAI,CAAC,YAAY,IAAI,MAAM,OAAO;IAC/C,MAAMC,QAAQZ,sBAAsBW;IACpC,OAAO;QACLD;QACAG,MAAMZ,mBAAmBU,SAASD;QAClCI,OAAO;YACLC,QAAQH,MAAMI,KAAK,CAACC,GAAG,CAAC,CAACC;;oBAEbN,uBACGA;uBAHuB;oBACpCF,IAAIQ;oBACJL,MAAMP,KAAIM,wBAAAA,MAAMG,MAAM,CAACG,QAAQ,qBAArBN,sBAAuBC,IAAI,KAAKK;oBAC1CC,MAAMC,gBAAOR,yBAAAA,MAAMG,MAAM,CAACG,QAAQ,qBAArBN,uBAAuBO,IAAI,mBAAI;gBAC9C;;YACAE,WAAWC,MAAMC,OAAO,CAACZ,IAAI,CAAC,YAAY,IAAIA,IAAI,CAAC,YAAY,GAAG,EAAE;QACtE;IACF;AACF;AAEA,+CAA+C,GAC/C,OAAO,SAASa,oBAAoBC,SAAsC;IACxE,MAAMC,WAAW,CAACC,QAAkBF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACF,OAAOC,UAAU,CAAC;IACvF,MAAME,SAAS,CAACC,KAAiCC,SAC/CD,OAAQ,CAAA,CAACC,UAAU9B,cAAc6B,IAAIjB,KAAK,CAAC,YAAY,EAAckB,OAAM,IAAKD,MAAM;IACxF5B,0BACE,WACA;QACE,MAAM8B,MAAK,EAAEN,KAAK,EAAEK,MAAM,EAAEE,KAAK,EAAE;YACjC,IAAI,CAACP,OAAO,OAAO;gBAAEQ,SAAS,EAAE;gBAAEC,WAAW;YAAM;YACnD,MAAMC,WAAW,MAAMX,SAASC,OAAOW,GAAG;YAC1C,MAAMH,UAAUE,SAASE,IAAI,CAC1BtB,GAAG,CAAC,CAACY,MAAQC,OAAOrB,OAAOoB,IAAInB,EAAE,EAAEmB,IAAIlB,IAAI,KAAKqB,SAChDQ,MAAM,CAAC,CAACT,MAAoCA,QAAQ;YACvD,OAAO;gBAAEI,SAASA,QAAQM,KAAK,CAAC,GAAGP;gBAAQE,WAAWD,QAAQO,MAAM,GAAGR;YAAM;QAC/E;QACA,MAAMI,KAAI,EAAEX,KAAK,EAAEK,MAAM,EAAEtB,EAAE,EAAE;YAC7B,IAAI,CAACiB,OAAO,OAAO;YACnB,MAAMU,WAAW,MAAMX,SAASC,OAAOE,GAAG,CAACnB,IAAI4B,GAAG;YAClD,OAAOD,SAASM,MAAM,GAAGb,OAAOrB,OAAO4B,SAAS3B,EAAE,EAAE2B,SAAS1B,IAAI,KAAKqB,UAAU;QAClF;IACF,GACA;QAAEY,UAAUvC;IAAM;AAEtB;AAEA,uEAAuE,GACvE,OAAO,SAASwC;IACdzC,yBAAyBC;AAC3B"}
|
|
@@ -21,11 +21,12 @@ import { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-su
|
|
|
21
21
|
import { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications';
|
|
22
22
|
import { type AiAllotmentStanding, type AiAllotmentThreshold } from '../model/ai-allotments';
|
|
23
23
|
/**
|
|
24
|
-
* A SOFT allotment's 80
|
|
25
|
-
* two channels: the console notification every
|
|
26
|
-
* (`billing.usage`, to `users/{uid}/notifications`)
|
|
27
|
-
* sent past the platform suppression list and
|
|
28
|
-
* the same primitives the usage-alert sweep
|
|
24
|
+
* A SOFT allotment's 75, 80, 90 and 100% steps (AGL-2942, AGL-3431),
|
|
25
|
+
* through the alert pipeline's two channels: the console notification every
|
|
26
|
+
* usage threshold writes (`billing.usage`, to `users/{uid}/notifications`)
|
|
27
|
+
* and the email beside it, sent past the platform suppression list and
|
|
28
|
+
* metered as platform mail — the same primitives the usage-alert sweep
|
|
29
|
+
* composes.
|
|
29
30
|
*
|
|
30
31
|
* Told when the request that finds the subject at a threshold is admitted,
|
|
31
32
|
* rather than on the next daily sweep: an allotment is a line a manager drew
|
|
@@ -36,8 +37,10 @@ import { type AiAllotmentStanding, type AiAllotmentThreshold } from '../model/ai
|
|
|
36
37
|
*
|
|
37
38
|
* The workspace's owners and admins — the audience of every usage alert —
|
|
38
39
|
* and, for a member's or a collaborator's allotment, the person it is for.
|
|
39
|
-
* The person gets
|
|
40
|
-
*
|
|
40
|
+
* The person gets their own words, in the second person and without the
|
|
41
|
+
* Billing link or the "make it hard" step, neither of which they can use.
|
|
42
|
+
* Both name the workspace, and a collaborator's the site, because an owner
|
|
43
|
+
* of two workspaces cannot tell from a figure which one it is about.
|
|
41
44
|
*
|
|
42
45
|
* ## Once per threshold per month
|
|
43
46
|
*
|
|
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
|
|
18
|
+
import { usageNotificationLevel } from "@aglyn/aglyn/app-utils/notifications";
|
|
18
19
|
import { sendEmail } from "@aglyn/shared-util-email";
|
|
19
20
|
import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
|
|
20
21
|
import { meterPlatformEmail } from "@aglyn/tenant-data-admin/server/email-metering";
|
|
@@ -51,9 +52,18 @@ const DEFAULT_CHANNELS = {
|
|
|
51
52
|
return true;
|
|
52
53
|
});
|
|
53
54
|
}
|
|
55
|
+
/** The name a site's managers know it by, then its address, then its id. */ async function siteName(firestore, hostId) {
|
|
56
|
+
var _host_get, _host_get1;
|
|
57
|
+
const host = await firestore.collection('hosts').doc(hostId).get();
|
|
58
|
+
return String((_host_get = host.get('displayName')) != null ? _host_get : '').trim() || String((_host_get1 = host.get('subdomain')) != null ? _host_get1 : '').trim() || hostId;
|
|
59
|
+
}
|
|
54
60
|
async function recipientsFor(firestore, orgId, standing, reachable) {
|
|
61
|
+
var _org_get, _org_get1;
|
|
55
62
|
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
56
|
-
const members = await
|
|
63
|
+
const [org, members] = await Promise.all([
|
|
64
|
+
orgRef.get(),
|
|
65
|
+
orgRef.collection('members').get()
|
|
66
|
+
]);
|
|
57
67
|
// A locked or disabled account is told nothing, by either channel; it is
|
|
58
68
|
// still named when the alert is about it.
|
|
59
69
|
const open = new Set(await reachable(members.docs.map((member)=>member.id)));
|
|
@@ -75,17 +85,16 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
|
|
|
75
85
|
subjectName = String((_member_get1 = member.get('displayName')) != null ? _member_get1 : '').trim() || email;
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const host = await firestore.collection('hosts').doc(standing.hostId).get();
|
|
81
|
-
subjectName = String((_host_get = host.get('displayName')) != null ? _host_get : '').trim() || String((_host_get1 = host.get('subdomain')) != null ? _host_get1 : '').trim();
|
|
82
|
-
}
|
|
88
|
+
const site = standing.hostId ? await siteName(firestore, standing.hostId) : '';
|
|
89
|
+
if (standing.scope === 'host') subjectName = site;
|
|
83
90
|
return {
|
|
84
91
|
managerUids,
|
|
85
92
|
managerEmails,
|
|
86
93
|
subjectUid: standing.scope === 'host' || !standing.uid || !open.has(standing.uid) ? null : standing.uid,
|
|
87
94
|
subjectEmail: standing.scope === 'host' ? null : subjectEmail,
|
|
88
|
-
subjectName: subjectName || (standing.scope === 'host' ? '
|
|
95
|
+
subjectName: subjectName || (standing.scope === 'host' ? 'A site' : 'A member'),
|
|
96
|
+
workspaceName: String((_org_get = org.get('name')) != null ? _org_get : '').trim() || String((_org_get1 = org.get('slug')) != null ? _org_get1 : '').trim() || 'your workspace',
|
|
97
|
+
siteName: site
|
|
89
98
|
};
|
|
90
99
|
}
|
|
91
100
|
/**
|
|
@@ -99,13 +108,19 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
|
|
|
99
108
|
const claimed = await claimAiAllotmentAlert(firestore, input.orgId, standing.subject, input.month, threshold);
|
|
100
109
|
if (!claimed) continue;
|
|
101
110
|
const recipients = await recipientsFor(firestore, input.orgId, standing, reachable);
|
|
102
|
-
const
|
|
111
|
+
const words = {
|
|
103
112
|
scope: standing.scope,
|
|
104
113
|
threshold,
|
|
105
114
|
used: standing.used,
|
|
106
115
|
credits: standing.credits,
|
|
107
|
-
name: recipients.subjectName
|
|
108
|
-
|
|
116
|
+
name: recipients.subjectName,
|
|
117
|
+
workspace: recipients.workspaceName,
|
|
118
|
+
site: recipients.siteName
|
|
119
|
+
};
|
|
120
|
+
const copy = aiAllotmentAlertCopy(words);
|
|
121
|
+
const ownCopy = aiAllotmentAlertCopy(_extends({}, words, {
|
|
122
|
+
reader: 'subject'
|
|
123
|
+
}));
|
|
109
124
|
const link = input.orgSlug ? `${buildRoute(Route.MANAGE_BILLING_USAGE, {
|
|
110
125
|
orgSlug: input.orgSlug
|
|
111
126
|
})}#ai-allotments` : null;
|
|
@@ -116,6 +131,7 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
|
|
|
116
131
|
type: 'billing.usage',
|
|
117
132
|
title: copy.title,
|
|
118
133
|
body: copy.body,
|
|
134
|
+
level: usageNotificationLevel(threshold),
|
|
119
135
|
orgId: input.orgId
|
|
120
136
|
}, link ? {
|
|
121
137
|
link
|
|
@@ -127,8 +143,9 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
|
|
|
127
143
|
recipients.subjectUid
|
|
128
144
|
], {
|
|
129
145
|
type: 'billing.usage',
|
|
130
|
-
title:
|
|
131
|
-
body:
|
|
146
|
+
title: ownCopy.title,
|
|
147
|
+
body: ownCopy.body,
|
|
148
|
+
level: usageNotificationLevel(threshold),
|
|
132
149
|
orgId: input.orgId
|
|
133
150
|
}, {
|
|
134
151
|
skipEmail: true
|
|
@@ -152,8 +169,8 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
|
|
|
152
169
|
if (subjectAddress.length) {
|
|
153
170
|
const result = await send({
|
|
154
171
|
to: subjectAddress,
|
|
155
|
-
subject:
|
|
156
|
-
text:
|
|
172
|
+
subject: ownCopy.title,
|
|
173
|
+
text: ownCopy.body,
|
|
157
174
|
context: 'ai-allotment-alert'
|
|
158
175
|
});
|
|
159
176
|
if (result.sent) await meter().catch(()=>undefined);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-allotment-alerts.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\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport { meterPlatformEmail } from '@aglyn/tenant-data-admin/server/email-metering'\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport {\n aiAllotmentAlertCopy,\n type AiAllotmentStanding,\n type AiAllotmentThreshold,\n} from '../model/ai-allotments'\nimport { aiAllotmentRef } from './ai-allotments'\n\n/**\n * A SOFT allotment's 80% and 100% (AGL-2942), through the alert pipeline's\n * two channels: the console notification every usage threshold writes\n * (`billing.usage`, to `users/{uid}/notifications`) and the email beside it,\n * sent past the platform suppression list and metered as platform mail —\n * the same primitives the usage-alert sweep composes.\n *\n * Told when the request that finds the subject at a threshold is admitted,\n * rather than on the next daily sweep: an allotment is a line a manager drew\n * for one person or one site, and a day's delay is most of what a soft line\n * is for. The sweep is untouched.\n *\n * ## Who is told\n *\n * The workspace's owners and admins — the audience of every usage alert —\n * and, for a member's or a collaborator's allotment, the person it is for.\n * The person gets the words without the Billing link, which a collaborator\n * cannot open.\n *\n * ## Once per threshold per month\n *\n * The marker is `alerted: { month, threshold }` on the allotment itself,\n * claimed in a transaction before anything is sent, so two requests at the\n * line cannot both announce it. A write to the allotment clears the marker,\n * so a raised allotment announces its own next crossing.\n */\n\n/** A crossing to announce, as the reservation measured it. */\nexport interface AiAllotmentAlertInput {\n orgId: string\n /** The workspace's slug, for the Billing link; `null` omits the link. */\n orgSlug: string | null\n month: string\n alerts: ReadonlyArray<{ standing: AiAllotmentStanding; threshold: AiAllotmentThreshold }>\n}\n\n/** The pipeline's primitives, replaceable in a spec. */\nexport interface AiAllotmentAlertChannels {\n notify: typeof notifyUsers\n send: typeof sendEmail\n filter: typeof filterSuppressedEmails\n meter: typeof meterPlatformEmail\n /** The uids given, minus locked or disabled accounts (AGL-3418). */\n reachable: typeof withoutMailWithheldAccounts\n}\n\nconst DEFAULT_CHANNELS: AiAllotmentAlertChannels = {\n notify: notifyUsers,\n send: sendEmail,\n filter: filterSuppressedEmails,\n meter: meterPlatformEmail,\n reachable: withoutMailWithheldAccounts,\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/** Take the marker for `threshold` this month; `false` when it was taken. */\nexport async function claimAiAllotmentAlert(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n subject: string,\n month: string,\n threshold: AiAllotmentThreshold,\n): Promise<boolean> {\n const ref = aiAllotmentRef(firestore.collection('orgs').doc(orgId), subject)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return false\n const alerted = snapshot.get('alerted') as { month?: unknown; threshold?: unknown } | null\n if (alerted?.month === month && Number(alerted?.threshold) >= threshold) return false\n tx.set(ref, { alerted: { month, threshold } }, { merge: true })\n return true\n })\n}\n\ninterface Recipients {\n managerUids: string[]\n managerEmails: string[]\n subjectUid: string | null\n subjectEmail: string | null\n subjectName: string\n}\n\nasync function recipientsFor(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n standing: AiAllotmentStanding,\n reachable: AiAllotmentAlertChannels['reachable'],\n): Promise<Recipients> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const members = await orgRef.collection('members').get()\n // A locked or disabled account is told nothing, by either channel; it is\n // still named when the alert is about it.\n const open = new Set(await reachable(members.docs.map((member) => member.id)))\n const managerUids: string[] = []\n const managerEmails: string[] = []\n let subjectEmail: string | null = null\n let subjectName = ''\n for (const member of members.docs) {\n const role = member.get('role')\n const email = String(member.get('email') ?? '').trim()\n if ((role === 'owner' || role === 'admin') && open.has(member.id)) {\n managerUids.push(member.id)\n if (email.includes('@')) managerEmails.push(email)\n }\n if (standing.uid && member.id === standing.uid) {\n subjectEmail = email.includes('@') && open.has(member.id) ? email : null\n subjectName = String(member.get('displayName') ?? '').trim() || email\n }\n }\n if (standing.scope === 'host' && standing.hostId) {\n const host = await firestore.collection('hosts').doc(standing.hostId).get()\n subjectName =\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim()\n }\n return {\n managerUids,\n managerEmails,\n subjectUid:\n standing.scope === 'host' || !standing.uid || !open.has(standing.uid)\n ? null\n : standing.uid,\n subjectEmail: standing.scope === 'host' ? null : subjectEmail,\n subjectName: subjectName || (standing.scope === 'host' ? 'this' : 'A member'),\n }\n}\n\n/**\n * Announce every crossing the reservation found. Never throws, and answers\n * how many were announced — one failing crossing does not stop the next.\n */\nexport async function announceAiAllotmentAlerts(\n firestore: FirebaseFirestore.Firestore,\n input: AiAllotmentAlertInput,\n channels: Partial<AiAllotmentAlertChannels> = {},\n): Promise<number> {\n const { notify, send, filter, meter, reachable } = { ...DEFAULT_CHANNELS, ...channels }\n let announced = 0\n for (const { standing, threshold } of input.alerts) {\n try {\n const claimed = await claimAiAllotmentAlert(\n firestore,\n input.orgId,\n standing.subject,\n input.month,\n threshold,\n )\n if (!claimed) continue\n const recipients = await recipientsFor(firestore, input.orgId, standing, reachable)\n const copy = aiAllotmentAlertCopy({\n scope: standing.scope,\n threshold,\n used: standing.used,\n credits: standing.credits,\n name: recipients.subjectName,\n })\n const link = input.orgSlug\n ? `${buildRoute(Route.MANAGE_BILLING_USAGE, { orgSlug: input.orgSlug })}#ai-allotments`\n : null\n // `skipEmail`: the email below is this crossing's email, so the\n // per-person channel does not send a second one to anybody who switched\n // billing email on (AGL-3431).\n await notify(\n recipients.managerUids,\n {\n type: 'billing.usage',\n title: copy.title,\n body: copy.body,\n orgId: input.orgId,\n ...(link ? { link } : {}),\n },\n { skipEmail: true },\n )\n if (recipients.subjectUid && !recipients.managerUids.includes(recipients.subjectUid)) {\n await notify(\n [recipients.subjectUid],\n {\n type: 'billing.usage',\n title: copy.title,\n body: copy.body,\n orgId: input.orgId,\n },\n { skipEmail: true },\n )\n }\n const origin = consoleOrigin()\n const managerText = link && origin ? `${copy.body}\\n\\n${origin}${link}` : copy.body\n const managers = await filter(recipients.managerEmails)\n if (managers.length) {\n const result = await send({\n to: managers,\n subject: copy.title,\n text: managerText,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n const subjectAddress = recipients.subjectEmail\n ? (await filter([recipients.subjectEmail])).filter(\n (address) => !managers.includes(address),\n )\n : []\n if (subjectAddress.length) {\n const result = await send({\n to: subjectAddress,\n subject: copy.title,\n text: copy.body,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n announced += 1\n } catch (error) {\n console.error('[ai-allotments] announcing a crossing failed', input.orgId, standing.subject, error)\n }\n }\n return announced\n}\n"],"names":["buildRoute","Route","sendEmail","withoutMailWithheldAccounts","meterPlatformEmail","filterSuppressedEmails","notifyUsers","aiAllotmentAlertCopy","aiAllotmentRef","DEFAULT_CHANNELS","notify","send","filter","meter","reachable","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","claimAiAllotmentAlert","firestore","orgId","subject","month","threshold","ref","collection","doc","runTransaction","tx","snapshot","get","exists","alerted","Number","set","merge","recipientsFor","standing","orgRef","members","open","Set","docs","map","member","id","managerUids","managerEmails","subjectEmail","subjectName","role","email","String","has","push","includes","uid","scope","hostId","host","subjectUid","announceAiAllotmentAlerts","input","channels","announced","alerts","claimed","recipients","copy","used","credits","name","link","orgSlug","MANAGE_BILLING_USAGE","type","title","body","skipEmail","origin","managerText","managers","length","result","to","text","context","sent","catch","undefined","subjectAddress","address","error","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-allotment-alerts.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\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport { usageNotificationLevel } from '@aglyn/aglyn/app-utils/notifications'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport { meterPlatformEmail } from '@aglyn/tenant-data-admin/server/email-metering'\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport {\n aiAllotmentAlertCopy,\n type AiAllotmentStanding,\n type AiAllotmentThreshold,\n} from '../model/ai-allotments'\nimport { aiAllotmentRef } from './ai-allotments'\n\n/**\n * A SOFT allotment's 75, 80, 90 and 100% steps (AGL-2942, AGL-3431),\n * through the alert pipeline's two channels: the console notification every\n * usage threshold writes (`billing.usage`, to `users/{uid}/notifications`)\n * and the email beside it, sent past the platform suppression list and\n * metered as platform mail — the same primitives the usage-alert sweep\n * composes.\n *\n * Told when the request that finds the subject at a threshold is admitted,\n * rather than on the next daily sweep: an allotment is a line a manager drew\n * for one person or one site, and a day's delay is most of what a soft line\n * is for. The sweep is untouched.\n *\n * ## Who is told\n *\n * The workspace's owners and admins — the audience of every usage alert —\n * and, for a member's or a collaborator's allotment, the person it is for.\n * The person gets their own words, in the second person and without the\n * Billing link or the \"make it hard\" step, neither of which they can use.\n * Both name the workspace, and a collaborator's the site, because an owner\n * of two workspaces cannot tell from a figure which one it is about.\n *\n * ## Once per threshold per month\n *\n * The marker is `alerted: { month, threshold }` on the allotment itself,\n * claimed in a transaction before anything is sent, so two requests at the\n * line cannot both announce it. A write to the allotment clears the marker,\n * so a raised allotment announces its own next crossing.\n */\n\n/** A crossing to announce, as the reservation measured it. */\nexport interface AiAllotmentAlertInput {\n orgId: string\n /** The workspace's slug, for the Billing link; `null` omits the link. */\n orgSlug: string | null\n month: string\n alerts: ReadonlyArray<{ standing: AiAllotmentStanding; threshold: AiAllotmentThreshold }>\n}\n\n/** The pipeline's primitives, replaceable in a spec. */\nexport interface AiAllotmentAlertChannels {\n notify: typeof notifyUsers\n send: typeof sendEmail\n filter: typeof filterSuppressedEmails\n meter: typeof meterPlatformEmail\n /** The uids given, minus locked or disabled accounts (AGL-3418). */\n reachable: typeof withoutMailWithheldAccounts\n}\n\nconst DEFAULT_CHANNELS: AiAllotmentAlertChannels = {\n notify: notifyUsers,\n send: sendEmail,\n filter: filterSuppressedEmails,\n meter: meterPlatformEmail,\n reachable: withoutMailWithheldAccounts,\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/** Take the marker for `threshold` this month; `false` when it was taken. */\nexport async function claimAiAllotmentAlert(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n subject: string,\n month: string,\n threshold: AiAllotmentThreshold,\n): Promise<boolean> {\n const ref = aiAllotmentRef(firestore.collection('orgs').doc(orgId), subject)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return false\n const alerted = snapshot.get('alerted') as { month?: unknown; threshold?: unknown } | null\n if (alerted?.month === month && Number(alerted?.threshold) >= threshold) return false\n tx.set(ref, { alerted: { month, threshold } }, { merge: true })\n return true\n })\n}\n\ninterface Recipients {\n managerUids: string[]\n managerEmails: string[]\n subjectUid: string | null\n subjectEmail: string | null\n subjectName: string\n /** The workspace's name, for the copy. */\n workspaceName: string\n /** The site a collaborator's allotment is on, for the copy. */\n siteName: string\n}\n\n/** The name a site's managers know it by, then its address, then its id. */\nasync function siteName(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<string> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n return (\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim() ||\n hostId\n )\n}\n\nasync function recipientsFor(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n standing: AiAllotmentStanding,\n reachable: AiAllotmentAlertChannels['reachable'],\n): Promise<Recipients> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [org, members] = await Promise.all([orgRef.get(), orgRef.collection('members').get()])\n // A locked or disabled account is told nothing, by either channel; it is\n // still named when the alert is about it.\n const open = new Set(await reachable(members.docs.map((member) => member.id)))\n const managerUids: string[] = []\n const managerEmails: string[] = []\n let subjectEmail: string | null = null\n let subjectName = ''\n for (const member of members.docs) {\n const role = member.get('role')\n const email = String(member.get('email') ?? '').trim()\n if ((role === 'owner' || role === 'admin') && open.has(member.id)) {\n managerUids.push(member.id)\n if (email.includes('@')) managerEmails.push(email)\n }\n if (standing.uid && member.id === standing.uid) {\n subjectEmail = email.includes('@') && open.has(member.id) ? email : null\n subjectName = String(member.get('displayName') ?? '').trim() || email\n }\n }\n const site = standing.hostId ? await siteName(firestore, standing.hostId) : ''\n if (standing.scope === 'host') subjectName = site\n return {\n managerUids,\n managerEmails,\n subjectUid:\n standing.scope === 'host' || !standing.uid || !open.has(standing.uid)\n ? null\n : standing.uid,\n subjectEmail: standing.scope === 'host' ? null : subjectEmail,\n subjectName: subjectName || (standing.scope === 'host' ? 'A site' : 'A member'),\n workspaceName:\n String(org.get('name') ?? '').trim() ||\n String(org.get('slug') ?? '').trim() ||\n 'your workspace',\n siteName: site,\n }\n}\n\n/**\n * Announce every crossing the reservation found. Never throws, and answers\n * how many were announced — one failing crossing does not stop the next.\n */\nexport async function announceAiAllotmentAlerts(\n firestore: FirebaseFirestore.Firestore,\n input: AiAllotmentAlertInput,\n channels: Partial<AiAllotmentAlertChannels> = {},\n): Promise<number> {\n const { notify, send, filter, meter, reachable } = { ...DEFAULT_CHANNELS, ...channels }\n let announced = 0\n for (const { standing, threshold } of input.alerts) {\n try {\n const claimed = await claimAiAllotmentAlert(\n firestore,\n input.orgId,\n standing.subject,\n input.month,\n threshold,\n )\n if (!claimed) continue\n const recipients = await recipientsFor(firestore, input.orgId, standing, reachable)\n const words = {\n scope: standing.scope,\n threshold,\n used: standing.used,\n credits: standing.credits,\n name: recipients.subjectName,\n workspace: recipients.workspaceName,\n site: recipients.siteName,\n }\n const copy = aiAllotmentAlertCopy(words)\n const ownCopy = aiAllotmentAlertCopy({ ...words, reader: 'subject' })\n const link = input.orgSlug\n ? `${buildRoute(Route.MANAGE_BILLING_USAGE, { orgSlug: input.orgSlug })}#ai-allotments`\n : null\n // `skipEmail`: the email below is this crossing's email, so the\n // per-person channel does not send a second one to anybody who switched\n // billing email on (AGL-3431).\n await notify(\n recipients.managerUids,\n {\n type: 'billing.usage',\n title: copy.title,\n body: copy.body,\n level: usageNotificationLevel(threshold),\n orgId: input.orgId,\n ...(link ? { link } : {}),\n },\n { skipEmail: true },\n )\n if (recipients.subjectUid && !recipients.managerUids.includes(recipients.subjectUid)) {\n await notify(\n [recipients.subjectUid],\n {\n type: 'billing.usage',\n title: ownCopy.title,\n body: ownCopy.body,\n level: usageNotificationLevel(threshold),\n orgId: input.orgId,\n },\n { skipEmail: true },\n )\n }\n const origin = consoleOrigin()\n const managerText = link && origin ? `${copy.body}\\n\\n${origin}${link}` : copy.body\n const managers = await filter(recipients.managerEmails)\n if (managers.length) {\n const result = await send({\n to: managers,\n subject: copy.title,\n text: managerText,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n const subjectAddress = recipients.subjectEmail\n ? (await filter([recipients.subjectEmail])).filter(\n (address) => !managers.includes(address),\n )\n : []\n if (subjectAddress.length) {\n const result = await send({\n to: subjectAddress,\n subject: ownCopy.title,\n text: ownCopy.body,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n announced += 1\n } catch (error) {\n console.error('[ai-allotments] announcing a crossing failed', input.orgId, standing.subject, error)\n }\n }\n return announced\n}\n"],"names":["buildRoute","Route","usageNotificationLevel","sendEmail","withoutMailWithheldAccounts","meterPlatformEmail","filterSuppressedEmails","notifyUsers","aiAllotmentAlertCopy","aiAllotmentRef","DEFAULT_CHANNELS","notify","send","filter","meter","reachable","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","claimAiAllotmentAlert","firestore","orgId","subject","month","threshold","ref","collection","doc","runTransaction","tx","snapshot","get","exists","alerted","Number","set","merge","siteName","hostId","host","String","recipientsFor","standing","org","orgRef","members","Promise","all","open","Set","docs","map","member","id","managerUids","managerEmails","subjectEmail","subjectName","role","email","has","push","includes","uid","site","scope","subjectUid","workspaceName","announceAiAllotmentAlerts","input","channels","announced","alerts","claimed","recipients","words","used","credits","name","workspace","copy","ownCopy","reader","link","orgSlug","MANAGE_BILLING_USAGE","type","title","body","level","skipEmail","origin","managerText","managers","length","result","to","text","context","sent","catch","undefined","subjectAddress","address","error","console"],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
import type { UsageQuotaCheck, UsageQuotaContext } from '@aglyn/aglyn/plugin-manager/usage-alert-contributors';
|
|
18
|
+
/** The guard key the credits band is warned under. */
|
|
19
|
+
export declare const AI_CREDITS_QUOTA_KEY = "assistCredits";
|
|
20
|
+
/**
|
|
21
|
+
* The AI credits band the WORKSPACE is warned about (AGL-2898), as a quota
|
|
22
|
+
* check the usage-alerts sweep runs beside its own.
|
|
23
|
+
*
|
|
24
|
+
* Measured in CREDITS — the unit the customer was sold and the meter shows —
|
|
25
|
+
* from this plugin's line of the month's spend (the billed `estCostUsd`)
|
|
26
|
+
* through the one conversion, so this alert and the Billing meter cannot
|
|
27
|
+
* disagree about how far into the band the org is. A plan with no band
|
|
28
|
+
* resolves `null` and reads as a limit of 0, which the sweep skips the way it
|
|
29
|
+
* skips Free's zero-quota dimensions.
|
|
30
|
+
*
|
|
31
|
+
* Approaching the band is the sweep's generic warning; REACHING it says what
|
|
32
|
+
* happens next, which differs by plan and by the org's own controls — metered
|
|
33
|
+
* at a rate unless a stop is set, or stopped until next month with nothing
|
|
34
|
+
* ever billed — and telling a Free org to "set a monthly cap" on a charge it
|
|
35
|
+
* can never incur would be the surprise bill in reverse.
|
|
36
|
+
*/
|
|
37
|
+
export declare function aiQuotaChecks(context: UsageQuotaContext): Promise<readonly UsageQuotaCheck[]>;
|