@agent-finops/core 0.9.1 → 0.9.3
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/README.md +2 -2
- package/dist/activitySnapshotCache.js +38 -2
- package/dist/emailAddress.d.ts +7 -0
- package/dist/emailAddress.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providerConnectors.d.ts +81 -0
- package/dist/providerConnectors.js +446 -12
- package/dist/receiptShare.d.ts +185 -0
- package/dist/receiptShare.js +118 -0
- package/dist/sourceRegistry.js +10 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -42,10 +42,10 @@ official provider-reported financial evidence, keep modeled/local value
|
|
|
42
42
|
`estimated` or `missing`, and leave unvalidated adapters `untested`.
|
|
43
43
|
|
|
44
44
|
This is the open foundation for aibill's financial-accountability mission. The
|
|
45
|
-
|
|
45
|
+
published v0.9.1 package includes contracts for locally confirmed ownership,
|
|
46
46
|
local self-attested approvals, and opt-in accepted GitHub outcomes. Those are
|
|
47
47
|
not company-wide identity, RBAC, approval routing, invoice reconciliation, or
|
|
48
|
-
verified business ROI
|
|
48
|
+
verified business ROI.
|
|
49
49
|
|
|
50
50
|
Local API-equivalent estimates, subscription context, and official
|
|
51
51
|
provider-reported cost are separate concepts and must not be added together.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
-
import { open, lstat, mkdir, chmod, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { open, lstat, mkdir, chmod, readdir, realpath, rename, unlink } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
@@ -343,12 +343,48 @@ async function ensureDefaultParent(homeDirectory, create) {
|
|
|
343
343
|
throw new ActivitySnapshotCacheError("unsafe_directory", "The private aibill directory is not a real directory.");
|
|
344
344
|
}
|
|
345
345
|
if (!create && !hasPrivatePermissions(info.mode)) {
|
|
346
|
-
|
|
346
|
+
// Self-heal (NEW-B1): 0.9.2's signup/telemetry state writers created
|
|
347
|
+
// ~/.aibill without a mode (755 under the default umask), which this
|
|
348
|
+
// guard then refused forever — bricking init on machines that ran
|
|
349
|
+
// 0.9.2 in that window. When the directory holds ONLY our own state
|
|
350
|
+
// files (no cache/ yet, nothing foreign), tighten it to 0700 and
|
|
351
|
+
// proceed instead of erroring. Anything unexpected still refuses.
|
|
352
|
+
if (!await selfHealStateOnlyAibillDirectory(parent)) {
|
|
353
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", `The private aibill directory is not private. Fix: chmod 700 ${parent}`);
|
|
354
|
+
}
|
|
347
355
|
}
|
|
348
356
|
if (create)
|
|
349
357
|
await chmod(parent, 0o700);
|
|
350
358
|
await ensureDefaultCacheGitPrivacy(parent, create);
|
|
351
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* The complete set of files aibill's own home-scope state writers may have
|
|
362
|
+
* placed in ~/.aibill before any cache exists. A directory with exactly
|
|
363
|
+
* these contents (or fewer) is provably ours to repair.
|
|
364
|
+
*/
|
|
365
|
+
const selfHealableAibillEntries = new Set([
|
|
366
|
+
"signup.json",
|
|
367
|
+
"signup.json.tmp",
|
|
368
|
+
"telemetry.json",
|
|
369
|
+
"telemetry.json.tmp",
|
|
370
|
+
".gitignore"
|
|
371
|
+
]);
|
|
372
|
+
async function selfHealStateOnlyAibillDirectory(parent) {
|
|
373
|
+
try {
|
|
374
|
+
const entries = await readdir(parent, { withFileTypes: true });
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
if (!entry.isFile() || !selfHealableAibillEntries.has(entry.name))
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
await chmod(parent, 0o700);
|
|
380
|
+
const confirmed = await lstat(parent);
|
|
381
|
+
return !confirmed.isSymbolicLink() && confirmed.isDirectory() &&
|
|
382
|
+
hasPrivatePermissions(confirmed.mode);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
352
388
|
/**
|
|
353
389
|
* When a synthetic or real HOME is itself inside a Git worktree, protect the
|
|
354
390
|
* complete top-level private state directory before any cache child is
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One intentionally pragmatic normalizer for waitlist and receipt-email
|
|
3
|
+
* boundaries. It prevents header/control injection without pretending to be
|
|
4
|
+
* a mailbox-verification system; ownership still requires an explicit flow.
|
|
5
|
+
*/
|
|
6
|
+
export declare function normalizeAibillEmailAddress(value: string): string | undefined;
|
|
7
|
+
//# sourceMappingURL=emailAddress.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const controlCharacters = /[\u0000-\u001F\u007F]/u;
|
|
2
|
+
const pragmaticEmailAddress = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
|
|
3
|
+
/**
|
|
4
|
+
* One intentionally pragmatic normalizer for waitlist and receipt-email
|
|
5
|
+
* boundaries. It prevents header/control injection without pretending to be
|
|
6
|
+
* a mailbox-verification system; ownership still requires an explicit flow.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeAibillEmailAddress(value) {
|
|
9
|
+
const normalized = value.trim().toLowerCase();
|
|
10
|
+
if (normalized.length < 3 || normalized.length > 254)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (controlCharacters.test(normalized))
|
|
13
|
+
return undefined;
|
|
14
|
+
return pragmaticEmailAddress.test(normalized) ? normalized : undefined;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=emailAddress.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export * from "./planDetection.js";
|
|
|
23
23
|
export * from "./planMath.js";
|
|
24
24
|
export * from "./projectEconomics.js";
|
|
25
25
|
export * from "./resultCard.js";
|
|
26
|
+
export * from "./receiptShare.js";
|
|
26
27
|
export * from "./projectEconomicsBuilder.js";
|
|
27
28
|
export * from "./qualitativeIndexCache.js";
|
|
28
29
|
export * from "./projectIndexStore.js";
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export * from "./planDetection.js";
|
|
|
21
21
|
export * from "./planMath.js";
|
|
22
22
|
export * from "./projectEconomics.js";
|
|
23
23
|
export * from "./resultCard.js";
|
|
24
|
+
export * from "./receiptShare.js";
|
|
24
25
|
export * from "./projectEconomicsBuilder.js";
|
|
25
26
|
export * from "./qualitativeIndexCache.js";
|
|
26
27
|
export * from "./projectIndexStore.js";
|
|
@@ -89,6 +89,13 @@ export type ProviderConnectorInput = {
|
|
|
89
89
|
* shipped CLI can run a reconciliation without new flags.
|
|
90
90
|
*/
|
|
91
91
|
reconciliation?: CursorReconciliationExpectation;
|
|
92
|
+
/**
|
|
93
|
+
* Operator-declared reconciliation anchor for a GitHub Copilot sync. When
|
|
94
|
+
* absent, the Copilot connector also accepts the
|
|
95
|
+
* AI_SPEND_COPILOT_RECONCILE_* environment variables so the shipped CLI can
|
|
96
|
+
* run a reconciliation without new flags.
|
|
97
|
+
*/
|
|
98
|
+
copilotReconciliation?: GitHubCopilotReconciliationExpectation;
|
|
92
99
|
};
|
|
93
100
|
/**
|
|
94
101
|
* A human-read billing anchor for one Cursor reconciliation run: the
|
|
@@ -141,6 +148,69 @@ export declare function parseCursorReconciliationEnv(env?: Record<string, string
|
|
|
141
148
|
expectation?: CursorReconciliationExpectation;
|
|
142
149
|
invalidReason?: string;
|
|
143
150
|
};
|
|
151
|
+
/**
|
|
152
|
+
* A human-read billing anchor for one GitHub Copilot reconciliation run: the
|
|
153
|
+
* AI-credit NET total the operator read off the organization's Billing &
|
|
154
|
+
* Licensing usage page for one calendar billing month (GitHub moved every
|
|
155
|
+
* Copilot Business/Enterprise account to usage-based AI-credit billing on
|
|
156
|
+
* 2026-06-01; 1 AI credit bills as $0.01). The connector compares its summed
|
|
157
|
+
* netAmount from the AI-credit usage report against this figure; only an
|
|
158
|
+
* in-run match within tolerance can produce verified financial evidence.
|
|
159
|
+
*/
|
|
160
|
+
export type GitHubCopilotReconciliationExpectation = {
|
|
161
|
+
/** Billing-page AI-credit net total for the declared month, in USD. */
|
|
162
|
+
expectedNetUsd: number;
|
|
163
|
+
/** The billing month shown next to that figure (YYYY-MM, UTC calendar month). */
|
|
164
|
+
expectedBillingMonth: string;
|
|
165
|
+
/**
|
|
166
|
+
* Optional absolute comparison tolerance in USD. Defaults to $0.01 (the
|
|
167
|
+
* billing page rounds to cents). Clamped to at most 1% of the expected total
|
|
168
|
+
* so a huge tolerance can never rubber-stamp a mismatch.
|
|
169
|
+
*/
|
|
170
|
+
toleranceUsd?: number;
|
|
171
|
+
/**
|
|
172
|
+
* Optional account binding: a bare slug or `org:<slug>` / `enterprise:<slug>`.
|
|
173
|
+
* When present, the anchor applies ONLY to a sync of that account; a sync
|
|
174
|
+
* of any other org/enterprise fails the reconciliation closed (QA C3 —
|
|
175
|
+
* leftover shell env must never verify a different account by coincidence).
|
|
176
|
+
*/
|
|
177
|
+
account?: string;
|
|
178
|
+
};
|
|
179
|
+
export type GitHubCopilotReconciliationOutcome = {
|
|
180
|
+
status: "verified" | "mismatch" | "not_provable";
|
|
181
|
+
/** Product-authored, terminal-safe explanation of the outcome. */
|
|
182
|
+
note: string;
|
|
183
|
+
connectorTotalUsd?: number;
|
|
184
|
+
expectedNetUsd?: number;
|
|
185
|
+
differenceUsd?: number;
|
|
186
|
+
toleranceUsd?: number;
|
|
187
|
+
/** The billing month the reconciliation was declared for (YYYY-MM). */
|
|
188
|
+
billingMonth?: string;
|
|
189
|
+
};
|
|
190
|
+
/** Environment variables the GitHub Copilot connector reads for a reconciliation run. */
|
|
191
|
+
export declare const gitHubCopilotReconciliationEnvVars: {
|
|
192
|
+
readonly expectedUsd: "AI_SPEND_COPILOT_RECONCILE_EXPECTED_USD";
|
|
193
|
+
readonly billingMonth: "AI_SPEND_COPILOT_RECONCILE_MONTH";
|
|
194
|
+
readonly toleranceUsd: "AI_SPEND_COPILOT_RECONCILE_TOLERANCE_USD";
|
|
195
|
+
/**
|
|
196
|
+
* Optional binding of the anchor to ONE account (post-hoc QA C3): a bare
|
|
197
|
+
* slug or `org:<slug>` / `enterprise:<slug>`. When set, a sync of any
|
|
198
|
+
* other account fails the reconciliation closed instead of stamping a
|
|
199
|
+
* coincidence-equal total verified with leftover shell env.
|
|
200
|
+
*/
|
|
201
|
+
readonly account: "AI_SPEND_COPILOT_RECONCILE_ACCOUNT";
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Read an operator-declared GitHub Copilot reconciliation anchor from the
|
|
205
|
+
* local environment. Absent variables mean "no reconciliation requested";
|
|
206
|
+
* present but invalid variables fail closed with a reason (records stay
|
|
207
|
+
* estimated) instead of throwing, so a typo can never abort or silently
|
|
208
|
+
* verify a sync. Raw variable values are never echoed into the reason.
|
|
209
|
+
*/
|
|
210
|
+
export declare function parseGitHubCopilotReconciliationEnv(env?: Record<string, string | undefined>): {
|
|
211
|
+
expectation?: GitHubCopilotReconciliationExpectation;
|
|
212
|
+
invalidReason?: string;
|
|
213
|
+
};
|
|
144
214
|
export type ProviderConnectorResult = {
|
|
145
215
|
provider: string;
|
|
146
216
|
source: ApprovedSource;
|
|
@@ -179,6 +249,17 @@ export declare function normalizeAnthropicClaudeCodeUsageResponse(response: unkn
|
|
|
179
249
|
export declare function normalizeGitHubCopilotSeatResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
180
250
|
export declare function normalizeAnthropicCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
181
251
|
export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
252
|
+
/**
|
|
253
|
+
* Normalize one GitHub AI-credit usage report (the 2026 usage-based billing
|
|
254
|
+
* model: GET /organizations/{org}/settings/billing/ai_credit/usage) into
|
|
255
|
+
* billed-dollar evidence records. Rows are NET provider-reported dollars, but
|
|
256
|
+
* this connector is fixture-verified, so — exactly like the Cursor connector —
|
|
257
|
+
* the records stay "estimated" until an in-run reconciliation proves the
|
|
258
|
+
* connector total against the operator-read billing page figure for the same
|
|
259
|
+
* billing month. Legacy premium-request units are deliberately not fetched or
|
|
260
|
+
* blended here (provider-contract exclusion).
|
|
261
|
+
*/
|
|
262
|
+
export declare function normalizeGitHubCopilotAiCreditUsageResponse(response: unknown, options: NormalizerOptions, reconciliation?: GitHubCopilotReconciliationOutcome): UsageRecord[];
|
|
182
263
|
export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions, reconciliation?: CursorReconciliationOutcome): UsageRecord[];
|
|
183
264
|
export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
|
|
184
265
|
export declare function summarizeProviderFinancials(records: UsageRecord[]): ProviderFinancialSummary;
|
|
@@ -69,6 +69,80 @@ function invalidCursorReconciliationExpectationReason(expectation) {
|
|
|
69
69
|
}
|
|
70
70
|
return undefined;
|
|
71
71
|
}
|
|
72
|
+
/** Environment variables the GitHub Copilot connector reads for a reconciliation run. */
|
|
73
|
+
export const gitHubCopilotReconciliationEnvVars = {
|
|
74
|
+
expectedUsd: "AI_SPEND_COPILOT_RECONCILE_EXPECTED_USD",
|
|
75
|
+
billingMonth: "AI_SPEND_COPILOT_RECONCILE_MONTH",
|
|
76
|
+
toleranceUsd: "AI_SPEND_COPILOT_RECONCILE_TOLERANCE_USD",
|
|
77
|
+
/**
|
|
78
|
+
* Optional binding of the anchor to ONE account (post-hoc QA C3): a bare
|
|
79
|
+
* slug or `org:<slug>` / `enterprise:<slug>`. When set, a sync of any
|
|
80
|
+
* other account fails the reconciliation closed instead of stamping a
|
|
81
|
+
* coincidence-equal total verified with leftover shell env.
|
|
82
|
+
*/
|
|
83
|
+
account: "AI_SPEND_COPILOT_RECONCILE_ACCOUNT"
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Read an operator-declared GitHub Copilot reconciliation anchor from the
|
|
87
|
+
* local environment. Absent variables mean "no reconciliation requested";
|
|
88
|
+
* present but invalid variables fail closed with a reason (records stay
|
|
89
|
+
* estimated) instead of throwing, so a typo can never abort or silently
|
|
90
|
+
* verify a sync. Raw variable values are never echoed into the reason.
|
|
91
|
+
*/
|
|
92
|
+
export function parseGitHubCopilotReconciliationEnv(env = process.env) {
|
|
93
|
+
const rawExpected = env[gitHubCopilotReconciliationEnvVars.expectedUsd];
|
|
94
|
+
const rawMonth = env[gitHubCopilotReconciliationEnvVars.billingMonth];
|
|
95
|
+
const rawTolerance = env[gitHubCopilotReconciliationEnvVars.toleranceUsd];
|
|
96
|
+
const rawAccount = env[gitHubCopilotReconciliationEnvVars.account];
|
|
97
|
+
// A dangling ACCOUNT binding alone requests nothing — it only constrains a
|
|
98
|
+
// reconciliation that the expected/month pair actually requested.
|
|
99
|
+
if (rawExpected === undefined && rawMonth === undefined && rawTolerance === undefined) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
if (rawExpected === undefined || rawMonth === undefined) {
|
|
103
|
+
return {
|
|
104
|
+
invalidReason: `both ${gitHubCopilotReconciliationEnvVars.expectedUsd} and ${gitHubCopilotReconciliationEnvVars.billingMonth} are required to request a reconciliation`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const expectedNetUsd = Number(rawExpected.trim());
|
|
108
|
+
const toleranceUsd = rawTolerance === undefined ? undefined : Number(rawTolerance.trim());
|
|
109
|
+
const account = rawAccount?.trim();
|
|
110
|
+
const expectation = {
|
|
111
|
+
expectedNetUsd,
|
|
112
|
+
expectedBillingMonth: rawMonth.trim(),
|
|
113
|
+
...(toleranceUsd === undefined ? {} : { toleranceUsd }),
|
|
114
|
+
...(account ? { account } : {})
|
|
115
|
+
};
|
|
116
|
+
const invalidReason = invalidGitHubCopilotReconciliationExpectationReason(expectation);
|
|
117
|
+
return invalidReason ? { invalidReason } : { expectation };
|
|
118
|
+
}
|
|
119
|
+
function invalidGitHubCopilotReconciliationExpectationReason(expectation) {
|
|
120
|
+
if (!Number.isFinite(expectation.expectedNetUsd) || expectation.expectedNetUsd <= 0) {
|
|
121
|
+
return `${gitHubCopilotReconciliationEnvVars.expectedUsd} must be a positive USD amount read off the GitHub billing usage page`;
|
|
122
|
+
}
|
|
123
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(expectation.expectedBillingMonth)) {
|
|
124
|
+
return `${gitHubCopilotReconciliationEnvVars.billingMonth} must be the billing month shown on the usage page, formatted YYYY-MM`;
|
|
125
|
+
}
|
|
126
|
+
if (expectation.toleranceUsd !== undefined &&
|
|
127
|
+
(!Number.isFinite(expectation.toleranceUsd) || expectation.toleranceUsd < 0)) {
|
|
128
|
+
return `${gitHubCopilotReconciliationEnvVars.toleranceUsd} must be a non-negative USD amount when set`;
|
|
129
|
+
}
|
|
130
|
+
if (expectation.account !== undefined &&
|
|
131
|
+
!/^(?:(?:org|enterprise):)?[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(expectation.account)) {
|
|
132
|
+
// Never echo the raw value — env parse reasons are printed verbatim.
|
|
133
|
+
return `${gitHubCopilotReconciliationEnvVars.account} must be an org/enterprise slug, optionally prefixed org: or enterprise:`;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Shared reconciliation tolerance rule: default $0.01 (billing pages round to
|
|
139
|
+
* cents) and clamp to at most 1% of the expected figure so an oversized
|
|
140
|
+
* tolerance can never manufacture a match.
|
|
141
|
+
*/
|
|
142
|
+
function clampReconciliationToleranceUsd(expectedUsd, requestedToleranceUsd) {
|
|
143
|
+
const requested = Math.max(requestedToleranceUsd ?? 0.01, 0.01);
|
|
144
|
+
return Math.min(requested, Math.max(0.01, expectedUsd * 0.01));
|
|
145
|
+
}
|
|
72
146
|
export function normalizeOpenAiCostResponse(response, options) {
|
|
73
147
|
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
74
148
|
const records = [];
|
|
@@ -449,6 +523,71 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
449
523
|
}
|
|
450
524
|
return records;
|
|
451
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Normalize one GitHub AI-credit usage report (the 2026 usage-based billing
|
|
528
|
+
* model: GET /organizations/{org}/settings/billing/ai_credit/usage) into
|
|
529
|
+
* billed-dollar evidence records. Rows are NET provider-reported dollars, but
|
|
530
|
+
* this connector is fixture-verified, so — exactly like the Cursor connector —
|
|
531
|
+
* the records stay "estimated" until an in-run reconciliation proves the
|
|
532
|
+
* connector total against the operator-read billing page figure for the same
|
|
533
|
+
* billing month. Legacy premium-request units are deliberately not fetched or
|
|
534
|
+
* blended here (provider-contract exclusion).
|
|
535
|
+
*/
|
|
536
|
+
export function normalizeGitHubCopilotAiCreditUsageResponse(response, options, reconciliation) {
|
|
537
|
+
const items = extractArray(response, "usageItems");
|
|
538
|
+
const timePeriod = isRecord(response) && isRecord(response.timePeriod) ? response.timePeriod : undefined;
|
|
539
|
+
const year = nonNegativeIntegerValue(timePeriod?.year);
|
|
540
|
+
const month = nonNegativeIntegerValue(timePeriod?.month);
|
|
541
|
+
const day = nonNegativeIntegerValue(timePeriod?.day);
|
|
542
|
+
const monthKey = typeof year === "number" && typeof month === "number" && month >= 1 && month <= 12
|
|
543
|
+
? `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`
|
|
544
|
+
: undefined;
|
|
545
|
+
const timestampDate = monthKey && typeof day === "number" && day >= 1 && day <= 31
|
|
546
|
+
? `${monthKey}-${String(day).padStart(2, "0")}`
|
|
547
|
+
: monthKey
|
|
548
|
+
? `${monthKey}-01`
|
|
549
|
+
: undefined;
|
|
550
|
+
const timestamp = timestampDate && Number.isFinite(Date.parse(`${timestampDate}T00:00:00Z`))
|
|
551
|
+
? new Date(`${timestampDate}T00:00:00Z`).toISOString()
|
|
552
|
+
: new Date().toISOString();
|
|
553
|
+
// Fail-closed stamping mirror of the Cursor connector: only an in-run
|
|
554
|
+
// reconciliation match may flip these records to "verified"; a mismatched or
|
|
555
|
+
// unprovable reconciliation keeps them estimated.
|
|
556
|
+
const reconciled = reconciliation?.status === "verified";
|
|
557
|
+
const confidence = reconciled ? "verified" : "estimated";
|
|
558
|
+
return items.flatMap((item, index) => {
|
|
559
|
+
if (!isRecord(item))
|
|
560
|
+
return [];
|
|
561
|
+
const product = stringValue(item.product);
|
|
562
|
+
const sku = stringValue(item.sku);
|
|
563
|
+
const netAmountUsd = parseDollarUsd(item.netAmount);
|
|
564
|
+
if (!product || !sku || typeof netAmountUsd !== "number")
|
|
565
|
+
return [];
|
|
566
|
+
const model = stringValue(item.model);
|
|
567
|
+
const grossAmountUsd = parseDollarUsd(item.grossAmount);
|
|
568
|
+
const discountAmountUsd = parseDollarUsd(item.discountAmount);
|
|
569
|
+
const netQuantity = nonNegativeNumberValue(item.netQuantity);
|
|
570
|
+
const amounts = typeof grossAmountUsd === "number" && typeof discountAmountUsd === "number"
|
|
571
|
+
? `gross $${grossAmountUsd.toFixed(2)} - discounts $${discountAmountUsd.toFixed(2)} = net $${netAmountUsd.toFixed(2)}`
|
|
572
|
+
: `net $${netAmountUsd.toFixed(2)}`;
|
|
573
|
+
const baseOperation = `GitHub AI-credit billed usage${monthKey ? ` for ${monthKey}` : ""} (${sku}; ${amounts}; metered spend, excludes seat license fees)`;
|
|
574
|
+
return [{
|
|
575
|
+
id: slugifySourceId(["github-copilot-ai-credit", monthKey ?? "unknown-month", options.accountId, product, sku, model, String(index)].filter(Boolean).join("-")),
|
|
576
|
+
timestamp,
|
|
577
|
+
source: { id: options.sourceId, name: "GitHub AI-credit usage report", provider: "github-copilot", confidence, observedFrom: options.observedFrom },
|
|
578
|
+
model: model ?? sku,
|
|
579
|
+
inputTokens: 0,
|
|
580
|
+
outputTokens: 0,
|
|
581
|
+
amountUsd: netAmountUsd,
|
|
582
|
+
costConfidence: confidence,
|
|
583
|
+
projectId: options.accountId,
|
|
584
|
+
providerCostType: "copilot_ai_credit_billing",
|
|
585
|
+
usageGranularity: "billing_bucket",
|
|
586
|
+
...(netQuantity === undefined ? {} : { quantity: netQuantity }),
|
|
587
|
+
operation: reconciled ? `${baseOperation}; ${reconciliation.note}` : baseOperation
|
|
588
|
+
}];
|
|
589
|
+
});
|
|
590
|
+
}
|
|
452
591
|
export function normalizeCursorSpendResponse(response, options, reconciliation) {
|
|
453
592
|
const users = extractArray(response, "teamMemberSpend");
|
|
454
593
|
const cycleStart = isRecord(response) ? numberValue(response.subscriptionCycleStart) : undefined;
|
|
@@ -622,9 +761,247 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
|
622
761
|
const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
|
|
623
762
|
if (seatFetch)
|
|
624
763
|
assessGitHubCopilotSeatCompleteness(seatFetch);
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
764
|
+
const requested = input.copilotReconciliation
|
|
765
|
+
? { expectation: input.copilotReconciliation, invalidReason: invalidGitHubCopilotReconciliationExpectationReason(input.copilotReconciliation) }
|
|
766
|
+
: parseGitHubCopilotReconciliationEnv();
|
|
767
|
+
const aiCredit = await fetchGitHubCopilotAiCreditUsage(fetcher, input, request, requested.expectation);
|
|
768
|
+
const syncedAccount = input.org
|
|
769
|
+
? { scope: "org", slug: input.org }
|
|
770
|
+
: { scope: "enterprise", slug: input.enterprise };
|
|
771
|
+
const reconciliation = assessGitHubCopilotReconciliation(aiCredit, requested.expectation, requested.invalidReason, syncedAccount);
|
|
772
|
+
// Dedupe every record family by stable id BEFORE the QA summary so a
|
|
773
|
+
// GitHub page-shift during churn (the same seat or day row returned on
|
|
774
|
+
// two pages) can never double-count estimated dollars or usage (QA M2).
|
|
775
|
+
const metricsRecords = dedupeGitHubCopilotRecordsById(metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId })), metricsFetch);
|
|
776
|
+
const seatRecords = seatFetch
|
|
777
|
+
? dedupeGitHubCopilotRecordsById(seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })), seatFetch)
|
|
778
|
+
: [];
|
|
779
|
+
const aiCreditRecords = aiCredit.fetch
|
|
780
|
+
? dedupeGitHubCopilotRecordsById(aiCredit.fetch.pages.flatMap((page) => normalizeGitHubCopilotAiCreditUsageResponse(page, { sourceId, observedFrom: "GitHub AI-credit usage report", accountId }, reconciliation)), aiCredit.fetch)
|
|
781
|
+
: [];
|
|
782
|
+
const qa = qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : []), ...(aiCredit.fetch ? [aiCredit.fetch] : [])]);
|
|
783
|
+
if (aiCredit.unavailableReason) {
|
|
784
|
+
// A token scoped to Copilot metrics/seats alone still yields honest usage
|
|
785
|
+
// evidence, so a missing billing permission degrades to estimated records
|
|
786
|
+
// with an explicit diagnostic instead of failing the whole sync.
|
|
787
|
+
qa.instructions = [...qa.instructions, aiCredit.unavailableReason];
|
|
788
|
+
qa.responseDrift.push({
|
|
789
|
+
label: "GitHub Copilot AI-credit usage report",
|
|
790
|
+
field: "usageItems",
|
|
791
|
+
issue: aiCredit.unavailableReason
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
if (reconciliation) {
|
|
795
|
+
// The outcome must survive the persisted-QA round trip, so it rides in
|
|
796
|
+
// instructions (kept verbatim) and, on failure, in responseDrift.
|
|
797
|
+
qa.instructions = [...qa.instructions, `Reconciliation ${reconciliation.status}: ${reconciliation.note}`];
|
|
798
|
+
if (reconciliation.status !== "verified") {
|
|
799
|
+
qa.responseDrift.push({
|
|
800
|
+
label: "GitHub Copilot AI-credit usage report",
|
|
801
|
+
field: "usageItems[].netAmount (billing month total)",
|
|
802
|
+
issue: reconciliation.note
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords, ...aiCreditRecords], qa);
|
|
807
|
+
}
|
|
808
|
+
function resolveGitHubCopilotBillingMonth(expectation) {
|
|
809
|
+
if (expectation && /^\d{4}-(0[1-9]|1[0-2])$/.test(expectation.expectedBillingMonth)) {
|
|
810
|
+
const [year, month] = expectation.expectedBillingMonth.split("-").map(Number);
|
|
811
|
+
return { year: year, month: month };
|
|
812
|
+
}
|
|
813
|
+
const now = new Date();
|
|
814
|
+
return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Read the org/enterprise AI-credit usage report for one billing month (the
|
|
818
|
+
* declared reconciliation month, else the current UTC month). The endpoint
|
|
819
|
+
* needs the fine-grained "Administration: read" organization permission
|
|
820
|
+
* (enterprise reads: billing read); a 401/403/404 therefore degrades with an
|
|
821
|
+
* honest note instead of failing metrics/seat evidence, while transport and
|
|
822
|
+
* schema failures still fail closed.
|
|
823
|
+
*/
|
|
824
|
+
async function fetchGitHubCopilotAiCreditUsage(fetcher, input, request, expectation) {
|
|
825
|
+
const label = "GitHub Copilot AI-credit usage report";
|
|
826
|
+
const billingMonth = resolveGitHubCopilotBillingMonth(expectation);
|
|
827
|
+
try {
|
|
828
|
+
const response = await fetchJsonOrThrow(fetcher, buildGitHubCopilotAiCreditUsageUrl(input, billingMonth), request, "github-copilot", label);
|
|
829
|
+
const fetchResult = {
|
|
830
|
+
pages: [response.payload],
|
|
831
|
+
pagination: { label, pagesFetched: 1, stoppedBecause: "complete", maxPages: 1 },
|
|
832
|
+
rateLimits: response.rateLimit ? [response.rateLimit] : [],
|
|
833
|
+
responseDrift: detectResponseDrift(response.payload, "github-copilot", label)
|
|
834
|
+
};
|
|
835
|
+
markMalformedGitHubCopilotAiCreditRows(fetchResult, label);
|
|
836
|
+
return { fetch: fetchResult, billingMonth };
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
const status = error instanceof ProviderConnectorError ? error.status : undefined;
|
|
840
|
+
if (error instanceof ProviderConnectorError && (error.code === "authentication_error" || status === 404)) {
|
|
841
|
+
return {
|
|
842
|
+
billingMonth,
|
|
843
|
+
unavailableReason: `GitHub AI-credit usage report was unavailable (HTTP ${status ?? "error"}); billed dollars were skipped. Grant the fine-grained "Administration: read" organization permission (enterprise: billing read) to include them.`
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function buildGitHubCopilotAiCreditUsageUrl(input, billingMonth) {
|
|
850
|
+
const base = input.enterprise
|
|
851
|
+
? `https://api.github.com/enterprises/${encodeURIComponent(input.enterprise)}/settings/billing/ai_credit/usage`
|
|
852
|
+
: input.org
|
|
853
|
+
? `https://api.github.com/organizations/${encodeURIComponent(input.org)}/settings/billing/ai_credit/usage`
|
|
854
|
+
: undefined;
|
|
855
|
+
if (!base)
|
|
856
|
+
throw new Error("GitHub Copilot connector requires --org or --enterprise.");
|
|
857
|
+
const url = new URL(base);
|
|
858
|
+
url.searchParams.set("year", String(billingMonth.year));
|
|
859
|
+
url.searchParams.set("month", String(billingMonth.month));
|
|
860
|
+
return url.toString();
|
|
861
|
+
}
|
|
862
|
+
function markMalformedGitHubCopilotAiCreditRows(fetchResult, label) {
|
|
863
|
+
const issues = [];
|
|
864
|
+
const report = (field, issue) => issues.push({ label, field, issue });
|
|
865
|
+
for (const page of fetchResult.pages) {
|
|
866
|
+
if (!isRecord(page) || !Array.isArray(page.usageItems)) {
|
|
867
|
+
report("usageItems", "AI-credit usage response omitted the canonical usageItems array; completeness cannot be proven");
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (!isRecord(page.timePeriod) || typeof nonNegativeIntegerValue(page.timePeriod.year) !== "number") {
|
|
871
|
+
report("timePeriod", "AI-credit usage response omitted the documented timePeriod object; the billing month cannot be proven");
|
|
872
|
+
}
|
|
873
|
+
for (const [index, item] of page.usageItems.entries()) {
|
|
874
|
+
const itemPath = `usageItems[${index}]`;
|
|
875
|
+
if (!isRecord(item)) {
|
|
876
|
+
report(itemPath, "AI-credit usage response contained a non-object usage row; the row was excluded");
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (!stringValue(item.product) || !stringValue(item.sku)) {
|
|
880
|
+
report(`${itemPath}.product/sku`, "AI-credit usage row is missing its product or sku label; the row was excluded");
|
|
881
|
+
}
|
|
882
|
+
if (typeof parseDollarUsd(item.netAmount) !== "number") {
|
|
883
|
+
report(`${itemPath}.netAmount`, "AI-credit usage row requires a non-negative USD netAmount; the row was excluded");
|
|
884
|
+
}
|
|
885
|
+
for (const field of ["grossAmount", "discountAmount", "pricePerUnit", "grossQuantity", "discountQuantity", "netQuantity"]) {
|
|
886
|
+
if (item[field] !== undefined && typeof nonNegativeNumberValue(item[field]) !== "number") {
|
|
887
|
+
report(`${itemPath}.${field}`, "AI-credit usage amounts and quantities must be non-negative numbers; the invalid value was excluded");
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (issues.length === 0)
|
|
893
|
+
return;
|
|
894
|
+
fetchResult.coverageIncomplete = true;
|
|
895
|
+
fetchResult.responseDrift.push(...issues);
|
|
896
|
+
}
|
|
897
|
+
/** Case-insensitive account-binding match; a bare slug matches either scope. */
|
|
898
|
+
function gitHubCopilotAccountBindingMatches(declared, synced) {
|
|
899
|
+
if (!synced)
|
|
900
|
+
return false;
|
|
901
|
+
const normalized = declared.trim().toLowerCase();
|
|
902
|
+
const scoped = normalized.match(/^(org|enterprise):(.+)$/);
|
|
903
|
+
if (scoped) {
|
|
904
|
+
return scoped[1] === synced.scope && scoped[2] === synced.slug.toLowerCase();
|
|
905
|
+
}
|
|
906
|
+
return normalized === synced.slug.toLowerCase();
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Compare the connector's summed AI-credit net total for one billing month
|
|
910
|
+
* against the operator-read billing page figure. Every exit that is not a
|
|
911
|
+
* window-proven match inside the clamped tolerance fails closed: the records
|
|
912
|
+
* stay estimated and the note says exactly why. Returns undefined when no
|
|
913
|
+
* reconciliation was requested.
|
|
914
|
+
*/
|
|
915
|
+
function assessGitHubCopilotReconciliation(aiCredit, expectation, invalidReason, syncedAccount) {
|
|
916
|
+
if (!expectation && !invalidReason)
|
|
917
|
+
return undefined;
|
|
918
|
+
if (invalidReason || !expectation) {
|
|
919
|
+
return {
|
|
920
|
+
status: "not_provable",
|
|
921
|
+
note: `GitHub Copilot reconciliation input was rejected (${invalidReason ?? "missing expectation"}); records remain estimated.`
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
const billingMonth = expectation.expectedBillingMonth;
|
|
925
|
+
if (expectation.account !== undefined &&
|
|
926
|
+
!gitHubCopilotAccountBindingMatches(expectation.account, syncedAccount)) {
|
|
927
|
+
// QA C3: leftover shell env from one account's runbook pass must never
|
|
928
|
+
// stamp a different account verified on a coincidence-equal total.
|
|
929
|
+
return {
|
|
930
|
+
status: "not_provable",
|
|
931
|
+
billingMonth,
|
|
932
|
+
note: `${gitHubCopilotReconciliationEnvVars.account} is bound to a different org/enterprise than this sync; the billing figure was not applied. Reconcile one account per shell, or update the binding. Records remain estimated.`
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
if (!aiCredit.fetch) {
|
|
936
|
+
return {
|
|
937
|
+
status: "not_provable",
|
|
938
|
+
billingMonth,
|
|
939
|
+
note: `GitHub Copilot reconciliation requires the AI-credit usage report; ${aiCredit.unavailableReason ?? "the report could not be read."} Records remain estimated.`
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
if (aiCredit.fetch.pagination.stoppedBecause !== "complete" || aiCredit.fetch.coverageIncomplete === true) {
|
|
943
|
+
return {
|
|
944
|
+
status: "not_provable",
|
|
945
|
+
billingMonth,
|
|
946
|
+
note: "GitHub Copilot reconciliation requires a complete AI-credit report; the response was incomplete or partially malformed, so billed dollars cannot be verified. Records remain estimated."
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
for (const page of aiCredit.fetch.pages) {
|
|
950
|
+
const timePeriod = isRecord(page) && isRecord(page.timePeriod) ? page.timePeriod : undefined;
|
|
951
|
+
const year = nonNegativeIntegerValue(timePeriod?.year);
|
|
952
|
+
const month = nonNegativeIntegerValue(timePeriod?.month);
|
|
953
|
+
const monthKey = typeof year === "number" && typeof month === "number"
|
|
954
|
+
? `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`
|
|
955
|
+
: undefined;
|
|
956
|
+
if (monthKey !== billingMonth) {
|
|
957
|
+
return {
|
|
958
|
+
status: "not_provable",
|
|
959
|
+
billingMonth,
|
|
960
|
+
note: `The declared billing month ${billingMonth} does not match the provider-reported time period${monthKey ? ` ${monthKey}` : ""}; the billing page figure and the connector read different windows. Records remain estimated.`
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
if (timePeriod?.day !== undefined) {
|
|
964
|
+
// Fail closed on any day field (null included), and say WHY without
|
|
965
|
+
// the self-contradictory month-vs-same-month wording (QA A4): a
|
|
966
|
+
// day-scoped report cannot anchor a whole-month billing figure.
|
|
967
|
+
return {
|
|
968
|
+
status: "not_provable",
|
|
969
|
+
billingMonth,
|
|
970
|
+
note: `GitHub Copilot reconciliation requires a whole-month AI-credit report; the provider-reported time period carries a day field, so the monthly billing page figure cannot be compared against it. Records remain estimated.`
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const connectorTotalUsd = aiCredit.fetch.pages.reduce((sum, page) => sum + extractArray(page, "usageItems").reduce((pageSum, item) => pageSum + (isRecord(item) ? parseDollarUsd(item.netAmount) ?? 0 : 0), 0), 0);
|
|
975
|
+
if (!(connectorTotalUsd > 0)) {
|
|
976
|
+
return {
|
|
977
|
+
status: "not_provable",
|
|
978
|
+
billingMonth,
|
|
979
|
+
connectorTotalUsd,
|
|
980
|
+
expectedNetUsd: expectation.expectedNetUsd,
|
|
981
|
+
note: "GitHub Copilot reconciliation needs a non-zero connector total; matching $0.00 against a billing page figure proves nothing (a fully promo-discounted month cannot verify). Records remain estimated."
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const toleranceUsd = clampReconciliationToleranceUsd(expectation.expectedNetUsd, expectation.toleranceUsd);
|
|
985
|
+
const differenceUsd = Math.abs(connectorTotalUsd - expectation.expectedNetUsd);
|
|
986
|
+
const shared = {
|
|
987
|
+
connectorTotalUsd,
|
|
988
|
+
expectedNetUsd: expectation.expectedNetUsd,
|
|
989
|
+
differenceUsd,
|
|
990
|
+
toleranceUsd,
|
|
991
|
+
billingMonth
|
|
992
|
+
};
|
|
993
|
+
if (differenceUsd <= toleranceUsd + 1e-9) {
|
|
994
|
+
return {
|
|
995
|
+
status: "verified",
|
|
996
|
+
...shared,
|
|
997
|
+
note: `reconciled to the operator-read GitHub billing usage AI-credit net total $${expectation.expectedNetUsd.toFixed(2)} for the billing month ${billingMonth}: connector total $${connectorTotalUsd.toFixed(2)}, difference $${differenceUsd.toFixed(2)} within tolerance $${toleranceUsd.toFixed(2)}`
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
return {
|
|
1001
|
+
status: "mismatch",
|
|
1002
|
+
...shared,
|
|
1003
|
+
note: `GitHub Copilot reconciliation mismatch: connector AI-credit net total $${connectorTotalUsd.toFixed(2)} vs operator-read $${expectation.expectedNetUsd.toFixed(2)} for the billing month ${billingMonth}; difference $${differenceUsd.toFixed(2)} exceeds tolerance $${toleranceUsd.toFixed(2)}. Records remain estimated until the totals agree.`
|
|
1004
|
+
};
|
|
628
1005
|
}
|
|
629
1006
|
async function fetchCursor(input, token, fetcher, sourceId) {
|
|
630
1007
|
const accountId = input.accountId ?? input.org ?? "cursor-team";
|
|
@@ -701,10 +1078,7 @@ function assessCursorReconciliation(spendFetch, expectation, invalidReason) {
|
|
|
701
1078
|
note: "Cursor reconciliation needs a non-zero connector total; matching $0.00 against a dashboard figure proves nothing. Records remain estimated."
|
|
702
1079
|
};
|
|
703
1080
|
}
|
|
704
|
-
|
|
705
|
-
// expected figure so an oversized tolerance cannot manufacture a match.
|
|
706
|
-
const requestedTolerance = Math.max(expectation.toleranceUsd ?? 0.01, 0.01);
|
|
707
|
-
const toleranceUsd = Math.min(requestedTolerance, Math.max(0.01, expectation.expectedOnDemandUsd * 0.01));
|
|
1081
|
+
const toleranceUsd = clampReconciliationToleranceUsd(expectation.expectedOnDemandUsd, expectation.toleranceUsd);
|
|
708
1082
|
const differenceUsd = Math.abs(connectorTotalUsd - expectation.expectedOnDemandUsd);
|
|
709
1083
|
const shared = {
|
|
710
1084
|
connectorTotalUsd,
|
|
@@ -918,7 +1292,27 @@ function assessGitHubCopilotSeatCompleteness(fetchResult) {
|
|
|
918
1292
|
const expected = fetchResult.pages
|
|
919
1293
|
.map((page) => isRecord(page) ? numberValue(page.total_seats) : undefined)
|
|
920
1294
|
.find((value) => typeof value === "number");
|
|
921
|
-
|
|
1295
|
+
// Count UNIQUE seat identities (same derivation as the normalizer), not raw
|
|
1296
|
+
// rows: a GitHub page-shift during churn can return the same assignee on
|
|
1297
|
+
// two pages, which would otherwise satisfy total_seats while a real seat
|
|
1298
|
+
// went unfetched (QA M2). Rows without any identity still count singly.
|
|
1299
|
+
const identities = new Set();
|
|
1300
|
+
let unidentifiedRows = 0;
|
|
1301
|
+
for (const page of fetchResult.pages) {
|
|
1302
|
+
for (const seat of extractArray(page, "seats")) {
|
|
1303
|
+
if (!isRecord(seat)) {
|
|
1304
|
+
unidentifiedRows += 1;
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
const assignee = isRecord(seat.assignee) ? seat.assignee : {};
|
|
1308
|
+
const identity = stringValue(assignee.login) ?? stringValue(assignee.email) ?? stringValue(seat.login) ?? stringValue(seat.id);
|
|
1309
|
+
if (identity)
|
|
1310
|
+
identities.add(identity);
|
|
1311
|
+
else
|
|
1312
|
+
unidentifiedRows += 1;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
const actual = identities.size + unidentifiedRows;
|
|
922
1316
|
if (typeof expected !== "number") {
|
|
923
1317
|
if (fetchResult.pagination.stoppedBecause === "complete")
|
|
924
1318
|
fetchResult.pagination.stoppedBecause = "missing_cursor";
|
|
@@ -927,10 +1321,42 @@ function assessGitHubCopilotSeatCompleteness(fetchResult) {
|
|
|
927
1321
|
}
|
|
928
1322
|
else if (fetchResult.pagination.stoppedBecause === "complete" && actual !== expected) {
|
|
929
1323
|
fetchResult.pagination.stoppedBecause = "missing_cursor";
|
|
930
|
-
fetchResult.pagination.note = `GitHub reported ${expected} seats but returned ${actual}; completeness cannot be proven.`;
|
|
1324
|
+
fetchResult.pagination.note = `GitHub reported ${expected} seats but returned ${actual} unique seat identit${actual === 1 ? "y" : "ies"}; completeness cannot be proven.`;
|
|
931
1325
|
fetchResult.responseDrift.push({ label: "GitHub Copilot seats", field: "total_seats", issue: fetchResult.pagination.note });
|
|
932
1326
|
}
|
|
933
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Dedupe one normalized GitHub Copilot record family by stable id, mirroring
|
|
1330
|
+
* the OpenAI dedupe semantics: an identical duplicate is excluded with a
|
|
1331
|
+
* drift note; conflicting rows sharing one id are ALL excluded (evidence
|
|
1332
|
+
* that cannot be told apart must not be counted). Any duplicate marks the
|
|
1333
|
+
* fetch's coverage incomplete so the sync reports partial instead of
|
|
1334
|
+
* silently inflating estimated dollars (QA M2).
|
|
1335
|
+
*/
|
|
1336
|
+
function dedupeGitHubCopilotRecordsById(records, fetchResult) {
|
|
1337
|
+
const byId = new Map();
|
|
1338
|
+
const conflicted = new Set();
|
|
1339
|
+
for (const record of records) {
|
|
1340
|
+
const existing = byId.get(record.id);
|
|
1341
|
+
if (!existing && !conflicted.has(record.id)) {
|
|
1342
|
+
byId.set(record.id, record);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
fetchResult.coverageIncomplete = true;
|
|
1346
|
+
fetchResult.responseDrift.push({
|
|
1347
|
+
label: fetchResult.pagination.label,
|
|
1348
|
+
field: "normalized records[].id",
|
|
1349
|
+
issue: existing && JSON.stringify(existing) === JSON.stringify(record)
|
|
1350
|
+
? "duplicate provider record was excluded"
|
|
1351
|
+
: "conflicting provider records shared one stable identity; all conflicting rows were excluded"
|
|
1352
|
+
});
|
|
1353
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
1354
|
+
byId.delete(record.id);
|
|
1355
|
+
conflicted.add(record.id);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return Array.from(byId.values());
|
|
1359
|
+
}
|
|
934
1360
|
async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
|
|
935
1361
|
const pages = [];
|
|
936
1362
|
const rateLimits = [];
|
|
@@ -1540,6 +1966,11 @@ function knownProviderFields(provider, label) {
|
|
|
1540
1966
|
if (provider === "github-copilot" && label.toLowerCase().includes("metrics")) {
|
|
1541
1967
|
return new Set([...common, "download_links", "download_links[]", "day_totals", "day_totals[]", "day_totals[].day", "day_totals[].daily_active_users", "day_totals[].totals_by_model_feature", "day_totals[].totals_by_model_feature[]", "day_totals[].totals_by_model_feature[].model", "day_totals[].totals_by_model_feature[].feature", "day_totals[].totals_by_model_feature[].engaged_users", "day_totals[].totals_by_model_feature[].total_requests", "day_totals[].totals_by_model_feature[].user_initiated_interaction_count", "day_totals[].totals_by_cli", "day_totals[].totals_by_cli.request_count", "day_totals[].totals_by_cli.prompt_count", "day_totals[].totals_by_cli.session_count", "day_totals[].totals_by_cli.token_usage", "day_totals[].totals_by_cli.token_usage.prompt_tokens_sum", "day_totals[].totals_by_cli.token_usage.output_tokens_sum", "day_totals[].totals_by_cli.token_usage.avg_tokens_per_request", "day_totals[].totals_by_cli.engaged_users", "day_totals[].totals_by_cli.total_requests", "report_start_day", "report_end_day", "created_at", "generated_at", "etl_id", "day_partition", "entity_id_partition", "enterprise_id", "organization_id"]);
|
|
1542
1968
|
}
|
|
1969
|
+
if (provider === "github-copilot" && label.toLowerCase().includes("ai-credit")) {
|
|
1970
|
+
// Documented 2026 enhanced-billing usage report shape: a timePeriod echo
|
|
1971
|
+
// plus usageItems rows (product/sku/model with gross/discount/net money).
|
|
1972
|
+
return new Set([...common, "timePeriod", "timePeriod.year", "timePeriod.month", "timePeriod.day", "usageItems", "usageItems[]", "usageItems[].date", "usageItems[].product", "usageItems[].sku", "usageItems[].model", "usageItems[].unitType", "usageItems[].pricePerUnit", "usageItems[].grossQuantity", "usageItems[].grossAmount", "usageItems[].discountQuantity", "usageItems[].discountAmount", "usageItems[].netQuantity", "usageItems[].netAmount", "usageItems[].organizationName", "usageItems[].repositoryName"]);
|
|
1973
|
+
}
|
|
1543
1974
|
if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
|
|
1544
1975
|
return new Set([...common, "total_seats", "seats", "seats[]", "seats[].created_at", "seats[].updated_at", "seats[].pending_cancellation_date", "seats[].last_activity_at", "seats[].last_activity_editor", "seats[].last_authenticated_at", "seats[].plan_type", "seats[].login", "seats[].id", "seats[].assignee", "seats[].assignee.login", "seats[].assignee.email", "seats[].assignee.id", "seats[].assignee.node_id", "seats[].assignee.avatar_url", "seats[].assignee.html_url", "seats[].assignee.type", "seats[].assignee.site_admin", "seats[].assigning_team", "seats[].organization"]);
|
|
1545
1976
|
}
|
|
@@ -1585,8 +2016,11 @@ function providerInstructions(provider) {
|
|
|
1585
2016
|
}
|
|
1586
2017
|
if (provider === "github-copilot") {
|
|
1587
2018
|
return [
|
|
1588
|
-
"Use a GitHub token reference with org or enterprise Copilot metrics and billing
|
|
1589
|
-
"
|
|
2019
|
+
"Use a GitHub token reference with org or enterprise Copilot metrics, billing seats, and billing usage read access.",
|
|
2020
|
+
"Fine-grained token permissions (2026): 'Organization Copilot metrics: read' for usage metrics, 'GitHub Copilot Business: read' for seats, and 'Administration: read' for the AI-credit usage report (classic PATs: read:org and/or manage_billing:copilot; enterprise reads accept read:enterprise). The org's Copilot 'usage metrics' policy must be enabled.",
|
|
2021
|
+
"Seat records estimate monthly plan commitment at list price ($19 Business / $39 Enterprise); metrics records are usage evidence without direct spend allocation.",
|
|
2022
|
+
"AI-credit records carry GitHub-billed net dollars (usage-based billing for every Copilot Business/Enterprise account since 2026-06-01; 1 AI credit bills as $0.01) and stay estimated until an in-sync reconciliation matches the billing page figure; set AI_SPEND_COPILOT_RECONCILE_EXPECTED_USD and AI_SPEND_COPILOT_RECONCILE_MONTH to run one, and optionally AI_SPEND_COPILOT_RECONCILE_ACCOUNT (org:<slug> or enterprise:<slug>) to bind the anchor to one account when several are synced from one shell. Legacy premium-request billing is not fetched or blended.",
|
|
2023
|
+
"Individual Copilot (Free/Pro/Pro+) exposes no org billing or metrics API; the personal-plan endpoints under /users/{username}/settings/billing need that user's own token with 'Plan: read' and are not implemented."
|
|
1590
2024
|
];
|
|
1591
2025
|
}
|
|
1592
2026
|
if (provider === "cursor") {
|
|
@@ -1610,7 +2044,7 @@ function providerPermissionPrompt(provider, label, response, payload) {
|
|
|
1610
2044
|
return `Missing Anthropic Admin read scopes for ${label} (${status}). Reconnect with organization cost report and Claude Code usage report read access. ${rawMessage}`;
|
|
1611
2045
|
}
|
|
1612
2046
|
if (provider === "github-copilot") {
|
|
1613
|
-
return `Missing GitHub Copilot org or enterprise read scopes for ${label} (${status}). Reconnect with Copilot metrics and billing
|
|
2047
|
+
return `Missing GitHub Copilot org or enterprise read scopes for ${label} (${status}). Reconnect with Copilot metrics, billing seats, and billing usage (Administration: read) access. ${rawMessage}`;
|
|
1614
2048
|
}
|
|
1615
2049
|
if (provider === "cursor") {
|
|
1616
2050
|
return `Missing Cursor team admin read scopes for ${label} (${status}). Use Cursor Admin API access or fall back to Browser Account UI/manual export. ${rawMessage}`;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type ResultCard } from "./resultCard.js";
|
|
3
|
+
/**
|
|
4
|
+
* Post-launch transport groundwork only.
|
|
5
|
+
*
|
|
6
|
+
* This module does not upload, send, persist, authenticate, render markup, or
|
|
7
|
+
* expose a CLI command. It defines the bounded aggregate payload that a future
|
|
8
|
+
* opt-in receipt route may accept after its own waitlist, durable-rate-limit,
|
|
9
|
+
* retention, and mail-provider controls exist.
|
|
10
|
+
*/
|
|
11
|
+
export declare const RECEIPT_SHARE_V0_VERSION: "0.1.0";
|
|
12
|
+
export declare const RECEIPT_SHARE_CARD_V0_KIND: "aibill.receipt_share_card";
|
|
13
|
+
export declare const RECEIPT_EMAIL_REQUEST_V0_KIND: "aibill.receipt_email_request";
|
|
14
|
+
export declare const receiptShareCutV0Schema: z.ZodObject<{
|
|
15
|
+
template: z.ZodEnum<{
|
|
16
|
+
route_lower_cost_model: "route_lower_cost_model";
|
|
17
|
+
narrow_context: "narrow_context";
|
|
18
|
+
cache_repeated_work: "cache_repeated_work";
|
|
19
|
+
use_batch_api: "use_batch_api";
|
|
20
|
+
}>;
|
|
21
|
+
modeledOpportunityUsd: z.ZodNumber;
|
|
22
|
+
evidence: z.ZodLiteral<"modeled_not_verified">;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
export declare const receiptShareCardV0Schema: z.ZodObject<{
|
|
25
|
+
kind: z.ZodLiteral<"aibill.receipt_share_card">;
|
|
26
|
+
schemaVersion: z.ZodLiteral<"0.1.0">;
|
|
27
|
+
currency: z.ZodLiteral<"USD">;
|
|
28
|
+
windowDays: z.ZodNumber;
|
|
29
|
+
mode: z.ZodEnum<{
|
|
30
|
+
mixed: "mixed";
|
|
31
|
+
connected: "connected";
|
|
32
|
+
"local-logs": "local-logs";
|
|
33
|
+
}>;
|
|
34
|
+
financials: z.ZodObject<{
|
|
35
|
+
subscriptionCommitted: z.ZodObject<{
|
|
36
|
+
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
37
|
+
pricedSubs: z.ZodNumber;
|
|
38
|
+
totalSubs: z.ZodNumber;
|
|
39
|
+
}, z.core.$strict>;
|
|
40
|
+
apiEquivalent: z.ZodObject<{
|
|
41
|
+
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
42
|
+
financialEvidence: z.ZodEnum<{
|
|
43
|
+
estimated: "estimated";
|
|
44
|
+
missing: "missing";
|
|
45
|
+
}>;
|
|
46
|
+
}, z.core.$strict>;
|
|
47
|
+
providerBilled: z.ZodObject<{
|
|
48
|
+
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
49
|
+
financialEvidence: z.ZodEnum<{
|
|
50
|
+
verified: "verified";
|
|
51
|
+
missing: "missing";
|
|
52
|
+
}>;
|
|
53
|
+
}, z.core.$strict>;
|
|
54
|
+
blended: z.ZodNull;
|
|
55
|
+
blendPolicy: z.ZodLiteral<"never_blended">;
|
|
56
|
+
}, z.core.$strict>;
|
|
57
|
+
providerCount: z.ZodNumber;
|
|
58
|
+
recordCount: z.ZodNumber;
|
|
59
|
+
confidence: z.ZodEnum<{
|
|
60
|
+
estimated: "estimated";
|
|
61
|
+
verified: "verified";
|
|
62
|
+
detected_unverified: "detected_unverified";
|
|
63
|
+
missing: "missing";
|
|
64
|
+
}>;
|
|
65
|
+
cuts: z.ZodArray<z.ZodObject<{
|
|
66
|
+
template: z.ZodEnum<{
|
|
67
|
+
route_lower_cost_model: "route_lower_cost_model";
|
|
68
|
+
narrow_context: "narrow_context";
|
|
69
|
+
cache_repeated_work: "cache_repeated_work";
|
|
70
|
+
use_batch_api: "use_batch_api";
|
|
71
|
+
}>;
|
|
72
|
+
modeledOpportunityUsd: z.ZodNumber;
|
|
73
|
+
evidence: z.ZodLiteral<"modeled_not_verified">;
|
|
74
|
+
}, z.core.$strict>>;
|
|
75
|
+
contentBoundary: z.ZodObject<{
|
|
76
|
+
rawHistoryIncluded: z.ZodLiteral<false>;
|
|
77
|
+
localIdentifiersIncluded: z.ZodLiteral<false>;
|
|
78
|
+
clientMarkupIncluded: z.ZodLiteral<false>;
|
|
79
|
+
}, z.core.$strict>;
|
|
80
|
+
}, z.core.$strict>;
|
|
81
|
+
export declare const receiptEmailRequestV0Schema: z.ZodObject<{
|
|
82
|
+
kind: z.ZodLiteral<"aibill.receipt_email_request">;
|
|
83
|
+
schemaVersion: z.ZodLiteral<"0.1.0">;
|
|
84
|
+
recipientEmail: z.ZodString;
|
|
85
|
+
consent: z.ZodLiteral<"email_and_aggregate_card_via_mail_provider">;
|
|
86
|
+
card: z.ZodObject<{
|
|
87
|
+
kind: z.ZodLiteral<"aibill.receipt_share_card">;
|
|
88
|
+
schemaVersion: z.ZodLiteral<"0.1.0">;
|
|
89
|
+
currency: z.ZodLiteral<"USD">;
|
|
90
|
+
windowDays: z.ZodNumber;
|
|
91
|
+
mode: z.ZodEnum<{
|
|
92
|
+
mixed: "mixed";
|
|
93
|
+
connected: "connected";
|
|
94
|
+
"local-logs": "local-logs";
|
|
95
|
+
}>;
|
|
96
|
+
financials: z.ZodObject<{
|
|
97
|
+
subscriptionCommitted: z.ZodObject<{
|
|
98
|
+
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
99
|
+
pricedSubs: z.ZodNumber;
|
|
100
|
+
totalSubs: z.ZodNumber;
|
|
101
|
+
}, z.core.$strict>;
|
|
102
|
+
apiEquivalent: z.ZodObject<{
|
|
103
|
+
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
104
|
+
financialEvidence: z.ZodEnum<{
|
|
105
|
+
estimated: "estimated";
|
|
106
|
+
missing: "missing";
|
|
107
|
+
}>;
|
|
108
|
+
}, z.core.$strict>;
|
|
109
|
+
providerBilled: z.ZodObject<{
|
|
110
|
+
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
111
|
+
financialEvidence: z.ZodEnum<{
|
|
112
|
+
verified: "verified";
|
|
113
|
+
missing: "missing";
|
|
114
|
+
}>;
|
|
115
|
+
}, z.core.$strict>;
|
|
116
|
+
blended: z.ZodNull;
|
|
117
|
+
blendPolicy: z.ZodLiteral<"never_blended">;
|
|
118
|
+
}, z.core.$strict>;
|
|
119
|
+
providerCount: z.ZodNumber;
|
|
120
|
+
recordCount: z.ZodNumber;
|
|
121
|
+
confidence: z.ZodEnum<{
|
|
122
|
+
estimated: "estimated";
|
|
123
|
+
verified: "verified";
|
|
124
|
+
detected_unverified: "detected_unverified";
|
|
125
|
+
missing: "missing";
|
|
126
|
+
}>;
|
|
127
|
+
cuts: z.ZodArray<z.ZodObject<{
|
|
128
|
+
template: z.ZodEnum<{
|
|
129
|
+
route_lower_cost_model: "route_lower_cost_model";
|
|
130
|
+
narrow_context: "narrow_context";
|
|
131
|
+
cache_repeated_work: "cache_repeated_work";
|
|
132
|
+
use_batch_api: "use_batch_api";
|
|
133
|
+
}>;
|
|
134
|
+
modeledOpportunityUsd: z.ZodNumber;
|
|
135
|
+
evidence: z.ZodLiteral<"modeled_not_verified">;
|
|
136
|
+
}, z.core.$strict>>;
|
|
137
|
+
contentBoundary: z.ZodObject<{
|
|
138
|
+
rawHistoryIncluded: z.ZodLiteral<false>;
|
|
139
|
+
localIdentifiersIncluded: z.ZodLiteral<false>;
|
|
140
|
+
clientMarkupIncluded: z.ZodLiteral<false>;
|
|
141
|
+
}, z.core.$strict>;
|
|
142
|
+
}, z.core.$strict>;
|
|
143
|
+
}, z.core.$strict>;
|
|
144
|
+
export type ReceiptShareCutV0 = z.infer<typeof receiptShareCutV0Schema>;
|
|
145
|
+
export type ReceiptShareCardV0 = z.infer<typeof receiptShareCardV0Schema>;
|
|
146
|
+
export type ReceiptEmailRequestV0 = z.infer<typeof receiptEmailRequestV0Schema>;
|
|
147
|
+
export type BuildReceiptShareCardV0Input = {
|
|
148
|
+
resultCard: ResultCard;
|
|
149
|
+
providerCount: number;
|
|
150
|
+
recordCount: number;
|
|
151
|
+
confidence: ReceiptShareCardV0["confidence"];
|
|
152
|
+
cuts: ReceiptShareCutV0[];
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Projects the canonical local result card into a smaller aggregate-only
|
|
156
|
+
* transport card. Subscription labels, project rows, runways, record IDs,
|
|
157
|
+
* source metadata, prompts, paths, and raw history are intentionally omitted.
|
|
158
|
+
*/
|
|
159
|
+
export declare function buildReceiptShareCardV0(input: BuildReceiptShareCardV0Input): ReceiptShareCardV0;
|
|
160
|
+
export declare const RECEIPT_EMAIL_MAX_SENDS_PER_EMAIL_24H: 1;
|
|
161
|
+
export declare const RECEIPT_EMAIL_MAX_SENDS_PER_IP_24H: 10;
|
|
162
|
+
declare const receiptEmailDeliveryStateV0Schema: z.ZodObject<{
|
|
163
|
+
waitlistMember: z.ZodBoolean;
|
|
164
|
+
emailSendsLast24Hours: z.ZodNumber;
|
|
165
|
+
ipSendsLast24Hours: z.ZodNumber;
|
|
166
|
+
}, z.core.$strict>;
|
|
167
|
+
export type ReceiptEmailDeliveryDecisionV0 = {
|
|
168
|
+
status: "join_first";
|
|
169
|
+
httpStatus: 403;
|
|
170
|
+
} | {
|
|
171
|
+
status: "rate_limited";
|
|
172
|
+
httpStatus: 429;
|
|
173
|
+
scope: "email" | "ip";
|
|
174
|
+
} | {
|
|
175
|
+
status: "accepted";
|
|
176
|
+
httpStatus: 202;
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Pure authorization policy for a future route. Counters must come from a
|
|
180
|
+
* durable shared store; this function creates no in-memory limiter and has no
|
|
181
|
+
* persistence or network side effects.
|
|
182
|
+
*/
|
|
183
|
+
export declare function decideReceiptEmailDeliveryV0(state: z.input<typeof receiptEmailDeliveryStateV0Schema>): ReceiptEmailDeliveryDecisionV0;
|
|
184
|
+
export {};
|
|
185
|
+
//# sourceMappingURL=receiptShare.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resultCardSchema, resultCardTotalsSchema } from "./resultCard.js";
|
|
3
|
+
/**
|
|
4
|
+
* Post-launch transport groundwork only.
|
|
5
|
+
*
|
|
6
|
+
* This module does not upload, send, persist, authenticate, render markup, or
|
|
7
|
+
* expose a CLI command. It defines the bounded aggregate payload that a future
|
|
8
|
+
* opt-in receipt route may accept after its own waitlist, durable-rate-limit,
|
|
9
|
+
* retention, and mail-provider controls exist.
|
|
10
|
+
*/
|
|
11
|
+
export const RECEIPT_SHARE_V0_VERSION = "0.1.0";
|
|
12
|
+
export const RECEIPT_SHARE_CARD_V0_KIND = "aibill.receipt_share_card";
|
|
13
|
+
export const RECEIPT_EMAIL_REQUEST_V0_KIND = "aibill.receipt_email_request";
|
|
14
|
+
const boundedUsdSchema = z.number().finite().nonnegative().max(1_000_000_000);
|
|
15
|
+
export const receiptShareCutV0Schema = z.object({
|
|
16
|
+
template: z.enum([
|
|
17
|
+
"route_lower_cost_model",
|
|
18
|
+
"narrow_context",
|
|
19
|
+
"cache_repeated_work",
|
|
20
|
+
"use_batch_api"
|
|
21
|
+
]),
|
|
22
|
+
modeledOpportunityUsd: boundedUsdSchema.positive(),
|
|
23
|
+
evidence: z.literal("modeled_not_verified")
|
|
24
|
+
}).strict();
|
|
25
|
+
export const receiptShareCardV0Schema = z.object({
|
|
26
|
+
kind: z.literal(RECEIPT_SHARE_CARD_V0_KIND),
|
|
27
|
+
schemaVersion: z.literal(RECEIPT_SHARE_V0_VERSION),
|
|
28
|
+
currency: z.literal("USD"),
|
|
29
|
+
windowDays: z.number().int().min(1).max(365),
|
|
30
|
+
// Demo/sample payloads are intentionally ineligible for real delivery.
|
|
31
|
+
mode: z.enum(["local-logs", "connected", "mixed"]),
|
|
32
|
+
/** Canonical three-basis stack, including blended:null/never_blended. */
|
|
33
|
+
financials: resultCardTotalsSchema,
|
|
34
|
+
providerCount: z.number().int().positive().max(64),
|
|
35
|
+
recordCount: z.number().int().positive().max(1_000_000),
|
|
36
|
+
confidence: z.enum(["verified", "estimated", "detected_unverified", "missing"]),
|
|
37
|
+
/** Fixed templates only: no client-provided title, model, operation, or markup. */
|
|
38
|
+
cuts: z.array(receiptShareCutV0Schema).max(3),
|
|
39
|
+
contentBoundary: z.object({
|
|
40
|
+
rawHistoryIncluded: z.literal(false),
|
|
41
|
+
localIdentifiersIncluded: z.literal(false),
|
|
42
|
+
clientMarkupIncluded: z.literal(false)
|
|
43
|
+
}).strict()
|
|
44
|
+
}).strict().superRefine((card, context) => {
|
|
45
|
+
if (card.providerCount > card.recordCount) {
|
|
46
|
+
context.addIssue({
|
|
47
|
+
code: "custom",
|
|
48
|
+
path: ["providerCount"],
|
|
49
|
+
message: "Provider count cannot exceed aggregate record count."
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
// Deliberately ASCII and single-line. The future route must additionally use
|
|
54
|
+
// the already-waitlisted normalized address as its authorization identity.
|
|
55
|
+
const receiptRecipientEmailV0Schema = z.string()
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.min(3)
|
|
59
|
+
.max(254)
|
|
60
|
+
.regex(/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/u);
|
|
61
|
+
export const receiptEmailRequestV0Schema = z.object({
|
|
62
|
+
kind: z.literal(RECEIPT_EMAIL_REQUEST_V0_KIND),
|
|
63
|
+
schemaVersion: z.literal(RECEIPT_SHARE_V0_VERSION),
|
|
64
|
+
recipientEmail: receiptRecipientEmailV0Schema,
|
|
65
|
+
/** Names both pieces of data and the third-party transit boundary. */
|
|
66
|
+
consent: z.literal("email_and_aggregate_card_via_mail_provider"),
|
|
67
|
+
card: receiptShareCardV0Schema
|
|
68
|
+
}).strict();
|
|
69
|
+
/**
|
|
70
|
+
* Projects the canonical local result card into a smaller aggregate-only
|
|
71
|
+
* transport card. Subscription labels, project rows, runways, record IDs,
|
|
72
|
+
* source metadata, prompts, paths, and raw history are intentionally omitted.
|
|
73
|
+
*/
|
|
74
|
+
export function buildReceiptShareCardV0(input) {
|
|
75
|
+
const resultCard = resultCardSchema.parse(input.resultCard);
|
|
76
|
+
return receiptShareCardV0Schema.parse({
|
|
77
|
+
kind: RECEIPT_SHARE_CARD_V0_KIND,
|
|
78
|
+
schemaVersion: RECEIPT_SHARE_V0_VERSION,
|
|
79
|
+
currency: "USD",
|
|
80
|
+
windowDays: resultCard.windowDays,
|
|
81
|
+
mode: resultCard.mode,
|
|
82
|
+
financials: resultCard.totals,
|
|
83
|
+
providerCount: input.providerCount,
|
|
84
|
+
recordCount: input.recordCount,
|
|
85
|
+
confidence: input.confidence,
|
|
86
|
+
cuts: input.cuts,
|
|
87
|
+
contentBoundary: {
|
|
88
|
+
rawHistoryIncluded: false,
|
|
89
|
+
localIdentifiersIncluded: false,
|
|
90
|
+
clientMarkupIncluded: false
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export const RECEIPT_EMAIL_MAX_SENDS_PER_EMAIL_24H = 1;
|
|
95
|
+
export const RECEIPT_EMAIL_MAX_SENDS_PER_IP_24H = 10;
|
|
96
|
+
const receiptEmailDeliveryStateV0Schema = z.object({
|
|
97
|
+
waitlistMember: z.boolean(),
|
|
98
|
+
emailSendsLast24Hours: z.number().int().nonnegative().max(1_000_000),
|
|
99
|
+
ipSendsLast24Hours: z.number().int().nonnegative().max(1_000_000)
|
|
100
|
+
}).strict();
|
|
101
|
+
/**
|
|
102
|
+
* Pure authorization policy for a future route. Counters must come from a
|
|
103
|
+
* durable shared store; this function creates no in-memory limiter and has no
|
|
104
|
+
* persistence or network side effects.
|
|
105
|
+
*/
|
|
106
|
+
export function decideReceiptEmailDeliveryV0(state) {
|
|
107
|
+
const parsed = receiptEmailDeliveryStateV0Schema.parse(state);
|
|
108
|
+
if (!parsed.waitlistMember)
|
|
109
|
+
return { status: "join_first", httpStatus: 403 };
|
|
110
|
+
if (parsed.emailSendsLast24Hours >= RECEIPT_EMAIL_MAX_SENDS_PER_EMAIL_24H) {
|
|
111
|
+
return { status: "rate_limited", httpStatus: 429, scope: "email" };
|
|
112
|
+
}
|
|
113
|
+
if (parsed.ipSendsLast24Hours >= RECEIPT_EMAIL_MAX_SENDS_PER_IP_24H) {
|
|
114
|
+
return { status: "rate_limited", httpStatus: 429, scope: "ip" };
|
|
115
|
+
}
|
|
116
|
+
return { status: "accepted", httpStatus: 202 };
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=receiptShare.js.map
|
package/dist/sourceRegistry.js
CHANGED
|
@@ -62,10 +62,17 @@ export const providerCatalog = [
|
|
|
62
62
|
label: "GitHub Copilot",
|
|
63
63
|
preferredSourceType: "provider_api",
|
|
64
64
|
preferredAccessMethod: "api",
|
|
65
|
-
|
|
65
|
+
// AI-credit billing (gross/discount/net) is implemented and shipped;
|
|
66
|
+
// billed dollars stay estimated until an AI_SPEND_COPILOT_RECONCILE_*
|
|
67
|
+
// reconciliation matches the billing page figure. Legacy premium-request
|
|
68
|
+
// billing is deliberately never fetched.
|
|
69
|
+
verifiedFields: [
|
|
70
|
+
"Copilot usage metrics",
|
|
71
|
+
"seat assignments and reported plan types",
|
|
72
|
+
"AI-credit gross, discount, and net billing usage report"
|
|
73
|
+
],
|
|
66
74
|
missingFields: [
|
|
67
75
|
"GitHub admin token reference and organization or enterprise slug",
|
|
68
|
-
"AI-credit gross, discount, and net billing",
|
|
69
76
|
"license invoice settlement"
|
|
70
77
|
]
|
|
71
78
|
},
|
|
@@ -177,7 +184,7 @@ export const providerConnectorCatalog = [
|
|
|
177
184
|
fallbackAuthModes: [],
|
|
178
185
|
scopes: ["fine-grained Administration: read", "organization or enterprise billing access"],
|
|
179
186
|
tokenStorage: "local_reference_only",
|
|
180
|
-
setupHint: "Use a local env reference to a GitHub token with read-only organization or enterprise Copilot metrics and
|
|
187
|
+
setupHint: "Use a local env reference to a GitHub token with read-only organization or enterprise Copilot metrics, seat, and AI-credit billing usage access; billed AI-credit dollars stay estimated until an AI_SPEND_COPILOT_RECONCILE_* reconciliation matches the billing page figure."
|
|
181
188
|
},
|
|
182
189
|
{
|
|
183
190
|
provider: "cursor",
|