@centerforagenticai/pi-multi-account 0.1.1
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/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective-dated USD rate records for canonical managed accounts.
|
|
3
|
+
*
|
|
4
|
+
* The account is the billing boundary: one canonical accountId owns one
|
|
5
|
+
* ordered sequence of rate records. Selecting a catalog preset COPIES its
|
|
6
|
+
* terms -- provider, account type, label, catalog version, and provenance --
|
|
7
|
+
* into the new record, so a later catalog edit never rewrites an existing
|
|
8
|
+
* record or a closed historical report. A rate stays effective until the next
|
|
9
|
+
* record for the same account begins; this module never inserts a default
|
|
10
|
+
* end, renewal, or effective instant, and an explicit `monthlyUsd: 0` is a
|
|
11
|
+
* valid rate, distinct from no rate being recorded at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
CatalogAccountType,
|
|
16
|
+
CatalogProvider,
|
|
17
|
+
SubscriptionPlanPreset,
|
|
18
|
+
} from "./subscription-plan-catalog.js";
|
|
19
|
+
import {
|
|
20
|
+
CATALOG_ACCOUNT_TYPES,
|
|
21
|
+
CATALOG_PROVIDERS,
|
|
22
|
+
MAX_PRESET_ID_LENGTH,
|
|
23
|
+
PRESET_ID_PATTERN,
|
|
24
|
+
} from "./subscription-plan-catalog.js";
|
|
25
|
+
|
|
26
|
+
const MAX_ACCOUNT_ID_LENGTH = 128;
|
|
27
|
+
const MAX_LABEL_LENGTH = 64;
|
|
28
|
+
const MAX_PROVENANCE_LENGTH = 256;
|
|
29
|
+
|
|
30
|
+
export class AccountRateHistoryError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(`[account-rate-history] ${message}`);
|
|
33
|
+
this.name = "AccountRateHistoryError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AccountRateRecord {
|
|
38
|
+
readonly accountId: string;
|
|
39
|
+
readonly provider: CatalogProvider;
|
|
40
|
+
readonly accountType: CatalogAccountType;
|
|
41
|
+
readonly monthlyUsd: number;
|
|
42
|
+
/** RFC 3339 instant with an explicit `Z` or numeric UTC offset. */
|
|
43
|
+
readonly effectiveFrom: string;
|
|
44
|
+
readonly presetId: string;
|
|
45
|
+
readonly presetLabel: string;
|
|
46
|
+
readonly catalogVersion: number;
|
|
47
|
+
readonly provenance: string;
|
|
48
|
+
/** Present only when the operator supplied an explicit rate override. */
|
|
49
|
+
readonly overrideProvenance?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isBoundedString(value: unknown, maxLength: number): value is string {
|
|
57
|
+
return (
|
|
58
|
+
typeof value === "string" &&
|
|
59
|
+
value.trim().length > 0 &&
|
|
60
|
+
value.length <= maxLength
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A strict RFC 3339 timestamp: date, `T`, time, and either `Z` or a signed
|
|
66
|
+
* `HH:MM` numeric offset. An offsetless date-time is rejected, matching the
|
|
67
|
+
* product's custom-range timestamp rule.
|
|
68
|
+
*/
|
|
69
|
+
const RFC3339_INSTANT_PATTERN =
|
|
70
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
|
|
71
|
+
|
|
72
|
+
function parseInstantMs(value: string): number {
|
|
73
|
+
const epochMs = Date.parse(value);
|
|
74
|
+
if (!Number.isFinite(epochMs)) {
|
|
75
|
+
throw new AccountRateHistoryError(
|
|
76
|
+
`"${value}" is not a valid RFC 3339 instant.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return epochMs;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Validates an RFC 3339 instant string and returns it unchanged. */
|
|
83
|
+
export function assertValidEffectiveFrom(value: unknown, context: string): string {
|
|
84
|
+
if (typeof value !== "string" || !RFC3339_INSTANT_PATTERN.test(value)) {
|
|
85
|
+
throw new AccountRateHistoryError(
|
|
86
|
+
`${context} must be an RFC 3339 timestamp with an explicit "Z" or numeric offset.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
parseInstantMs(value);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assertValidAccountId(value: unknown, context: string): string {
|
|
94
|
+
if (!isBoundedString(value, MAX_ACCOUNT_ID_LENGTH)) {
|
|
95
|
+
throw new AccountRateHistoryError(
|
|
96
|
+
`${context}.accountId must be a non-empty string of at most ${MAX_ACCOUNT_ID_LENGTH} characters.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertValidPresetId(value: unknown, context: string): string {
|
|
103
|
+
if (
|
|
104
|
+
!isBoundedString(value, MAX_PRESET_ID_LENGTH) ||
|
|
105
|
+
!PRESET_ID_PATTERN.test(value)
|
|
106
|
+
) {
|
|
107
|
+
throw new AccountRateHistoryError(
|
|
108
|
+
`${context}.presetId must be a lowercase, hyphen-separated identifier of at most ${MAX_PRESET_ID_LENGTH} characters.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function assertValidMonthlyUsd(value: unknown, context: string): number {
|
|
115
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
116
|
+
throw new AccountRateHistoryError(
|
|
117
|
+
`${context}.monthlyUsd must be a finite, non-negative USD amount.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assertValidCatalogVersion(value: unknown, context: string): number {
|
|
124
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
125
|
+
throw new AccountRateHistoryError(
|
|
126
|
+
`${context}.catalogVersion must be a positive integer.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function assertValidProvenance(
|
|
133
|
+
value: unknown,
|
|
134
|
+
context: string,
|
|
135
|
+
field: string,
|
|
136
|
+
): string {
|
|
137
|
+
if (!isBoundedString(value, MAX_PROVENANCE_LENGTH)) {
|
|
138
|
+
throw new AccountRateHistoryError(
|
|
139
|
+
`${context}.${field} must be a non-empty string of at most ${MAX_PROVENANCE_LENGTH} characters.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertValidPresetLabel(value: unknown, context: string): string {
|
|
146
|
+
if (!isBoundedString(value, MAX_LABEL_LENGTH)) {
|
|
147
|
+
throw new AccountRateHistoryError(
|
|
148
|
+
`${context}.presetLabel must be a non-empty string of at most ${MAX_LABEL_LENGTH} characters.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function assertValidProvider(value: unknown, context: string): CatalogProvider {
|
|
155
|
+
if (
|
|
156
|
+
typeof value !== "string" ||
|
|
157
|
+
!(CATALOG_PROVIDERS as readonly string[]).includes(value)
|
|
158
|
+
) {
|
|
159
|
+
throw new AccountRateHistoryError(
|
|
160
|
+
`${context}.provider must be one of [${CATALOG_PROVIDERS.join(", ")}].`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return value as CatalogProvider;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function assertValidAccountType(value: unknown, context: string): CatalogAccountType {
|
|
167
|
+
if (
|
|
168
|
+
typeof value !== "string" ||
|
|
169
|
+
!(CATALOG_ACCOUNT_TYPES as readonly string[]).includes(value)
|
|
170
|
+
) {
|
|
171
|
+
throw new AccountRateHistoryError(
|
|
172
|
+
`${context}.accountType must be one of [${CATALOG_ACCOUNT_TYPES.join(", ")}].`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return value as CatalogAccountType;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const RECORD_FIELDS = new Set([
|
|
179
|
+
"accountId",
|
|
180
|
+
"provider",
|
|
181
|
+
"accountType",
|
|
182
|
+
"monthlyUsd",
|
|
183
|
+
"effectiveFrom",
|
|
184
|
+
"presetId",
|
|
185
|
+
"presetLabel",
|
|
186
|
+
"catalogVersion",
|
|
187
|
+
"provenance",
|
|
188
|
+
"overrideProvenance",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validates one already-persisted rate record, allowlisting exactly the
|
|
193
|
+
* fields above and rejecting any other field. Structural only: never
|
|
194
|
+
* resolves `presetId` against a live catalog, so a stored record stays valid
|
|
195
|
+
* -- and unchanged -- after the catalog that produced it is edited.
|
|
196
|
+
*/
|
|
197
|
+
export function parseAccountRateRecord(
|
|
198
|
+
value: unknown,
|
|
199
|
+
context: string,
|
|
200
|
+
): AccountRateRecord {
|
|
201
|
+
if (!isRecord(value)) {
|
|
202
|
+
throw new AccountRateHistoryError(`${context} must be a JSON object.`);
|
|
203
|
+
}
|
|
204
|
+
const unknownKeys = Object.keys(value).filter((key) => !RECORD_FIELDS.has(key));
|
|
205
|
+
if (unknownKeys.length > 0) {
|
|
206
|
+
throw new AccountRateHistoryError(
|
|
207
|
+
`${context} has unsupported field "${unknownKeys[0]}".`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const hasOverrideProvenance = value["overrideProvenance"] !== undefined;
|
|
211
|
+
const record: AccountRateRecord = {
|
|
212
|
+
accountId: assertValidAccountId(value["accountId"], context),
|
|
213
|
+
provider: assertValidProvider(value["provider"], context),
|
|
214
|
+
accountType: assertValidAccountType(value["accountType"], context),
|
|
215
|
+
monthlyUsd: assertValidMonthlyUsd(value["monthlyUsd"], context),
|
|
216
|
+
effectiveFrom: assertValidEffectiveFrom(
|
|
217
|
+
value["effectiveFrom"],
|
|
218
|
+
`${context}.effectiveFrom`,
|
|
219
|
+
),
|
|
220
|
+
presetId: assertValidPresetId(value["presetId"], context),
|
|
221
|
+
presetLabel: assertValidPresetLabel(value["presetLabel"], context),
|
|
222
|
+
catalogVersion: assertValidCatalogVersion(value["catalogVersion"], context),
|
|
223
|
+
provenance: assertValidProvenance(value["provenance"], context, "provenance"),
|
|
224
|
+
...(hasOverrideProvenance
|
|
225
|
+
? {
|
|
226
|
+
overrideProvenance: assertValidProvenance(
|
|
227
|
+
value["overrideProvenance"],
|
|
228
|
+
context,
|
|
229
|
+
"overrideProvenance",
|
|
230
|
+
),
|
|
231
|
+
}
|
|
232
|
+
: {}),
|
|
233
|
+
};
|
|
234
|
+
return Object.freeze(record);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface CreateAccountRateRecordInput {
|
|
238
|
+
readonly accountId: string;
|
|
239
|
+
readonly preset: SubscriptionPlanPreset;
|
|
240
|
+
readonly catalogVersion: number;
|
|
241
|
+
readonly effectiveFrom: string;
|
|
242
|
+
readonly monthlyUsdOverride?: number;
|
|
243
|
+
readonly overrideProvenance?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Builds one immutable rate record by COPYING the preset's terms -- provider,
|
|
248
|
+
* account type, label, provenance -- and the caller-supplied catalog version
|
|
249
|
+
* into plain fields on the record. The record holds no reference back to the
|
|
250
|
+
* preset or catalog, so a later catalog edit or removal cannot change it.
|
|
251
|
+
*/
|
|
252
|
+
export function createAccountRateRecord(
|
|
253
|
+
input: CreateAccountRateRecordInput,
|
|
254
|
+
): AccountRateRecord {
|
|
255
|
+
const context = "accountRateRecord";
|
|
256
|
+
const accountId = assertValidAccountId(input.accountId, context);
|
|
257
|
+
const effectiveFrom = assertValidEffectiveFrom(
|
|
258
|
+
input.effectiveFrom,
|
|
259
|
+
`${context}.effectiveFrom`,
|
|
260
|
+
);
|
|
261
|
+
const catalogVersion = assertValidCatalogVersion(input.catalogVersion, context);
|
|
262
|
+
const hasOverride = input.monthlyUsdOverride !== undefined;
|
|
263
|
+
if (hasOverride !== (input.overrideProvenance !== undefined)) {
|
|
264
|
+
throw new AccountRateHistoryError(
|
|
265
|
+
`${context}.monthlyUsdOverride and ${context}.overrideProvenance must be supplied together.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const monthlyUsd = hasOverride
|
|
269
|
+
? assertValidMonthlyUsd(input.monthlyUsdOverride, context)
|
|
270
|
+
: input.preset.monthlyUsd;
|
|
271
|
+
const overrideProvenance = hasOverride
|
|
272
|
+
? assertValidProvenance(input.overrideProvenance, context, "overrideProvenance")
|
|
273
|
+
: undefined;
|
|
274
|
+
const record: AccountRateRecord = {
|
|
275
|
+
accountId,
|
|
276
|
+
provider: input.preset.provider,
|
|
277
|
+
accountType: input.preset.accountType,
|
|
278
|
+
monthlyUsd,
|
|
279
|
+
effectiveFrom,
|
|
280
|
+
presetId: input.preset.id,
|
|
281
|
+
presetLabel: input.preset.label,
|
|
282
|
+
catalogVersion,
|
|
283
|
+
provenance: input.preset.provenance,
|
|
284
|
+
...(overrideProvenance !== undefined ? { overrideProvenance } : {}),
|
|
285
|
+
};
|
|
286
|
+
return Object.freeze(record);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One candidate going into {@link normalizeAccountRateHistory}. `effectiveUntil`
|
|
291
|
+
* is an optional, input-only, EXPLICIT upper bound the caller asserts for this
|
|
292
|
+
* record's coverage; it is exclusive, never inferred when omitted, and never
|
|
293
|
+
* copied onto the normalized {@link AccountRateRecord}. It lets a caller
|
|
294
|
+
* reconstructing history from known bounds catch an inconsistent, overlapping
|
|
295
|
+
* claim before it reaches storage.
|
|
296
|
+
*/
|
|
297
|
+
export interface AccountRateHistoryCandidate {
|
|
298
|
+
readonly record: AccountRateRecord;
|
|
299
|
+
readonly effectiveUntil?: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Orders one account's candidate rate records ascending by `effectiveFrom`
|
|
304
|
+
* instant, and rejects:
|
|
305
|
+
*
|
|
306
|
+
* - more than one accountId in the input (each call normalizes one account);
|
|
307
|
+
* - two records that resolve to the same effective instant, even when their
|
|
308
|
+
* raw strings differ (e.g. `Z` vs `+00:00`); and
|
|
309
|
+
* - an explicit `effectiveUntil` bound that does not follow its own record's
|
|
310
|
+
* `effectiveFrom`, or that overlaps a later record's `effectiveFrom`.
|
|
311
|
+
*
|
|
312
|
+
* Never inserts a default end, renewal, or effective instant: an omitted
|
|
313
|
+
* `effectiveUntil` places no upper bound on a record other than the next
|
|
314
|
+
* record's `effectiveFrom`, once sorted.
|
|
315
|
+
*/
|
|
316
|
+
export function normalizeAccountRateHistory(
|
|
317
|
+
candidates: readonly AccountRateHistoryCandidate[],
|
|
318
|
+
): readonly AccountRateRecord[] {
|
|
319
|
+
if (candidates.length === 0) return Object.freeze([]);
|
|
320
|
+
const accountId = candidates[0]!.record.accountId;
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
if (candidate.record.accountId !== accountId) {
|
|
323
|
+
throw new AccountRateHistoryError(
|
|
324
|
+
"normalizeAccountRateHistory received records for more than one accountId.",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (candidate.effectiveUntil !== undefined) {
|
|
328
|
+
assertValidEffectiveFrom(
|
|
329
|
+
candidate.effectiveUntil,
|
|
330
|
+
`accountRateHistory.${accountId}.effectiveUntil`,
|
|
331
|
+
);
|
|
332
|
+
if (
|
|
333
|
+
parseInstantMs(candidate.effectiveUntil) <=
|
|
334
|
+
parseInstantMs(candidate.record.effectiveFrom)
|
|
335
|
+
) {
|
|
336
|
+
throw new AccountRateHistoryError(
|
|
337
|
+
`Account "${accountId}" has an explicit effectiveUntil ("${candidate.effectiveUntil}") that does not follow its own effectiveFrom ("${candidate.record.effectiveFrom}").`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const sorted = [...candidates].sort(
|
|
344
|
+
(a, b) =>
|
|
345
|
+
parseInstantMs(a.record.effectiveFrom) - parseInstantMs(b.record.effectiveFrom),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
for (let i = 0; i < sorted.length; i += 1) {
|
|
349
|
+
const current = sorted[i]!;
|
|
350
|
+
const currentFrom = parseInstantMs(current.record.effectiveFrom);
|
|
351
|
+
const currentUntil =
|
|
352
|
+
current.effectiveUntil !== undefined
|
|
353
|
+
? parseInstantMs(current.effectiveUntil)
|
|
354
|
+
: undefined;
|
|
355
|
+
for (let j = i + 1; j < sorted.length; j += 1) {
|
|
356
|
+
const other = sorted[j]!;
|
|
357
|
+
const otherFrom = parseInstantMs(other.record.effectiveFrom);
|
|
358
|
+
if (otherFrom === currentFrom) {
|
|
359
|
+
throw new AccountRateHistoryError(
|
|
360
|
+
`Account "${accountId}" has duplicate effectiveFrom "${current.record.effectiveFrom}".`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (currentUntil !== undefined && otherFrom < currentUntil) {
|
|
364
|
+
throw new AccountRateHistoryError(
|
|
365
|
+
`Account "${accountId}" has overlapping explicit intervals: the record effective "${current.record.effectiveFrom}" claims coverage until "${current.effectiveUntil}", which overlaps the record effective "${other.record.effectiveFrom}".`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return Object.freeze(sorted.map((candidate) => candidate.record));
|
|
372
|
+
}
|