@aglyn/plugins-ai 1.0.0-beta.199 → 1.0.0-beta.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-products-hub-card.component.js +3 -0
  3. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  4. package/src/lib/components/ai-products-proposals.component.js +4 -0
  5. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  6. package/src/lib/components/ai-site-batch-card.component.js +5 -1
  7. package/src/lib/components/ai-site-batch-card.component.js.map +1 -1
  8. package/src/lib/components/billing-ai-allotments.component.d.ts +6 -0
  9. package/src/lib/components/billing-ai-allotments.component.js +17 -5
  10. package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
  11. package/src/lib/components/billing-ai-top-users.component.js +8 -3
  12. package/src/lib/components/billing-ai-top-users.component.js.map +1 -1
  13. package/src/lib/components/staff-org-ai-card.component.js +12 -4
  14. package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
  15. package/src/lib/components/staff-user-ai-usage-card.component.js +7 -3
  16. package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
  17. package/src/lib/jobs/ai-job-drafts.d.ts +3 -1
  18. package/src/lib/jobs/ai-job-drafts.js +23 -7
  19. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  20. package/src/lib/jobs/ai-jobs.js +2 -1
  21. package/src/lib/jobs/ai-jobs.js.map +1 -1
  22. package/src/lib/server/ai-allotments.js +2 -1
  23. package/src/lib/server/ai-allotments.js.map +1 -1
  24. package/src/lib/server/ai-usage.js +2 -1
  25. package/src/lib/server/ai-usage.js.map +1 -1
  26. package/src/lib/server/billing-overage.js +3 -2
  27. package/src/lib/server/billing-overage.js.map +1 -1
  28. package/src/lib/usage/assist-free-taste.js +2 -1
  29. package/src/lib/usage/assist-free-taste.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from '@aglyn/aglyn/app-utils/assist-credits'\nimport {\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n accountCostUsd: Number(account?.get('estCostUsd') ?? 0),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","FREE_AI_TASTE_CREDITS_PER_MONTH","resolveEffectivePlan","sendStaffAlertEmail","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","add","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SACEC,oBAAoB,QAEf,wCAAuC;AAC9C,SACEC,+BAA+B,EAC/BC,oBAAoB,QACf,2CAA0C;AAEjD,SAASC,mBAAmB,QAAQ,oDAAmD;AAEvF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED,0EAA0E,GAC1E,OAAO,MAAMC,mCAAmC,UAAS;AAEzD,6EAA6E,GAC7E,OAAO,MAAMC,oCAAoC,sBAAqB;AAEtE;;;;;CAKC,GACD,OAAO,MAAMC,2BAA2B,EAAC;AAEzC,2EAA2E,GAC3E,OAAO,MAAMC,+BAA+B,IAAG;AAE/C,6EAA6E,GAC7E,OAAO,MAAMC,6BAA6BR,qBACxCC,iCACD;AAED;;;;;;;;;;;CAWC,GACD,OAAO,SAASQ;IACd,MAAMC,MAAMC,QAAQC,GAAG,CAACC,sBAAsB;IAC9C,MAAMC,SAASJ,MAAMK,OAAOL,OAAOK,OAAOC,GAAG;IAC7C,OAAOD,OAAOE,QAAQ,CAACH,WAAWA,UAAU,IAAII,KAAKC,KAAK,CAACL,UAAU;AACvE;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASM;QACKT;IAAnB,MAAMD,MAAMW,QAAOV,kDAAAA,QAAQC,GAAG,CAACU,kCAAkC,YAA9CX,kDAAkD,IAAIY,IAAI;IAC7E,MAAMT,SAASJ,MAAMK,OAAOL,OAAOK,OAAOC,GAAG;IAC7C,OAAOD,OAAOE,QAAQ,CAACH,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAmBA;;;;;;CAMC,GACD,OAAO,SAASU,kBACdC,GAAwC;;IAExC,IAAIvB,qBAAqBuB,SAAS,QAAQ,OAAO;IACjD,MAAMC,QAAQL,eAAOI,uBAAAA,IAAKE,QAAQ,mBAAI,IAAIJ,IAAI;IAC9C,MAAMK,UAAUP,gBAAOI,uBAAAA,IAAKI,YAAY,oBAAI,IAAIN,IAAI;IACpD,OAAO;QAAEO,YAAYJ,SAASE,WAAW;IAAK;AAChD;AAEA,OAAO,SAASG,oBACdC,SAAsC,EACtCF,UAAkB,EAClBG,KAAa;IAEb,OAAOD,UACJE,UAAU,CAAC,SACXC,GAAG,CAACL,YACJI,UAAU,CAAC9B,kCACX+B,GAAG,CAACF;AACT;AAEA,OAAO,SAASG,qBACdJ,SAAsC,EACtCK,GAAW;IAEX,OAAOL,UAAUE,UAAU,CAAC7B,mCAAmC8B,GAAG,CAACE;AACrE;AAWA,0EAA0E,GAC1E,OAAO,SAASC,mBACdC,OAAkD,EAClDC,QAAmD,EACnDH,GAAW;cAMGI,kBAGiBC,iBACAA;IAR/B,MAAMD,eAAQF,2BAAAA,QAASI,GAAG,CAAC,0BAAW,CAAC;IAIvC,MAAMD,SAAQD,YAAAA,IAAI,CAACJ,IAAI,YAATI,YAAa,CAAC;IAC5B,OAAO;QACLG,gBAAgB7B,gBAAOwB,2BAAAA,QAASI,GAAG,CAAC,iCAAiB;QACrDE,sBAAsB9B,QAAO2B,kBAAAA,MAAMI,QAAQ,YAAdJ,kBAAkB;QAC/CK,sBAAsBhC,QAAO2B,kBAAAA,MAAMM,QAAQ,YAAdN,kBAAkB;QAC/CO,iBAAiBlC,gBAAOyB,4BAAAA,SAAUG,GAAG,CAAC,iCAAiB;QACvDO,gBAAgBC,QAAQX,4BAAAA,SAAUG,GAAG,CAAC;IACxC;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASS,iBACdC,KAAqB,EACrBC,SAKI,CAAC,CAAC;QAEgBA,uBACCA,wBACEA,0BAEvBA;IAJF,MAAMC,iBAAgBD,wBAAAA,OAAOC,aAAa,YAApBD,wBAAwB7C;IAC9C,MAAM+C,kBAAiBF,yBAAAA,OAAOE,cAAc,YAArBF,yBAAyBhD;IAChD,MAAMmD,oBAAmBH,2BAAAA,OAAOG,gBAAgB,YAAvBH,2BAA2B9C;IACpD,MAAMkD,sBACJJ,6BAAAA,OAAOI,kBAAkB,YAAzBJ,6BAA6BlC;IAC/B,IAAIiC,MAAMR,oBAAoB,IAAIU,eAAe,OAAO;IACxD,IAAIF,MAAMN,oBAAoB,IAAIS,gBAAgB,OAAO;IACzD,IAAIH,MAAMT,cAAc,IAAIa,kBAAkB,OAAO;IACrD,IAAIJ,MAAMH,cAAc,IAAIG,MAAMJ,eAAe,IAAIS,oBAAoB;QACvE,OAAO;IACT;IACA,OAAO;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASC,4BACd1B,KAAa,EACbI,GAAW;IAEX,MAAMuB,YAAY7D,WAAW6D,SAAS;IACtC,OAAO;QACL3B;QACAa,UAAUc,UAAU;QACpBnB,MAAM;YAAE,CAACJ,IAAI,EAAE;gBAAES,UAAUc,UAAU;YAAG;QAAE;QAC1CC,WAAW9D,WAAW+D,eAAe;IACvC;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,qBAAqBC,KAKpC;IACC,MAAMJ,YAAY7D,WAAW6D,SAAS;IACtC,MAAME,kBAAkB/D,WAAW+D,eAAe;IAClD,MAAM,EAAEG,UAAU,EAAEC,OAAO,EAAE7B,GAAG,EAAEJ,KAAK,EAAE,GAAG+B;IAC5C,OAAO;QACLzB,SAAS2B,UACL;YACEjC;YACAQ,MAAM;gBAAE,CAACJ,IAAI,EAAE;oBAAEW,UAAUY,UAAU;gBAAG;YAAE;YAC1CC,WAAWC;QACb,IACA;YAAE7B;YAAOgC,YAAYL,UAAUK;YAAaJ,WAAWC;QAAkB;QAC7EtB,UAAU;YACRH;YACA4B,YAAYL,UAAUK;YACtBnB,UAAUc,UAAU;YACpBZ,UAAUY,UAAUM,UAAU,IAAI;YAClCL,WAAWC;QACb;IACF;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeK,0BACpBnC,SAAsC,EACtCK,GAAW;IAEX,MAAM+B,aAAahD;IACnB,MAAMiD,MAAMjC,qBAAqBJ,WAAWK;IAC5C,MAAMiC,UAAU,MAAMtC,UAAUuC,cAAc,CAAC,OAAOC;YAE1BC;QAD1B,MAAMA,WAAW,MAAMD,GAAG7B,GAAG,CAAC0B;QAC9B,MAAMJ,aAAalD,QAAO0D,gBAAAA,SAAS9B,GAAG,CAAC,yBAAb8B,gBAA8B;QACxD,MAAMC,QACJ,CAACD,SAAS9B,GAAG,CAAC,gBACdsB,cAAcG,aAAa7D;QAC7B,MAAMoE,QAAQ,CAACF,SAAS9B,GAAG,CAAC,eAAesB,cAAcG;QACzD,IAAIM,SAASC,OAAO;YAClBH,GAAGI,GAAG,CACJP,KACA,aACMK,QAAQ;gBAAEG,WAAW9E,WAAW+D,eAAe;YAAG,IAAI,CAAC,GACvDa,QAAQ;gBAAEG,UAAU/E,WAAW+D,eAAe;YAAG,IAAI,CAAC,IAE5D;gBAAEiB,OAAO;YAAK;QAElB;QACA,OAAO;YAAEC,SAASN;YAAOO,QAAQN;YAAOV;QAAW;IACrD;IACA,IAAI,CAACK,QAAQU,OAAO,IAAI,CAACV,QAAQW,MAAM,EAAE,OAAOX;IAEhD,MAAMY,SAAS,CAAC,CAAC,EAAEZ,QAAQL,UAAU,CAACkB,OAAO,CAAC,GAAG,SAAS,EAAEf,WAAWe,OAAO,CAAC,GAAG,QAAQ,CAAC;IAC3F,IAAIb,QAAQW,MAAM,EAAE;QAClB,MAAMjD,UACHE,UAAU,CAAC,cACXkD,GAAG,CAAC;YACHC,UAAU;YACVC,QAAQ;YACRC,QAAQ,GAAGlF,kCAAkC,CAAC,EAAEgC,KAAK;YACrDmD,OAAO;gBAAEnD;gBAAK4B,YAAYK,QAAQL,UAAU;gBAAEG;YAAW;YACzDqB,IAAI1F,WAAW+D,eAAe;QAChC,GACC4B,KAAK,CAAC,CAACC,QACNC,QAAQD,KAAK,CAAC,oCAAoCA;IAExD;IACA,MAAMxF,oBAAoB;QACxB0F,SAASvB,QAAQW,MAAM,GACnB,CAAC,8BAA8B,EAAE5C,IAAI,EAAE,EAAE6C,QAAQ,GACjD,CAAC,yCAAyC,EAAE7C,IAAI,GAAG,EAAE6C,QAAQ;QACjEY,MAAMxB,QAAQW,MAAM,GAChB,CAAC,uBAAuB,EAAE5C,IAAI,eAAe,EAAE6C,OAAO,CAAC,CAAC,GACxD,mEACA,wEACA,uEACA,qEACA,sEACA,cACA,CAAC,uBAAuB,EAAE7C,IAAI,aAAa,EAAE6C,OAAO,CAAC,CAAC,GACtD,8DACA,iEACA,iEACA;QACJa,SAAS;IACX;IACA,OAAOzB;AACT;AAeA,OAAO,eAAe0B,sBACpBhE,SAAsC,EACtCK,GAAW;QAIeoC,eAIPA,gBACAA;IAPnB,MAAMA,WAAW,MAAMrC,qBAAqBJ,WAAWK,KAAKM,GAAG;IAC/D,MAAMyB,aAAahD;IACnB,MAAM6C,aAAalD,QAAO0D,gBAAAA,SAAS9B,GAAG,CAAC,yBAAb8B,gBAA8B;IACxD,OAAO;QACLpC;QACA4B;QACAnB,UAAU/B,QAAO0D,iBAAAA,SAAS9B,GAAG,CAAC,uBAAb8B,iBAA4B;QAC7CzB,UAAUjC,QAAO0D,iBAAAA,SAAS9B,GAAG,CAAC,uBAAb8B,iBAA4B;QAC7CL;QACAY,SAAS7B,QAAQsB,SAAS9B,GAAG,CAAC;QAC9B,oEAAoE;QACpE,qEAAqE;QACrE,uDAAuD;QACvDsC,QAAQ9B,QAAQsB,SAAS9B,GAAG,CAAC,gBAAgBsB,cAAcG;IAC7D;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from '@aglyn/aglyn/app-utils/assist-credits'\nimport {\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n accountCostUsd: Number(account?.get('estCostUsd') ?? 0),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","FREE_AI_TASTE_CREDITS_PER_MONTH","resolveEffectivePlan","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":"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"}