@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 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
- unreleased source preview includes contracts for locally confirmed ownership,
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; the public npm v0.8.1 package predates this preview.
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
- throw new ActivitySnapshotCacheError("unsafe_directory", "The private aibill directory is not private.");
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 metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
626
- const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
627
- return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
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
- // Default $0.01 (dashboards round to cents); clamp to at most 1% of the
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
- const actual = fetchResult.pages.reduce((sum, page) => sum + extractArray(page, "seats").length, 0);
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 seats read access.",
1589
- "Seat records estimate monthly commitment; metrics records are usage evidence without direct spend allocation."
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 seats read access. ${rawMessage}`;
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
@@ -62,10 +62,17 @@ export const providerCatalog = [
62
62
  label: "GitHub Copilot",
63
63
  preferredSourceType: "provider_api",
64
64
  preferredAccessMethod: "api",
65
- verifiedFields: ["Copilot usage metrics", "seat assignments and reported plan types"],
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 seat access; AI-credit billing is not implemented."
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",
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@agent-finops/core",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
+ "funding": "https://asktilden.com",
4
5
  "type": "module",
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",