@aglyn/aglyn 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.
Files changed (188) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/actions.d.ts +1 -59
  3. package/src/lib/app-utils/actions.js +7 -72
  4. package/src/lib/app-utils/actions.js.map +1 -1
  5. package/src/lib/app-utils/consent-groups.d.ts +1 -1
  6. package/src/lib/app-utils/consent-groups.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +0 -36
  8. package/src/lib/app-utils/console-routes.js +2 -37
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contacts.d.ts +1 -1
  11. package/src/lib/app-utils/contacts.js.map +1 -1
  12. package/src/lib/app-utils/container-membership.d.ts +93 -0
  13. package/src/lib/app-utils/container-membership.js +165 -0
  14. package/src/lib/app-utils/container-membership.js.map +1 -0
  15. package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
  16. package/src/lib/app-utils/crm-task-reminders.js +32 -11
  17. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  18. package/src/lib/app-utils/crm.d.ts +24 -4
  19. package/src/lib/app-utils/crm.js +23 -0
  20. package/src/lib/app-utils/crm.js.map +1 -1
  21. package/src/lib/app-utils/csv-upload.d.ts +1 -1
  22. package/src/lib/app-utils/csv-upload.js +1 -1
  23. package/src/lib/app-utils/csv-upload.js.map +1 -1
  24. package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
  25. package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
  26. package/src/lib/app-utils/csv.js.map +1 -0
  27. package/src/lib/app-utils/datasets.d.ts +0 -19
  28. package/src/lib/app-utils/datasets.js +0 -13
  29. package/src/lib/app-utils/datasets.js.map +1 -1
  30. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  31. package/src/lib/app-utils/docs-help.generated.js +5 -0
  32. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  33. package/src/lib/app-utils/docs-index.generated.js +71 -36
  34. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  35. package/src/lib/app-utils/dynamic-list-rule.js +3 -3
  36. package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
  37. package/src/lib/app-utils/email-text.d.ts +80 -0
  38. package/src/lib/app-utils/email-text.js +189 -0
  39. package/src/lib/app-utils/email-text.js.map +1 -0
  40. package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
  41. package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
  42. package/src/lib/app-utils/enrollment-basis.js.map +1 -0
  43. package/src/lib/app-utils/expand-repeatables.js +4 -4
  44. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  45. package/src/lib/app-utils/forms.js +2 -2
  46. package/src/lib/app-utils/forms.js.map +1 -1
  47. package/src/lib/app-utils/health-report.d.ts +2 -3
  48. package/src/lib/app-utils/health-report.js +30 -6
  49. package/src/lib/app-utils/health-report.js.map +1 -1
  50. package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
  51. package/src/lib/app-utils/inbound-mail-domain.js +31 -0
  52. package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
  53. package/src/lib/app-utils/leaving-notice.d.ts +96 -0
  54. package/src/lib/app-utils/leaving-notice.js +157 -0
  55. package/src/lib/app-utils/leaving-notice.js.map +1 -0
  56. package/src/lib/app-utils/lockdown.d.ts +11 -0
  57. package/src/lib/app-utils/lockdown.js +33 -3
  58. package/src/lib/app-utils/lockdown.js.map +1 -1
  59. package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
  60. package/src/lib/app-utils/member-email-aliases.js +29 -0
  61. package/src/lib/app-utils/member-email-aliases.js.map +1 -1
  62. package/src/lib/app-utils/notifications.d.ts +69 -0
  63. package/src/lib/app-utils/notifications.js +119 -0
  64. package/src/lib/app-utils/notifications.js.map +1 -1
  65. package/src/lib/app-utils/operator-alerts.d.ts +15 -1
  66. package/src/lib/app-utils/operator-alerts.js +64 -10
  67. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  68. package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
  69. package/src/lib/app-utils/plan-entitlements.js +200 -224
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
  72. package/src/lib/app-utils/platform-marketing-consent.js +6 -5
  73. package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
  74. package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
  75. package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
  76. package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
  77. package/src/lib/app-utils/repeat-query.d.ts +56 -0
  78. package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
  79. package/src/lib/app-utils/repeat-query.js.map +1 -0
  80. package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
  81. package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
  82. package/src/lib/app-utils/screen-link-context.js +14 -2
  83. package/src/lib/app-utils/screen-link-context.js.map +1 -1
  84. package/src/lib/app-utils/screen-route.d.ts +3 -0
  85. package/src/lib/app-utils/screen-route.js +6 -1
  86. package/src/lib/app-utils/screen-route.js.map +1 -1
  87. package/src/lib/app-utils/server.d.ts +7 -9
  88. package/src/lib/app-utils/server.js +18 -26
  89. package/src/lib/app-utils/server.js.map +1 -1
  90. package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
  91. package/src/lib/app-utils/starter-template-nodes.js +129 -0
  92. package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
  93. package/src/lib/app-utils/starter-templates.d.ts +11 -34
  94. package/src/lib/app-utils/starter-templates.js +19 -230
  95. package/src/lib/app-utils/starter-templates.js.map +1 -1
  96. package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
  97. package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
  98. package/src/lib/app-utils/subscription-topics.js.map +1 -0
  99. package/src/lib/app-utils/usage-budget.d.ts +73 -188
  100. package/src/lib/app-utils/usage-budget.js +58 -194
  101. package/src/lib/app-utils/usage-budget.js.map +1 -1
  102. package/src/lib/app-utils/utm-attribution.d.ts +1 -1
  103. package/src/lib/app-utils/utm-attribution.js +1 -1
  104. package/src/lib/app-utils/utm-attribution.js.map +1 -1
  105. package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
  106. package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
  107. package/src/lib/app-utils/utm-touch.js.map +1 -0
  108. package/src/lib/app-utils/variables.d.ts +9 -9
  109. package/src/lib/app-utils/variables.js.map +1 -1
  110. package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
  111. package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
  112. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  113. package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
  114. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  115. package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
  116. package/src/lib/plugin-manager/feature-plugins.js +22 -35
  117. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  118. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
  119. package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
  120. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  121. package/src/lib/plugin-manager/index.d.ts +0 -1
  122. package/src/lib/plugin-manager/index.js +0 -1
  123. package/src/lib/plugin-manager/index.js.map +1 -1
  124. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
  125. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  126. package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
  127. package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
  128. package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
  129. package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
  130. package/src/lib/plugin-manager/plugin-containers.js +27 -0
  131. package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
  132. package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
  133. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  134. package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
  135. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  136. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
  137. package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
  138. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  139. package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
  140. package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
  141. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
  142. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
  143. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  144. package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
  145. package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
  146. package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
  147. package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
  148. package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
  149. package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
  150. package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
  151. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
  152. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
  153. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
  154. package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
  155. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  156. package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
  157. package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
  158. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
  159. package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
  160. package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
  161. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
  162. package/src/lib/app-utils/assist-credits.d.ts +0 -482
  163. package/src/lib/app-utils/assist-credits.js +0 -510
  164. package/src/lib/app-utils/assist-credits.js.map +0 -1
  165. package/src/lib/app-utils/campaign-membership.d.ts +0 -121
  166. package/src/lib/app-utils/campaign-membership.js +0 -189
  167. package/src/lib/app-utils/campaign-membership.js.map +0 -1
  168. package/src/lib/app-utils/campaign-touch.js.map +0 -1
  169. package/src/lib/app-utils/console-record-links.d.ts +0 -84
  170. package/src/lib/app-utils/console-record-links.js +0 -76
  171. package/src/lib/app-utils/console-record-links.js.map +0 -1
  172. package/src/lib/app-utils/crm-booking.d.ts +0 -75
  173. package/src/lib/app-utils/crm-booking.js +0 -133
  174. package/src/lib/app-utils/crm-booking.js.map +0 -1
  175. package/src/lib/app-utils/crm-inbound.d.ts +0 -245
  176. package/src/lib/app-utils/crm-inbound.js +0 -407
  177. package/src/lib/app-utils/crm-inbound.js.map +0 -1
  178. package/src/lib/app-utils/crm-reports.d.ts +0 -479
  179. package/src/lib/app-utils/crm-reports.js +0 -680
  180. package/src/lib/app-utils/crm-reports.js.map +0 -1
  181. package/src/lib/app-utils/dataset-csv.js.map +0 -1
  182. package/src/lib/app-utils/dataset-query.d.ts +0 -57
  183. package/src/lib/app-utils/dataset-query.js.map +0 -1
  184. package/src/lib/app-utils/email-topics.js.map +0 -1
  185. package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
  186. package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
  187. package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
  188. package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
