Monorepo for Aesthetic.Computer
aesthetic.computer
1// Billing Aggregation, 25.12.30
2// 🧾 Aggregates billing data from AC's SaaS providers
3// Credentials stored in MongoDB to avoid Netlify's 4KB env var limit
4// Endpoint: /api/billing
5
6import { respond } from "../../backend/http.mjs";
7import { connect } from "../../backend/database.mjs";
8import { authorize, hasAdmin } from "../../backend/authorization.mjs";
9
10const dev = process.env.CONTEXT === "dev";
11
12// Cache credentials in memory for warm function invocations
13let cachedCredentials = null;
14
15async function getBillingCredentials() {
16 if (cachedCredentials) return cachedCredentials;
17
18 const { db } = await connect();
19 const secrets = await db.collection("secrets").findOne({ _id: "billing" });
20
21 if (!secrets) {
22 console.log("Billing credentials not found in database - using static estimates only");
23 return null;
24 }
25
26 cachedCredentials = {
27 digitalocean: secrets.digitalocean, // { token }
28 cloudflare: secrets.cloudflare, // { email, apiKey, accountId }
29 pinata: secrets.pinata, // { apiKey, apiSecret }
30 };
31
32 return cachedCredentials;
33}
34
35// Provider configurations
36const PROVIDERS = {
37 digitalocean: {
38 name: "DigitalOcean",
39 description: "Servers, databases, spaces",
40 endpoints: {
41 balance: "https://api.digitalocean.com/v2/customers/my/balance",
42 billing_history: "https://api.digitalocean.com/v2/customers/my/billing_history",
43 },
44 },
45 stripe: {
46 name: "Stripe",
47 description: "Payment processing",
48 // Stripe billing is for incoming payments, not outgoing costs
49 // We track fees separately
50 },
51 netlify: {
52 name: "Netlify",
53 description: "Hosting, functions, edge",
54 // Netlify billing API is limited - we'll estimate based on usage
55 },
56 cloudflare: {
57 name: "Cloudflare",
58 description: "DNS, CDN, tunnels",
59 endpoints: {
60 billing: (accountId) => `https://api.cloudflare.com/client/v4/accounts/${accountId}/billing/profile`,
61 },
62 },
63 mongodb: {
64 name: "MongoDB",
65 description: "Database (self-hosted on DO silo)",
66 // Migrated from Atlas to self-hosted on silo.aesthetic.computer
67 },
68 shopify: {
69 name: "Shopify",
70 description: "E-commerce / product store",
71 // shop.aesthetic.computer — currently offline
72 },
73 pinata: {
74 name: "Pinata",
75 description: "IPFS pinning",
76 endpoints: {
77 usage: "https://api.pinata.cloud/data/userPinnedDataTotal",
78 },
79 },
80 jamsocket: {
81 name: "Jamsocket",
82 description: "Session server",
83 // Would need to check their API
84 },
85 vercel: {
86 name: "Vercel",
87 description: "Edge functions (backup)",
88 // Minimal usage currently
89 },
90 openai: {
91 name: "OpenAI",
92 description: "AI/LLM APIs",
93 endpoints: {
94 usage: "https://api.openai.com/v1/dashboard/billing/usage",
95 },
96 },
97 anthropic: {
98 name: "Anthropic",
99 description: "Claude AI",
100 // Check for billing API
101 },
102};
103
104/**
105 * Fetch DigitalOcean billing data
106 */
107async function fetchDigitalOcean(credentials) {
108 const token = credentials?.digitalocean?.token;
109 if (!token) return { provider: "digitalocean", name: PROVIDERS.digitalocean.name, skipped: true, reason: "No credentials configured" };
110
111 try {
112 const headers = { Authorization: `Bearer ${token}` };
113
114 const [balanceRes, historyRes] = await Promise.all([
115 fetch(PROVIDERS.digitalocean.endpoints.balance, { headers }),
116 fetch(PROVIDERS.digitalocean.endpoints.billing_history, { headers }),
117 ]);
118
119 const balance = await balanceRes.json();
120 const history = await historyRes.json();
121
122 return {
123 provider: "digitalocean",
124 name: PROVIDERS.digitalocean.name,
125 description: PROVIDERS.digitalocean.description,
126 balance: {
127 monthToDate: balance.month_to_date_balance,
128 accountBalance: balance.account_balance,
129 monthToDateUsage: balance.month_to_date_usage,
130 generatedAt: balance.generated_at,
131 },
132 recentHistory: history.billing_history?.slice(0, 5).map(h => ({
133 date: h.date,
134 type: h.type,
135 description: h.description,
136 amount: h.amount,
137 })),
138 };
139 } catch (error) {
140 return { provider: "digitalocean", error: error.message };
141 }
142}
143
144/**
145 * Fetch Cloudflare billing data
146 */
147async function fetchCloudflare(credentials) {
148 const email = credentials?.cloudflare?.email;
149 const apiKey = credentials?.cloudflare?.apiKey;
150 const accountId = credentials?.cloudflare?.accountId;
151
152 if (!email || !apiKey || !accountId) {
153 return { provider: "cloudflare", name: PROVIDERS.cloudflare.name, skipped: true, reason: "Cloudflare credentials not configured" };
154 }
155
156 try {
157 const headers = {
158 'X-Auth-Email': email,
159 'X-Auth-Key': apiKey,
160 'Content-Type': 'application/json',
161 };
162
163 const res = await fetch(PROVIDERS.cloudflare.endpoints.billing(accountId), { headers });
164 const data = await res.json();
165
166 if (!data.success) {
167 return { provider: "cloudflare", error: data.errors?.[0]?.message || "Unknown error" };
168 }
169
170 return {
171 provider: "cloudflare",
172 name: PROVIDERS.cloudflare.name,
173 description: PROVIDERS.cloudflare.description,
174 plan: data.result?.plan?.name,
175 // Most CF features are free tier for AC
176 };
177 } catch (error) {
178 return { provider: "cloudflare", error: error.message };
179 }
180}
181
182/**
183 * Fetch Pinata usage data
184 */
185async function fetchPinata(credentials) {
186 const apiKey = credentials?.pinata?.apiKey;
187 const apiSecret = credentials?.pinata?.apiSecret;
188
189 if (!apiKey || !apiSecret) {
190 return { provider: "pinata", name: PROVIDERS.pinata.name, skipped: true, reason: "Pinata credentials not configured" };
191 }
192
193 try {
194 const res = await fetch(PROVIDERS.pinata.endpoints.usage, {
195 headers: {
196 pinata_api_key: apiKey,
197 pinata_secret_api_key: apiSecret,
198 },
199 });
200 const data = await res.json();
201
202 return {
203 provider: "pinata",
204 name: PROVIDERS.pinata.name,
205 description: PROVIDERS.pinata.description,
206 usage: {
207 pinnedCount: data.pin_count,
208 pinnedSizeBytes: data.pin_size_total,
209 pinnedSizeGB: (data.pin_size_total / (1024 * 1024 * 1024)).toFixed(2),
210 },
211 };
212 } catch (error) {
213 return { provider: "pinata", error: error.message };
214 }
215}
216
217/**
218 * Fetch OpenAI usage (if available)
219 */
220async function fetchOpenAI() {
221 const apiKey = process.env.OPENAI_API_KEY;
222 if (!apiKey) return { provider: "openai", name: PROVIDERS.openai.name, skipped: true, reason: "No OPENAI_API_KEY configured" };
223
224 // Note: OpenAI billing API requires organization-level access
225 // This is a placeholder - actual implementation may vary
226 return {
227 provider: "openai",
228 name: PROVIDERS.openai.name,
229 description: PROVIDERS.openai.description,
230 note: "Check dashboard.openai.com for usage",
231 };
232}
233
234/**
235 * Static/estimated costs for providers without APIs
236 */
237function getStaticEstimates() {
238 return [
239 {
240 provider: "netlify",
241 name: PROVIDERS.netlify.name,
242 description: PROVIDERS.netlify.description,
243 estimated: true,
244 monthlyEstimate: 19, // Pro plan
245 note: "Pro plan - check netlify.com/billing",
246 },
247 {
248 provider: "jamsocket",
249 name: PROVIDERS.jamsocket.name,
250 description: PROVIDERS.jamsocket.description,
251 estimated: true,
252 monthlyEstimate: 0, // Currently on free/early tier?
253 note: "Session server hosting",
254 },
255 {
256 provider: "mongodb",
257 name: PROVIDERS.mongodb.name,
258 description: PROVIDERS.mongodb.description,
259 estimated: true,
260 monthlyEstimate: 0,
261 note: "Self-hosted on DO silo (cost included in DigitalOcean)",
262 },
263 {
264 provider: "shopify",
265 name: PROVIDERS.shopify.name,
266 description: PROVIDERS.shopify.description,
267 estimated: true,
268 monthlyEstimate: 0,
269 status: "offline",
270 note: "shop.aesthetic.computer — store offline as of March 2026",
271 },
272 {
273 provider: "anthropic",
274 name: PROVIDERS.anthropic.name,
275 description: PROVIDERS.anthropic.description,
276 estimated: true,
277 note: "Usage-based - check console.anthropic.com",
278 },
279 ];
280}
281
282export async function handler(event, context) {
283 // Only allow GET
284 if (event.httpMethod !== "GET") {
285 return respond(405, { error: "Method not allowed" });
286 }
287
288 // Sensitive endpoint: require @jeffrey admin auth.
289 const user = await authorize(event.headers || {});
290 if (!user) {
291 return respond(401, { error: "Unauthorized" });
292 }
293 const isAdmin = await hasAdmin(user);
294 if (!isAdmin) {
295 return respond(403, { error: "Admin access required" });
296 }
297
298 const query = event.queryStringParameters || {};
299 const provider = query.provider; // Optional: filter by provider
300
301 try {
302 // Get credentials from MongoDB
303 const credentials = await getBillingCredentials();
304
305 // Fetch from all providers in parallel
306 const [digitalocean, cloudflare, pinata, openai] = await Promise.all([
307 fetchDigitalOcean(credentials),
308 fetchCloudflare(credentials),
309 fetchPinata(credentials),
310 fetchOpenAI(),
311 ]);
312
313 const dynamicProviders = [digitalocean, cloudflare, pinata, openai];
314 const staticProviders = getStaticEstimates();
315
316 let allProviders = [...dynamicProviders, ...staticProviders];
317
318 // Filter by provider if requested
319 if (provider) {
320 allProviders = allProviders.filter(p => p.provider === provider);
321 }
322
323 // Calculate totals where possible
324 const monthlyEstimateTotal = staticProviders
325 .filter(p => p.monthlyEstimate)
326 .reduce((sum, p) => sum + p.monthlyEstimate, 0);
327
328 const doBalance = digitalocean.balance?.monthToDateUsage
329 ? parseFloat(digitalocean.balance.monthToDateUsage.replace('$', ''))
330 : 0;
331
332 return respond(200, {
333 generated: new Date().toISOString(),
334 summary: {
335 monthlyEstimate: `$${(monthlyEstimateTotal + doBalance).toFixed(2)}`,
336 note: "Partial data - some providers require manual checking",
337 },
338 providers: allProviders,
339 });
340 } catch (error) {
341 console.error("Billing aggregation error:", error);
342 return respond(500, { error: "Failed to aggregate billing data" });
343 }
344}