1import * as Neon from "@distilled.cloud/neon";
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";
9
10export interface OrganizationSpendingLimitProps {
11 /** Existing Neon organization ID. Restore and remove this control before changing organizations. */
12 orgId: string;
13 /** Positive integer monthly alert threshold in cents; zero and null are not supported. */
14 spendingLimitCents: number;
15}
16
17export interface OrganizationSpendingLimitAttributes {
18 /** Organization whose billing alert is managed. */
19 orgId: string;
20 /** Observed monthly threshold in cents, or null when no threshold is configured. */
21 spendingLimitCents: number | null;
22 /** Original threshold captured before adoption; null means Alchemy created the threshold. */
23 initialSpendingLimitCents: number | null;
24 /** Last managed threshold, retained across refreshes to detect external changes on destroy. */
25 managedSpendingLimitCents: number | null;
26}
27
28export interface OrganizationSpendingLimit extends Resource<
29 "Neon.OrganizationSpendingLimit",
30 OrganizationSpendingLimitProps,
31 OrganizationSpendingLimitAttributes,
32 never,
33 Providers
34> {}
35
36/**
37 * Manage an ALERT-ONLY monthly spending threshold on an existing organization.
38 * Neon emails at 80% and 100%; reaching the threshold NEVER stops compute or
39 * enforces a hard budget. Requires an organization admin on Launch or Scale.
40 * This resource does not create or delete organizations.
41 *
42 * Existing thresholds require explicit, preferably resource-scoped adoption.
43 * The engine durably captures the original threshold, including an absent
44 * (null) threshold, before the first write; destroy restores it. A newly created
45 * threshold is removed on destroy. All initial props must resolve during
46 * planning so that this snapshot can be persisted before reconciliation.
47 * Initial unresolved props or missing snapshots fail safely without mutation;
48 * first deploy with resolved values, then update using dependent values.
49 * Restore and remove this control before targeting another organization.
50 * Replacement generations without a snapshot also fail safely; use a new
51 * logical ID to plan a baseline for another organization before managing it.
52 * Cleanup refuses external drift rather than overwriting somebody else's policy.
53 * Keep the state store: losing it loses the ownership and restoration record.
54 * The API has no conditional writes; serialize management of each organization.
55 *
56 * ### Set a billing alert
57 * **Example:** Adopt only this organization's existing threshold
58 * ```typescript
59 * const alert = yield* Neon.OrganizationSpendingLimit("BillingAlert", {
60 * orgId: "org-example-12345678",
61 * spendingLimitCents: 10000,
62 * }).pipe(Alchemy.adopt(true));
63 * ```
64 *
65 * @resource
66 * @product Organization
67 */
68export const OrganizationSpendingLimit = Resource<OrganizationSpendingLimit>(
69 "Neon.OrganizationSpendingLimit",
70);
71
72export class InvalidOrganizationSpendingLimit extends Data.TaggedError(
73 "InvalidOrganizationSpendingLimit",
74)<{
75 /** Invalid configuration or unsafe cleanup condition. */
76 message: string;
77}> {}
78
79/** @internal */
80export const validateOrganizationSpendingLimit = (
81 props: OrganizationSpendingLimitProps,
82) =>
83 /^[a-z0-9-]{1,60}$/.test(props.orgId) &&
84 Number.isSafeInteger(props.spendingLimitCents) &&
85 props.spendingLimitCents > 0
86 ? Effect.void
87 : Effect.fail(
88 new InvalidOrganizationSpendingLimit({
89 message:
90 "An organization ID and a positive safe integer threshold in cents are required",
91 }),
92 );
93
94const observe = (orgId: string) =>
95 Neon.getOrganizationSpendingLimit({ org_id: orgId }).pipe(
96 Effect.map((value) => value.spending_limit_cents),
97 );
98
99/** Preserve the original baseline and last managed value across refreshes. @internal */
100export const organizationSpendingLimitSnapshot = (
101 orgId: string,
102 spendingLimitCents: number | null,
103 output?: OrganizationSpendingLimitAttributes,
104): OrganizationSpendingLimitAttributes => {
105 if (output) return { ...output, spendingLimitCents };
106 const snapshot = {
107 orgId,
108 spendingLimitCents,
109 initialSpendingLimitCents: spendingLimitCents,
110 managedSpendingLimitCents: spendingLimitCents,
111 };
112 return spendingLimitCents === null ? snapshot : Unowned(snapshot);
113};
114
115export const OrganizationSpendingLimitProvider = () =>
116 Provider.succeed(OrganizationSpendingLimit, {
117 nuke: { singleton: true },
118 stables: ["orgId", "initialSpendingLimitCents"],
119 diff: Effect.fn(function* ({ olds, news, output }) {
120 if (!isResolved(news)) return;
121 if (news.orgId !== (output?.orgId ?? olds.orgId)) {
122 return yield* new InvalidOrganizationSpendingLimit({
123 message:
124 "Restore and remove the existing spending control before changing organizations; the new organization requires a separately persisted baseline",
125 });
126 }
127 yield* validateOrganizationSpendingLimit(news);
128 if ((yield* observe(news.orgId)) !== news.spendingLimitCents) {
129 return { action: "update" } as const;
130 }
131 }),
132 read: Effect.fn(function* ({ olds, output }) {
133 const orgId = output?.orgId ?? olds?.orgId;
134 if (!orgId) return;
135 const value = yield* observe(orgId);
136 return organizationSpendingLimitSnapshot(orgId, value, output);
137 }),
138 reconcile: Effect.fn(function* ({ news, output, olds }) {
139 yield* validateOrganizationSpendingLimit(news);
140 if (
141 !output ||
142 output.initialSpendingLimitCents === undefined ||
143 output.managedSpendingLimitCents === undefined
144 ) {
145 return yield* new InvalidOrganizationSpendingLimit({
146 message:
147 "A persisted spending baseline is required before mutation; resolve all initial props during planning",
148 });
149 }
150 if (output.orgId !== news.orgId) {
151 return yield* new InvalidOrganizationSpendingLimit({
152 message: "Organization identity changed without replacement",
153 });
154 }
155 const observed = yield* observe(news.orgId);
156 if (
157 !olds &&
158 observed !== output.managedSpendingLimitCents &&
159 observed !== news.spendingLimitCents
160 ) {
161 return yield* new InvalidOrganizationSpendingLimit({
162 message:
163 "Spending threshold changed since its adoption baseline was captured",
164 });
165 }
166 if (observed !== news.spendingLimitCents) {
167 yield* Neon.setOrganizationSpendingLimit({
168 org_id: news.orgId,
169 spending_limit_cents: news.spendingLimitCents,
170 });
171 }
172 const value = yield* observe(news.orgId);
173 if (value !== news.spendingLimitCents) {
174 return yield* new InvalidOrganizationSpendingLimit({
175 message: "Spending threshold changed during reconciliation",
176 });
177 }
178 return {
179 orgId: news.orgId,
180 spendingLimitCents: value,
181 initialSpendingLimitCents: output.initialSpendingLimitCents,
182 managedSpendingLimitCents: news.spendingLimitCents,
183 };
184 }),
185 delete: Effect.fn(function* ({ output, olds }) {
186 const observed = yield* observe(output.orgId);
187 if (observed === output.initialSpendingLimitCents) return;
188 if (
189 observed !== output.managedSpendingLimitCents &&
190 observed !== olds.spendingLimitCents
191 ) {
192 return yield* new InvalidOrganizationSpendingLimit({
193 message:
194 "Refusing to overwrite an externally changed spending threshold during cleanup",
195 });
196 }
197 if (output.initialSpendingLimitCents === null) {
198 yield* Neon.deleteOrganizationSpendingLimit({ org_id: output.orgId });
199 } else {
200 yield* Neon.setOrganizationSpendingLimit({
201 org_id: output.orgId,
202 spending_limit_cents: output.initialSpendingLimitCents,
203 });
204 }
205 }),
206 });