@@ -13,10 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ // The ONE unit conversion between our provider bill and what a customer is
17
- // shown. Imported here so `publicOrgMonthlySpend` cannot grow a second copy of
18
- // it — see that function, and the module header of `assist-credits.ts`.
19
- import { assistCreditsFromUsd } from "./assist-credits.js";
16
+ */ import { pluginSpendLines } from "../plugin-manager/plugin-usage-axes.js";
20
17
  /**
21
18
  * Per-org USAGE BUDGETS, modelled on a Google Cloud billing budget (AGL-1528).
22
19
  *
@@ -43,9 +40,9 @@ import { assistCreditsFromUsd } from "./assist-credits.js";
43
40
  * - **threshold rules** as percentages of the amount (default 50/90/100 —
44
41
  * GCP's own default trio);
45
42
  * - one alert per threshold per **month**, which is our budget period
46
- * because every meter underneath it (`orgs/{id}/usage/{month}`,
47
- * `assistUsage/{month}`, the counters' `YYYY-MM` fields) already resets
48
- * monthly. A budget period the meters do not share would silently compare
43
+ * because every meter underneath it (`orgs/{id}/usage/{month}`, each
44
+ * plugin's spend-line document, the counters' `YYYY-MM` fields) already
45
+ * resets monthly. A budget period the meters do not share would silently compare
49
46
  * a month of spend against a quarter of budget.
50
47
  *
51
48
  * ## WHY THIS IS NOT THE STORAGE CAP, AND MUST NOT BECOME IT
@@ -193,229 +190,96 @@ export const BUDGET_THRESHOLD_MAX_PCT = 200;
193
190
  return crossed;
194
191
  }
195
192
  /**
196
- * Whether `month`'s invoice charges for Assist tokens.
193
+ * Whether `month`'s invoice charges for a line whose deployment variable
194
+ * holds `configuredStart`.
197
195
  *
198
196
  * Same START-MONTH shape and the same fail-closed posture as
199
197
  * `billsOrgLibraryStorage`, and for the identical reason: a boolean flipped
200
198
  * mid-period would retroactively bill a re-run of an earlier month at that
201
- * month's accumulated tokens. A start month cannot reach backwards.
199
+ * month's accumulated usage. A start month cannot reach backwards.
202
200
  *
203
- * `BILL_ASSIST_TOKENS_FROM=YYYY-MM`. Anything else — `true`, `1`, a date, a
204
- * typo — bills nothing.
205
- */ export function billsAssistTokens(month, configuredStart) {
201
+ * Anything but a `YYYY-MM` — `true`, `1`, a date, a typo — bills nothing.
202
+ */ export function billsFromMonth(month, configuredStart) {
206
203
  const start = String(configuredStart != null ? configuredStart : '').trim();
207
204
  if (!/^\d{4}-\d{2}$/.test(start)) return false;
208
205
  if (!/^\d{4}-\d{2}$/.test(month)) return false;
209
206
  return month >= start;
210
207
  }
208
+ /** A positive finite dollar figure, or 0. */ function dollars(value) {
209
+ const parsed = Number(value);
210
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
211
+ }
211
212
  /**
212
213
  * Builds the month's spend from figures already read elsewhere.
213
214
  *
214
215
  * `rollupMonth` is compared to `month` rather than trusted: see
215
- * {@link OrgSpendBreakdown.meteredFresh}.
216
+ * {@link OrgSpendBreakdown.meteredFresh}. Every declared spend line is
217
+ * reported, read as nothing when its document was not read.
216
218
  */ export function orgMonthlySpend(input) {
217
- const { month, rollupBilledCents, rollupMonth, assistEstCostUsd } = input;
219
+ const { month, rollupBilledCents, rollupMonth } = input;
218
220
  const meteredFresh = Boolean(rollupMonth) && rollupMonth === month;
219
221
  const cents = Number(rollupBilledCents);
220
222
  const meteredUsd = meteredFresh && Number.isFinite(cents) && cents > 0 ? cents / 100 : 0;
221
- const assistRaw = Number(assistEstCostUsd);
222
- const assistUsd = Number.isFinite(assistRaw) && assistRaw > 0 ? assistRaw : 0;
223
- const assistBilled = billsAssistTokens(month, input.assistBilledFrom);
223
+ const lines = pluginSpendLines().map((line)=>{
224
+ var _input_lineReadings, _input_env;
225
+ return {
226
+ id: line.id,
227
+ pluginId: line.pluginId,
228
+ label: line.label,
229
+ usd: dollars((_input_lineReadings = input.lineReadings) == null ? void 0 : _input_lineReadings[line.id]),
230
+ billed: billsFromMonth(month, (_input_env = input.env) == null ? void 0 : _input_env[line.billedFromEnv])
231
+ };
232
+ });
224
233
  return {
225
234
  meteredUsd,
226
- assistUsd,
227
- assistBilled,
228
- totalUsd: meteredUsd + (assistBilled ? assistUsd : 0),
235
+ lines,
236
+ totalUsd: meteredUsd + lines.reduce((sum, line)=>sum + (line.billed ? line.usd : 0), 0),
229
237
  meteredFresh
230
238
  };
231
239
  }
240
+ /** Dollars in a unit of cost, rounded UP — spend already made never reads as less. */ function unitsOf(usd, costUsd) {
241
+ if (!Number.isFinite(usd) || usd <= 0) return 0;
242
+ return Math.ceil(usd / costUsd);
243
+ }
232
244
  /**
233
245
  * The projection that crosses the boundary to a customer's browser.
234
246
  *
235
- * `OrgSpendBreakdown.assistUsd` is `orgs/{id}/assistUsage/{month}.estCostUsd`
236
- * verbatim — our provider bill at the serving model's list rates, not a price
247
+ * A line declared with a `unit` stores the platform's own cost — for the AI
248
+ * plugin, its provider bill at the serving model's list rates, not a price
237
249
  * and not a rate the platform publishes. Serving it to an org-scoped caller
238
- * puts our model choice, our per-token rates and our margin on a billing page,
239
- * which is the boundary `publicAssistCredits` and `publicAssistQuota` already
240
- * exist to hold on the assist routes. This is the same boundary, one route
241
- * over, and it was the last place the dollar figure still crossed it.
242
- *
243
- * `assistCreditsFromUsd` is the ONE conversion between the two units, so this
244
- * cannot come to disagree with the credits meter about the same month's
245
- * consumption.
250
+ * would put a model choice, per-token rates and a margin on a billing page,
251
+ * so such a line crosses in its unit only, through the same rounding-up
252
+ * conversion the plugin's own meter uses, and its dollars never do.
246
253
  *
247
254
  * `meteredUsd` and `totalUsd` stay dollars: `billedCents` is what an invoice
248
- * charges, which is the customer's own money and the whole subject of a budget.
255
+ * charges, which is the customer's own money and the whole subject of a
256
+ * budget.
249
257
  *
250
- * The two units are deliberately not summed. A budget is a dollar rule, credits
251
- * are not dollars, and folding one into the other would republish the cost the
252
- * conversion just removed.
258
+ * Units and dollars are deliberately not summed. A budget is a dollar rule,
259
+ * a unit is not a dollar, and folding one into the other would republish the
260
+ * cost the conversion just removed.
253
261
  */ export function publicOrgMonthlySpend(spend) {
262
+ const declared = new Map(pluginSpendLines().map((line)=>[
263
+ line.id,
264
+ line
265
+ ]));
254
266
  return {
255
267
  meteredUsd: spend.meteredUsd,
256
- assistCredits: assistCreditsFromUsd(spend.assistUsd),
268
+ lines: spend.lines.map((line)=>{
269
+ var _declared_get;
270
+ const unit = (_declared_get = declared.get(line.id)) == null ? void 0 : _declared_get.unit;
271
+ return {
272
+ id: line.id,
273
+ label: line.label,
274
+ billed: line.billed,
275
+ units: unit ? unitsOf(line.usd, unit.costUsd) : null,
276
+ unitLabel: unit ? unit.label : null,
277
+ usd: unit ? null : line.usd
278
+ };
279
+ }),
257
280
  totalUsd: spend.totalUsd,
258
- assistBilled: spend.assistBilled,
259
281
  meteredFresh: spend.meteredFresh
260
282
  };
261
283
  }
262
- /**
263
- * The MARGIN GUARD (the other half of the ask, and the live half).
264
- *
265
- * A customer budget protects the customer. This protects us, from the one
266
- * meter on the platform whose unit cost is real money paid to a third party
267
- * and whose ceiling is a message count rather than a dollar figure:
268
- * `assistEntitledMonthlyLimit` caps an entitled org at 1,000 MESSAGES a
269
- * month, and a thousand long, cache-cold Opus-class exchanges is a
270
- * three-figure bill against a subscription that did not move.
271
- *
272
- * So one org's Assist COGS crossing this threshold notifies STAFF, not the
273
- * customer — the customer is not being charged and has done nothing wrong.
274
- * Returns the threshold crossed, or 0.
275
- *
276
- * `ASSIST_ORG_MONTHLY_COGS_ALERT_USD` overrides the default and FAILS TO THE
277
- * DEFAULT: a blank or malformed value that disabled the guard would be
278
- * another alert that cannot fire.
279
- */ export const ASSIST_ORG_MONTHLY_COGS_ALERT_USD_DEFAULT = 25;
280
- export function assistCogsAlertThresholdUsd(configured) {
281
- const parsed = Number(String(configured != null ? configured : '').trim());
282
- if (!Number.isFinite(parsed) || parsed <= 0) {
283
- return ASSIST_ORG_MONTHLY_COGS_ALERT_USD_DEFAULT;
284
- }
285
- return parsed;
286
- }
287
- /**
288
- * The repo default spend ceiling: **$40 per org per month** (AGL-2264).
289
- *
290
- * The number is arithmetic rather than pricing. After AGL-2441 the
291
- * 1,000-message entitled guard bounds roughly $28/org/month of worst-case
292
- * spend at Sonnet, and the staff margin alert already fires at $25. $40
293
- * therefore sits ABOVE anything the message cap can produce at the current
294
- * model, so it changes no charged amount and no plan's behaviour — it is a
295
- * ceiling on OUR cost, not a customer price, which is why it is outside the
296
- * Sept-1 pricing lock. It binds only when an assumption behind that
297
- * arithmetic has moved: an `ASSIST_MODEL` swap to an Opus-class tier, a
298
- * longer prompt, or a caller finding another way to inflate input. That is
299
- * the failure it exists for.
300
- *
301
- * It ships as a DEFAULT rather than as an environment variable someone must
302
- * remember, because an unset ceiling is the fail-open this issue was opened
303
- * about: a fresh deployment and a self-hoster both inherit a sane bound
304
- * without knowing the variable exists.
305
- *
306
- * The free tier gets no separate figure and needs none — its 10 messages a
307
- * UTC day bound it at roughly $0.28/day, so this is a backstop it cannot
308
- * reach rather than a cap it runs into.
309
- *
310
- * ⚠️ It applies only to an org whose plan sells NO assist band. Agency and
311
- * Enterprise include more assist than $40 of spend, so the meter's
312
- * `assistMonthlyCeilingUsd` keeps this default off every plan that sells a
313
- * band — see there for which figure binds when.
314
- */ export const ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD = 40;
315
- /**
316
- * The per-org monthly PROVIDER-SPEND ceiling in USD — the dollar half of
317
- * the cap (AGL-2264), from `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD` as the caller
318
- * read it. Defaults to {@link ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD};
319
- * `off` removes it.
320
- *
321
- * Pure over the configured value, like {@link assistCogsAlertThresholdUsd}:
322
- * the meter that refuses at the ceiling and the cron that announces the
323
- * refusal read the one variable and must agree on what it means.
324
- *
325
- * The meter's message caps bound spend only through an assumed cost per
326
- * message, and that assumption is exactly what drifts: `ASSIST_MODEL` is an
327
- * env override, prompts grow, and a client controls how much history it
328
- * posts. The ceiling is measured against the real figure instead: the
329
- * running `estCostUsd` on the month's assist usage document, which the meter
330
- * increments at the SERVING model's rates. So it bounds the actual bill
331
- * rather than a forecast of it.
332
- *
333
- * The refusal is deliberately the same shape as the message refusal (a
334
- * reservation that did not move the counter), so an org at the ceiling
335
- * spends nothing at all rather than spending less. It refuses — it does
336
- * NOT quietly swap to a cheaper model. A silent quality drop is worse than
337
- * an honest stop, because nobody can tell it happened.
338
- *
339
- * ⚠️ **Fails to the DEFAULT, never to “no ceiling”.** An empty, negative or
340
- * unparseable value reads as unconfigured and takes the repo default, so a
341
- * typo cannot reopen the fail-open this exists to close. It cannot become a
342
- * ceiling of `$0` either — a value of zero is not “refuse everyone”, it is
343
- * “not a number I will honour” — because an outage across every workspace
344
- * would be worse than the overspend being guarded.
345
- *
346
- * Removing the ceiling therefore takes a WORD rather than a number:
347
- * `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD=off`. A deployment paying its own
348
- * provider bill may genuinely want none, and that is a decision someone
349
- * should have to write down rather than reach by mistyping a digit.
350
- */ export function assistOrgMonthlyCostLimitUsd(configured) {
351
- const operator = assistOperatorCeilingUsd(configured);
352
- return operator === undefined ? ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD : operator;
353
- }
354
- /**
355
- * The operator's ceiling exactly as CONFIGURED — three states, not two:
356
- * a number, `null` for the word `off`, and `undefined` for unset or
357
- * unparseable.
358
- *
359
- * `assistOrgMonthlyCostLimitUsd` collapses `undefined` onto the repo default
360
- * and is the reading every existing caller wants. The composition with a
361
- * plan's own band needs the third state, because "the operator wrote a
362
- * number" and "nobody configured anything" have to bind differently against a
363
- * band that was sold: an operator's figure is a decision, and the repo
364
- * default is a backstop for orgs that have no band of their own.
365
- */ export function assistOperatorCeilingUsd(configured) {
366
- const raw = String(configured != null ? configured : '').trim();
367
- if (raw === '') return undefined;
368
- if (raw.toLowerCase() === 'off') return null;
369
- const parsed = Number(raw);
370
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
371
- }
372
- /**
373
- * Whether this org's Assist spend has newly crossed the margin threshold.
374
- *
375
- * Dedupes through the same guard shape and the same month semantics as the
376
- * customer budget, under its own key, so a staff alert and a customer alert
377
- * can never suppress one another.
378
- */ export function assistMarginBreach(input) {
379
- var _ref;
380
- const { assistUsd, thresholdUsd, guard, month } = input;
381
- if (!Number.isFinite(assistUsd) || !Number.isFinite(thresholdUsd)) return false;
382
- if (thresholdUsd <= 0 || assistUsd < thresholdUsd) return false;
383
- // The guard records how many WHOLE multiples of the threshold have been
384
- // announced, so an org at 1x is announced once and an org that climbs to 2x
385
- // is announced again — an escalating cost must not go quiet because it
386
- // already spoke.
387
- const multiple = Math.floor(assistUsd / thresholdUsd);
388
- if ((guard == null ? void 0 : guard.month) === month && Number((_ref = guard == null ? void 0 : guard.threshold) != null ? _ref : 0) >= multiple) {
389
- return false;
390
- }
391
- return true;
392
- }
393
- /**
394
- * Whether this org has newly crossed the HARD spend ceiling — the one that
395
- * refuses (AGL-2264), as distinct from {@link assistMarginBreach}'s review
396
- * threshold, which only warns.
397
- *
398
- * It needs its own guard and its own announcement because the margin alert
399
- * cannot carry this news. That alert speaks in WHOLE MULTIPLES of its $25
400
- * threshold, so an org that climbs from $25 to the $40 ceiling is still at
401
- * 1x and stays silent — staff would learn that the assistant had stopped
402
- * answering for a customer only when someone complained. A ceiling that
403
- * refuses quietly is the same defect as a ceiling that does not refuse.
404
- *
405
- * Once per org per month: crossing is a state, not an escalating figure, and
406
- * the org is refused from here to the month boundary regardless of how far
407
- * past it the recorded spend sits.
408
- */ export function assistCeilingBreach(input) {
409
- const { assistUsd, ceilingUsd, guard, month } = input;
410
- if (ceilingUsd === null) return false;
411
- if (!Number.isFinite(assistUsd) || !Number.isFinite(ceilingUsd)) return false;
412
- if (ceilingUsd <= 0 || assistUsd < ceilingUsd) return false;
413
- return (guard == null ? void 0 : guard.month) !== month;
414
- }
415
- /** The multiple {@link assistMarginBreach} would record. */ export function assistMarginMultiple(assistUsd, thresholdUsd) {
416
- if (!Number.isFinite(assistUsd) || !Number.isFinite(thresholdUsd)) return 0;
417
- if (thresholdUsd <= 0) return 0;
418
- return Math.max(0, Math.floor(assistUsd / thresholdUsd));
419
- }
420
284
 
421
285
  //# sourceMappingURL=usage-budget.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/usage-budget.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The ONE unit conversion between our provider bill and what a customer is\n// shown. Imported here so `publicOrgMonthlySpend` cannot grow a second copy of\n// it — see that function, and the module header of `assist-credits.ts`.\nimport { assistCreditsFromUsd } from './assist-credits'\n\n/**\n * Per-org USAGE BUDGETS, modelled on a Google Cloud billing budget (AGL-1528).\n *\n * ## the directive, 2026-08-18, verbatim\n *\n * \"*our usage metering, usage alerts, budgets for usage alerts, similar to\n * how google cloud charges*\"\n *\n * and, on the org-library storage decision the same day: bill from today\n * \"*but with overage protection + usage alerts, so customers don't get a\n * surprise bill*\".\n *\n * ## What a GCP billing budget actually is, and what this copies\n *\n * A GCP budget is **an amount plus a set of percentage alert rules**. It is\n * not a cap: crossing 100% of a budget stops nothing, it sends the email that\n * makes the bill stop being a surprise. Each rule fires **once per budget\n * period per rule** — the console shows the budget and its progress, and the\n * same rules push mail.\n *\n * This module is that shape, and only that shape:\n *\n * - an **amount** (`amountUsd`), chosen by the customer;\n * - **threshold rules** as percentages of the amount (default 50/90/100 —\n * GCP's own default trio);\n * - one alert per threshold per **month**, which is our budget period\n * because every meter underneath it (`orgs/{id}/usage/{month}`,\n * `assistUsage/{month}`, the counters' `YYYY-MM` fields) already resets\n * monthly. A budget period the meters do not share would silently compare\n * a month of spend against a quarter of budget.\n *\n * ## WHY THIS IS NOT THE STORAGE CAP, AND MUST NOT BECOME IT\n *\n * `utils/storage-overage.ts` owns the customer's optional **hard cap**: past\n * it uploads are refused. That is a wall, it is off by default, and it exists\n * because the requirement was for a control \"*by the end user*\".\n *\n * A budget is the OTHER half and the softer one — it never refuses anything.\n * The distinction is load-bearing in both directions: a budget that quietly\n * became a cap would take a customer's site down to save them $2 (the failure\n * mode AGL-1529 rejected on arrival), and a cap with no budget beneath it\n * means the only warning a customer gets is the refusal itself.\n *\n * ## WHY DOLLARS AND NOT PERCENT-OF-QUOTA\n *\n * `usage-alerts` has warned at 80%/100% of individual QUOTAS since AGL-276 —\n * sites, media storage, email sends, bandwidth. Those answer \"am I near a\n * band\". They cannot answer \"what will I owe\", which is the question a\n * metered customer actually has, and no sum of per-quota percentages produces\n * it: an org at 60% of four different bands may owe nothing at all, and an\n * org at 101% of one band may owe $40. Dollars are the customer's unit.\n *\n * ## THE SPEND FIGURE IS READ, NEVER RE-DERIVED\n *\n * `report-usage` already computes `billedCents` daily onto\n * `orgs/{orgId}/usage/{month}` — metered infra excess at cost x 1.3, plus the\n * data, API and contacts overages. That document is the invoice's own\n * arithmetic, and this module takes it verbatim.\n *\n * A second aggregation here is the specific mistake AGL-1371 exists about:\n * the console meter and the warning email each summed their own figures and\n * disagreed by up to `hostLimit`x. A budget alert quoting a number the\n * invoice will not show is worse than no budget alert, because it is believed.\n */\n\n/** GCP's own default budget alert rules, and the same three here. */\nexport const DEFAULT_BUDGET_THRESHOLD_PCTS: readonly number[] = [50, 90, 100]\n\n/** Bounds on the amount a self-serve org may set as its own budget. */\nexport const BUDGET_MIN_USD = 1\nexport const BUDGET_MAX_USD = 100_000\n\n/**\n * Bounds on a threshold rule, as a percentage of the amount.\n *\n * The ceiling is 200 rather than 100 because GCP allows rules above the\n * budget and they are the useful ones on a runaway: an org that set $20 and\n * is at $45 wants to hear about 200%, and a ladder that stops at 100 says\n * exactly as much at $45 as it did at $20.\n */\nexport const BUDGET_THRESHOLD_MIN_PCT = 1\nexport const BUDGET_THRESHOLD_MAX_PCT = 200\n/** At most this many rules, so one org cannot mail its admins 40 times. */\nexport const BUDGET_MAX_THRESHOLDS = 6\n\n/** The guard key this budget dedupes under, inside `orgs/{id}.usageAlerts`. */\nexport const BUDGET_GUARD_KEY = 'budget'\n\nexport interface UsageBudget {\n /** Whether the customer has set a budget at all. Absent by default. */\n budgetSet: boolean\n /** The amount in USD, or `null` when no budget is set. */\n amountUsd: number | null\n /**\n * Threshold rules as percentages, ascending and deduplicated. Always the\n * default trio when a budget exists but named no rules — a budget with an\n * empty rule set is a budget that cannot alert, which is the defect this\n * whole file exists to remove.\n */\n thresholdPcts: number[]\n}\n\n/**\n * Coerces whatever is stored (or posted) into a usable ascending rule set.\n *\n * FAILS TO THE DEFAULT, never to empty. Every rejection path here — a blank,\n * a string, a negative, a duplicate, an over-long list — lands on\n * {@link DEFAULT_BUDGET_THRESHOLD_PCTS} rather than on `[]`, because an empty\n * ladder is silence that reads as coverage.\n */\nexport function normalizeBudgetThresholds(\n input: unknown,\n): number[] {\n const list = Array.isArray(input) ? input : []\n const cleaned = [\n ...new Set(\n list\n .map((entry) => Math.round(Number(entry)))\n .filter(\n (pct) =>\n Number.isFinite(pct) &&\n pct >= BUDGET_THRESHOLD_MIN_PCT &&\n pct <= BUDGET_THRESHOLD_MAX_PCT,\n ),\n ),\n ].sort((a, b) => a - b)\n if (!cleaned.length) return [...DEFAULT_BUDGET_THRESHOLD_PCTS]\n return cleaned.slice(0, BUDGET_MAX_THRESHOLDS)\n}\n\n/**\n * The budget this org has in force, from its raw org document.\n *\n * `null` amount means no budget — deliberately `null` rather than `Infinity`,\n * matching `resolveStorageCap`: the value is serialised into the budget\n * route's JSON and `JSON.stringify(Infinity)` is `null` anyway, so one\n * spelling for \"no budget\" on both sides of the wire.\n *\n * An amount that is present but unusable (blank, negative, `NaN`) reads as NO\n * budget, which is the opposite of how `resolveStorageCap` treats a corrupt\n * CAP — and deliberately so. A corrupt cap must fall back to a low ceiling\n * because failing open there bills someone who asked not to be billed. A\n * budget refuses nothing, so the only cost of failing open is a missing\n * warning, whereas inventing an amount nobody typed produces alerts about a\n * number the customer never chose.\n */\nexport function resolveUsageBudget(\n org: Record<string, unknown> | null | undefined,\n): UsageBudget {\n const raw = (org as any)?.usageBudget\n const amount = Number(raw?.amountUsd)\n if (!raw || !Number.isFinite(amount) || amount <= 0) {\n return { budgetSet: false, amountUsd: null, thresholdPcts: [...DEFAULT_BUDGET_THRESHOLD_PCTS] }\n }\n return {\n budgetSet: true,\n amountUsd: amount,\n thresholdPcts: normalizeBudgetThresholds(raw?.thresholdPcts),\n }\n}\n\n/**\n * The HIGHEST rule this spend has crossed, or 0 for none.\n *\n * Highest rather than all-of-them because the dedupe guard stores one number\n * per key (the shape `usage-alerts` has used since AGL-276), and because an\n * org that goes from $0 to $80 against a $50 budget between two cron ticks\n * wants one email saying 160%, not three saying 50, 90 and 100. GCP behaves\n * the same way — the rules are notification points on one number, not a queue.\n */\nexport function budgetThresholdCrossed(\n spendUsd: number,\n budget: UsageBudget,\n): number {\n if (!budget.budgetSet || budget.amountUsd == null) return 0\n if (!Number.isFinite(budget.amountUsd) || budget.amountUsd <= 0) return 0\n if (!Number.isFinite(spendUsd) || spendUsd <= 0) return 0\n const pct = (spendUsd / budget.amountUsd) * 100\n let crossed = 0\n for (const rule of budget.thresholdPcts) {\n if (pct >= rule && rule > crossed) crossed = rule\n }\n return crossed\n}\n\n/** One entry of `orgs/{orgId}.usageAlerts` — the existing guard shape. */\nexport interface UsageAlertGuard {\n month?: string\n threshold?: number\n}\n\n/**\n * The rule to alert on RIGHT NOW, or 0 for silence.\n *\n * This is the whole of the idempotency requirement and it is one expression,\n * so a caller cannot implement half of it:\n *\n * - nothing crossed -> silent;\n * - crossed, but a guard from THIS month already recorded that rule or a\n * higher one -> silent. A cron that ticks hourly must not mail hourly.\n * - crossed, and the guard is from a PREVIOUS month -> alert. The budget\n * period rolled over, and a customer who exceeded last month's budget\n * must hear about this month's too.\n *\n * The comparison is `>=` on purpose: after alerting at 100 an org that climbs\n * to 200 alerts again (200 > 100), and an org that stays at 100 does not.\n */\nexport function budgetAlertDue(input: {\n spendUsd: number\n budget: UsageBudget\n guard: UsageAlertGuard | null | undefined\n month: string\n}): number {\n const { spendUsd, budget, guard, month } = input\n const crossed = budgetThresholdCrossed(spendUsd, budget)\n if (!crossed) return 0\n if (guard?.month === month && Number(guard?.threshold ?? 0) >= crossed) {\n return 0\n }\n return crossed\n}\n\n/**\n * What an org is spending this month, split by where it comes from.\n *\n * Every field is a figure some other module already owns; nothing here is\n * re-derived (see the header). The split exists because a budget alert that\n * says only \"$62\" invites a support ticket asking what the $62 was.\n */\nexport interface OrgSpendBreakdown {\n /**\n * Metered infrastructure + plan-priced overages, from the invoice's own\n * `billedCents`. This is the part that reaches a Stripe invoice.\n */\n meteredUsd: number\n /**\n * Aglyn Assist token spend for the month, at the serving model's list\n * rates (`orgs/{id}/assistUsage/{month}.estCostUsd`).\n *\n * ALWAYS REPORTED, and counted toward the budget only when\n * {@link billsAssistTokens} says this month invoices for it — which today\n * it does not, because Assist is a plan ENTITLEMENT (`aiAssist: true`) with\n * no per-token price anywhere in the platform. Adding it to a customer's\n * \"you will owe\" figure would be a surprise bill invented by a\n * notification, which is precisely the thing this feature exists to stop.\n *\n * It is nonetheless carried here rather than left out, for two reasons that\n * both bite: the day Assist is priced this is a one-line switch instead of\n * a new pipeline, and — live today — {@link assistMarginBreach} reads this\n * same number as OUR cost, which is the margin-guard half of the job.\n */\n assistUsd: number\n /** What counts against the budget: metered, plus assist when it bills. */\n totalUsd: number\n /** Whether `assistUsd` entered `totalUsd`. */\n assistBilled: boolean\n /**\n * FALSE when no rollup exists for this month yet, so `meteredUsd` is 0\n * because nothing has been computed rather than because nothing was spent.\n *\n * The distinction is the difference between a quiet budget and a broken\n * one, and it is not theoretical: `usage-alerts` reads the LATEST usage\n * rollup by `computedAt`, which on the first days of a month is still LAST\n * month's document. Alerting from that would compare August's budget\n * against July's spend; suppressing the alert without recording why would\n * make a budget that never fires look like a budget that never needed to.\n */\n meteredFresh: boolean\n}\n\n/**\n * Whether `month`'s invoice charges for Assist tokens.\n *\n * Same START-MONTH shape and the same fail-closed posture as\n * `billsOrgLibraryStorage`, and for the identical reason: a boolean flipped\n * mid-period would retroactively bill a re-run of an earlier month at that\n * month's accumulated tokens. A start month cannot reach backwards.\n *\n * `BILL_ASSIST_TOKENS_FROM=YYYY-MM`. Anything else — `true`, `1`, a date, a\n * typo — bills nothing.\n */\nexport function billsAssistTokens(\n month: string,\n configuredStart: string | null | undefined,\n): boolean {\n const start = String(configuredStart ?? '').trim()\n if (!/^\\d{4}-\\d{2}$/.test(start)) return false\n if (!/^\\d{4}-\\d{2}$/.test(month)) return false\n return month >= start\n}\n\n/**\n * Builds the month's spend from figures already read elsewhere.\n *\n * `rollupMonth` is compared to `month` rather than trusted: see\n * {@link OrgSpendBreakdown.meteredFresh}.\n */\nexport function orgMonthlySpend(input: {\n month: string\n /** `billedCents` off the latest `orgs/{id}/usage/*` document. */\n rollupBilledCents: number | null | undefined\n /** The `month` field of that same document. */\n rollupMonth: string | null | undefined\n /** `estCostUsd` off `orgs/{id}/assistUsage/{month}`. */\n assistEstCostUsd: number | null | undefined\n /** `BILL_ASSIST_TOKENS_FROM`, verbatim. */\n assistBilledFrom?: string | null\n}): OrgSpendBreakdown {\n const { month, rollupBilledCents, rollupMonth, assistEstCostUsd } = input\n const meteredFresh = Boolean(rollupMonth) && rollupMonth === month\n const cents = Number(rollupBilledCents)\n const meteredUsd =\n meteredFresh && Number.isFinite(cents) && cents > 0 ? cents / 100 : 0\n const assistRaw = Number(assistEstCostUsd)\n const assistUsd = Number.isFinite(assistRaw) && assistRaw > 0 ? assistRaw : 0\n const assistBilled = billsAssistTokens(month, input.assistBilledFrom)\n return {\n meteredUsd,\n assistUsd,\n assistBilled,\n totalUsd: meteredUsd + (assistBilled ? assistUsd : 0),\n meteredFresh,\n }\n}\n\n/**\n * The customer-facing half of {@link OrgSpendBreakdown} — dollars the customer\n * is charged, and Assist consumption in CREDITS.\n */\nexport interface PublicOrgSpend {\n /** Metered infrastructure and plan overages, from the invoice's own figure. */\n meteredUsd: number\n /**\n * Assist consumption this month, in credits. The stored figure is\n * `estCostUsd` — our provider bill — and a credit is the unit that expresses\n * it without publishing it.\n */\n assistCredits: number\n /** What counts against the budget. */\n totalUsd: number\n /** Whether Assist consumption entered `totalUsd`. */\n assistBilled: boolean\n meteredFresh: boolean\n}\n\n/**\n * The projection that crosses the boundary to a customer's browser.\n *\n * `OrgSpendBreakdown.assistUsd` is `orgs/{id}/assistUsage/{month}.estCostUsd`\n * verbatim — our provider bill at the serving model's list rates, not a price\n * and not a rate the platform publishes. Serving it to an org-scoped caller\n * puts our model choice, our per-token rates and our margin on a billing page,\n * which is the boundary `publicAssistCredits` and `publicAssistQuota` already\n * exist to hold on the assist routes. This is the same boundary, one route\n * over, and it was the last place the dollar figure still crossed it.\n *\n * `assistCreditsFromUsd` is the ONE conversion between the two units, so this\n * cannot come to disagree with the credits meter about the same month's\n * consumption.\n *\n * `meteredUsd` and `totalUsd` stay dollars: `billedCents` is what an invoice\n * charges, which is the customer's own money and the whole subject of a budget.\n *\n * The two units are deliberately not summed. A budget is a dollar rule, credits\n * are not dollars, and folding one into the other would republish the cost the\n * conversion just removed.\n */\nexport function publicOrgMonthlySpend(spend: OrgSpendBreakdown): PublicOrgSpend {\n return {\n meteredUsd: spend.meteredUsd,\n assistCredits: assistCreditsFromUsd(spend.assistUsd),\n totalUsd: spend.totalUsd,\n assistBilled: spend.assistBilled,\n meteredFresh: spend.meteredFresh,\n }\n}\n\n/**\n * The MARGIN GUARD (the other half of the ask, and the live half).\n *\n * A customer budget protects the customer. This protects us, from the one\n * meter on the platform whose unit cost is real money paid to a third party\n * and whose ceiling is a message count rather than a dollar figure:\n * `assistEntitledMonthlyLimit` caps an entitled org at 1,000 MESSAGES a\n * month, and a thousand long, cache-cold Opus-class exchanges is a\n * three-figure bill against a subscription that did not move.\n *\n * So one org's Assist COGS crossing this threshold notifies STAFF, not the\n * customer — the customer is not being charged and has done nothing wrong.\n * Returns the threshold crossed, or 0.\n *\n * `ASSIST_ORG_MONTHLY_COGS_ALERT_USD` overrides the default and FAILS TO THE\n * DEFAULT: a blank or malformed value that disabled the guard would be\n * another alert that cannot fire.\n */\nexport const ASSIST_ORG_MONTHLY_COGS_ALERT_USD_DEFAULT = 25\n\nexport function assistCogsAlertThresholdUsd(\n configured?: string | null,\n): number {\n const parsed = Number(String(configured ?? '').trim())\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return ASSIST_ORG_MONTHLY_COGS_ALERT_USD_DEFAULT\n }\n return parsed\n}\n\n/**\n * The repo default spend ceiling: **$40 per org per month** (AGL-2264).\n *\n * The number is arithmetic rather than pricing. After AGL-2441 the\n * 1,000-message entitled guard bounds roughly $28/org/month of worst-case\n * spend at Sonnet, and the staff margin alert already fires at $25. $40\n * therefore sits ABOVE anything the message cap can produce at the current\n * model, so it changes no charged amount and no plan's behaviour — it is a\n * ceiling on OUR cost, not a customer price, which is why it is outside the\n * Sept-1 pricing lock. It binds only when an assumption behind that\n * arithmetic has moved: an `ASSIST_MODEL` swap to an Opus-class tier, a\n * longer prompt, or a caller finding another way to inflate input. That is\n * the failure it exists for.\n *\n * It ships as a DEFAULT rather than as an environment variable someone must\n * remember, because an unset ceiling is the fail-open this issue was opened\n * about: a fresh deployment and a self-hoster both inherit a sane bound\n * without knowing the variable exists.\n *\n * The free tier gets no separate figure and needs none — its 10 messages a\n * UTC day bound it at roughly $0.28/day, so this is a backstop it cannot\n * reach rather than a cap it runs into.\n *\n * ⚠️ It applies only to an org whose plan sells NO assist band. Agency and\n * Enterprise include more assist than $40 of spend, so the meter's\n * `assistMonthlyCeilingUsd` keeps this default off every plan that sells a\n * band — see there for which figure binds when.\n */\nexport const ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD = 40\n\n/**\n * The per-org monthly PROVIDER-SPEND ceiling in USD — the dollar half of\n * the cap (AGL-2264), from `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD` as the caller\n * read it. Defaults to {@link ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD};\n * `off` removes it.\n *\n * Pure over the configured value, like {@link assistCogsAlertThresholdUsd}:\n * the meter that refuses at the ceiling and the cron that announces the\n * refusal read the one variable and must agree on what it means.\n *\n * The meter's message caps bound spend only through an assumed cost per\n * message, and that assumption is exactly what drifts: `ASSIST_MODEL` is an\n * env override, prompts grow, and a client controls how much history it\n * posts. The ceiling is measured against the real figure instead: the\n * running `estCostUsd` on the month's assist usage document, which the meter\n * increments at the SERVING model's rates. So it bounds the actual bill\n * rather than a forecast of it.\n *\n * The refusal is deliberately the same shape as the message refusal (a\n * reservation that did not move the counter), so an org at the ceiling\n * spends nothing at all rather than spending less. It refuses — it does\n * NOT quietly swap to a cheaper model. A silent quality drop is worse than\n * an honest stop, because nobody can tell it happened.\n *\n * ⚠️ **Fails to the DEFAULT, never to “no ceiling”.** An empty, negative or\n * unparseable value reads as unconfigured and takes the repo default, so a\n * typo cannot reopen the fail-open this exists to close. It cannot become a\n * ceiling of `$0` either — a value of zero is not “refuse everyone”, it is\n * “not a number I will honour” — because an outage across every workspace\n * would be worse than the overspend being guarded.\n *\n * Removing the ceiling therefore takes a WORD rather than a number:\n * `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD=off`. A deployment paying its own\n * provider bill may genuinely want none, and that is a decision someone\n * should have to write down rather than reach by mistyping a digit.\n */\nexport function assistOrgMonthlyCostLimitUsd(\n configured?: string | null,\n): number | null {\n const operator = assistOperatorCeilingUsd(configured)\n return operator === undefined\n ? ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD\n : operator\n}\n\n/**\n * The operator's ceiling exactly as CONFIGURED — three states, not two:\n * a number, `null` for the word `off`, and `undefined` for unset or\n * unparseable.\n *\n * `assistOrgMonthlyCostLimitUsd` collapses `undefined` onto the repo default\n * and is the reading every existing caller wants. The composition with a\n * plan's own band needs the third state, because \"the operator wrote a\n * number\" and \"nobody configured anything\" have to bind differently against a\n * band that was sold: an operator's figure is a decision, and the repo\n * default is a backstop for orgs that have no band of their own.\n */\nexport function assistOperatorCeilingUsd(\n configured?: string | null,\n): number | null | undefined {\n const raw = String(configured ?? '').trim()\n if (raw === '') return undefined\n if (raw.toLowerCase() === 'off') return null\n const parsed = Number(raw)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/**\n * Whether this org's Assist spend has newly crossed the margin threshold.\n *\n * Dedupes through the same guard shape and the same month semantics as the\n * customer budget, under its own key, so a staff alert and a customer alert\n * can never suppress one another.\n */\nexport function assistMarginBreach(input: {\n assistUsd: number\n thresholdUsd: number\n guard: UsageAlertGuard | null | undefined\n month: string\n}): boolean {\n const { assistUsd, thresholdUsd, guard, month } = input\n if (!Number.isFinite(assistUsd) || !Number.isFinite(thresholdUsd)) return false\n if (thresholdUsd <= 0 || assistUsd < thresholdUsd) return false\n // The guard records how many WHOLE multiples of the threshold have been\n // announced, so an org at 1x is announced once and an org that climbs to 2x\n // is announced again — an escalating cost must not go quiet because it\n // already spoke.\n const multiple = Math.floor(assistUsd / thresholdUsd)\n if (guard?.month === month && Number(guard?.threshold ?? 0) >= multiple) {\n return false\n }\n return true\n}\n\n/**\n * Whether this org has newly crossed the HARD spend ceiling — the one that\n * refuses (AGL-2264), as distinct from {@link assistMarginBreach}'s review\n * threshold, which only warns.\n *\n * It needs its own guard and its own announcement because the margin alert\n * cannot carry this news. That alert speaks in WHOLE MULTIPLES of its $25\n * threshold, so an org that climbs from $25 to the $40 ceiling is still at\n * 1x and stays silent — staff would learn that the assistant had stopped\n * answering for a customer only when someone complained. A ceiling that\n * refuses quietly is the same defect as a ceiling that does not refuse.\n *\n * Once per org per month: crossing is a state, not an escalating figure, and\n * the org is refused from here to the month boundary regardless of how far\n * past it the recorded spend sits.\n */\nexport function assistCeilingBreach(input: {\n assistUsd: number\n ceilingUsd: number | null\n guard: UsageAlertGuard | null | undefined\n month: string\n}): boolean {\n const { assistUsd, ceilingUsd, guard, month } = input\n if (ceilingUsd === null) return false\n if (!Number.isFinite(assistUsd) || !Number.isFinite(ceilingUsd)) return false\n if (ceilingUsd <= 0 || assistUsd < ceilingUsd) return false\n return guard?.month !== month\n}\n\n/** The multiple {@link assistMarginBreach} would record. */\nexport function assistMarginMultiple(\n assistUsd: number,\n thresholdUsd: number,\n): number {\n if (!Number.isFinite(assistUsd) || !Number.isFinite(thresholdUsd)) return 0\n if (thresholdUsd <= 0) return 0\n return Math.max(0, Math.floor(assistUsd / thresholdUsd))\n}\n"],"names":["assistCreditsFromUsd","DEFAULT_BUDGET_THRESHOLD_PCTS","BUDGET_MIN_USD","BUDGET_MAX_USD","BUDGET_THRESHOLD_MIN_PCT","BUDGET_THRESHOLD_MAX_PCT","BUDGET_MAX_THRESHOLDS","BUDGET_GUARD_KEY","normalizeBudgetThresholds","input","list","Array","isArray","cleaned","Set","map","entry","Math","round","Number","filter","pct","isFinite","sort","a","b","length","slice","resolveUsageBudget","org","raw","usageBudget","amount","amountUsd","budgetSet","thresholdPcts","budgetThresholdCrossed","spendUsd","budget","crossed","rule","budgetAlertDue","guard","month","threshold","billsAssistTokens","configuredStart","start","String","trim","test","orgMonthlySpend","rollupBilledCents","rollupMonth","assistEstCostUsd","meteredFresh","Boolean","cents","meteredUsd","assistRaw","assistUsd","assistBilled","assistBilledFrom","totalUsd","publicOrgMonthlySpend","spend","assistCredits","ASSIST_ORG_MONTHLY_COGS_ALERT_USD_DEFAULT","assistCogsAlertThresholdUsd","configured","parsed","ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD","assistOrgMonthlyCostLimitUsd","operator","assistOperatorCeilingUsd","undefined","toLowerCase","assistMarginBreach","thresholdUsd","multiple","floor","assistCeilingBreach","ceilingUsd","assistMarginMultiple","max"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/usage-budget.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 {\n pluginSpendLines,\n type ResolvedPluginSpendLine,\n} from '../plugin-manager/plugin-usage-axes'\n\n/**\n * Per-org USAGE BUDGETS, modelled on a Google Cloud billing budget (AGL-1528).\n *\n * ## the directive, 2026-08-18, verbatim\n *\n * \"*our usage metering, usage alerts, budgets for usage alerts, similar to\n * how google cloud charges*\"\n *\n * and, on the org-library storage decision the same day: bill from today\n * \"*but with overage protection + usage alerts, so customers don't get a\n * surprise bill*\".\n *\n * ## What a GCP billing budget actually is, and what this copies\n *\n * A GCP budget is **an amount plus a set of percentage alert rules**. It is\n * not a cap: crossing 100% of a budget stops nothing, it sends the email that\n * makes the bill stop being a surprise. Each rule fires **once per budget\n * period per rule** — the console shows the budget and its progress, and the\n * same rules push mail.\n *\n * This module is that shape, and only that shape:\n *\n * - an **amount** (`amountUsd`), chosen by the customer;\n * - **threshold rules** as percentages of the amount (default 50/90/100 —\n * GCP's own default trio);\n * - one alert per threshold per **month**, which is our budget period\n * because every meter underneath it (`orgs/{id}/usage/{month}`, each\n * plugin's spend-line document, the counters' `YYYY-MM` fields) already\n * resets monthly. A budget period the meters do not share would silently compare\n * a month of spend against a quarter of budget.\n *\n * ## WHY THIS IS NOT THE STORAGE CAP, AND MUST NOT BECOME IT\n *\n * `utils/storage-overage.ts` owns the customer's optional **hard cap**: past\n * it uploads are refused. That is a wall, it is off by default, and it exists\n * because the requirement was for a control \"*by the end user*\".\n *\n * A budget is the OTHER half and the softer one — it never refuses anything.\n * The distinction is load-bearing in both directions: a budget that quietly\n * became a cap would take a customer's site down to save them $2 (the failure\n * mode AGL-1529 rejected on arrival), and a cap with no budget beneath it\n * means the only warning a customer gets is the refusal itself.\n *\n * ## WHY DOLLARS AND NOT PERCENT-OF-QUOTA\n *\n * `usage-alerts` has warned at 80%/100% of individual QUOTAS since AGL-276 —\n * sites, media storage, email sends, bandwidth. Those answer \"am I near a\n * band\". They cannot answer \"what will I owe\", which is the question a\n * metered customer actually has, and no sum of per-quota percentages produces\n * it: an org at 60% of four different bands may owe nothing at all, and an\n * org at 101% of one band may owe $40. Dollars are the customer's unit.\n *\n * ## THE SPEND FIGURE IS READ, NEVER RE-DERIVED\n *\n * `report-usage` already computes `billedCents` daily onto\n * `orgs/{orgId}/usage/{month}` — metered infra excess at cost x 1.3, plus the\n * data, API and contacts overages. That document is the invoice's own\n * arithmetic, and this module takes it verbatim.\n *\n * A second aggregation here is the specific mistake AGL-1371 exists about:\n * the console meter and the warning email each summed their own figures and\n * disagreed by up to `hostLimit`x. A budget alert quoting a number the\n * invoice will not show is worse than no budget alert, because it is believed.\n */\n\n/** GCP's own default budget alert rules, and the same three here. */\nexport const DEFAULT_BUDGET_THRESHOLD_PCTS: readonly number[] = [50, 90, 100]\n\n/** Bounds on the amount a self-serve org may set as its own budget. */\nexport const BUDGET_MIN_USD = 1\nexport const BUDGET_MAX_USD = 100_000\n\n/**\n * Bounds on a threshold rule, as a percentage of the amount.\n *\n * The ceiling is 200 rather than 100 because GCP allows rules above the\n * budget and they are the useful ones on a runaway: an org that set $20 and\n * is at $45 wants to hear about 200%, and a ladder that stops at 100 says\n * exactly as much at $45 as it did at $20.\n */\nexport const BUDGET_THRESHOLD_MIN_PCT = 1\nexport const BUDGET_THRESHOLD_MAX_PCT = 200\n/** At most this many rules, so one org cannot mail its admins 40 times. */\nexport const BUDGET_MAX_THRESHOLDS = 6\n\n/** The guard key this budget dedupes under, inside `orgs/{id}.usageAlerts`. */\nexport const BUDGET_GUARD_KEY = 'budget'\n\nexport interface UsageBudget {\n /** Whether the customer has set a budget at all. Absent by default. */\n budgetSet: boolean\n /** The amount in USD, or `null` when no budget is set. */\n amountUsd: number | null\n /**\n * Threshold rules as percentages, ascending and deduplicated. Always the\n * default trio when a budget exists but named no rules — a budget with an\n * empty rule set is a budget that cannot alert, which is the defect this\n * whole file exists to remove.\n */\n thresholdPcts: number[]\n}\n\n/**\n * Coerces whatever is stored (or posted) into a usable ascending rule set.\n *\n * FAILS TO THE DEFAULT, never to empty. Every rejection path here — a blank,\n * a string, a negative, a duplicate, an over-long list — lands on\n * {@link DEFAULT_BUDGET_THRESHOLD_PCTS} rather than on `[]`, because an empty\n * ladder is silence that reads as coverage.\n */\nexport function normalizeBudgetThresholds(\n input: unknown,\n): number[] {\n const list = Array.isArray(input) ? input : []\n const cleaned = [\n ...new Set(\n list\n .map((entry) => Math.round(Number(entry)))\n .filter(\n (pct) =>\n Number.isFinite(pct) &&\n pct >= BUDGET_THRESHOLD_MIN_PCT &&\n pct <= BUDGET_THRESHOLD_MAX_PCT,\n ),\n ),\n ].sort((a, b) => a - b)\n if (!cleaned.length) return [...DEFAULT_BUDGET_THRESHOLD_PCTS]\n return cleaned.slice(0, BUDGET_MAX_THRESHOLDS)\n}\n\n/**\n * The budget this org has in force, from its raw org document.\n *\n * `null` amount means no budget — deliberately `null` rather than `Infinity`,\n * matching `resolveStorageCap`: the value is serialised into the budget\n * route's JSON and `JSON.stringify(Infinity)` is `null` anyway, so one\n * spelling for \"no budget\" on both sides of the wire.\n *\n * An amount that is present but unusable (blank, negative, `NaN`) reads as NO\n * budget, which is the opposite of how `resolveStorageCap` treats a corrupt\n * CAP — and deliberately so. A corrupt cap must fall back to a low ceiling\n * because failing open there bills someone who asked not to be billed. A\n * budget refuses nothing, so the only cost of failing open is a missing\n * warning, whereas inventing an amount nobody typed produces alerts about a\n * number the customer never chose.\n */\nexport function resolveUsageBudget(\n org: Record<string, unknown> | null | undefined,\n): UsageBudget {\n const raw = (org as any)?.usageBudget\n const amount = Number(raw?.amountUsd)\n if (!raw || !Number.isFinite(amount) || amount <= 0) {\n return { budgetSet: false, amountUsd: null, thresholdPcts: [...DEFAULT_BUDGET_THRESHOLD_PCTS] }\n }\n return {\n budgetSet: true,\n amountUsd: amount,\n thresholdPcts: normalizeBudgetThresholds(raw?.thresholdPcts),\n }\n}\n\n/**\n * The HIGHEST rule this spend has crossed, or 0 for none.\n *\n * Highest rather than all-of-them because the dedupe guard stores one number\n * per key (the shape `usage-alerts` has used since AGL-276), and because an\n * org that goes from $0 to $80 against a $50 budget between two cron ticks\n * wants one email saying 160%, not three saying 50, 90 and 100. GCP behaves\n * the same way — the rules are notification points on one number, not a queue.\n */\nexport function budgetThresholdCrossed(\n spendUsd: number,\n budget: UsageBudget,\n): number {\n if (!budget.budgetSet || budget.amountUsd == null) return 0\n if (!Number.isFinite(budget.amountUsd) || budget.amountUsd <= 0) return 0\n if (!Number.isFinite(spendUsd) || spendUsd <= 0) return 0\n const pct = (spendUsd / budget.amountUsd) * 100\n let crossed = 0\n for (const rule of budget.thresholdPcts) {\n if (pct >= rule && rule > crossed) crossed = rule\n }\n return crossed\n}\n\n/** One entry of `orgs/{orgId}.usageAlerts` — the existing guard shape. */\nexport interface UsageAlertGuard {\n month?: string\n threshold?: number\n}\n\n/**\n * The rule to alert on RIGHT NOW, or 0 for silence.\n *\n * This is the whole of the idempotency requirement and it is one expression,\n * so a caller cannot implement half of it:\n *\n * - nothing crossed -> silent;\n * - crossed, but a guard from THIS month already recorded that rule or a\n * higher one -> silent. A cron that ticks hourly must not mail hourly.\n * - crossed, and the guard is from a PREVIOUS month -> alert. The budget\n * period rolled over, and a customer who exceeded last month's budget\n * must hear about this month's too.\n *\n * The comparison is `>=` on purpose: after alerting at 100 an org that climbs\n * to 200 alerts again (200 > 100), and an org that stays at 100 does not.\n */\nexport function budgetAlertDue(input: {\n spendUsd: number\n budget: UsageBudget\n guard: UsageAlertGuard | null | undefined\n month: string\n}): number {\n const { spendUsd, budget, guard, month } = input\n const crossed = budgetThresholdCrossed(spendUsd, budget)\n if (!crossed) return 0\n if (guard?.month === month && Number(guard?.threshold ?? 0) >= crossed) {\n return 0\n }\n return crossed\n}\n\n/**\n * One plugin's line of the month's spend, as its `spendLines` declaration\n * names it (`plugin-usage-axes.ts`).\n */\nexport interface OrgSpendLine {\n id: string\n pluginId: string\n label: string\n /**\n * The month's figure in dollars at the rates the plugin bills, read off the\n * document the declaration names. For a line declared with a `unit` these\n * dollars are the platform's cost and never cross to a customer's browser\n * (see {@link publicOrgMonthlySpend}).\n */\n usd: number\n /** Whether `usd` entered `totalUsd` — see {@link billsFromMonth}. */\n billed: boolean\n}\n\n/**\n * What an org is spending this month, split by where it comes from.\n *\n * Every field is a figure some other module already owns; nothing here is\n * re-derived (see the header). The split exists because a budget alert that\n * says only \"$62\" invites a support ticket asking what the $62 was.\n */\nexport interface OrgSpendBreakdown {\n /**\n * Metered infrastructure + plan-priced overages, from the invoice's own\n * `billedCents`. This is the part that reaches a Stripe invoice.\n */\n meteredUsd: number\n /**\n * Each plugin's line, in catalog order.\n *\n * ALWAYS REPORTED, and counted toward the budget only once the line's own\n * start month says this month is charged for it. Until then the plugin's\n * usage is an entitlement with no price anywhere in the platform, and adding\n * it to a customer's \"you will owe\" figure would be a surprise bill invented\n * by a notification — precisely the thing this feature exists to stop.\n *\n * Carried rather than left out for two reasons that both bite: the day a\n * line is priced this is a deployment variable instead of a new pipeline,\n * and a plugin's staff alert reads its own line as the platform's cost.\n */\n lines: readonly OrgSpendLine[]\n /** What counts against the budget: metered, plus every billed line. */\n totalUsd: number\n /**\n * FALSE when no rollup exists for this month yet, so `meteredUsd` is 0\n * because nothing has been computed rather than because nothing was spent.\n *\n * The distinction is the difference between a quiet budget and a broken\n * one, and it is not theoretical: `usage-alerts` reads the LATEST usage\n * rollup by `computedAt`, which on the first days of a month is still LAST\n * month's document. Alerting from that would compare August's budget\n * against July's spend; suppressing the alert without recording why would\n * make a budget that never fires look like a budget that never needed to.\n */\n meteredFresh: boolean\n}\n\n/**\n * Whether `month`'s invoice charges for a line whose deployment variable\n * holds `configuredStart`.\n *\n * Same START-MONTH shape and the same fail-closed posture as\n * `billsOrgLibraryStorage`, and for the identical reason: a boolean flipped\n * mid-period would retroactively bill a re-run of an earlier month at that\n * month's accumulated usage. A start month cannot reach backwards.\n *\n * Anything but a `YYYY-MM` — `true`, `1`, a date, a typo — bills nothing.\n */\nexport function billsFromMonth(\n month: string,\n configuredStart: string | null | undefined,\n): boolean {\n const start = String(configuredStart ?? '').trim()\n if (!/^\\d{4}-\\d{2}$/.test(start)) return false\n if (!/^\\d{4}-\\d{2}$/.test(month)) return false\n return month >= start\n}\n\n/** A positive finite dollar figure, or 0. */\nfunction dollars(value: unknown): number {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 0\n}\n\n/**\n * Builds the month's spend from figures already read elsewhere.\n *\n * `rollupMonth` is compared to `month` rather than trusted: see\n * {@link OrgSpendBreakdown.meteredFresh}. Every declared spend line is\n * reported, read as nothing when its document was not read.\n */\nexport function orgMonthlySpend(input: {\n month: string\n /** `billedCents` off the latest `orgs/{id}/usage/*` document. */\n rollupBilledCents: number | null | undefined\n /** The `month` field of that same document. */\n rollupMonth: string | null | undefined\n /**\n * Each declared spend line's figure, keyed by line id: the `live.field` of\n * its month document, as read.\n */\n lineReadings?: Readonly<Record<string, unknown>>\n /** The deployment's variables, holding each line's start month. */\n env?: Readonly<Record<string, string | undefined>>\n}): OrgSpendBreakdown {\n const { month, rollupBilledCents, rollupMonth } = input\n const meteredFresh = Boolean(rollupMonth) && rollupMonth === month\n const cents = Number(rollupBilledCents)\n const meteredUsd =\n meteredFresh && Number.isFinite(cents) && cents > 0 ? cents / 100 : 0\n const lines = pluginSpendLines().map(\n (line): OrgSpendLine => ({\n id: line.id,\n pluginId: line.pluginId,\n label: line.label,\n usd: dollars(input.lineReadings?.[line.id]),\n billed: billsFromMonth(month, input.env?.[line.billedFromEnv]),\n }),\n )\n return {\n meteredUsd,\n lines,\n totalUsd:\n meteredUsd +\n lines.reduce((sum, line) => sum + (line.billed ? line.usd : 0), 0),\n meteredFresh,\n }\n}\n\n/** A spend line as a customer's browser receives it. */\nexport interface PublicOrgSpendLine {\n id: string\n label: string\n billed: boolean\n /**\n * The month's figure in the line's declared unit (`ceil(dollars / unit\n * cost)`), or `null` for a line declared without one.\n */\n units: number | null\n /** What the unit is called, or `null` for a line without one. */\n unitLabel: string | null\n /** Dollars, only for a line declared WITHOUT a unit; `null` otherwise. */\n usd: number | null\n}\n\n/**\n * The customer-facing half of {@link OrgSpendBreakdown} — dollars the customer\n * is charged, and each plugin's line in the unit it declared.\n */\nexport interface PublicOrgSpend {\n /** Metered infrastructure and plan overages, from the invoice's own figure. */\n meteredUsd: number\n lines: PublicOrgSpendLine[]\n /** What counts against the budget. */\n totalUsd: number\n meteredFresh: boolean\n}\n\n/** Dollars in a unit of cost, rounded UP — spend already made never reads as less. */\nfunction unitsOf(usd: number, costUsd: number): number {\n if (!Number.isFinite(usd) || usd <= 0) return 0\n return Math.ceil(usd / costUsd)\n}\n\n/**\n * The projection that crosses the boundary to a customer's browser.\n *\n * A line declared with a `unit` stores the platform's own cost — for the AI\n * plugin, its provider bill at the serving model's list rates, not a price\n * and not a rate the platform publishes. Serving it to an org-scoped caller\n * would put a model choice, per-token rates and a margin on a billing page,\n * so such a line crosses in its unit only, through the same rounding-up\n * conversion the plugin's own meter uses, and its dollars never do.\n *\n * `meteredUsd` and `totalUsd` stay dollars: `billedCents` is what an invoice\n * charges, which is the customer's own money and the whole subject of a\n * budget.\n *\n * Units and dollars are deliberately not summed. A budget is a dollar rule,\n * a unit is not a dollar, and folding one into the other would republish the\n * cost the conversion just removed.\n */\nexport function publicOrgMonthlySpend(spend: OrgSpendBreakdown): PublicOrgSpend {\n const declared = new Map<string, ResolvedPluginSpendLine>(\n pluginSpendLines().map((line) => [line.id, line]),\n )\n return {\n meteredUsd: spend.meteredUsd,\n lines: spend.lines.map((line) => {\n const unit = declared.get(line.id)?.unit\n return {\n id: line.id,\n label: line.label,\n billed: line.billed,\n units: unit ? unitsOf(line.usd, unit.costUsd) : null,\n unitLabel: unit ? unit.label : null,\n usd: unit ? null : line.usd,\n }\n }),\n totalUsd: spend.totalUsd,\n meteredFresh: spend.meteredFresh,\n }\n}\n"],"names":["pluginSpendLines","DEFAULT_BUDGET_THRESHOLD_PCTS","BUDGET_MIN_USD","BUDGET_MAX_USD","BUDGET_THRESHOLD_MIN_PCT","BUDGET_THRESHOLD_MAX_PCT","BUDGET_MAX_THRESHOLDS","BUDGET_GUARD_KEY","normalizeBudgetThresholds","input","list","Array","isArray","cleaned","Set","map","entry","Math","round","Number","filter","pct","isFinite","sort","a","b","length","slice","resolveUsageBudget","org","raw","usageBudget","amount","amountUsd","budgetSet","thresholdPcts","budgetThresholdCrossed","spendUsd","budget","crossed","rule","budgetAlertDue","guard","month","threshold","billsFromMonth","configuredStart","start","String","trim","test","dollars","value","parsed","orgMonthlySpend","rollupBilledCents","rollupMonth","meteredFresh","Boolean","cents","meteredUsd","lines","line","id","pluginId","label","usd","lineReadings","billed","env","billedFromEnv","totalUsd","reduce","sum","unitsOf","costUsd","ceil","publicOrgMonthlySpend","spend","declared","Map","unit","get","units","unitLabel"],"mappings":"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"}
@@ -21,7 +21,7 @@
21
21
  * Named for what it parses, the web's UTM labels. The `campaign` in
22
22
  * `utm_campaign` is the label an ad push carries; it is not the Marketing
23
23
  * plugin's campaign, and nothing here reads one. What a site's own visitors
24
- * carry to the moment they identify is `campaign-touch.ts`'s.
24
+ * carry to the moment they identify is `utm-touch.ts`'s.
25
25
  *
26
26
  * Until this existed a paid click, an organic visit and a partner link
27
27
  * arrived indistinguishable: `sign_up` carried `method` and nothing else, so
@@ -21,7 +21,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
21
21
  * Named for what it parses, the web's UTM labels. The `campaign` in
22
22
  * `utm_campaign` is the label an ad push carries; it is not the Marketing
23
23
  * plugin's campaign, and nothing here reads one. What a site's own visitors
24
- * carry to the moment they identify is `campaign-touch.ts`'s.
24
+ * carry to the moment they identify is `utm-touch.ts`'s.
25
25
  *
26
26
  * Until this existed a paid click, an organic visit and a partner link
27
27
  * arrived indistinguishable: `sign_up` carried `method` and nothing else, so
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/utm-attribution.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Where an account came from (AGL-1731) — the campaign contract between the\n * marketing site's links and the console's signup.\n *\n * Named for what it parses, the web's UTM labels. The `campaign` in\n * `utm_campaign` is the label an ad push carries; it is not the Marketing\n * plugin's campaign, and nothing here reads one. What a site's own visitors\n * carry to the moment they identify is `campaign-touch.ts`'s.\n *\n * Until this existed a paid click, an organic visit and a partner link\n * arrived indistinguishable: `sign_up` carried `method` and nothing else, so\n * no report could say which spend produced a customer. That is tolerable\n * while nothing is being spent and not tolerable the day advertising starts,\n * because the measurement has to exist BEFORE the traffic — a signup that\n * lands unattributed is unattributed forever.\n *\n * ## Why an allowlist, and why exactly three\n *\n * `utm_source`, `utm_medium` and `utm_campaign` are the three axes an ad\n * report is read on: which channel, what kind of placement, which push. They\n * are also the three the marketing site can be told to emit without further\n * decisions.\n *\n * `utm_term` and `utm_content` are deliberately OUT, matching the refusal the\n * tenant's own first-party collector already made for its visitors\n * (`apps/tenant/app/api/analytics/collect/route.ts`): keyword- and\n * variant-level labels multiply cardinality without answering a question\n * anyone asks of a signup, and a keyword string is the most likely of the\n * five to carry something a person typed. `gclid` and bare `ref` are out for\n * that same cardinality reason. Not for a privacy one: the property runs ads\n * personalization ON in every region, and Google's own auto-tagging already\n * carries the click identifier into it, so re-collecting `gclid` here would\n * duplicate a field the platform owns rather than withhold anything.\n *\n * The allowlist is the privacy mechanism, not a convenience. A parser that\n * copied \"the campaign-ish parameters\" would be one marketing link away from\n * storing `?email=` against a person, and the standing rule is that personal\n * data never travels in a URL parameter in the first place.\n *\n * ## Two exits, and only one of them is already guarded\n *\n * A parsed campaign leaves this process twice: onto the GA4 `sign_up` hit,\n * and into `users/{uid}` so revenue can be attributed to it months later.\n * Only the first passes `sanitizeEventParams`. The Firestore write does not,\n * so the scrub — email shape refused, length capped, blanks dropped — lives\n * HERE, on the way in, and both exits inherit it.\n *\n * ## Why the wire form is what gets stored\n *\n * Same contract as the AGL-1535 plan intent it rides beside: the stored value\n * is the query string, re-parsed by this parser on the way out. `users/{uid}`\n * is owner-writable, so a stored field is a client-FORGEABLE field. Re-parsing\n * means a hand-edited document can claim no more than a hand-edited URL could,\n * which is a marketing label and nothing that touches entitlement.\n */\n\n/**\n * The URL parameter names, in report order. Exported because the marketing\n * site's links, the stored wire form and this parser all have to spell them\n * identically — two spellings would read as two dimensions in GA4 and split\n * every report in half.\n */\nexport const UTM_QUERY_KEYS = [\n 'utm_source',\n 'utm_medium',\n 'utm_campaign',\n] as const\n\n/** A campaign, with every field optional — a partial one is still worth having. */\nexport interface UtmAttribution {\n /** `utm_source` — the channel: `google`, `hn`, a partner's name. */\n source?: string\n /** `utm_medium` — the kind of placement: `cpc`, `email`, `referral`. */\n medium?: string\n /** `utm_campaign` — which push: `sept-launch`. */\n campaign?: string\n}\n\n/** Longest value stored or sent, matching `sanitizeEventParams`'s own cap. */\nconst MAX_VALUE_LENGTH = 100\n\n/** The same shape test the event sanitizer uses, applied to the stored exit too. */\nconst EMAIL_SHAPED = /[^\\s@]+@[^\\s@]+\\.[^\\s@]+/\n\n/** Which `UtmAttribution` field each URL key fills. */\nconst FIELD_FOR_KEY: Record<\n (typeof UTM_QUERY_KEYS)[number],\n keyof UtmAttribution\n> = {\n utm_source: 'source',\n utm_medium: 'medium',\n utm_campaign: 'campaign',\n}\n\n/** What a Server Component holds before it has a `URLSearchParams`. */\nexport type UtmParamSource =\n | URLSearchParams\n | Record<string, string | string[] | undefined>\n | null\n | undefined\n\nfunction rawValue(params: UtmParamSource, key: string): string | null {\n if (!params) return null\n if (typeof (params as URLSearchParams).get === 'function') {\n return (params as URLSearchParams).get(key)\n }\n const value = (params as Record<string, string | string[] | undefined>)[key]\n // A repeated parameter arrives as an array. The first one wins rather than\n // being joined: `utm_medium=cpc&utm_medium=organic` is a malformed link, and\n // inventing `cpc,organic` would put a value in the reports that no campaign\n // ever set.\n if (Array.isArray(value)) return value.length ? value[0] : null\n return typeof value === 'string' ? value : null\n}\n\n/**\n * Reduce one raw parameter to something storable, or null to drop it.\n *\n * Dropping the single value rather than the whole campaign is deliberate: a\n * mail-merge link that puts an address in `utm_source` still names a real\n * `utm_campaign`, and keeping the half that is safe beats losing the\n * attribution to protect the half that is not.\n */\nfunction scrubValue(value: string | null): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n if (EMAIL_SHAPED.test(trimmed)) return null\n return trimmed.slice(0, MAX_VALUE_LENGTH)\n}\n\n/**\n * Read the campaign off a signup URL, or null when it names none.\n *\n * Null rather than `{}` so a caller can branch on \"was there a campaign at\n * all\". An empty object written to an account would read as \"arrived from\n * nowhere, confirmed\" instead of \"never asked\", and those are different facts.\n */\nexport function parseUtmAttribution(\n params: UtmParamSource,\n): UtmAttribution | null {\n if (!params) return null\n const attribution: UtmAttribution = {}\n for (const key of UTM_QUERY_KEYS) {\n const value = scrubValue(rawValue(params, key))\n if (value) attribution[FIELD_FOR_KEY[key]] = value\n }\n return Object.keys(attribution).length ? attribution : null\n}\n\n/**\n * The canonical query string for a campaign — the ONLY place the stored form\n * is written, so it cannot drift from what the parser reads back.\n */\nexport function utmAttributionQuery(\n attribution: UtmAttribution | null | undefined,\n): string {\n if (!attribution) return ''\n const params = new URLSearchParams()\n for (const key of UTM_QUERY_KEYS) {\n const value = attribution[FIELD_FOR_KEY[key]]\n if (value) params.set(key, value)\n }\n return params.toString()\n}\n\n/**\n * The campaign as GA4 event parameters, ready to spread into `sign_up`.\n *\n * Renamed off the `utm_` spellings on purpose. These are OUR event parameters,\n * registered as custom dimensions on the property (see `docs/ANALYTICS.md`);\n * the `utm_` names belong to GA's own automatic campaign collection, and\n * shipping a custom parameter under a name the platform also owns is how a\n * dimension ends up meaning two things.\n *\n * Returns an empty object — never keys with `undefined` values — so that\n * spreading it into an organic signup's params adds nothing at all. A\n * `campaign_source: undefined` on every unattributed signup would survive the\n * sanitizer as an absence but read, to whoever writes the report, as a\n * dimension that is populated sometimes and broken the rest of the time.\n */\nexport function utmEventParams(\n attribution: UtmAttribution | null | undefined,\n): {\n campaign_source?: string\n campaign_medium?: string\n campaign_name?: string\n} {\n if (!attribution) return {}\n return {\n ...(attribution.source ? { campaign_source: attribution.source } : {}),\n ...(attribution.medium ? { campaign_medium: attribution.medium } : {}),\n ...(attribution.campaign ? { campaign_name: attribution.campaign } : {}),\n }\n}\n"],"names":["UTM_QUERY_KEYS","MAX_VALUE_LENGTH","EMAIL_SHAPED","FIELD_FOR_KEY","utm_source","utm_medium","utm_campaign","rawValue","params","key","get","value","Array","isArray","length","scrubValue","trimmed","trim","test","slice","parseUtmAttribution","attribution","Object","keys","utmAttributionQuery","URLSearchParams","set","toString","utmEventParams","source","campaign_source","medium","campaign_medium","campaign","campaign_name"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsDC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,iBAAiB;IAC5B;IACA;IACA;CACD,CAAS;AAYV,4EAA4E,GAC5E,MAAMC,mBAAmB;AAEzB,kFAAkF,GAClF,MAAMC,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAGF;IACFC,YAAY;IACZC,YAAY;IACZC,cAAc;AAChB;AASA,SAASC,SAASC,MAAsB,EAAEC,GAAW;IACnD,IAAI,CAACD,QAAQ,OAAO;IACpB,IAAI,OAAO,AAACA,OAA2BE,GAAG,KAAK,YAAY;QACzD,OAAO,AAACF,OAA2BE,GAAG,CAACD;IACzC;IACA,MAAME,QAAQ,AAACH,MAAwD,CAACC,IAAI;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,4EAA4E;IAC5E,YAAY;IACZ,IAAIG,MAAMC,OAAO,CAACF,QAAQ,OAAOA,MAAMG,MAAM,GAAGH,KAAK,CAAC,EAAE,GAAG;IAC3D,OAAO,OAAOA,UAAU,WAAWA,QAAQ;AAC7C;AAEA;;;;;;;CAOC,GACD,SAASI,WAAWJ,KAAoB;IACtC,IAAI,OAAOA,UAAU,UAAU,OAAO;IACtC,MAAMK,UAAUL,MAAMM,IAAI;IAC1B,IAAI,CAACD,SAAS,OAAO;IACrB,IAAId,aAAagB,IAAI,CAACF,UAAU,OAAO;IACvC,OAAOA,QAAQG,KAAK,CAAC,GAAGlB;AAC1B;AAEA;;;;;;CAMC,GACD,OAAO,SAASmB,oBACdZ,MAAsB;IAEtB,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMa,cAA8B,CAAC;IACrC,KAAK,MAAMZ,OAAOT,eAAgB;QAChC,MAAMW,QAAQI,WAAWR,SAASC,QAAQC;QAC1C,IAAIE,OAAOU,WAAW,CAAClB,aAAa,CAACM,IAAI,CAAC,GAAGE;IAC/C;IACA,OAAOW,OAAOC,IAAI,CAACF,aAAaP,MAAM,GAAGO,cAAc;AACzD;AAEA;;;CAGC,GACD,OAAO,SAASG,oBACdH,WAA8C;IAE9C,IAAI,CAACA,aAAa,OAAO;IACzB,MAAMb,SAAS,IAAIiB;IACnB,KAAK,MAAMhB,OAAOT,eAAgB;QAChC,MAAMW,QAAQU,WAAW,CAAClB,aAAa,CAACM,IAAI,CAAC;QAC7C,IAAIE,OAAOH,OAAOkB,GAAG,CAACjB,KAAKE;IAC7B;IACA,OAAOH,OAAOmB,QAAQ;AACxB;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,eACdP,WAA8C;IAM9C,IAAI,CAACA,aAAa,OAAO,CAAC;IAC1B,OAAO,aACDA,YAAYQ,MAAM,GAAG;QAAEC,iBAAiBT,YAAYQ,MAAM;IAAC,IAAI,CAAC,GAChER,YAAYU,MAAM,GAAG;QAAEC,iBAAiBX,YAAYU,MAAM;IAAC,IAAI,CAAC,GAChEV,YAAYY,QAAQ,GAAG;QAAEC,eAAeb,YAAYY,QAAQ;IAAC,IAAI,CAAC;AAE1E"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/utm-attribution.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Where an account came from (AGL-1731) — the campaign contract between the\n * marketing site's links and the console's signup.\n *\n * Named for what it parses, the web's UTM labels. The `campaign` in\n * `utm_campaign` is the label an ad push carries; it is not the Marketing\n * plugin's campaign, and nothing here reads one. What a site's own visitors\n * carry to the moment they identify is `utm-touch.ts`'s.\n *\n * Until this existed a paid click, an organic visit and a partner link\n * arrived indistinguishable: `sign_up` carried `method` and nothing else, so\n * no report could say which spend produced a customer. That is tolerable\n * while nothing is being spent and not tolerable the day advertising starts,\n * because the measurement has to exist BEFORE the traffic — a signup that\n * lands unattributed is unattributed forever.\n *\n * ## Why an allowlist, and why exactly three\n *\n * `utm_source`, `utm_medium` and `utm_campaign` are the three axes an ad\n * report is read on: which channel, what kind of placement, which push. They\n * are also the three the marketing site can be told to emit without further\n * decisions.\n *\n * `utm_term` and `utm_content` are deliberately OUT, matching the refusal the\n * tenant's own first-party collector already made for its visitors\n * (`apps/tenant/app/api/analytics/collect/route.ts`): keyword- and\n * variant-level labels multiply cardinality without answering a question\n * anyone asks of a signup, and a keyword string is the most likely of the\n * five to carry something a person typed. `gclid` and bare `ref` are out for\n * that same cardinality reason. Not for a privacy one: the property runs ads\n * personalization ON in every region, and Google's own auto-tagging already\n * carries the click identifier into it, so re-collecting `gclid` here would\n * duplicate a field the platform owns rather than withhold anything.\n *\n * The allowlist is the privacy mechanism, not a convenience. A parser that\n * copied \"the campaign-ish parameters\" would be one marketing link away from\n * storing `?email=` against a person, and the standing rule is that personal\n * data never travels in a URL parameter in the first place.\n *\n * ## Two exits, and only one of them is already guarded\n *\n * A parsed campaign leaves this process twice: onto the GA4 `sign_up` hit,\n * and into `users/{uid}` so revenue can be attributed to it months later.\n * Only the first passes `sanitizeEventParams`. The Firestore write does not,\n * so the scrub — email shape refused, length capped, blanks dropped — lives\n * HERE, on the way in, and both exits inherit it.\n *\n * ## Why the wire form is what gets stored\n *\n * Same contract as the AGL-1535 plan intent it rides beside: the stored value\n * is the query string, re-parsed by this parser on the way out. `users/{uid}`\n * is owner-writable, so a stored field is a client-FORGEABLE field. Re-parsing\n * means a hand-edited document can claim no more than a hand-edited URL could,\n * which is a marketing label and nothing that touches entitlement.\n */\n\n/**\n * The URL parameter names, in report order. Exported because the marketing\n * site's links, the stored wire form and this parser all have to spell them\n * identically — two spellings would read as two dimensions in GA4 and split\n * every report in half.\n */\nexport const UTM_QUERY_KEYS = [\n 'utm_source',\n 'utm_medium',\n 'utm_campaign',\n] as const\n\n/** A campaign, with every field optional — a partial one is still worth having. */\nexport interface UtmAttribution {\n /** `utm_source` — the channel: `google`, `hn`, a partner's name. */\n source?: string\n /** `utm_medium` — the kind of placement: `cpc`, `email`, `referral`. */\n medium?: string\n /** `utm_campaign` — which push: `sept-launch`. */\n campaign?: string\n}\n\n/** Longest value stored or sent, matching `sanitizeEventParams`'s own cap. */\nconst MAX_VALUE_LENGTH = 100\n\n/** The same shape test the event sanitizer uses, applied to the stored exit too. */\nconst EMAIL_SHAPED = /[^\\s@]+@[^\\s@]+\\.[^\\s@]+/\n\n/** Which `UtmAttribution` field each URL key fills. */\nconst FIELD_FOR_KEY: Record<\n (typeof UTM_QUERY_KEYS)[number],\n keyof UtmAttribution\n> = {\n utm_source: 'source',\n utm_medium: 'medium',\n utm_campaign: 'campaign',\n}\n\n/** What a Server Component holds before it has a `URLSearchParams`. */\nexport type UtmParamSource =\n | URLSearchParams\n | Record<string, string | string[] | undefined>\n | null\n | undefined\n\nfunction rawValue(params: UtmParamSource, key: string): string | null {\n if (!params) return null\n if (typeof (params as URLSearchParams).get === 'function') {\n return (params as URLSearchParams).get(key)\n }\n const value = (params as Record<string, string | string[] | undefined>)[key]\n // A repeated parameter arrives as an array. The first one wins rather than\n // being joined: `utm_medium=cpc&utm_medium=organic` is a malformed link, and\n // inventing `cpc,organic` would put a value in the reports that no campaign\n // ever set.\n if (Array.isArray(value)) return value.length ? value[0] : null\n return typeof value === 'string' ? value : null\n}\n\n/**\n * Reduce one raw parameter to something storable, or null to drop it.\n *\n * Dropping the single value rather than the whole campaign is deliberate: a\n * mail-merge link that puts an address in `utm_source` still names a real\n * `utm_campaign`, and keeping the half that is safe beats losing the\n * attribution to protect the half that is not.\n */\nfunction scrubValue(value: string | null): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n if (EMAIL_SHAPED.test(trimmed)) return null\n return trimmed.slice(0, MAX_VALUE_LENGTH)\n}\n\n/**\n * Read the campaign off a signup URL, or null when it names none.\n *\n * Null rather than `{}` so a caller can branch on \"was there a campaign at\n * all\". An empty object written to an account would read as \"arrived from\n * nowhere, confirmed\" instead of \"never asked\", and those are different facts.\n */\nexport function parseUtmAttribution(\n params: UtmParamSource,\n): UtmAttribution | null {\n if (!params) return null\n const attribution: UtmAttribution = {}\n for (const key of UTM_QUERY_KEYS) {\n const value = scrubValue(rawValue(params, key))\n if (value) attribution[FIELD_FOR_KEY[key]] = value\n }\n return Object.keys(attribution).length ? attribution : null\n}\n\n/**\n * The canonical query string for a campaign — the ONLY place the stored form\n * is written, so it cannot drift from what the parser reads back.\n */\nexport function utmAttributionQuery(\n attribution: UtmAttribution | null | undefined,\n): string {\n if (!attribution) return ''\n const params = new URLSearchParams()\n for (const key of UTM_QUERY_KEYS) {\n const value = attribution[FIELD_FOR_KEY[key]]\n if (value) params.set(key, value)\n }\n return params.toString()\n}\n\n/**\n * The campaign as GA4 event parameters, ready to spread into `sign_up`.\n *\n * Renamed off the `utm_` spellings on purpose. These are OUR event parameters,\n * registered as custom dimensions on the property (see `docs/ANALYTICS.md`);\n * the `utm_` names belong to GA's own automatic campaign collection, and\n * shipping a custom parameter under a name the platform also owns is how a\n * dimension ends up meaning two things.\n *\n * Returns an empty object — never keys with `undefined` values — so that\n * spreading it into an organic signup's params adds nothing at all. A\n * `campaign_source: undefined` on every unattributed signup would survive the\n * sanitizer as an absence but read, to whoever writes the report, as a\n * dimension that is populated sometimes and broken the rest of the time.\n */\nexport function utmEventParams(\n attribution: UtmAttribution | null | undefined,\n): {\n campaign_source?: string\n campaign_medium?: string\n campaign_name?: string\n} {\n if (!attribution) return {}\n return {\n ...(attribution.source ? { campaign_source: attribution.source } : {}),\n ...(attribution.medium ? { campaign_medium: attribution.medium } : {}),\n ...(attribution.campaign ? { campaign_name: attribution.campaign } : {}),\n }\n}\n"],"names":["UTM_QUERY_KEYS","MAX_VALUE_LENGTH","EMAIL_SHAPED","FIELD_FOR_KEY","utm_source","utm_medium","utm_campaign","rawValue","params","key","get","value","Array","isArray","length","scrubValue","trimmed","trim","test","slice","parseUtmAttribution","attribution","Object","keys","utmAttributionQuery","URLSearchParams","set","toString","utmEventParams","source","campaign_source","medium","campaign_medium","campaign","campaign_name"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsDC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,iBAAiB;IAC5B;IACA;IACA;CACD,CAAS;AAYV,4EAA4E,GAC5E,MAAMC,mBAAmB;AAEzB,kFAAkF,GAClF,MAAMC,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAGF;IACFC,YAAY;IACZC,YAAY;IACZC,cAAc;AAChB;AASA,SAASC,SAASC,MAAsB,EAAEC,GAAW;IACnD,IAAI,CAACD,QAAQ,OAAO;IACpB,IAAI,OAAO,AAACA,OAA2BE,GAAG,KAAK,YAAY;QACzD,OAAO,AAACF,OAA2BE,GAAG,CAACD;IACzC;IACA,MAAME,QAAQ,AAACH,MAAwD,CAACC,IAAI;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,4EAA4E;IAC5E,YAAY;IACZ,IAAIG,MAAMC,OAAO,CAACF,QAAQ,OAAOA,MAAMG,MAAM,GAAGH,KAAK,CAAC,EAAE,GAAG;IAC3D,OAAO,OAAOA,UAAU,WAAWA,QAAQ;AAC7C;AAEA;;;;;;;CAOC,GACD,SAASI,WAAWJ,KAAoB;IACtC,IAAI,OAAOA,UAAU,UAAU,OAAO;IACtC,MAAMK,UAAUL,MAAMM,IAAI;IAC1B,IAAI,CAACD,SAAS,OAAO;IACrB,IAAId,aAAagB,IAAI,CAACF,UAAU,OAAO;IACvC,OAAOA,QAAQG,KAAK,CAAC,GAAGlB;AAC1B;AAEA;;;;;;CAMC,GACD,OAAO,SAASmB,oBACdZ,MAAsB;IAEtB,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMa,cAA8B,CAAC;IACrC,KAAK,MAAMZ,OAAOT,eAAgB;QAChC,MAAMW,QAAQI,WAAWR,SAASC,QAAQC;QAC1C,IAAIE,OAAOU,WAAW,CAAClB,aAAa,CAACM,IAAI,CAAC,GAAGE;IAC/C;IACA,OAAOW,OAAOC,IAAI,CAACF,aAAaP,MAAM,GAAGO,cAAc;AACzD;AAEA;;;CAGC,GACD,OAAO,SAASG,oBACdH,WAA8C;IAE9C,IAAI,CAACA,aAAa,OAAO;IACzB,MAAMb,SAAS,IAAIiB;IACnB,KAAK,MAAMhB,OAAOT,eAAgB;QAChC,MAAMW,QAAQU,WAAW,CAAClB,aAAa,CAACM,IAAI,CAAC;QAC7C,IAAIE,OAAOH,OAAOkB,GAAG,CAACjB,KAAKE;IAC7B;IACA,OAAOH,OAAOmB,QAAQ;AACxB;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,eACdP,WAA8C;IAM9C,IAAI,CAACA,aAAa,OAAO,CAAC;IAC1B,OAAO,aACDA,YAAYQ,MAAM,GAAG;QAAEC,iBAAiBT,YAAYQ,MAAM;IAAC,IAAI,CAAC,GAChER,YAAYU,MAAM,GAAG;QAAEC,iBAAiBX,YAAYU,MAAM;IAAC,IAAI,CAAC,GAChEV,YAAYY,QAAQ,GAAG;QAAEC,eAAeb,YAAYY,QAAQ;IAAC,IAAI,CAAC;AAE1E"}
@@ -15,8 +15,13 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * THE VISITOR'S HALF OF CAMPAIGN ATTRIBUTION — the touch that has to survive
19
- * the gap between arriving from a campaign and becoming somebody.
18
+ * THE VISITOR'S HALF OF ATTRIBUTION — the UTM touch that has to survive the
19
+ * gap between arriving from a labeled link and becoming somebody.
20
+ *
21
+ * The labels are the web's (`utm_source`/`utm_medium`/`utm_campaign`), not
22
+ * any plugin's model: every door that identifies a visitor — a form, a
23
+ * booking, a signup — attaches the touch, and whichever plugin credits its
24
+ * campaigns reads it on the server.
20
25
  *
21
26
  * ## The gap this exists to cross
22
27
  *
@@ -125,8 +130,11 @@ import { type UtmAttribution } from './utm-attribution';
125
130
  export declare const ATTRIBUTION_WINDOW_DAYS = 7;
126
131
  /** The same window in milliseconds. */
127
132
  export declare const ATTRIBUTION_WINDOW_MS: number;
128
- /** Where the touch is held. Namespaced like every other key this app sets. */
129
- export declare const CAMPAIGN_TOUCH_STORAGE_KEY = "aglyn:campaign-touch";
133
+ /**
134
+ * Where the touch is held. Namespaced like every other key this app sets, and
135
+ * spelled as visitors' devices already hold it.
136
+ */
137
+ export declare const UTM_TOUCH_STORAGE_KEY = "aglyn:campaign-touch";
130
138
  /**
131
139
  * The parameter the touch's instant rides under inside the wire form.
132
140
  *
@@ -135,17 +143,17 @@ export declare const CAMPAIGN_TOUCH_STORAGE_KEY = "aglyn:campaign-touch";
135
143
  * by it or have to widen it. `t` is outside the allowlist and read
136
144
  * separately.
137
145
  */
138
- export declare const CAMPAIGN_TOUCH_TIME_KEY = "t";
146
+ export declare const UTM_TOUCH_TIME_KEY = "t";
139
147
  /** A campaign the visitor arrived from, and when they arrived from it. */
140
- export interface CampaignTouch extends UtmAttribution {
148
+ export interface UtmTouch extends UtmAttribution {
141
149
  /** When the visitor followed the campaign link, epoch ms. */
142
150
  atMs: number;
143
151
  }
144
152
  /**
145
153
  * The canonical stored form of a touch — the ONLY place it is written, so it
146
- * cannot drift from what {@link parseCampaignTouch} reads back.
154
+ * cannot drift from what {@link parseUtmTouch} reads back.
147
155
  */
148
- export declare function campaignTouchWire(touch: CampaignTouch | null | undefined): string;
156
+ export declare function utmTouchWire(touch: UtmTouch | null | undefined): string;
149
157
  /**
150
158
  * Read a stored or transmitted touch back, or `null`.
151
159
  *
@@ -155,7 +163,7 @@ export declare function campaignTouchWire(touch: CampaignTouch | null | undefine
155
163
  * future is refused for the reason the revenue join refuses one: a click
156
164
  * after the conversion is the receipt, not the cause.
157
165
  */
158
- export declare function parseCampaignTouch(wire: unknown, nowMs?: number): CampaignTouch | null;
166
+ export declare function parseUtmTouch(wire: unknown, nowMs?: number): UtmTouch | null;
159
167
  /**
160
168
  * Tell this module what the visitor's analytics consent state is.
161
169
  *
@@ -163,7 +171,7 @@ export declare function parseCampaignTouch(wire: unknown, nowMs?: number): Campa
163
171
  * grant remembers the arrival the moment it is given and a withdrawal drops
164
172
  * the stored touch immediately.
165
173
  */
166
- export declare function setCampaignTouchConsent(allowed: boolean | null): void;
174
+ export declare function setUtmTouchConsent(allowed: boolean | null): void;
167
175
  /**
168
176
  * Remember the campaign on the current URL as this visitor's latest touch.
169
177
  *
@@ -174,7 +182,7 @@ export declare function setCampaignTouchConsent(allowed: boolean | null): void;
174
182
  *
175
183
  * @returns what is now remembered, or `null`.
176
184
  */
177
- export declare function rememberCampaignTouch(search?: string, nowMs?: number): CampaignTouch | null;
185
+ export declare function rememberUtmTouch(search?: string, nowMs?: number): UtmTouch | null;
178
186
  /**
179
187
  * The touch to credit a conversion happening right now, or `null`.
180
188
  *
@@ -191,7 +199,7 @@ export declare function rememberCampaignTouch(search?: string, nowMs?: number):
191
199
  * device would be keeping a record of where somebody came from for no purpose
192
200
  * anything reads. The read that finds it removes it.
193
201
  */
194
- export declare function readCampaignTouch(nowMs?: number): CampaignTouch | null;
202
+ export declare function readUtmTouch(nowMs?: number): UtmTouch | null;
195
203
  /**
196
204
  * The fragment a conversion request spreads into its body, or `{}`.
197
205
  *
@@ -201,6 +209,6 @@ export declare function readCampaignTouch(nowMs?: number): CampaignTouch | null;
201
209
  * never `campaignTouch: undefined`, so that "arrived from nowhere" and "this
202
210
  * door does not report" stay distinguishable on the wire.
203
211
  */
204
- export declare function campaignTouchField(nowMs?: number): {
212
+ export declare function utmTouchField(nowMs?: number): {
205
213
  campaignTouch?: string;
206
214
  };