1import * as billingbudgets from "@distilled.cloud/gcp/billingbudgets_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 billingAccountIdOf,
11 billingAccountParent,
12 budgetNameOf,
13 compact,
14 filterChanged,
15 findBudgetByDisplayName,
16 generatedDisplayName,
17 getBudget,
18 jsonEqual,
19 parseBudgetName,
20 resolveBillingAccountId,
21 resolveProjectRefs,
22 sortedStrings,
23} from "./internal.ts";
24
25export type Money = {
26 /**
27 * Whole units of the amount. For USD, `1` is one dollar.
28 */
29 units?: string;
30 /**
31 * ISO 4217 currency code. If set on create, it must match the billing
32 * account currency.
33 */
34 currencyCode?: string;
35 /**
36 * Nano (10^-9) units of the amount. Must be between -999,999,999 and
37 * 999,999,999.
38 */
39 nanos?: number;
40};
41
42export type BudgetAmount = {
43 /**
44 * Use last calendar period's actual spend as this period's budget.
45 * Cannot be combined with `customPeriod`. Empty object selects this
46 * mode.
47 */
48 lastPeriodAmount?: Record<string, never> | {};
49 /**
50 * Explicit budgeted amount. `currencyCode` is optional on create and
51 * must match the billing account (and the existing budget on update).
52 */
53 specifiedAmount?: Money;
54};
55
56export type CustomPeriod = {
57 /**
58 * Inclusive start date. Must be after 2017-01-01.
59 */
60 startDate?: { year?: number; month?: number; day?: number };
61 /**
62 * Inclusive end date. Budgets past this date are not processed. Omit
63 * to track all usage since `startDate`.
64 */
65 endDate?: { year?: number; month?: number; day?: number };
66};
67
68export type BudgetFilter = {
69 /**
70 * Folders and organizations whose usage is included, as
71 * `folders/{folderId}` or `organizations/{organizationId}`.
72 */
73 resourceAncestors?: string[];
74 /**
75 * Subaccounts of the form `billingAccounts/{account_id}`.
76 */
77 subaccounts?: string[];
78 /**
79 * Recurring calendar period (`MONTH`, `QUARTER`, `YEAR`). Default
80 * `MONTH` when `customPeriod` is unset.
81 */
82 calendarPeriod?: string;
83 /**
84 * Services of the form `services/{service_id}`.
85 */
86 services?: string[];
87 /**
88 * Credit types subtracted from gross cost when
89 * `creditTypesTreatment` is `INCLUDE_SPECIFIED_CREDITS`.
90 */
91 creditTypes?: string[];
92 /**
93 * How credits are treated (`INCLUDE_ALL_CREDITS`,
94 * `EXCLUDE_ALL_CREDITS`, `INCLUDE_SPECIFIED_CREDITS`).
95 */
96 creditTypesTreatment?: string;
97 /**
98 * Projects of the form `projects/{project}`. Omit to include every
99 * project the billing account pays for.
100 */
101 projects?: string[];
102 /**
103 * Static (non-recurring) time period. Mutually exclusive with
104 * `calendarPeriod`.
105 */
106 customPeriod?: CustomPeriod;
107 /**
108 * Single resource-label key mapped to a list of values. Usage from
109 * only those labeled resources is included.
110 */
111 labels?: Record<string, unknown[] | undefined>;
112};
113
114export type ThresholdRule = {
115 /**
116 * Alert when spend exceeds this fraction of the budget (`0.5` is 50%).
117 */
118 thresholdPercent?: number;
119 /**
120 * Spend basis (`CURRENT_SPEND` or `FORECASTED_SPEND`). Defaults to
121 * `CURRENT_SPEND`.
122 */
123 spendBasis?: string;
124};
125
126export type NotificationsRule = {
127 /**
128 * Schema version of Pub/Sub notifications. Only `"1.0"` is accepted.
129 */
130 schemaVersion?: string;
131 /**
132 * Pub/Sub topic for programmatic notifications, as
133 * `projects/{project}/topics/{topic}`.
134 */
135 pubsubTopic?: string;
136 /**
137 * When true, default IAM billing-account recipients are not emailed.
138 */
139 disableDefaultIamRecipients?: boolean;
140 /**
141 * When true and the budget tracks a single project, also notify that
142 * project's Owners.
143 */
144 enableProjectLevelRecipients?: boolean;
145 /**
146 * Cloud Monitoring email notification channels, as
147 * `projects/{project}/notificationChannels/{channel}`. Max 5.
148 */
149 monitoringNotificationChannels?: string[];
150};
151
152export type BillingBudgetProps = {
153 /**
154 * Billing account id (`XXXXXX-XXXXXX-XXXXXX` or
155 * `billingAccounts/{id}`). If omitted, Alchemy uses the billing
156 * account linked to the current project. Immutable — changing it
157 * replaces the budget.
158 */
159 billingAccountId?: string;
160 /**
161 * Server-assigned budget id (the `{budget}` segment of
162 * `billingAccounts/{billingAccount}/budgets/{budget}`). Omit on
163 * create; pass the observed id to update in place. Immutable —
164 * changing it replaces the budget.
165 */
166 budgetId?: string;
167 /**
168 * User-visible display name (60 characters), shown in budget alert
169 * emails. If omitted, a unique name is generated from the stack, stage,
170 * and logical id.
171 */
172 displayName?: string;
173 /**
174 * Budgeted amount. Required. Either `specifiedAmount` or
175 * `lastPeriodAmount`.
176 */
177 amount: BudgetAmount;
178 /**
179 * Filters that scope actual spend (projects, services, period, …).
180 */
181 budgetFilter?: BudgetFilter;
182 /**
183 * Thresholds that trigger alert emails and populate Pub/Sub payloads.
184 */
185 thresholdRules?: ThresholdRule[];
186 /**
187 * Notification targets (email channels and/or Pub/Sub).
188 */
189 notificationsRule?: NotificationsRule;
190 /**
191 * Who owns the budget (`ALL_USERS` or `BILLING_ACCOUNT`).
192 */
193 ownershipScope?: string;
194};
195
196export type BillingBudget = Resource<
197 "GCP.BillingBudgets.BillingBudget",
198 BillingBudgetProps,
199 {
200 /** Full resource name `billingAccounts/{billingAccount}/budgets/{budget}`. */
201 name: string;
202 /** Server-assigned budget id (last path segment). */
203 budgetId: string;
204 /** Billing account id. */
205 billingAccountId: string;
206 /** Display name. */
207 displayName: string | undefined;
208 /** Budgeted amount. */
209 amount: BudgetAmount | undefined;
210 /** Spend filter. */
211 budgetFilter: BudgetFilter | undefined;
212 /** Threshold rules. */
213 thresholdRules: ThresholdRule[];
214 /** Notification rules. */
215 notificationsRule: NotificationsRule | undefined;
216 /** Ownership scope. */
217 ownershipScope: string | undefined;
218 /** Server etag for read-modify-write. */
219 etag: string | undefined;
220 },
221 never,
222 Providers
223>;
224
225/**
226 * A Cloud Billing budget.
227 *
228 * Budgets have no labels field and the display name appears in alert
229 * emails, so Alchemy finds its budget by display name within the current
230 * project's scope: `read` reports a budget as owned only when it carries
231 * the generated name (an explicit `displayName` is reported as unowned —
232 * adopt it with `--adopt`), and nuke cannot discover budgets. The
233 * physical id is a server-assigned UUID. Changing `billingAccountId` or
234 * `budgetId` replaces the budget. Amount, filter, thresholds,
235 * notifications, and display name update in place.
236 *
237 * ### Creating a Budget
238 * **Example:** Project-scoped monthly budget
239 * ```typescript
240 * const budget = yield* GCP.BillingBudgets.BillingBudget("Spend", {
241 * displayName: "cap",
242 * amount: { specifiedAmount: { currencyCode: "USD", units: "10" } },
243 * budgetFilter: {
244 * projects: ["projects/my-project"],
245 * calendarPeriod: "MONTH",
246 * },
247 * thresholdRules: [{ thresholdPercent: 0.5, spendBasis: "CURRENT_SPEND" }],
248 * });
249 * ```
250 *
251 * **Example:** Last-period amount
252 * ```typescript
253 * const budget = yield* GCP.BillingBudgets.BillingBudget("Spend", {
254 * amount: { lastPeriodAmount: {} },
255 * budgetFilter: { calendarPeriod: "MONTH" },
256 * });
257 * ```
258 *
259 * ### Updating a Budget
260 * **Example:** Raise the cap and add a forecasted threshold
261 * ```typescript
262 * const budget = yield* GCP.BillingBudgets.BillingBudget("Spend", {
263 * displayName: "cap",
264 * amount: { specifiedAmount: { currencyCode: "USD", units: "25" } },
265 * budgetFilter: {
266 * projects: ["projects/my-project"],
267 * calendarPeriod: "MONTH",
268 * },
269 * thresholdRules: [
270 * { thresholdPercent: 0.5, spendBasis: "CURRENT_SPEND" },
271 * { thresholdPercent: 0.9, spendBasis: "FORECASTED_SPEND" },
272 * ],
273 * });
274 * ```
275 *
276 * @resource
277 * @category BillingBudgets
278 */
279export const BillingBudget = Resource<BillingBudget>(
280 "GCP.BillingBudgets.BillingBudget",
281);
282
283export class BillingBudgetNotResolved extends Data.TaggedError(
284 "GCP.BillingBudgets.BillingBudgetNotResolved",
285)<{
286 name: string;
287}> {}
288
289const toMoney = (
290 money: billingbudgets.GoogleTypeMoney | Money | undefined,
291): Money | undefined => {
292 if (money === undefined) return undefined;
293 if (
294 money.units === undefined &&
295 money.currencyCode === undefined &&
296 money.nanos === undefined
297 ) {
298 return undefined;
299 }
300 return compact({
301 units: money.units,
302 currencyCode: money.currencyCode,
303 nanos: money.nanos === 0 ? undefined : money.nanos,
304 });
305};
306
307const toAmount = (
308 amount:
309 | billingbudgets.GoogleCloudBillingBudgetsV1BudgetAmount
310 | BudgetAmount
311 | undefined,
312): BudgetAmount | undefined => {
313 if (amount === undefined) return undefined;
314 const specifiedAmount = toMoney(amount.specifiedAmount);
315 const lastPeriodAmount =
316 amount.lastPeriodAmount !== undefined ? {} : undefined;
317 if (specifiedAmount === undefined && lastPeriodAmount === undefined) {
318 return undefined;
319 }
320 return compact({ specifiedAmount, lastPeriodAmount });
321};
322
323const toDate = (
324 date: billingbudgets.GoogleTypeDate | CustomPeriod["startDate"] | undefined,
325): CustomPeriod["startDate"] | undefined => {
326 if (date === undefined) return undefined;
327 if (
328 date.year === undefined &&
329 date.month === undefined &&
330 date.day === undefined
331 ) {
332 return undefined;
333 }
334 return compact({ year: date.year, month: date.month, day: date.day });
335};
336
337const toCustomPeriod = (
338 period:
339 | billingbudgets.GoogleCloudBillingBudgetsV1CustomPeriod
340 | CustomPeriod
341 | undefined,
342): CustomPeriod | undefined => {
343 if (period === undefined) return undefined;
344 const startDate = toDate(period.startDate);
345 const endDate = toDate(period.endDate);
346 if (startDate === undefined && endDate === undefined) return undefined;
347 return compact({ startDate, endDate });
348};
349
350const toFilter = (
351 filter:
352 | billingbudgets.GoogleCloudBillingBudgetsV1Filter
353 | BudgetFilter
354 | undefined,
355): BudgetFilter | undefined => {
356 if (filter === undefined) return undefined;
357 const next = compact({
358 resourceAncestors: filter.resourceAncestors
359 ? sortedStrings(filter.resourceAncestors)
360 : undefined,
361 subaccounts: filter.subaccounts
362 ? sortedStrings(filter.subaccounts)
363 : undefined,
364 calendarPeriod: filter.calendarPeriod,
365 services: filter.services ? sortedStrings(filter.services) : undefined,
366 creditTypes: filter.creditTypes
367 ? sortedStrings(filter.creditTypes)
368 : undefined,
369 creditTypesTreatment: filter.creditTypesTreatment,
370 projects: filter.projects ? sortedStrings(filter.projects) : undefined,
371 customPeriod: toCustomPeriod(filter.customPeriod),
372 labels: filter.labels,
373 });
374 return Object.keys(next).length > 0 ? next : undefined;
375};
376
377const toThresholdRules = (
378 rules:
379 | readonly billingbudgets.GoogleCloudBillingBudgetsV1ThresholdRule[]
380 | readonly ThresholdRule[]
381 | undefined,
382): ThresholdRule[] =>
383 (rules ?? [])
384 .map((rule) =>
385 compact({
386 thresholdPercent: rule.thresholdPercent,
387 spendBasis: rule.spendBasis ?? "CURRENT_SPEND",
388 }),
389 )
390 .sort((left, right) => {
391 const percent =
392 (left.thresholdPercent ?? 0) - (right.thresholdPercent ?? 0);
393 if (percent !== 0) return percent;
394 return (left.spendBasis ?? "").localeCompare(right.spendBasis ?? "");
395 });
396
397const toNotifications = (
398 rule:
399 | billingbudgets.GoogleCloudBillingBudgetsV1NotificationsRule
400 | NotificationsRule
401 | undefined,
402): NotificationsRule | undefined => {
403 if (rule === undefined) return undefined;
404 const next = compact({
405 schemaVersion: rule.schemaVersion,
406 pubsubTopic: rule.pubsubTopic,
407 disableDefaultIamRecipients: rule.disableDefaultIamRecipients,
408 enableProjectLevelRecipients: rule.enableProjectLevelRecipients,
409 monitoringNotificationChannels: rule.monitoringNotificationChannels
410 ? sortedStrings(rule.monitoringNotificationChannels)
411 : undefined,
412 });
413 return Object.keys(next).length > 0 ? next : undefined;
414};
415
416const toAttrs = (
417 budget: billingbudgets.GoogleCloudBillingBudgetsV1Budget,
418 billingAccountId: string,
419) => {
420 const name = budget.name ?? "";
421 const parsed = parseBudgetName(name, billingAccountId);
422 return {
423 name,
424 budgetId: parsed.budgetId,
425 billingAccountId: parsed.billingAccountId || billingAccountId,
426 displayName: budget.displayName,
427 amount: toAmount(budget.amount),
428 budgetFilter: toFilter(budget.budgetFilter),
429 thresholdRules: toThresholdRules(budget.thresholdRules),
430 notificationsRule: toNotifications(budget.notificationsRule),
431 ownershipScope: budget.ownershipScope,
432 etag: budget.etag,
433 };
434};
435
436const desiredAmount = (
437 news: BudgetAmount,
438 observed: BudgetAmount | undefined,
439): billingbudgets.GoogleCloudBillingBudgetsV1BudgetAmount => {
440 if (news.lastPeriodAmount !== undefined) {
441 return { lastPeriodAmount: {} };
442 }
443 const specified = news.specifiedAmount ?? {};
444 return {
445 specifiedAmount: compact({
446 units: specified.units,
447 currencyCode:
448 specified.currencyCode ??
449 observed?.specifiedAmount?.currencyCode ??
450 "USD",
451 nanos: specified.nanos,
452 }),
453 };
454};
455
456const toCreateBody = (
457 news: BillingBudgetProps,
458 displayName: string,
459 observed: BudgetAmount | undefined,
460): billingbudgets.GoogleCloudBillingBudgetsV1Budget =>
461 compact({
462 displayName,
463 amount: desiredAmount(news.amount, observed),
464 budgetFilter: news.budgetFilter
465 ? (toFilter(news.budgetFilter) as
466 | billingbudgets.GoogleCloudBillingBudgetsV1Filter
467 | undefined)
468 : undefined,
469 thresholdRules:
470 news.thresholdRules !== undefined
471 ? toThresholdRules(news.thresholdRules)
472 : undefined,
473 notificationsRule: news.notificationsRule
474 ? (toNotifications(news.notificationsRule) as
475 | billingbudgets.GoogleCloudBillingBudgetsV1NotificationsRule
476 | undefined)
477 : undefined,
478 ownershipScope: news.ownershipScope,
479 }) as billingbudgets.GoogleCloudBillingBudgetsV1Budget;
480
481export const BillingBudgetProvider = () =>
482 Provider.succeed(BillingBudget, {
483 stables: ["name", "budgetId", "billingAccountId"],
484
485 diff: Effect.fn(function* ({ news, olds, output }) {
486 if (!isResolved(news)) return undefined;
487 const previousId = olds?.budgetId ?? output?.budgetId;
488 const idChanged =
489 previousId !== undefined &&
490 news.budgetId !== undefined &&
491 news.budgetId !== previousId;
492 const previousAccount =
493 olds?.billingAccountId ?? output?.billingAccountId;
494 const accountChanged =
495 previousAccount !== undefined &&
496 news.billingAccountId !== undefined &&
497 billingAccountIdOf(news.billingAccountId) !==
498 billingAccountIdOf(previousAccount);
499 if (!idChanged && !accountChanged) return undefined;
500 return { action: "replace" as const, deleteFirst: true };
501 }),
502
503 read: Effect.fn(function* ({ id, olds, output }) {
504 const billingAccountId = yield* resolveBillingAccountId(
505 olds?.billingAccountId,
506 output?.billingAccountId,
507 );
508 const budgetId = output?.budgetId ?? olds?.budgetId;
509 const name =
510 output?.name ??
511 (budgetId ? budgetNameOf(billingAccountId, budgetId) : "");
512 const generated = yield* generatedDisplayName(id);
513 const existing =
514 (yield* getBudget(name)) ??
515 (yield* findBudgetByDisplayName(
516 billingAccountParent(billingAccountId),
517 olds?.displayName ?? output?.displayName ?? generated,
518 ));
519 if (existing === undefined) return undefined;
520 const attrs = toAttrs(existing, billingAccountId);
521 return existing.displayName === generated ? attrs : Unowned(attrs);
522 }),
523
524 reconcile: Effect.fn(function* ({ id, news, output }) {
525 const billingAccountId = yield* resolveBillingAccountId(
526 news.billingAccountId,
527 output?.billingAccountId,
528 );
529 const parent = billingAccountParent(billingAccountId);
530 const desiredDisplayName =
531 news.displayName ??
532 output?.displayName ??
533 (yield* generatedDisplayName(id));
534 const resolvedProjects = yield* resolveProjectRefs(
535 news.budgetFilter?.projects,
536 );
537 const desiredNews =
538 resolvedProjects === undefined
539 ? news
540 : {
541 ...news,
542 budgetFilter: {
543 ...news.budgetFilter,
544 projects: resolvedProjects,
545 },
546 };
547
548 let current = yield* getBudget(
549 output?.name ??
550 (news.budgetId ? budgetNameOf(billingAccountId, news.budgetId) : ""),
551 );
552 if (current === undefined && news.budgetId === undefined) {
553 current = yield* findBudgetByDisplayName(parent, desiredDisplayName);
554 }
555
556 if (current === undefined) {
557 const created = yield* billingbudgets
558 .createBillingAccountsBudgets({
559 parent,
560 body: toCreateBody(desiredNews, desiredDisplayName, undefined),
561 })
562 .pipe(
563 Effect.catchTag("Conflict", () =>
564 findBudgetByDisplayName(parent, desiredDisplayName),
565 ),
566 );
567 current = created ?? undefined;
568 }
569
570 if (current === undefined || !current.name) {
571 return yield* new BillingBudgetNotResolved({
572 name: output?.name ?? `${parent}/budgets`,
573 });
574 }
575
576 const observedAmount = toAmount(current.amount);
577 const desiredBody = toCreateBody(
578 desiredNews,
579 desiredDisplayName,
580 observedAmount,
581 );
582 const mask: string[] = [];
583 if ((current.displayName ?? "") !== desiredDisplayName) {
584 mask.push("displayName");
585 }
586 if (!jsonEqual(toAmount(current.amount), toAmount(desiredBody.amount))) {
587 mask.push("amount");
588 }
589 if (
590 desiredNews.budgetFilter !== undefined &&
591 filterChanged(
592 toFilter(current.budgetFilter) as Record<string, unknown> | undefined,
593 toFilter(desiredNews.budgetFilter) as
594 | Record<string, unknown>
595 | undefined,
596 )
597 ) {
598 mask.push("budgetFilter");
599 }
600 if (
601 desiredNews.thresholdRules !== undefined &&
602 !jsonEqual(
603 toThresholdRules(current.thresholdRules),
604 toThresholdRules(desiredNews.thresholdRules),
605 )
606 ) {
607 mask.push("thresholdRules");
608 }
609 if (
610 desiredNews.notificationsRule !== undefined &&
611 !jsonEqual(
612 toNotifications(current.notificationsRule),
613 toNotifications(desiredNews.notificationsRule),
614 )
615 ) {
616 mask.push("notificationsRule");
617 }
618 if (
619 desiredNews.ownershipScope !== undefined &&
620 (current.ownershipScope ?? "") !== desiredNews.ownershipScope
621 ) {
622 mask.push("ownershipScope");
623 }
624
625 if (mask.length > 0) {
626 current = yield* billingbudgets.patchBillingAccountsBudgets({
627 name: current.name,
628 updateMask: mask.join(","),
629 body: {
630 ...desiredBody,
631 etag: current.etag,
632 },
633 });
634 }
635
636 return toAttrs(current, billingAccountId);
637 }),
638
639 delete: Effect.fn(function* ({ output }) {
640 if (!output.name) return;
641 yield* billingbudgets
642 .deleteBillingAccountsBudgets({ name: output.name })
643 .pipe(
644 Effect.catchTag(["NotFound", "BudgetNotFound"], () => Effect.void),
645 );
646 }),
647 });