@aglyn/plugins-ai 1.0.0-beta.219 → 1.0.0-beta.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -18
- package/src/lib/billing/ai-month-meter.d.ts +51 -0
- package/src/lib/billing/ai-month-meter.js +89 -0
- package/src/lib/billing/ai-month-meter.js.map +1 -0
- package/src/lib/billing/ai-overage-close.d.ts +11 -33
- package/src/lib/billing/ai-overage-close.js +4 -3
- package/src/lib/billing/ai-overage-close.js.map +1 -1
- package/src/lib/billing/ai-overage-cutover.d.ts +28 -12
- package/src/lib/billing/ai-overage-cutover.js +1 -28
- package/src/lib/billing/ai-overage-cutover.js.map +1 -1
- package/src/lib/billing/ai-overage-ledger.js +1 -1
- package/src/lib/billing/ai-overage-ledger.js.map +1 -1
- package/src/lib/billing/ai-overage-standing.js +1 -1
- package/src/lib/billing/ai-overage-standing.js.map +1 -1
- package/src/lib/components/ai-allotment-editor.component.js +1 -1
- package/src/lib/components/ai-allotment-editor.component.js.map +1 -1
- package/src/lib/components/ai-assist-provider.component.js +3 -2
- package/src/lib/components/ai-assist-provider.component.js.map +1 -1
- package/src/lib/components/ai-automation-zones.d.ts +63 -0
- package/src/lib/components/ai-automation-zones.js +31 -0
- package/src/lib/components/ai-automation-zones.js.map +1 -0
- package/src/lib/components/ai-describe-automation.component.d.ts +1 -1
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-describe-button.component.d.ts +6 -3
- package/src/lib/components/ai-describe-button.component.js +4 -1
- package/src/lib/components/ai-describe-button.component.js.map +1 -1
- package/src/lib/components/ai-explain-automation.component.d.ts +1 -1
- package/src/lib/components/ai-explain-automation.component.js.map +1 -1
- package/src/lib/components/billing-ai-allotments.component.js +1 -1
- package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
- package/src/lib/components/billing-assist-overage-card.component.js +1 -1
- package/src/lib/components/billing-assist-overage-card.component.js.map +1 -1
- package/src/lib/constants.d.ts +5 -0
- package/src/lib/constants.js +4 -0
- package/src/lib/constants.js.map +1 -1
- package/src/lib/declarations.js +2 -0
- package/src/lib/declarations.js.map +1 -1
- package/src/lib/declarations.server.js +24 -10
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/insights/ai-dataset-catalog.d.ts +49 -0
- package/src/lib/insights/ai-dataset-catalog.js +57 -0
- package/src/lib/insights/ai-dataset-catalog.js.map +1 -0
- package/src/lib/insights/ai-figure-readers.d.ts +6 -39
- package/src/lib/insights/ai-figure-readers.js +2 -391
- package/src/lib/insights/ai-figure-readers.js.map +1 -1
- package/src/lib/insights/ai-insight-digest.d.ts +10 -1
- package/src/lib/insights/ai-insight-digest.js +16 -4
- package/src/lib/insights/ai-insight-digest.js.map +1 -1
- package/src/lib/insights/ai-insight-readers.d.ts +1 -1
- package/src/lib/insights/ai-insight-readers.js.map +1 -1
- package/src/lib/jobs/ai-job-component-step.d.ts +6 -1
- package/src/lib/jobs/ai-job-component-step.js +23 -6
- package/src/lib/jobs/ai-job-component-step.js.map +1 -1
- package/src/lib/jobs/ai-job-draft-ids.js +11 -0
- package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
- package/src/lib/jobs/ai-job-email-step.d.ts +5 -1
- package/src/lib/jobs/ai-job-email-step.js +10 -1
- package/src/lib/jobs/ai-job-email-step.js.map +1 -1
- package/src/lib/jobs/ai-job-form-step.d.ts +17 -1
- package/src/lib/jobs/ai-job-form-step.js +60 -8
- package/src/lib/jobs/ai-job-form-step.js.map +1 -1
- package/src/lib/jobs/ai-job-generation.d.ts +21 -0
- package/src/lib/jobs/ai-job-generation.js +53 -1
- package/src/lib/jobs/ai-job-generation.js.map +1 -1
- package/src/lib/jobs/ai-job-insight-step.js +1 -1
- package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
- package/src/lib/jobs/ai-job-layout-step.d.ts +15 -4
- package/src/lib/jobs/ai-job-layout-step.js +45 -12
- package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-conformance.d.ts +5 -0
- package/src/lib/jobs/ai-job-plan-conformance.js +7 -1
- package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-creations.d.ts +80 -0
- package/src/lib/jobs/ai-job-plan-creations.js +199 -0
- package/src/lib/jobs/ai-job-plan-creations.js.map +1 -0
- package/src/lib/jobs/ai-job-plan-step.d.ts +8 -2
- package/src/lib/jobs/ai-job-plan-step.js +20 -6
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +2 -0
- package/src/lib/jobs/ai-job-site-step.js +6 -6
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-template-step.d.ts +9 -6
- package/src/lib/jobs/ai-job-template-step.js +72 -15
- package/src/lib/jobs/ai-job-template-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.js +1 -1
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-workflow-records.d.ts +5 -4
- package/src/lib/jobs/ai-workflow-records.js +26 -22
- package/src/lib/jobs/ai-workflow-records.js.map +1 -1
- package/src/lib/model/ai-allotments.d.ts +16 -3
- package/src/lib/model/ai-allotments.js +34 -11
- package/src/lib/model/ai-allotments.js.map +1 -1
- package/src/lib/model/ai-job-creations.d.ts +55 -0
- package/src/lib/model/ai-job-creations.js +85 -0
- package/src/lib/model/ai-job-creations.js.map +1 -0
- package/src/lib/model/ai-page-job.d.ts +3 -2
- package/src/lib/model/ai-page-job.js +4 -3
- package/src/lib/model/ai-page-job.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +4 -2
- package/src/lib/model/ai-site-job.js +8 -4
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-template-subjects.d.ts +10 -0
- package/src/lib/model/ai-template-subjects.js +14 -0
- package/src/lib/model/ai-template-subjects.js.map +1 -1
- package/src/lib/model/ai-tokens.js +1 -1
- package/src/lib/model/ai-tokens.js.map +1 -1
- package/src/lib/model/ai-usage-by-user.js +1 -1
- package/src/lib/model/ai-usage-by-user.js.map +1 -1
- package/src/lib/providers/catalog.js +1 -1
- package/src/lib/providers/catalog.js.map +1 -1
- package/src/lib/providers/model-choice.js +1 -1
- package/src/lib/providers/model-choice.js.map +1 -1
- package/src/lib/runtime/ai-device-audit.d.ts +1 -1
- package/src/lib/runtime/ai-device-audit.js +1 -1
- package/src/lib/runtime/ai-device-audit.js.map +1 -1
- package/src/lib/runtime/ai-eval-live.js +1 -1
- package/src/lib/runtime/ai-eval-live.js.map +1 -1
- package/src/lib/runtime/ai-eval.d.ts +1 -1
- package/src/lib/runtime/ai-eval.js.map +1 -1
- package/src/lib/runtime/ai-gate.js +1 -1
- package/src/lib/runtime/ai-gate.js.map +1 -1
- package/src/lib/runtime/ai-palette.d.ts +1 -1
- package/src/lib/runtime/ai-palette.generated.d.ts +1 -1
- package/src/lib/runtime/ai-palette.generated.js +1 -1
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/ai-palette.js.map +1 -1
- package/src/lib/runtime/site-inventory.js +28 -22
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-admin-org.js +1 -1
- package/src/lib/server/ai-admin-org.js.map +1 -1
- package/src/lib/server/ai-admin-orgs-spend.js +1 -1
- package/src/lib/server/ai-admin-orgs-spend.js.map +1 -1
- package/src/lib/server/ai-admin-signals.js +1 -1
- package/src/lib/server/ai-admin-signals.js.map +1 -1
- package/src/lib/server/ai-allotments.js +1 -1
- package/src/lib/server/ai-allotments.js.map +1 -1
- package/src/lib/server/ai-assist.js +2 -2
- package/src/lib/server/ai-assist.js.map +1 -1
- package/src/lib/server/assist-chat.d.ts +1 -1
- package/src/lib/server/assist-chat.js +2 -2
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/billing-credits.d.ts +5 -0
- package/src/lib/server/billing-credits.js +8 -2
- package/src/lib/server/billing-credits.js.map +1 -1
- package/src/lib/server/billing-overage.js +2 -2
- package/src/lib/server/billing-overage.js.map +1 -1
- package/src/lib/testing/stand-in-dataset-index.d.ts +20 -0
- package/src/lib/testing/stand-in-dataset-index.js +80 -0
- package/src/lib/testing/stand-in-dataset-index.js.map +1 -0
- package/src/lib/usage/ai-allotment-alerts.d.ts +10 -7
- package/src/lib/usage/ai-allotment-alerts.js +31 -14
- package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
- package/src/lib/usage/ai-quota-checks.d.ts +37 -0
- package/src/lib/usage/ai-quota-checks.js +69 -0
- package/src/lib/usage/ai-quota-checks.js.map +1 -0
- package/src/lib/usage/ai-usage-alerts.js +22 -10
- package/src/lib/usage/ai-usage-alerts.js.map +1 -1
- package/src/lib/usage/ai-usage-by-user.js +1 -1
- package/src/lib/usage/ai-usage-by-user.js.map +1 -1
- package/src/lib/usage/assist-ceiling.d.ts +2 -2
- package/src/lib/usage/assist-ceiling.js +4 -4
- package/src/lib/usage/assist-ceiling.js.map +1 -1
- package/src/lib/usage/assist-credits.d.ts +494 -0
- package/src/lib/usage/assist-credits.js +524 -0
- package/src/lib/usage/assist-credits.js.map +1 -0
- package/src/lib/usage/assist-free-taste.d.ts +1 -1
- package/src/lib/usage/assist-free-taste.js +1 -1
- package/src/lib/usage/assist-free-taste.js.map +1 -1
- package/src/lib/usage/assist-signal-mining.js +1 -1
- package/src/lib/usage/assist-signal-mining.js.map +1 -1
- package/src/lib/usage/assist-spend-guards.d.ts +153 -0
- package/src/lib/usage/assist-spend-guards.js +182 -0
- package/src/lib/usage/assist-spend-guards.js.map +1 -0
- package/src/lib/usage/assist-usage.d.ts +1 -1
- package/src/lib/usage/assist-usage.js +1 -1
- package/src/lib/usage/assist-usage.js.map +1 -1
- package/src/lib/usage/staff-org-ai.js +1 -1
- package/src/lib/usage/staff-org-ai.js.map +1 -1
- package/src/lib/usage-axes.d.ts +10 -0
- package/src/lib/usage-axes.js +40 -2
- package/src/lib/usage-axes.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-ai",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.221",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-util-errors": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
41
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
42
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
43
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.221",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.221",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.221",
|
|
31
|
+
"@aglyn/shared-data-types": "1.0.0-beta.221",
|
|
32
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.221",
|
|
33
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.221",
|
|
34
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.221",
|
|
35
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.221",
|
|
36
|
+
"@aglyn/shared-util-email": "1.0.0-beta.221",
|
|
37
|
+
"@aglyn/shared-util-errors": "1.0.0-beta.221",
|
|
38
|
+
"@aglyn/shared-util-http": "1.0.0-beta.221",
|
|
39
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.221",
|
|
40
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.221",
|
|
41
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.221",
|
|
42
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.221",
|
|
43
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.221",
|
|
44
44
|
"@swc/helpers": "0.5.23"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@mui/x-data-grid": "^9.12.0",
|
|
49
49
|
"firebase": "^12.18.0",
|
|
50
50
|
"firebase-admin": "^14.3.0",
|
|
51
|
-
"next": "16.3.3",
|
|
51
|
+
"next": "^16.3.3",
|
|
52
52
|
"react": "^19.2.8"
|
|
53
53
|
},
|
|
54
54
|
"sideEffects": [
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginUsageMeterCloseContext, PluginUsageMeterContext, PluginUsageMeterReading } from '@aglyn/aglyn/plugin-manager/plugin-usage-meters';
|
|
18
|
+
/**
|
|
19
|
+
* One workspace's month of Aglyn Assist, as the usage sweep records and bills
|
|
20
|
+
* it (AGL-2280, AGL-2653, AGL-3011).
|
|
21
|
+
*
|
|
22
|
+
* THE SPEND is recorded and never billed. `assistCostUsd` is the provider
|
|
23
|
+
* figure of the two the month's document keeps (AGL-3015) — what the tokens
|
|
24
|
+
* cost US, at the provider's rates — and enters the cost model at ×1 through
|
|
25
|
+
* this plugin's cost axis, because the discount guardrail's whole job is to
|
|
26
|
+
* compare revenue with what a workspace costs. On the invoice it would charge
|
|
27
|
+
* for what the subscription already bought.
|
|
28
|
+
*
|
|
29
|
+
* THE OVERAGE is billed. The credits past the plan's band were sold at the
|
|
30
|
+
* plan's per-1,000 rate, so they enter the month's billed figure beside the
|
|
31
|
+
* platform's plan-priced overages. One derivation — `assistMonthOverage` turns
|
|
32
|
+
* the BILLED spend (`estCostUsd`) into credits, subtracts the band the
|
|
33
|
+
* workspace resolves to and prices the rest — so the customer's meter and this
|
|
34
|
+
* line cannot disagree about what was over. The workspace's hard-cap switch is
|
|
35
|
+
* not read here: it lives at the gate, and reading it at sweep time would let a
|
|
36
|
+
* flip on the 1st erase a month's overage.
|
|
37
|
+
*
|
|
38
|
+
* From the month `AI_OVERAGE_INVOICED_FROM` names, the overage is billed by
|
|
39
|
+
* invoice as it accrues instead, and leaves the meter figure so one month's
|
|
40
|
+
* usage reaches exactly one invoice. It is still priced and recorded either
|
|
41
|
+
* way, so a month billed through the other channel never reads as a month
|
|
42
|
+
* inside its band.
|
|
43
|
+
*/
|
|
44
|
+
export declare function measureAiMonth(context: PluginUsageMeterContext): Promise<PluginUsageMeterReading>;
|
|
45
|
+
/**
|
|
46
|
+
* The close-out of a month whose overage is billed by invoice: the remainder
|
|
47
|
+
* that never reached the charge threshold, reconciled and invoiced
|
|
48
|
+
* (`ai-overage-close.ts`). Before the cutover month there is nothing of this
|
|
49
|
+
* plugin's to settle, and the workspace's billing document is never read.
|
|
50
|
+
*/
|
|
51
|
+
export declare function closeAiMonth(context: PluginUsageMeterCloseContext): Promise<void>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { ASSIST_PROVIDER_COST_FIELD, assistMonthOverage, assistProviderCostUsd } from "../usage/assist-credits.js";
|
|
17
|
+
import { aiOverageBillsByInvoice } from "./ai-overage-cutover.js";
|
|
18
|
+
import { aiOverageFirestore } from "./ai-overage-trigger.js";
|
|
19
|
+
/**
|
|
20
|
+
* One workspace's month of Aglyn Assist, as the usage sweep records and bills
|
|
21
|
+
* it (AGL-2280, AGL-2653, AGL-3011).
|
|
22
|
+
*
|
|
23
|
+
* THE SPEND is recorded and never billed. `assistCostUsd` is the provider
|
|
24
|
+
* figure of the two the month's document keeps (AGL-3015) — what the tokens
|
|
25
|
+
* cost US, at the provider's rates — and enters the cost model at ×1 through
|
|
26
|
+
* this plugin's cost axis, because the discount guardrail's whole job is to
|
|
27
|
+
* compare revenue with what a workspace costs. On the invoice it would charge
|
|
28
|
+
* for what the subscription already bought.
|
|
29
|
+
*
|
|
30
|
+
* THE OVERAGE is billed. The credits past the plan's band were sold at the
|
|
31
|
+
* plan's per-1,000 rate, so they enter the month's billed figure beside the
|
|
32
|
+
* platform's plan-priced overages. One derivation — `assistMonthOverage` turns
|
|
33
|
+
* the BILLED spend (`estCostUsd`) into credits, subtracts the band the
|
|
34
|
+
* workspace resolves to and prices the rest — so the customer's meter and this
|
|
35
|
+
* line cannot disagree about what was over. The workspace's hard-cap switch is
|
|
36
|
+
* not read here: it lives at the gate, and reading it at sweep time would let a
|
|
37
|
+
* flip on the 1st erase a month's overage.
|
|
38
|
+
*
|
|
39
|
+
* From the month `AI_OVERAGE_INVOICED_FROM` names, the overage is billed by
|
|
40
|
+
* invoice as it accrues instead, and leaves the meter figure so one month's
|
|
41
|
+
* usage reaches exactly one invoice. It is still priced and recorded either
|
|
42
|
+
* way, so a month billed through the other channel never reads as a month
|
|
43
|
+
* inside its band.
|
|
44
|
+
*/ export async function measureAiMonth(context) {
|
|
45
|
+
var _snapshot_get;
|
|
46
|
+
const snapshot = await aiOverageFirestore().collection('orgs').doc(context.orgId).collection('assistUsage').doc(context.month).get();
|
|
47
|
+
const assistCostUsd = assistProviderCostUsd(snapshot.get('estCostUsd'), snapshot.get(ASSIST_PROVIDER_COST_FIELD));
|
|
48
|
+
const billedRaw = Number((_snapshot_get = snapshot.get('estCostUsd')) != null ? _snapshot_get : 0);
|
|
49
|
+
const overage = assistMonthOverage(context.org, Number.isFinite(billedRaw) && billedRaw > 0 ? billedRaw : 0);
|
|
50
|
+
const billedByInvoice = aiOverageBillsByInvoice(context.month);
|
|
51
|
+
const meteredUsd = billedByInvoice ? 0 : overage.overageMonthlyUsd;
|
|
52
|
+
return {
|
|
53
|
+
fields: {
|
|
54
|
+
assistCostUsd,
|
|
55
|
+
// The credit view of the same month: what was drawn, the band it was
|
|
56
|
+
// drawn against (`null` where the plan sells none), the part past it
|
|
57
|
+
// and the rate it was priced at. Recorded always, zeros included, so a
|
|
58
|
+
// month inside its band is legible as one.
|
|
59
|
+
assistCredits: overage.usedCredits,
|
|
60
|
+
assistCreditsBand: overage.bandCredits,
|
|
61
|
+
assistCreditsOverage: overage.overageCredits,
|
|
62
|
+
assistOverageUsd: overage.overageMonthlyUsd,
|
|
63
|
+
assistOverageRateUsd: overage.overageRateUsd,
|
|
64
|
+
// What entered the meter figure, and whether the invoice channel took
|
|
65
|
+
// it instead — countable from the rows rather than inferred from a
|
|
66
|
+
// deployment variable.
|
|
67
|
+
assistOverageMeteredUsd: meteredUsd,
|
|
68
|
+
assistOverageBilledByPlugin: billedByInvoice
|
|
69
|
+
},
|
|
70
|
+
billedUsd: meteredUsd
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The close-out of a month whose overage is billed by invoice: the remainder
|
|
75
|
+
* that never reached the charge threshold, reconciled and invoiced
|
|
76
|
+
* (`ai-overage-close.ts`). Before the cutover month there is nothing of this
|
|
77
|
+
* plugin's to settle, and the workspace's billing document is never read.
|
|
78
|
+
*/ export async function closeAiMonth(context) {
|
|
79
|
+
if (!aiOverageBillsByInvoice(context.month)) return;
|
|
80
|
+
const { closeAiOverageMonth } = await import("./ai-overage-close.js");
|
|
81
|
+
await closeAiOverageMonth({
|
|
82
|
+
orgId: context.orgId,
|
|
83
|
+
month: context.month,
|
|
84
|
+
org: context.org,
|
|
85
|
+
stripeCustomerId: await context.stripeCustomerId()
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//# sourceMappingURL=ai-month-meter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-month-meter.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 ASSIST_PROVIDER_COST_FIELD,\n assistMonthOverage,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport type {\n PluginUsageMeterCloseContext,\n PluginUsageMeterContext,\n PluginUsageMeterReading,\n} from '@aglyn/aglyn/plugin-manager/plugin-usage-meters'\nimport { aiOverageBillsByInvoice } from './ai-overage-cutover'\nimport { aiOverageFirestore } from './ai-overage-trigger'\n\n/**\n * One workspace's month of Aglyn Assist, as the usage sweep records and bills\n * it (AGL-2280, AGL-2653, AGL-3011).\n *\n * THE SPEND is recorded and never billed. `assistCostUsd` is the provider\n * figure of the two the month's document keeps (AGL-3015) — what the tokens\n * cost US, at the provider's rates — and enters the cost model at ×1 through\n * this plugin's cost axis, because the discount guardrail's whole job is to\n * compare revenue with what a workspace costs. On the invoice it would charge\n * for what the subscription already bought.\n *\n * THE OVERAGE is billed. The credits past the plan's band were sold at the\n * plan's per-1,000 rate, so they enter the month's billed figure beside the\n * platform's plan-priced overages. One derivation — `assistMonthOverage` turns\n * the BILLED spend (`estCostUsd`) into credits, subtracts the band the\n * workspace resolves to and prices the rest — so the customer's meter and this\n * line cannot disagree about what was over. The workspace's hard-cap switch is\n * not read here: it lives at the gate, and reading it at sweep time would let a\n * flip on the 1st erase a month's overage.\n *\n * From the month `AI_OVERAGE_INVOICED_FROM` names, the overage is billed by\n * invoice as it accrues instead, and leaves the meter figure so one month's\n * usage reaches exactly one invoice. It is still priced and recorded either\n * way, so a month billed through the other channel never reads as a month\n * inside its band.\n */\nexport async function measureAiMonth(\n context: PluginUsageMeterContext,\n): Promise<PluginUsageMeterReading> {\n const snapshot = await aiOverageFirestore()\n .collection('orgs')\n .doc(context.orgId)\n .collection('assistUsage')\n .doc(context.month)\n .get()\n const assistCostUsd = assistProviderCostUsd(\n snapshot.get('estCostUsd'),\n snapshot.get(ASSIST_PROVIDER_COST_FIELD),\n )\n const billedRaw = Number(snapshot.get('estCostUsd') ?? 0)\n const overage = assistMonthOverage(\n context.org as never,\n Number.isFinite(billedRaw) && billedRaw > 0 ? billedRaw : 0,\n )\n const billedByInvoice = aiOverageBillsByInvoice(context.month)\n const meteredUsd = billedByInvoice ? 0 : overage.overageMonthlyUsd\n return {\n fields: {\n assistCostUsd,\n // The credit view of the same month: what was drawn, the band it was\n // drawn against (`null` where the plan sells none), the part past it\n // and the rate it was priced at. Recorded always, zeros included, so a\n // month inside its band is legible as one.\n assistCredits: overage.usedCredits,\n assistCreditsBand: overage.bandCredits,\n assistCreditsOverage: overage.overageCredits,\n assistOverageUsd: overage.overageMonthlyUsd,\n assistOverageRateUsd: overage.overageRateUsd,\n // What entered the meter figure, and whether the invoice channel took\n // it instead — countable from the rows rather than inferred from a\n // deployment variable.\n assistOverageMeteredUsd: meteredUsd,\n assistOverageBilledByPlugin: billedByInvoice,\n },\n billedUsd: meteredUsd,\n }\n}\n\n/**\n * The close-out of a month whose overage is billed by invoice: the remainder\n * that never reached the charge threshold, reconciled and invoiced\n * (`ai-overage-close.ts`). Before the cutover month there is nothing of this\n * plugin's to settle, and the workspace's billing document is never read.\n */\nexport async function closeAiMonth(\n context: PluginUsageMeterCloseContext,\n): Promise<void> {\n if (!aiOverageBillsByInvoice(context.month)) return\n const { closeAiOverageMonth } = await import('./ai-overage-close')\n await closeAiOverageMonth({\n orgId: context.orgId,\n month: context.month,\n org: context.org,\n stripeCustomerId: await context.stripeCustomerId(),\n })\n}\n"],"names":["ASSIST_PROVIDER_COST_FIELD","assistMonthOverage","assistProviderCostUsd","aiOverageBillsByInvoice","aiOverageFirestore","measureAiMonth","context","snapshot","collection","doc","orgId","month","get","assistCostUsd","billedRaw","Number","overage","org","isFinite","billedByInvoice","meteredUsd","overageMonthlyUsd","fields","assistCredits","usedCredits","assistCreditsBand","bandCredits","assistCreditsOverage","overageCredits","assistOverageUsd","assistOverageRateUsd","overageRateUsd","assistOverageMeteredUsd","assistOverageBilledByPlugin","billedUsd","closeAiMonth","closeAiOverageMonth","stripeCustomerId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,0BAA0B,EAC1BC,kBAAkB,EAClBC,qBAAqB,QAChB,6BAAyB;AAMhC,SAASC,uBAAuB,QAAQ,0BAAsB;AAC9D,SAASC,kBAAkB,QAAQ,0BAAsB;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,eACpBC,OAAgC;QAYPC;IAVzB,MAAMA,WAAW,MAAMH,qBACpBI,UAAU,CAAC,QACXC,GAAG,CAACH,QAAQI,KAAK,EACjBF,UAAU,CAAC,eACXC,GAAG,CAACH,QAAQK,KAAK,EACjBC,GAAG;IACN,MAAMC,gBAAgBX,sBACpBK,SAASK,GAAG,CAAC,eACbL,SAASK,GAAG,CAACZ;IAEf,MAAMc,YAAYC,QAAOR,gBAAAA,SAASK,GAAG,CAAC,yBAAbL,gBAA8B;IACvD,MAAMS,UAAUf,mBACdK,QAAQW,GAAG,EACXF,OAAOG,QAAQ,CAACJ,cAAcA,YAAY,IAAIA,YAAY;IAE5D,MAAMK,kBAAkBhB,wBAAwBG,QAAQK,KAAK;IAC7D,MAAMS,aAAaD,kBAAkB,IAAIH,QAAQK,iBAAiB;IAClE,OAAO;QACLC,QAAQ;YACNT;YACA,qEAAqE;YACrE,qEAAqE;YACrE,uEAAuE;YACvE,2CAA2C;YAC3CU,eAAeP,QAAQQ,WAAW;YAClCC,mBAAmBT,QAAQU,WAAW;YACtCC,sBAAsBX,QAAQY,cAAc;YAC5CC,kBAAkBb,QAAQK,iBAAiB;YAC3CS,sBAAsBd,QAAQe,cAAc;YAC5C,sEAAsE;YACtE,mEAAmE;YACnE,uBAAuB;YACvBC,yBAAyBZ;YACzBa,6BAA6Bd;QAC/B;QACAe,WAAWd;IACb;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAee,aACpB7B,OAAqC;IAErC,IAAI,CAACH,wBAAwBG,QAAQK,KAAK,GAAG;IAC7C,MAAM,EAAEyB,mBAAmB,EAAE,GAAG,MAAM,MAAM,CAAC;IAC7C,MAAMA,oBAAoB;QACxB1B,OAAOJ,QAAQI,KAAK;QACpBC,OAAOL,QAAQK,KAAK;QACpBM,KAAKX,QAAQW,GAAG;QAChBoB,kBAAkB,MAAM/B,QAAQ+B,gBAAgB;IAClD;AACF"}
|
|
@@ -14,38 +14,16 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
* for the same fact is a second schedule to keep in step, and the sweep that
|
|
28
|
-
* closes the month is by definition on time for this.
|
|
29
|
-
*
|
|
30
|
-
* ## Three things, in order
|
|
31
|
-
*
|
|
32
|
-
* 1. **Reconcile.** A claim that never reported an outcome is asked about in
|
|
33
|
-
* Stripe, and either recorded or released. It comes first because a stuck
|
|
34
|
-
* open charge blocks the close-out behind it.
|
|
35
|
-
* 2. **Close out.** Whatever is left unbilled is invoiced, however small —
|
|
36
|
-
* down to Stripe's minimum, below which it is carried rather than sent as
|
|
37
|
-
* an invoice nobody can pay.
|
|
38
|
-
* 3. **Correct the ladder.** A month that ended with an AI charge unpaid is
|
|
39
|
-
* taken back off the workspace's qualifying months. A month in which a
|
|
40
|
-
* workspace both paid an invoice and left an AI charge unpaid is not
|
|
41
|
-
* evidence of the thing the ladder measures.
|
|
42
|
-
*
|
|
43
|
-
* Idempotent, because the sweep calls it every day the month is closed: the
|
|
44
|
-
* reconcile is a no-op once the charge is settled, the close-out claims
|
|
45
|
-
* nothing once there is nothing unbilled, and the month is dropped from the
|
|
46
|
-
* ladder only while it is on it.
|
|
47
|
-
*/
|
|
48
|
-
import type { PluginMeteredLineMonthContext } from '@aglyn/aglyn/plugin-manager/plugin-metered-lines';
|
|
17
|
+
/** One workspace's closed month, as the usage sweep's meter hands it over. */
|
|
18
|
+
export interface AiOverageMonthContext {
|
|
19
|
+
orgId: string;
|
|
20
|
+
/** `YYYY-MM`, the month that closed. */
|
|
21
|
+
month: string;
|
|
22
|
+
/** The workspace's own document, as the sweep already read it. */
|
|
23
|
+
org: Readonly<Record<string, unknown>>;
|
|
24
|
+
/** Its Stripe customer, or `null` when it has none. */
|
|
25
|
+
stripeCustomerId: string | null;
|
|
26
|
+
}
|
|
49
27
|
/** What the close-out did, for the sweep's log and the specs. */
|
|
50
28
|
export interface AiOverageCloseResult {
|
|
51
29
|
reconciled: number;
|
|
@@ -59,4 +37,4 @@ export interface AiOverageCloseResult {
|
|
|
59
37
|
* each step is attempted independently so one workspace's failed Stripe call
|
|
60
38
|
* does not cost it the ladder correction it also needed.
|
|
61
39
|
*/
|
|
62
|
-
export declare function closeAiOverageMonth(context:
|
|
40
|
+
export declare function closeAiOverageMonth(context: AiOverageMonthContext, now?: Date): Promise<AiOverageCloseResult>;
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* that never reached the threshold before the month ended. This settles it,
|
|
21
21
|
* and does the two other things a month's end is the right moment for.
|
|
22
22
|
*
|
|
23
|
-
* Run from the platform's closed-month usage sweep through
|
|
24
|
-
*
|
|
25
|
-
* Stripe customer — rather than from a cron of
|
|
23
|
+
* Run from the platform's closed-month usage sweep through this plugin's
|
|
24
|
+
* usage meter (`ai-month-meter.ts`) — the one job that already knows every
|
|
25
|
+
* workspace, its month and its Stripe customer — rather than from a cron of
|
|
26
|
+
* its own. A second schedule
|
|
26
27
|
* for the same fact is a second schedule to keep in step, and the sweep that
|
|
27
28
|
* closes the month is by definition on time for this.
|
|
28
29
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-overage-close.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 * CLOSING A MONTH OF AI OVERAGE (AGL-3011).\n *\n * Charging as usage accrues always leaves a remainder: the part of a month\n * that never reached the threshold before the month ended. This settles it,\n * and does the two other things a month's end is the right moment for.\n *\n * Run from the platform's closed-month usage sweep through
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-overage-close.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 * CLOSING A MONTH OF AI OVERAGE (AGL-3011).\n *\n * Charging as usage accrues always leaves a remainder: the part of a month\n * that never reached the threshold before the month ended. This settles it,\n * and does the two other things a month's end is the right moment for.\n *\n * Run from the platform's closed-month usage sweep through this plugin's\n * usage meter (`ai-month-meter.ts`) — the one job that already knows every\n * workspace, its month and its Stripe customer — rather than from a cron of\n * its own. A second schedule\n * for the same fact is a second schedule to keep in step, and the sweep that\n * closes the month is by definition on time for this.\n *\n * ## Three things, in order\n *\n * 1. **Reconcile.** A claim that never reported an outcome is asked about in\n * Stripe, and either recorded or released. It comes first because a stuck\n * open charge blocks the close-out behind it.\n * 2. **Close out.** Whatever is left unbilled is invoiced, however small —\n * down to Stripe's minimum, below which it is carried rather than sent as\n * an invoice nobody can pay.\n * 3. **Correct the ladder.** A month that ended with an AI charge unpaid is\n * taken back off the workspace's qualifying months. A month in which a\n * workspace both paid an invoice and left an AI charge unpaid is not\n * evidence of the thing the ladder measures.\n *\n * Idempotent, because the sweep calls it every day the month is closed: the\n * reconcile is a no-op once the charge is settled, the close-out claims\n * nothing once there is nothing unbilled, and the month is dropped from the\n * ladder only while it is on it.\n */\n\nimport {\n closeOutAiOverage,\n reconcileAiOverageCharge,\n} from './ai-overage-charge'\nimport {\n AI_OVERAGE_CHARGES_SUBCOLLECTION,\n readAiOverageMonthLedger,\n} from './ai-overage-ledger'\nimport { dropAiOverageQualifyingMonth } from './ai-overage-standing-writes'\nimport { aiOverageFirestore } from './ai-overage-trigger'\n\n/** One workspace's closed month, as the usage sweep's meter hands it over. */\nexport interface AiOverageMonthContext {\n orgId: string\n /** `YYYY-MM`, the month that closed. */\n month: string\n /** The workspace's own document, as the sweep already read it. */\n org: Readonly<Record<string, unknown>>\n /** Its Stripe customer, or `null` when it has none. */\n stripeCustomerId: string | null\n}\n\n/** How many of a month's charge rows the close-out will look at. */\nconst CHARGE_SCAN = 100\n\n/** What the close-out did, for the sweep's log and the specs. */\nexport interface AiOverageCloseResult {\n reconciled: number\n closedOutUsd: number\n droppedFromLadder: boolean\n}\n\n/**\n * Settles one workspace's closed month.\n *\n * Errors are the caller's to isolate — the registry logs and continues — but\n * each step is attempted independently so one workspace's failed Stripe call\n * does not cost it the ladder correction it also needed.\n */\nexport async function closeAiOverageMonth(\n context: AiOverageMonthContext,\n now = new Date(),\n): Promise<AiOverageCloseResult> {\n const firestore = aiOverageFirestore()\n const orgRef = firestore.collection('orgs').doc(context.orgId)\n const usageSnapshot = await orgRef\n .collection('assistUsage')\n .doc(context.month)\n .get()\n const ledger = readAiOverageMonthLedger(\n usageSnapshot.exists ? (usageSnapshot.data() ?? null) : null,\n )\n const result: AiOverageCloseResult = {\n reconciled: 0,\n closedOutUsd: 0,\n droppedFromLadder: false,\n }\n // A month that never sold any overage has nothing here at all, and asking\n // Stripe about it would be a round trip per workspace per day.\n if (!ledger.charges && !ledger.invoicedUsd && !ledger.open) {\n return result\n }\n\n // 1. An open claim that never reported. `since` is on the open slot, so\n // its age is known without a second read.\n if (ledger.open) {\n const open = usageSnapshot.get('overageInvoiceOpen') as\n | { since?: unknown }\n | null\n | undefined\n const since = Date.parse(String(open?.since ?? ''))\n const claimedAgeMs = Number.isFinite(since)\n ? Math.max(0, now.getTime() - since)\n : Number.MAX_SAFE_INTEGER\n const reconciled = await reconcileAiOverageCharge(firestore, {\n orgId: context.orgId,\n month: context.month,\n chargeId: ledger.open.chargeId,\n claimedAgeMs,\n })\n if (reconciled.resolved) result.reconciled = 1\n }\n\n // 2. The remainder.\n const closed = await closeOutAiOverage(firestore, {\n orgId: context.orgId,\n org: context.org as never,\n month: context.month,\n stripeCustomerId: context.stripeCustomerId,\n now,\n })\n if (closed.charged) {\n const after = await orgRef.collection('assistUsage').doc(context.month).get()\n result.closedOutUsd =\n readAiOverageMonthLedger(after.data() ?? null).invoicedUsd - ledger.invoicedUsd\n }\n\n // 3. The ladder. Read AFTER the two steps above, so a charge they settled\n // is not counted as unpaid.\n if (await aiOverageMonthLeftUnpaid(orgRef, context.month)) {\n await dropAiOverageQualifyingMonth(firestore, context.orgId, context.month)\n result.droppedFromLadder = true\n }\n return result\n}\n\n/**\n * Did this month end with an AI overage charge still unpaid?\n *\n * `failed` and `open` both count; `void` does not — an invoice written off\n * is a decision that the money is not owed, not a debt the workspace left.\n * A `claimed` row that survived the reconcile above counts too: something\n * claimed the money and never billed it, which is exactly the state that\n * should not raise a ceiling.\n */\nasync function aiOverageMonthLeftUnpaid(\n orgRef: FirebaseFirestore.DocumentReference,\n month: string,\n): Promise<boolean> {\n const charges = await orgRef\n .collection(AI_OVERAGE_CHARGES_SUBCOLLECTION)\n .where('month', '==', month)\n .limit(CHARGE_SCAN)\n .get()\n return charges.docs.some((doc) =>\n ['claimed', 'open', 'failed'].includes(String(doc.get('status') ?? '')),\n )\n}\n"],"names":["closeOutAiOverage","reconcileAiOverageCharge","AI_OVERAGE_CHARGES_SUBCOLLECTION","readAiOverageMonthLedger","dropAiOverageQualifyingMonth","aiOverageFirestore","CHARGE_SCAN","closeAiOverageMonth","context","now","Date","usageSnapshot","firestore","orgRef","collection","doc","orgId","month","get","ledger","exists","data","result","reconciled","closedOutUsd","droppedFromLadder","charges","invoicedUsd","open","since","parse","String","claimedAgeMs","Number","isFinite","Math","max","getTime","MAX_SAFE_INTEGER","chargeId","resolved","closed","org","stripeCustomerId","charged","after","aiOverageMonthLeftUnpaid","where","limit","docs","some","includes"],"mappings":"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"}
|
|
@@ -15,10 +15,35 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* THE CUTOVER (AGL-3011): one switch, and everything hangs off it.
|
|
19
|
+
*
|
|
20
|
+
* `AI_OVERAGE_INVOICED_FROM` names the first month (`YYYY-MM`) in which AI
|
|
21
|
+
* overage is billed by invoice as it accrues. Unset — which is how it ships
|
|
22
|
+
* — every workspace behaves exactly as it does today: overage accrues, the
|
|
23
|
+
* monthly sweep prices it into the meter event, and the renewal invoice
|
|
24
|
+
* bills it. Nothing charges mid-period and nothing new refuses.
|
|
25
|
+
*
|
|
26
|
+
* ## Why the GUARDS are behind the same switch as the charging
|
|
27
|
+
*
|
|
28
|
+
* The unpaid bound refuses a workspace whose accrued overage has not been
|
|
29
|
+
* paid for. Before cutover nothing charges, so nothing is ever paid for, so
|
|
30
|
+
* every workspace past $50 of overage would be refused with no invoice to
|
|
31
|
+
* pay and no way to clear it. A bound with no way to settle is not a bound,
|
|
32
|
+
* it is a wall — and a wall nobody agreed to.
|
|
33
|
+
*
|
|
34
|
+
* So the guards read this same switch. Turning it on turns on the charging
|
|
35
|
+
* and the guards together, which is the only combination in which each one
|
|
36
|
+
* makes sense.
|
|
37
|
+
*
|
|
38
|
+
* ## Why it is a month and not a boolean
|
|
39
|
+
*
|
|
40
|
+
* The meter and the invoice must never both bill one month's overage. A
|
|
41
|
+
* month is the unit the meter reports in, so a month is the unit the handover
|
|
42
|
+
* has to be expressed in: everything before the named month bills through the
|
|
43
|
+
* meter exactly as it always did, and everything from it bills by invoice.
|
|
44
|
+
* A boolean flipped mid-month would split one month's overage across both
|
|
45
|
+
* channels and bill part of it twice.
|
|
20
46
|
*/
|
|
21
|
-
export declare const AI_OVERAGE_METER_LINE_ID = "assist-overage";
|
|
22
47
|
/** The environment variable that starts mid-period charging. */
|
|
23
48
|
export declare const AI_OVERAGE_INVOICED_FROM_ENV = "AI_OVERAGE_INVOICED_FROM";
|
|
24
49
|
/**
|
|
@@ -52,12 +77,3 @@ export declare function aiOverageBillsByInvoice(month: string): boolean;
|
|
|
52
77
|
* supposed to pay it.
|
|
53
78
|
*/
|
|
54
79
|
export declare function aiOverageGuardsApply(month: string): boolean;
|
|
55
|
-
/**
|
|
56
|
-
* Claims the meter line, so the monthly sweep leaves AI overage out of the
|
|
57
|
-
* figure it meters from the cutover month on.
|
|
58
|
-
*
|
|
59
|
-
* Registered from the plugin's server declarations, at boot, because the
|
|
60
|
-
* sweep is a core cron that never loads an AI door. Idempotent by the
|
|
61
|
-
* registry's own rule: the same plugin re-claiming replaces its claim.
|
|
62
|
-
*/
|
|
63
|
-
export declare function registerAiOverageMeteredLine(): void;
|
|
@@ -42,13 +42,7 @@
|
|
|
42
42
|
* meter exactly as it always did, and everything from it bills by invoice.
|
|
43
43
|
* A boolean flipped mid-month would split one month's overage across both
|
|
44
44
|
* channels and bill part of it twice.
|
|
45
|
-
*/
|
|
46
|
-
import { AI_PLUGIN_ID } from "../constants.js";
|
|
47
|
-
/**
|
|
48
|
-
* Core's own name for the AI overage line in the monthly usage sweep. The
|
|
49
|
-
* sweep asks the registry by this id; the plugin answers.
|
|
50
|
-
*/ export const AI_OVERAGE_METER_LINE_ID = 'assist-overage';
|
|
51
|
-
/** The environment variable that starts mid-period charging. */ export const AI_OVERAGE_INVOICED_FROM_ENV = 'AI_OVERAGE_INVOICED_FROM';
|
|
45
|
+
*/ /** The environment variable that starts mid-period charging. */ export const AI_OVERAGE_INVOICED_FROM_ENV = 'AI_OVERAGE_INVOICED_FROM';
|
|
52
46
|
const MONTH = /^\d{4}-\d{2}$/;
|
|
53
47
|
/**
|
|
54
48
|
* The configured cutover month, or `null` when there is none.
|
|
@@ -89,26 +83,5 @@ const MONTH = /^\d{4}-\d{2}$/;
|
|
|
89
83
|
*/ export function aiOverageGuardsApply(month) {
|
|
90
84
|
return aiOverageBillsByInvoice(month);
|
|
91
85
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Claims the meter line, so the monthly sweep leaves AI overage out of the
|
|
94
|
-
* figure it meters from the cutover month on.
|
|
95
|
-
*
|
|
96
|
-
* Registered from the plugin's server declarations, at boot, because the
|
|
97
|
-
* sweep is a core cron that never loads an AI door. Idempotent by the
|
|
98
|
-
* registry's own rule: the same plugin re-claiming replaces its claim.
|
|
99
|
-
*/ export function registerAiOverageMeteredLine() {
|
|
100
|
-
registerPluginMeteredLine({
|
|
101
|
-
lineId: AI_OVERAGE_METER_LINE_ID,
|
|
102
|
-
pluginId: AI_PLUGIN_ID,
|
|
103
|
-
billsFrom: aiOverageInvoicedFrom,
|
|
104
|
-
// Loaded on the first closed month rather than at boot, so the console's
|
|
105
|
-
// every process does not carry the Stripe path for a sweep that runs
|
|
106
|
-
// once a day in one of them.
|
|
107
|
-
closeMonth: async (context)=>{
|
|
108
|
-
const { closeAiOverageMonth } = await import("./ai-overage-close.js");
|
|
109
|
-
await closeAiOverageMonth(context);
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
86
|
|
|
114
87
|
//# sourceMappingURL=ai-overage-cutover.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-overage-cutover.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 * THE CUTOVER (AGL-3011): one switch, and everything hangs off it.\n *\n * `AI_OVERAGE_INVOICED_FROM` names the first month (`YYYY-MM`) in which AI\n * overage is billed by invoice as it accrues. Unset — which is how it ships\n * — every workspace behaves exactly as it does today: overage accrues, the\n * monthly sweep prices it into the meter event, and the renewal invoice\n * bills it. Nothing charges mid-period and nothing new refuses.\n *\n * ## Why the GUARDS are behind the same switch as the charging\n *\n * The unpaid bound refuses a workspace whose accrued overage has not been\n * paid for. Before cutover nothing charges, so nothing is ever paid for, so\n * every workspace past $50 of overage would be refused with no invoice to\n * pay and no way to clear it. A bound with no way to settle is not a bound,\n * it is a wall — and a wall nobody agreed to.\n *\n * So the guards read this same switch. Turning it on turns on the charging\n * and the guards together, which is the only combination in which each one\n * makes sense.\n *\n * ## Why it is a month and not a boolean\n *\n * The meter and the invoice must never both bill one month's overage. A\n * month is the unit the meter reports in, so a month is the unit the handover\n * has to be expressed in: everything before the named month bills through the\n * meter exactly as it always did, and everything from it bills by invoice.\n * A boolean flipped mid-month would split one month's overage across both\n * channels and bill part of it twice.\n */\n\
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-overage-cutover.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 * THE CUTOVER (AGL-3011): one switch, and everything hangs off it.\n *\n * `AI_OVERAGE_INVOICED_FROM` names the first month (`YYYY-MM`) in which AI\n * overage is billed by invoice as it accrues. Unset — which is how it ships\n * — every workspace behaves exactly as it does today: overage accrues, the\n * monthly sweep prices it into the meter event, and the renewal invoice\n * bills it. Nothing charges mid-period and nothing new refuses.\n *\n * ## Why the GUARDS are behind the same switch as the charging\n *\n * The unpaid bound refuses a workspace whose accrued overage has not been\n * paid for. Before cutover nothing charges, so nothing is ever paid for, so\n * every workspace past $50 of overage would be refused with no invoice to\n * pay and no way to clear it. A bound with no way to settle is not a bound,\n * it is a wall — and a wall nobody agreed to.\n *\n * So the guards read this same switch. Turning it on turns on the charging\n * and the guards together, which is the only combination in which each one\n * makes sense.\n *\n * ## Why it is a month and not a boolean\n *\n * The meter and the invoice must never both bill one month's overage. A\n * month is the unit the meter reports in, so a month is the unit the handover\n * has to be expressed in: everything before the named month bills through the\n * meter exactly as it always did, and everything from it bills by invoice.\n * A boolean flipped mid-month would split one month's overage across both\n * channels and bill part of it twice.\n */\n\n/** The environment variable that starts mid-period charging. */\nexport const AI_OVERAGE_INVOICED_FROM_ENV = 'AI_OVERAGE_INVOICED_FROM'\n\nconst MONTH = /^\\d{4}-\\d{2}$/\n\n/**\n * The configured cutover month, or `null` when there is none.\n *\n * Read on every call rather than cached, so setting the variable takes\n * effect without a restart and a process that started before it was set is\n * not stuck a month behind.\n *\n * An unparseable value is `null`, not an error: a typo must leave the\n * platform billing through the channel that works, not stop billing.\n */\nexport function aiOverageInvoicedFrom(): string | null {\n const raw = String(process.env[AI_OVERAGE_INVOICED_FROM_ENV] ?? '').trim()\n return MONTH.test(raw) ? raw : null\n}\n\n/**\n * Is AI overage billed by invoice — rather than through the monthly meter —\n * for this month?\n *\n * `YYYY-MM` is zero-padded and fixed width, so it orders identically as a\n * string and as a date; the format checks are what make that true rather\n * than lucky.\n */\nexport function aiOverageBillsByInvoice(month: string): boolean {\n const from = aiOverageInvoicedFrom()\n if (!from) return false\n if (!MONTH.test(month)) return false\n return month >= from\n}\n\n/**\n * Are the overage guards live for this month?\n *\n * The same answer as `aiOverageBillsByInvoice`, given its own name because\n * it is asked for a different reason and a reader of the gate should not\n * have to work out why the gate consults a billing question. If the two ever\n * need to differ, this is where that would be said — and it would need a\n * paragraph explaining how a workspace refused for unpaid overage is\n * supposed to pay it.\n */\nexport function aiOverageGuardsApply(month: string): boolean {\n return aiOverageBillsByInvoice(month)\n}\n"],"names":["AI_OVERAGE_INVOICED_FROM_ENV","MONTH","aiOverageInvoicedFrom","process","raw","String","env","trim","test","aiOverageBillsByInvoice","month","from","aiOverageGuardsApply"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BC,GAED,8DAA8D,GAC9D,OAAO,MAAMA,+BAA+B,2BAA0B;AAEtE,MAAMC,QAAQ;AAEd;;;;;;;;;CASC,GACD,OAAO,SAASC;QACKC;IAAnB,MAAMC,MAAMC,QAAOF,4CAAAA,QAAQG,GAAG,CAACN,6BAA6B,YAAzCG,4CAA6C,IAAII,IAAI;IACxE,OAAON,MAAMO,IAAI,CAACJ,OAAOA,MAAM;AACjC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASK,wBAAwBC,KAAa;IACnD,MAAMC,OAAOT;IACb,IAAI,CAACS,MAAM,OAAO;IAClB,IAAI,CAACV,MAAMO,IAAI,CAACE,QAAQ,OAAO;IAC/B,OAAOA,SAASC;AAClB;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASC,qBAAqBF,KAAa;IAChD,OAAOD,wBAAwBC;AACjC"}
|
|
@@ -55,7 +55,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
55
55
|
* Both are server-written: the rules allow the same readers as
|
|
56
56
|
* `aiAllotments` and no writer at all.
|
|
57
57
|
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
58
|
-
import { assistMonthOverage } from "
|
|
58
|
+
import { assistMonthOverage } from "../usage/assist-credits.js";
|
|
59
59
|
import { AI_OVERAGE_MIN_CHARGE_USD, AI_OVERAGE_THRESHOLD_USD, aiOverageCardOnFile, readAiOverageStanding } from "./ai-overage-standing.js";
|
|
60
60
|
/** The subcollection and document the standing lives at. */ export const AI_BILLING_SUBCOLLECTION = 'aiBilling';
|
|
61
61
|
export const AI_BILLING_STANDING_DOC = 'standing';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-overage-ledger.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 * THE OVERAGE CHARGE LEDGER (AGL-3011).\n *\n * Every dollar of AI overage a workspace is charged mid-period passes\n * through here twice: once as a CLAIM, taken in a transaction before any\n * money is asked for, and once as a SETTLEMENT, recorded after Stripe\n * answers. Nothing in this module talks to Stripe.\n *\n * ## Why claim first\n *\n * The figure that decides a charge — the month's overage — is a counter many\n * requests move. Two requests crossing the threshold a millisecond apart\n * both read \"$25 unbilled\" and would both charge for it. The claim closes\n * that: inside one transaction it moves `overageInvoicedUsd` forward by the\n * amount it is about to charge and records an open charge, so the second\n * request reads an already-claimed balance and stands down. The invoice is\n * created afterwards, outside the transaction, because a Stripe call inside\n * a Firestore transaction would be retried by the transaction.\n *\n * ## What a lost process costs\n *\n * A claim that never became an invoice leaves `overageInvoicedUsd` ahead of\n * what was billed, which UNDER-charges rather than over-charges, and leaves\n * an open charge that blocks the next one. Both are repaired by the\n * reconcile sweep, which asks Stripe whether the claim ever became an\n * invoice and either records it or releases the claim. Under-charging while\n * confused is the direction this must err in.\n *\n * ## The documents\n *\n * `orgs/{orgId}/assistUsage/{YYYY-MM}` — the month's rollup, which the\n * reservation already reads, gains four fields so the gate costs no extra\n * read: `overageInvoicedUsd`, `overagePaidUsd`, `overageCharges` and\n * `overageInvoiceOpen`.\n *\n * `orgs/{orgId}/aiOverageCharges/{chargeId}` — one row per charge, the\n * audit trail staff read and the reconcile sweep walks.\n *\n * Both are server-written: the rules allow the same readers as\n * `aiAllotments` and no writer at all.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { assistMonthOverage } from '@aglyn/aglyn/app-utils/assist-credits'\nimport {\n AI_OVERAGE_MIN_CHARGE_USD,\n AI_OVERAGE_THRESHOLD_USD,\n aiOverageCardOnFile,\n readAiOverageStanding,\n type AiOverageOrg,\n} from './ai-overage-standing'\n\n/** The subcollection and document the standing lives at. */\nexport const AI_BILLING_SUBCOLLECTION = 'aiBilling'\nexport const AI_BILLING_STANDING_DOC = 'standing'\n/** The subcollection one row per charge lives in. */\nexport const AI_OVERAGE_CHARGES_SUBCOLLECTION = 'aiOverageCharges'\n\n/** Which sweep asked for the charge. */\nexport type AiOverageChargeKind = 'threshold' | 'closeout'\n\n/** Where a charge is in its life. */\nexport type AiOverageChargeStatus =\n | 'claimed'\n | 'open'\n | 'paid'\n | 'failed'\n | 'void'\n\n/** A claim taken, ready to be charged. */\nexport interface AiOverageClaim {\n chargeId: string\n orgId: string\n month: string\n seq: number\n kind: AiOverageChargeKind\n /** Floored to the cent — Stripe bills whole cents. */\n amountUsd: number\n /** The credits and rate the amount came from, for the audit row. */\n credits: number\n rateUsdPer1k: number | null\n}\n\n/** Why no claim was taken. Each one is a different thing to do about it. */\nexport type AiOverageClaimRefusal =\n | 'below-threshold'\n | 'charge-already-open'\n | 'no-card'\n | 'paused'\n | 'not-sold'\n\nexport type AiOverageClaimResult =\n | { claimed: AiOverageClaim; refused: null }\n | { claimed: null; refused: AiOverageClaimRefusal }\n\nfunction money(value: unknown): number {\n const number = Number(value)\n return Number.isFinite(number) && number > 0 ? number : 0\n}\n\n/** Down to the cent. Never up: a rounded-up cent is a cent never accrued. */\nexport function floorToCent(usd: number): number {\n return Math.floor(money(usd) * 100) / 100\n}\n\n/**\n * `{month}-{seq}` — readable, sortable, and unique per workspace-month.\n *\n * The id is also the Stripe idempotency key's payload and the metadata the\n * reconcile search matches on, so it must survive a URL and a Stripe search\n * query unescaped: digits, a hyphen, nothing else.\n */\nexport function aiOverageChargeId(month: string, seq: number): string {\n return `${month}-${String(Math.max(1, Math.floor(seq))).padStart(3, '0')}`\n}\n\n/**\n * Takes a claim on the workspace's unbilled overage, or explains why not.\n *\n * The four conditions are the design's, in one transaction with the reads\n * they are decided from:\n *\n * - enough unbilled overage (the threshold, or the minimum for a close-out);\n * - no charge already open — one at a time, so a failure has one owner;\n * - a card on file, since the bound assumes a payment method that settles\n * now rather than in days;\n * - not paused.\n *\n * The card condition abstains when the workspace's payment method has never\n * been observed, for the reason `AiOverageStanding.paymentMethodType` gives.\n * A charge attempted against a customer with no default simply fails, and\n * the failure pauses accrual like any other — the guard is an optimization\n * here, not the safety property.\n */\nexport async function claimAiOverageCharge(\n firestore: FirebaseFirestore.Firestore,\n request: {\n orgId: string\n org: AiOverageOrg\n month: string\n kind: AiOverageChargeKind\n now?: Date\n },\n): Promise<AiOverageClaimResult> {\n const now = request.now ?? new Date()\n const orgRef = firestore.collection('orgs').doc(request.orgId)\n const usageRef = orgRef.collection('assistUsage').doc(request.month)\n const standingRef = orgRef\n .collection(AI_BILLING_SUBCOLLECTION)\n .doc(AI_BILLING_STANDING_DOC)\n // A close-out settles a month that is over: there is nothing left to\n // accrue, so anything Stripe will accept is worth billing. A threshold\n // charge waits for the threshold, which is what keeps the invoice count\n // and its fees down.\n const floorUsd =\n request.kind === 'closeout' ? AI_OVERAGE_MIN_CHARGE_USD : AI_OVERAGE_THRESHOLD_USD\n\n return firestore.runTransaction(async (tx) => {\n const [usageSnapshot, standingSnapshot] = await Promise.all([\n tx.get(usageRef),\n tx.get(standingRef),\n ])\n const standing = readAiOverageStanding(\n standingSnapshot.exists ? (standingSnapshot.data() ?? null) : null,\n )\n if (aiOverageCardOnFile(standing) === false) {\n return { claimed: null, refused: 'no-card' as const }\n }\n if (standing.pause) return { claimed: null, refused: 'paused' as const }\n if (usageSnapshot.get('overageInvoiceOpen')) {\n return { claimed: null, refused: 'charge-already-open' as const }\n }\n const priced = assistMonthOverage(\n request.org,\n money(usageSnapshot.get('estCostUsd')),\n )\n // A plan that sells nothing past its band prices its overage to zero, so\n // this is structural rather than a check that could be forgotten — but\n // it is named, because \"we billed nothing\" and \"there is nothing to\n // bill\" are different answers to a staff question.\n if (priced.overageRateUsd === null) {\n return { claimed: null, refused: 'not-sold' as const }\n }\n const invoicedUsd = money(usageSnapshot.get('overageInvoicedUsd'))\n const unbilledUsd = floorToCent(priced.overageMonthlyUsd - invoicedUsd)\n if (!(unbilledUsd >= floorUsd)) {\n return { claimed: null, refused: 'below-threshold' as const }\n }\n const seq = Math.max(0, Math.floor(money(usageSnapshot.get('overageCharges')))) + 1\n const chargeId = aiOverageChargeId(request.month, seq)\n const claim: AiOverageClaim = {\n chargeId,\n orgId: request.orgId,\n month: request.month,\n seq,\n kind: request.kind,\n amountUsd: unbilledUsd,\n credits: priced.overageCredits,\n rateUsdPer1k: priced.overageRateUsd,\n }\n tx.set(\n usageRef,\n {\n month: request.month,\n overageInvoicedUsd: FieldValue.increment(unbilledUsd),\n overageCharges: FieldValue.increment(1),\n overageInvoiceOpen: {\n chargeId,\n invoiceId: null,\n amountUsd: unbilledUsd,\n since: now.toISOString(),\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n tx.set(\n orgRef.collection(AI_OVERAGE_CHARGES_SUBCOLLECTION).doc(chargeId),\n {\n month: request.month,\n seq,\n kind: request.kind,\n amountUsd: unbilledUsd,\n credits: priced.overageCredits,\n rateUsdPer1k: priced.overageRateUsd,\n invoiceId: null,\n status: 'claimed' satisfies AiOverageChargeStatus,\n createdAt: FieldValue.serverTimestamp(),\n paidAt: null,\n failedAt: null,\n },\n { merge: true },\n )\n return { claimed: claim, refused: null }\n })\n}\n\n/** What Stripe said, as the ledger records it. */\nexport interface AiOverageSettlement {\n chargeId: string\n month: string\n /** `null` only when no invoice was ever created. */\n invoiceId: string | null\n status: AiOverageChargeStatus\n /**\n * What Stripe actually collected, in dollars (AGL-3023).\n *\n * The month's paid total moves by THIS and never by the claim. The two are\n * normally equal, and the case where they are not is the one that matters:\n * an invoice that finalized at zero or collected less than it billed would\n * otherwise clear a balance nobody paid, and the gate's unpaid bound would\n * be satisfied by our own bookkeeping instead of by money.\n *\n * Omitted on a settlement that is not a payment, where it is not read.\n */\n paidUsd?: number\n}\n\n/**\n * Records what became of a claimed charge.\n *\n * `paid` adds the amount to the month's `overagePaidUsd`, which is what the\n * gate's unpaid bound subtracts — so paying is what lets a workspace keep\n * going, and nothing else does.\n *\n * An invoice that exists but is not paid KEEPS its claim on\n * `overageInvoicedUsd`: it is a real bill, Stripe's retries may still settle\n * it, and re-billing the same dollars on a second invoice would charge the\n * customer twice for one month's usage. Only a claim that never became an\n * invoice at all releases what it claimed, so those dollars can be charged\n * by a later attempt.\n *\n * The open-charge slot is always cleared. Leaving it set on a failure would\n * block the close-out and every later charge behind an invoice that is\n * never going to move on its own.\n */\nexport async function settleAiOverageCharge(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n settlement: AiOverageSettlement,\n): Promise<void> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const usageRef = orgRef.collection('assistUsage').doc(settlement.month)\n const chargeRef = orgRef\n .collection(AI_OVERAGE_CHARGES_SUBCOLLECTION)\n .doc(settlement.chargeId)\n\n await firestore.runTransaction(async (tx) => {\n const [usageSnapshot, chargeSnapshot] = await Promise.all([\n tx.get(usageRef),\n tx.get(chargeRef),\n ])\n const amountUsd = money(chargeSnapshot.get('amountUsd'))\n // What the month's paid total moves by: what Stripe took. A settlement\n // that claims to be a payment and names no collected figure moves\n // nothing — silence is not evidence of money (AGL-3023).\n const paidUsd = money(settlement.paidUsd)\n const open = usageSnapshot.get('overageInvoiceOpen') as\n | { chargeId?: unknown }\n | null\n | undefined\n const ownsTheSlot = String(open?.chargeId ?? '') === settlement.chargeId\n // Already settled, or settled by another path (the webhook and the\n // synchronous answer both arrive). Recording the status again is\n // harmless; moving the money counters again is not.\n const alreadyPaid = chargeSnapshot.get('status') === 'paid'\n const releases = settlement.status === 'failed' && !settlement.invoiceId\n tx.set(\n chargeRef,\n {\n invoiceId: settlement.invoiceId,\n status: settlement.status,\n ...(settlement.status === 'paid' ? { paidUsd } : {}),\n ...(settlement.status === 'paid'\n ? { paidAt: FieldValue.serverTimestamp() }\n : {}),\n ...(settlement.status === 'failed'\n ? { failedAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n tx.set(\n usageRef,\n {\n month: settlement.month,\n ...(settlement.status === 'paid' && !alreadyPaid && paidUsd > 0\n ? { overagePaidUsd: FieldValue.increment(paidUsd) }\n : {}),\n ...(releases\n ? {\n overageInvoicedUsd: FieldValue.increment(-amountUsd),\n overageCharges: FieldValue.increment(-1),\n }\n : {}),\n ...(ownsTheSlot ? { overageInvoiceOpen: null } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n}\n\n/** The month's charge counters, as the customer card and staff card read them. */\nexport interface AiOverageMonthLedger {\n invoicedUsd: number\n paidUsd: number\n charges: number\n open: { chargeId: string; invoiceId: string | null; amountUsd: number } | null\n}\n\n/** Reads the four fields off a month's rollup, defensively. */\nexport function readAiOverageMonthLedger(\n data: Record<string, unknown> | null | undefined,\n): AiOverageMonthLedger {\n const open = data?.['overageInvoiceOpen'] as Record<string, unknown> | null | undefined\n const chargeId = typeof open?.['chargeId'] === 'string' ? open['chargeId'] : ''\n return {\n invoicedUsd: money(data?.['overageInvoicedUsd']),\n paidUsd: money(data?.['overagePaidUsd']),\n charges: Math.max(0, Math.floor(money(data?.['overageCharges']))),\n open: chargeId\n ? {\n chargeId,\n invoiceId:\n typeof open?.['invoiceId'] === 'string' ? (open['invoiceId'] as string) : null,\n amountUsd: money(open?.['amountUsd']),\n }\n : null,\n }\n}\n"],"names":["FieldValue","assistMonthOverage","AI_OVERAGE_MIN_CHARGE_USD","AI_OVERAGE_THRESHOLD_USD","aiOverageCardOnFile","readAiOverageStanding","AI_BILLING_SUBCOLLECTION","AI_BILLING_STANDING_DOC","AI_OVERAGE_CHARGES_SUBCOLLECTION","money","value","number","Number","isFinite","floorToCent","usd","Math","floor","aiOverageChargeId","month","seq","String","max","padStart","claimAiOverageCharge","firestore","request","now","Date","orgRef","collection","doc","orgId","usageRef","standingRef","floorUsd","kind","runTransaction","tx","standingSnapshot","usageSnapshot","Promise","all","get","standing","exists","data","claimed","refused","pause","priced","org","overageRateUsd","invoicedUsd","unbilledUsd","overageMonthlyUsd","chargeId","claim","amountUsd","credits","overageCredits","rateUsdPer1k","set","overageInvoicedUsd","increment","overageCharges","overageInvoiceOpen","invoiceId","since","toISOString","updatedAt","serverTimestamp","merge","status","createdAt","paidAt","failedAt","settleAiOverageCharge","settlement","chargeRef","chargeSnapshot","paidUsd","open","ownsTheSlot","alreadyPaid","releases","overagePaidUsd","readAiOverageMonthLedger","charges"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/billing/ai-overage-ledger.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 * THE OVERAGE CHARGE LEDGER (AGL-3011).\n *\n * Every dollar of AI overage a workspace is charged mid-period passes\n * through here twice: once as a CLAIM, taken in a transaction before any\n * money is asked for, and once as a SETTLEMENT, recorded after Stripe\n * answers. Nothing in this module talks to Stripe.\n *\n * ## Why claim first\n *\n * The figure that decides a charge — the month's overage — is a counter many\n * requests move. Two requests crossing the threshold a millisecond apart\n * both read \"$25 unbilled\" and would both charge for it. The claim closes\n * that: inside one transaction it moves `overageInvoicedUsd` forward by the\n * amount it is about to charge and records an open charge, so the second\n * request reads an already-claimed balance and stands down. The invoice is\n * created afterwards, outside the transaction, because a Stripe call inside\n * a Firestore transaction would be retried by the transaction.\n *\n * ## What a lost process costs\n *\n * A claim that never became an invoice leaves `overageInvoicedUsd` ahead of\n * what was billed, which UNDER-charges rather than over-charges, and leaves\n * an open charge that blocks the next one. Both are repaired by the\n * reconcile sweep, which asks Stripe whether the claim ever became an\n * invoice and either records it or releases the claim. Under-charging while\n * confused is the direction this must err in.\n *\n * ## The documents\n *\n * `orgs/{orgId}/assistUsage/{YYYY-MM}` — the month's rollup, which the\n * reservation already reads, gains four fields so the gate costs no extra\n * read: `overageInvoicedUsd`, `overagePaidUsd`, `overageCharges` and\n * `overageInvoiceOpen`.\n *\n * `orgs/{orgId}/aiOverageCharges/{chargeId}` — one row per charge, the\n * audit trail staff read and the reconcile sweep walks.\n *\n * Both are server-written: the rules allow the same readers as\n * `aiAllotments` and no writer at all.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { assistMonthOverage } from '../usage/assist-credits'\nimport {\n AI_OVERAGE_MIN_CHARGE_USD,\n AI_OVERAGE_THRESHOLD_USD,\n aiOverageCardOnFile,\n readAiOverageStanding,\n type AiOverageOrg,\n} from './ai-overage-standing'\n\n/** The subcollection and document the standing lives at. */\nexport const AI_BILLING_SUBCOLLECTION = 'aiBilling'\nexport const AI_BILLING_STANDING_DOC = 'standing'\n/** The subcollection one row per charge lives in. */\nexport const AI_OVERAGE_CHARGES_SUBCOLLECTION = 'aiOverageCharges'\n\n/** Which sweep asked for the charge. */\nexport type AiOverageChargeKind = 'threshold' | 'closeout'\n\n/** Where a charge is in its life. */\nexport type AiOverageChargeStatus =\n | 'claimed'\n | 'open'\n | 'paid'\n | 'failed'\n | 'void'\n\n/** A claim taken, ready to be charged. */\nexport interface AiOverageClaim {\n chargeId: string\n orgId: string\n month: string\n seq: number\n kind: AiOverageChargeKind\n /** Floored to the cent — Stripe bills whole cents. */\n amountUsd: number\n /** The credits and rate the amount came from, for the audit row. */\n credits: number\n rateUsdPer1k: number | null\n}\n\n/** Why no claim was taken. Each one is a different thing to do about it. */\nexport type AiOverageClaimRefusal =\n | 'below-threshold'\n | 'charge-already-open'\n | 'no-card'\n | 'paused'\n | 'not-sold'\n\nexport type AiOverageClaimResult =\n | { claimed: AiOverageClaim; refused: null }\n | { claimed: null; refused: AiOverageClaimRefusal }\n\nfunction money(value: unknown): number {\n const number = Number(value)\n return Number.isFinite(number) && number > 0 ? number : 0\n}\n\n/** Down to the cent. Never up: a rounded-up cent is a cent never accrued. */\nexport function floorToCent(usd: number): number {\n return Math.floor(money(usd) * 100) / 100\n}\n\n/**\n * `{month}-{seq}` — readable, sortable, and unique per workspace-month.\n *\n * The id is also the Stripe idempotency key's payload and the metadata the\n * reconcile search matches on, so it must survive a URL and a Stripe search\n * query unescaped: digits, a hyphen, nothing else.\n */\nexport function aiOverageChargeId(month: string, seq: number): string {\n return `${month}-${String(Math.max(1, Math.floor(seq))).padStart(3, '0')}`\n}\n\n/**\n * Takes a claim on the workspace's unbilled overage, or explains why not.\n *\n * The four conditions are the design's, in one transaction with the reads\n * they are decided from:\n *\n * - enough unbilled overage (the threshold, or the minimum for a close-out);\n * - no charge already open — one at a time, so a failure has one owner;\n * - a card on file, since the bound assumes a payment method that settles\n * now rather than in days;\n * - not paused.\n *\n * The card condition abstains when the workspace's payment method has never\n * been observed, for the reason `AiOverageStanding.paymentMethodType` gives.\n * A charge attempted against a customer with no default simply fails, and\n * the failure pauses accrual like any other — the guard is an optimization\n * here, not the safety property.\n */\nexport async function claimAiOverageCharge(\n firestore: FirebaseFirestore.Firestore,\n request: {\n orgId: string\n org: AiOverageOrg\n month: string\n kind: AiOverageChargeKind\n now?: Date\n },\n): Promise<AiOverageClaimResult> {\n const now = request.now ?? new Date()\n const orgRef = firestore.collection('orgs').doc(request.orgId)\n const usageRef = orgRef.collection('assistUsage').doc(request.month)\n const standingRef = orgRef\n .collection(AI_BILLING_SUBCOLLECTION)\n .doc(AI_BILLING_STANDING_DOC)\n // A close-out settles a month that is over: there is nothing left to\n // accrue, so anything Stripe will accept is worth billing. A threshold\n // charge waits for the threshold, which is what keeps the invoice count\n // and its fees down.\n const floorUsd =\n request.kind === 'closeout' ? AI_OVERAGE_MIN_CHARGE_USD : AI_OVERAGE_THRESHOLD_USD\n\n return firestore.runTransaction(async (tx) => {\n const [usageSnapshot, standingSnapshot] = await Promise.all([\n tx.get(usageRef),\n tx.get(standingRef),\n ])\n const standing = readAiOverageStanding(\n standingSnapshot.exists ? (standingSnapshot.data() ?? null) : null,\n )\n if (aiOverageCardOnFile(standing) === false) {\n return { claimed: null, refused: 'no-card' as const }\n }\n if (standing.pause) return { claimed: null, refused: 'paused' as const }\n if (usageSnapshot.get('overageInvoiceOpen')) {\n return { claimed: null, refused: 'charge-already-open' as const }\n }\n const priced = assistMonthOverage(\n request.org,\n money(usageSnapshot.get('estCostUsd')),\n )\n // A plan that sells nothing past its band prices its overage to zero, so\n // this is structural rather than a check that could be forgotten — but\n // it is named, because \"we billed nothing\" and \"there is nothing to\n // bill\" are different answers to a staff question.\n if (priced.overageRateUsd === null) {\n return { claimed: null, refused: 'not-sold' as const }\n }\n const invoicedUsd = money(usageSnapshot.get('overageInvoicedUsd'))\n const unbilledUsd = floorToCent(priced.overageMonthlyUsd - invoicedUsd)\n if (!(unbilledUsd >= floorUsd)) {\n return { claimed: null, refused: 'below-threshold' as const }\n }\n const seq = Math.max(0, Math.floor(money(usageSnapshot.get('overageCharges')))) + 1\n const chargeId = aiOverageChargeId(request.month, seq)\n const claim: AiOverageClaim = {\n chargeId,\n orgId: request.orgId,\n month: request.month,\n seq,\n kind: request.kind,\n amountUsd: unbilledUsd,\n credits: priced.overageCredits,\n rateUsdPer1k: priced.overageRateUsd,\n }\n tx.set(\n usageRef,\n {\n month: request.month,\n overageInvoicedUsd: FieldValue.increment(unbilledUsd),\n overageCharges: FieldValue.increment(1),\n overageInvoiceOpen: {\n chargeId,\n invoiceId: null,\n amountUsd: unbilledUsd,\n since: now.toISOString(),\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n tx.set(\n orgRef.collection(AI_OVERAGE_CHARGES_SUBCOLLECTION).doc(chargeId),\n {\n month: request.month,\n seq,\n kind: request.kind,\n amountUsd: unbilledUsd,\n credits: priced.overageCredits,\n rateUsdPer1k: priced.overageRateUsd,\n invoiceId: null,\n status: 'claimed' satisfies AiOverageChargeStatus,\n createdAt: FieldValue.serverTimestamp(),\n paidAt: null,\n failedAt: null,\n },\n { merge: true },\n )\n return { claimed: claim, refused: null }\n })\n}\n\n/** What Stripe said, as the ledger records it. */\nexport interface AiOverageSettlement {\n chargeId: string\n month: string\n /** `null` only when no invoice was ever created. */\n invoiceId: string | null\n status: AiOverageChargeStatus\n /**\n * What Stripe actually collected, in dollars (AGL-3023).\n *\n * The month's paid total moves by THIS and never by the claim. The two are\n * normally equal, and the case where they are not is the one that matters:\n * an invoice that finalized at zero or collected less than it billed would\n * otherwise clear a balance nobody paid, and the gate's unpaid bound would\n * be satisfied by our own bookkeeping instead of by money.\n *\n * Omitted on a settlement that is not a payment, where it is not read.\n */\n paidUsd?: number\n}\n\n/**\n * Records what became of a claimed charge.\n *\n * `paid` adds the amount to the month's `overagePaidUsd`, which is what the\n * gate's unpaid bound subtracts — so paying is what lets a workspace keep\n * going, and nothing else does.\n *\n * An invoice that exists but is not paid KEEPS its claim on\n * `overageInvoicedUsd`: it is a real bill, Stripe's retries may still settle\n * it, and re-billing the same dollars on a second invoice would charge the\n * customer twice for one month's usage. Only a claim that never became an\n * invoice at all releases what it claimed, so those dollars can be charged\n * by a later attempt.\n *\n * The open-charge slot is always cleared. Leaving it set on a failure would\n * block the close-out and every later charge behind an invoice that is\n * never going to move on its own.\n */\nexport async function settleAiOverageCharge(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n settlement: AiOverageSettlement,\n): Promise<void> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const usageRef = orgRef.collection('assistUsage').doc(settlement.month)\n const chargeRef = orgRef\n .collection(AI_OVERAGE_CHARGES_SUBCOLLECTION)\n .doc(settlement.chargeId)\n\n await firestore.runTransaction(async (tx) => {\n const [usageSnapshot, chargeSnapshot] = await Promise.all([\n tx.get(usageRef),\n tx.get(chargeRef),\n ])\n const amountUsd = money(chargeSnapshot.get('amountUsd'))\n // What the month's paid total moves by: what Stripe took. A settlement\n // that claims to be a payment and names no collected figure moves\n // nothing — silence is not evidence of money (AGL-3023).\n const paidUsd = money(settlement.paidUsd)\n const open = usageSnapshot.get('overageInvoiceOpen') as\n | { chargeId?: unknown }\n | null\n | undefined\n const ownsTheSlot = String(open?.chargeId ?? '') === settlement.chargeId\n // Already settled, or settled by another path (the webhook and the\n // synchronous answer both arrive). Recording the status again is\n // harmless; moving the money counters again is not.\n const alreadyPaid = chargeSnapshot.get('status') === 'paid'\n const releases = settlement.status === 'failed' && !settlement.invoiceId\n tx.set(\n chargeRef,\n {\n invoiceId: settlement.invoiceId,\n status: settlement.status,\n ...(settlement.status === 'paid' ? { paidUsd } : {}),\n ...(settlement.status === 'paid'\n ? { paidAt: FieldValue.serverTimestamp() }\n : {}),\n ...(settlement.status === 'failed'\n ? { failedAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n tx.set(\n usageRef,\n {\n month: settlement.month,\n ...(settlement.status === 'paid' && !alreadyPaid && paidUsd > 0\n ? { overagePaidUsd: FieldValue.increment(paidUsd) }\n : {}),\n ...(releases\n ? {\n overageInvoicedUsd: FieldValue.increment(-amountUsd),\n overageCharges: FieldValue.increment(-1),\n }\n : {}),\n ...(ownsTheSlot ? { overageInvoiceOpen: null } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n}\n\n/** The month's charge counters, as the customer card and staff card read them. */\nexport interface AiOverageMonthLedger {\n invoicedUsd: number\n paidUsd: number\n charges: number\n open: { chargeId: string; invoiceId: string | null; amountUsd: number } | null\n}\n\n/** Reads the four fields off a month's rollup, defensively. */\nexport function readAiOverageMonthLedger(\n data: Record<string, unknown> | null | undefined,\n): AiOverageMonthLedger {\n const open = data?.['overageInvoiceOpen'] as Record<string, unknown> | null | undefined\n const chargeId = typeof open?.['chargeId'] === 'string' ? open['chargeId'] : ''\n return {\n invoicedUsd: money(data?.['overageInvoicedUsd']),\n paidUsd: money(data?.['overagePaidUsd']),\n charges: Math.max(0, Math.floor(money(data?.['overageCharges']))),\n open: chargeId\n ? {\n chargeId,\n invoiceId:\n typeof open?.['invoiceId'] === 'string' ? (open['invoiceId'] as string) : null,\n amountUsd: money(open?.['amountUsd']),\n }\n : null,\n }\n}\n"],"names":["FieldValue","assistMonthOverage","AI_OVERAGE_MIN_CHARGE_USD","AI_OVERAGE_THRESHOLD_USD","aiOverageCardOnFile","readAiOverageStanding","AI_BILLING_SUBCOLLECTION","AI_BILLING_STANDING_DOC","AI_OVERAGE_CHARGES_SUBCOLLECTION","money","value","number","Number","isFinite","floorToCent","usd","Math","floor","aiOverageChargeId","month","seq","String","max","padStart","claimAiOverageCharge","firestore","request","now","Date","orgRef","collection","doc","orgId","usageRef","standingRef","floorUsd","kind","runTransaction","tx","standingSnapshot","usageSnapshot","Promise","all","get","standing","exists","data","claimed","refused","pause","priced","org","overageRateUsd","invoicedUsd","unbilledUsd","overageMonthlyUsd","chargeId","claim","amountUsd","credits","overageCredits","rateUsdPer1k","set","overageInvoicedUsd","increment","overageCharges","overageInvoiceOpen","invoiceId","since","toISOString","updatedAt","serverTimestamp","merge","status","createdAt","paidAt","failedAt","settleAiOverageCharge","settlement","chargeRef","chargeSnapshot","paidUsd","open","ownsTheSlot","alreadyPaid","releases","overagePaidUsd","readAiOverageMonthLedger","charges"],"mappings":"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"}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* `AI_OVERAGE_THRESHOLD_USD` exists to keep a workspace working, by settling
|
|
38
38
|
* the balance before it reaches the bound; it is not what makes the bound
|
|
39
39
|
* true.
|
|
40
|
-
*/ import { resolveAssistOverageCapUsd } from "
|
|
40
|
+
*/ import { resolveAssistOverageCapUsd } from "../usage/assist-credits.js";
|
|
41
41
|
/**
|
|
42
42
|
* The unbilled overage at which the card on file is charged.
|
|
43
43
|
*
|