@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.
- package/package.json +12 -12
- package/src/lib/app-utils/actions.d.ts +1 -59
- package/src/lib/app-utils/actions.js +7 -72
- package/src/lib/app-utils/actions.js.map +1 -1
- package/src/lib/app-utils/consent-groups.d.ts +1 -1
- package/src/lib/app-utils/consent-groups.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +0 -36
- package/src/lib/app-utils/console-routes.js +2 -37
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/contacts.d.ts +1 -1
- package/src/lib/app-utils/contacts.js.map +1 -1
- package/src/lib/app-utils/container-membership.d.ts +93 -0
- package/src/lib/app-utils/container-membership.js +165 -0
- package/src/lib/app-utils/container-membership.js.map +1 -0
- package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
- package/src/lib/app-utils/crm-task-reminders.js +32 -11
- package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +24 -4
- package/src/lib/app-utils/crm.js +23 -0
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/csv-upload.d.ts +1 -1
- package/src/lib/app-utils/csv-upload.js +1 -1
- package/src/lib/app-utils/csv-upload.js.map +1 -1
- package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
- package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
- package/src/lib/app-utils/csv.js.map +1 -0
- package/src/lib/app-utils/datasets.d.ts +0 -19
- package/src/lib/app-utils/datasets.js +0 -13
- package/src/lib/app-utils/datasets.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
- package/src/lib/app-utils/docs-help.generated.js +5 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +71 -36
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/dynamic-list-rule.js +3 -3
- package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
- package/src/lib/app-utils/email-text.d.ts +80 -0
- package/src/lib/app-utils/email-text.js +189 -0
- package/src/lib/app-utils/email-text.js.map +1 -0
- package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
- package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
- package/src/lib/app-utils/enrollment-basis.js.map +1 -0
- package/src/lib/app-utils/expand-repeatables.js +4 -4
- package/src/lib/app-utils/expand-repeatables.js.map +1 -1
- package/src/lib/app-utils/forms.js +2 -2
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +2 -3
- package/src/lib/app-utils/health-report.js +30 -6
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
- package/src/lib/app-utils/inbound-mail-domain.js +31 -0
- package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
- package/src/lib/app-utils/leaving-notice.d.ts +96 -0
- package/src/lib/app-utils/leaving-notice.js +157 -0
- package/src/lib/app-utils/leaving-notice.js.map +1 -0
- package/src/lib/app-utils/lockdown.d.ts +11 -0
- package/src/lib/app-utils/lockdown.js +33 -3
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
- package/src/lib/app-utils/member-email-aliases.js +29 -0
- package/src/lib/app-utils/member-email-aliases.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +69 -0
- package/src/lib/app-utils/notifications.js +119 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/operator-alerts.d.ts +15 -1
- package/src/lib/app-utils/operator-alerts.js +64 -10
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
- package/src/lib/app-utils/plan-entitlements.js +200 -224
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
- package/src/lib/app-utils/platform-marketing-consent.js +6 -5
- package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
- package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
- package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
- package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
- package/src/lib/app-utils/repeat-query.d.ts +56 -0
- package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
- package/src/lib/app-utils/repeat-query.js.map +1 -0
- package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
- package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
- package/src/lib/app-utils/screen-link-context.js +14 -2
- package/src/lib/app-utils/screen-link-context.js.map +1 -1
- package/src/lib/app-utils/screen-route.d.ts +3 -0
- package/src/lib/app-utils/screen-route.js +6 -1
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +7 -9
- package/src/lib/app-utils/server.js +18 -26
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
- package/src/lib/app-utils/starter-template-nodes.js +129 -0
- package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
- package/src/lib/app-utils/starter-templates.d.ts +11 -34
- package/src/lib/app-utils/starter-templates.js +19 -230
- package/src/lib/app-utils/starter-templates.js.map +1 -1
- package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
- package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
- package/src/lib/app-utils/subscription-topics.js.map +1 -0
- package/src/lib/app-utils/usage-budget.d.ts +73 -188
- package/src/lib/app-utils/usage-budget.js +58 -194
- package/src/lib/app-utils/usage-budget.js.map +1 -1
- package/src/lib/app-utils/utm-attribution.d.ts +1 -1
- package/src/lib/app-utils/utm-attribution.js +1 -1
- package/src/lib/app-utils/utm-attribution.js.map +1 -1
- package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
- package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
- package/src/lib/app-utils/utm-touch.js.map +1 -0
- package/src/lib/app-utils/variables.d.ts +9 -9
- package/src/lib/app-utils/variables.js.map +1 -1
- package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
- package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
- package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
- package/src/lib/plugin-manager/feature-plugins.js +22 -35
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/index.d.ts +0 -1
- package/src/lib/plugin-manager/index.js +0 -1
- package/src/lib/plugin-manager/index.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
- package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
- package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
- package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
- package/src/lib/plugin-manager/plugin-containers.js +27 -0
- package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
- package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
- package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
- package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
- package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
- package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
- package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
- package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
- package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
- package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
- package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
- package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
- package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
- package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
- package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
- package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
- package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
- package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
- package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
- package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
- package/src/lib/app-utils/assist-credits.d.ts +0 -482
- package/src/lib/app-utils/assist-credits.js +0 -510
- package/src/lib/app-utils/assist-credits.js.map +0 -1
- package/src/lib/app-utils/campaign-membership.d.ts +0 -121
- package/src/lib/app-utils/campaign-membership.js +0 -189
- package/src/lib/app-utils/campaign-membership.js.map +0 -1
- package/src/lib/app-utils/campaign-touch.js.map +0 -1
- package/src/lib/app-utils/console-record-links.d.ts +0 -84
- package/src/lib/app-utils/console-record-links.js +0 -76
- package/src/lib/app-utils/console-record-links.js.map +0 -1
- package/src/lib/app-utils/crm-booking.d.ts +0 -75
- package/src/lib/app-utils/crm-booking.js +0 -133
- package/src/lib/app-utils/crm-booking.js.map +0 -1
- package/src/lib/app-utils/crm-inbound.d.ts +0 -245
- package/src/lib/app-utils/crm-inbound.js +0 -407
- package/src/lib/app-utils/crm-inbound.js.map +0 -1
- package/src/lib/app-utils/crm-reports.d.ts +0 -479
- package/src/lib/app-utils/crm-reports.js +0 -680
- package/src/lib/app-utils/crm-reports.js.map +0 -1
- package/src/lib/app-utils/dataset-csv.js.map +0 -1
- package/src/lib/app-utils/dataset-query.d.ts +0 -57
- package/src/lib/app-utils/dataset-query.js.map +0 -1
- package/src/lib/app-utils/email-topics.js.map +0 -1
- package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
- package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
- package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
- 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
|
-
*/
|
|
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
|
-
*
|
|
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
|
|
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
|
|
199
|
+
* month's accumulated usage. A start month cannot reach backwards.
|
|
202
200
|
*
|
|
203
|
-
* `
|
|
204
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
* `
|
|
236
|
-
*
|
|
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
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
255
|
+
* charges, which is the customer's own money and the whole subject of a
|
|
256
|
+
* budget.
|
|
249
257
|
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
154
|
+
* cannot drift from what {@link parseUtmTouch} reads back.
|
|
147
155
|
*/
|
|
148
|
-
export declare function
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
212
|
+
export declare function utmTouchField(nowMs?: number): {
|
|
205
213
|
campaignTouch?: string;
|
|
206
214
|
};
|