Monorepo for Aesthetic.Computer aesthetic.computer
at main 344 lines 11 kB view raw
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}