mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 08:56:58 +00:00
* feat: usage rendered as credits on a 50c base, free allowance doubled Usage is now displayed in credits: a configurable creditsPerDollar rate (default 2,000, making the free tier's 50c allowance an even 1,000-credit base) ships with the metering usage response, and the dashboard's usage cards and per-API table show raw credit numbers through one shared formatter. The dashboard budget math is also fixed — capacity is spend plus server-netted remainder, so held purchased credit can never render as negative usage. The registered-user free allowance doubles to 50c, tier display names move to Basic/Plus/Pro, and dead referral-promise strings leave the English dictionary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: the credits display rate exists only in deployment config No default in code and no rate on any wire the plan surfaces read: a deployment that doesn't configure creditsPerDollar renders usage in dollars, exactly as before credits existed. The usage endpoint sends the rate only when configured, and nothing in code or comments states what any deployment's rate is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: metering reports pre-scaled credits, never raw amounts The usage, per-app usage, and cost-catalogue endpoints multiply every monetary field by the config multiplier (renamed creditMultiplier) before responding, and flag the unit; the multiplier itself never ships. Counts, units, and byte figures pass through untouched. With no multiplier configured the endpoints report raw amounts and clients render dollars, as before credits existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: absent addon fields stay absent when scaling to credits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
262 lines
8.6 KiB
TypeScript
262 lines
8.6 KiB
TypeScript
import { Context } from '@heyputer/backend/src/core';
|
|
import { HttpError } from '@heyputer/backend/src/core/http';
|
|
import {
|
|
controllersContainers,
|
|
driversContainers,
|
|
servicesContainers,
|
|
} from '@heyputer/backend/src/exports';
|
|
import { extension } from '@heyputer/backend/src/extensions';
|
|
import {
|
|
creditMultiplierFrom,
|
|
toCredits,
|
|
} from '@heyputer/backend/src/services/metering/utils';
|
|
import type { Request, Response } from 'express';
|
|
|
|
const services = extension.import('service');
|
|
const clients = extension.import('client');
|
|
|
|
// Cached on first request — the underlying cost catalogues are baked into
|
|
// driver/controller source so they only change on deploy.
|
|
let cachedAllCosts: Record<string, unknown>[] | null = null;
|
|
|
|
function collectAllCosts(): Record<string, unknown>[] {
|
|
const all: Record<string, unknown>[] = [];
|
|
const collect = (
|
|
source: Record<string, unknown>,
|
|
kind: 'driver' | 'controller' | 'service',
|
|
) => {
|
|
for (const [name, instance] of Object.entries(source)) {
|
|
const fn = (
|
|
instance as {
|
|
getReportedCosts?: () => Record<string, unknown>[];
|
|
}
|
|
)?.getReportedCosts;
|
|
if (typeof fn !== 'function') continue;
|
|
try {
|
|
const entries = fn.call(instance);
|
|
if (!Array.isArray(entries)) continue;
|
|
for (const entry of entries) {
|
|
all.push({ ...entry, registry: kind, registryKey: name });
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
`[metering] getReportedCosts failed for ${kind}:${name}:`,
|
|
(e as Error).message,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
collect(driversContainers as Record<string, unknown>, 'driver');
|
|
collect(controllersContainers as Record<string, unknown>, 'controller');
|
|
// Services report the costs that aren't tied to one endpoint — egress,
|
|
// which is metered for every response there is.
|
|
collect(servicesContainers as Record<string, unknown>, 'service');
|
|
return all;
|
|
}
|
|
|
|
// -- Credits scaling ---------------------------------------------------
|
|
//
|
|
// When the deployment configures a credit multiplier, every monetary amount
|
|
// is scaled to display credits BEFORE it leaves the server — raw metered
|
|
// amounts are never reported. Counts, units, and byte figures pass through
|
|
// untouched. A response that was scaled says so (`unit: 'credits'`); without
|
|
// a configured multiplier amounts pass through raw and clients render them
|
|
// as dollars.
|
|
|
|
/** The per-usage-type records of a UsageByType map, costs scaled. */
|
|
const scaleUsageByType = (
|
|
usage: Record<string, unknown>,
|
|
multiplier: number,
|
|
): Record<string, unknown> => {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(usage)) {
|
|
if (key === 'total') {
|
|
out.total = toCredits(Number(value) || 0, multiplier);
|
|
} else if (
|
|
value &&
|
|
typeof value === 'object' &&
|
|
typeof (value as { cost?: unknown }).cost === 'number'
|
|
) {
|
|
out[key] = {
|
|
...(value as Record<string, unknown>),
|
|
cost: toCredits((value as { cost: number }).cost, multiplier),
|
|
};
|
|
} else {
|
|
out[key] = value;
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
export const handleMeteringUsage = async (
|
|
_req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const actor = Context.get('actor');
|
|
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
|
|
|
const [actorUsage, allowanceInfo] = await Promise.all([
|
|
services.metering.getActorCurrentMonthUsageDetails(actor),
|
|
services.metering.getAllowedUsage(actor),
|
|
]);
|
|
const multiplier = creditMultiplierFrom(extension.config);
|
|
if (!multiplier) {
|
|
res.json({ ...actorUsage, allowanceInfo });
|
|
return;
|
|
}
|
|
res.json({
|
|
usage: scaleUsageByType(
|
|
actorUsage.usage as unknown as Record<string, unknown>,
|
|
multiplier,
|
|
),
|
|
appTotals: Object.fromEntries(
|
|
Object.entries(actorUsage.appTotals).map(([appId, totals]) => [
|
|
appId,
|
|
{ ...totals, total: toCredits(totals.total, multiplier) },
|
|
]),
|
|
),
|
|
allowanceInfo: {
|
|
...allowanceInfo,
|
|
remaining: toCredits(allowanceInfo.remaining, multiplier),
|
|
monthUsageAllowance: toCredits(
|
|
allowanceInfo.monthUsageAllowance,
|
|
multiplier,
|
|
),
|
|
// Monetary addon fields scale; absent ones stay absent rather
|
|
// than materializing as NaN/null. purchasedStorage is bytes.
|
|
addons: Object.fromEntries(
|
|
Object.entries(allowanceInfo.addons ?? {}).map(([k, v]) => [
|
|
k,
|
|
(k === 'purchasedCredits' ||
|
|
k === 'consumedPurchaseCredits') &&
|
|
typeof v === 'number'
|
|
? toCredits(v, multiplier)
|
|
: v,
|
|
]),
|
|
),
|
|
unit: 'credits',
|
|
},
|
|
});
|
|
};
|
|
|
|
export const handleMeteringUsageForApp = async (
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const actor = Context.get('actor');
|
|
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
|
|
|
let appId = String(req.params.appIdOrName ?? '');
|
|
if (!appId) throw new HttpError(400, 'appId parameter is required');
|
|
|
|
// If not a UUID-shaped app UID, look up by name
|
|
if (!appId.startsWith('app-')) {
|
|
const appRows = (await clients.db.read(
|
|
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
|
|
[appId],
|
|
)) as Array<{ uid: string }>;
|
|
if (appRows.length > 0) {
|
|
appId = appRows[0].uid;
|
|
} else {
|
|
throw new HttpError(404, 'App not found');
|
|
}
|
|
}
|
|
|
|
const appUsage =
|
|
await services.metering.getActorCurrentMonthAppUsageDetails(
|
|
actor,
|
|
appId,
|
|
);
|
|
const multiplier = creditMultiplierFrom(extension.config);
|
|
if (!multiplier) {
|
|
res.json(appUsage);
|
|
return;
|
|
}
|
|
res.json({
|
|
...scaleUsageByType(
|
|
appUsage as unknown as Record<string, unknown>,
|
|
multiplier,
|
|
),
|
|
unit: 'credits',
|
|
});
|
|
};
|
|
|
|
export const handleMeteringGlobalUsage = async (
|
|
_req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const globalUsage = await services.metering.getGlobalUsage();
|
|
res.json(globalUsage);
|
|
};
|
|
|
|
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
|
export const handleMeteringAllCosts = async (
|
|
_req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
if (!cachedAllCosts) {
|
|
const raw = collectAllCosts();
|
|
const multiplier = creditMultiplierFrom(extension.config);
|
|
// Price the catalogue in the same unit the usage endpoints report:
|
|
// per-unit credits when a multiplier is configured, raw otherwise.
|
|
cachedAllCosts = multiplier
|
|
? raw.map(({ ucentsPerUnit, costs_currency: _cc, ...rest }) =>
|
|
typeof ucentsPerUnit === 'number'
|
|
? {
|
|
...rest,
|
|
creditsPerUnit: toCredits(
|
|
ucentsPerUnit,
|
|
multiplier,
|
|
),
|
|
}
|
|
: rest,
|
|
)
|
|
: raw;
|
|
}
|
|
res.json({ costs: cachedAllCosts });
|
|
};
|
|
|
|
/** Dashboard reads over the per-actor KV aggregates. */
|
|
const USAGE_READ_LIMIT = {
|
|
scope: 'metering-usage',
|
|
limit: 120,
|
|
window: 60_000,
|
|
key: 'user' as const,
|
|
};
|
|
|
|
extension.get(
|
|
'/metering/usage',
|
|
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
|
handleMeteringUsage,
|
|
);
|
|
|
|
extension.get(
|
|
'/metering/usage/:appIdOrName',
|
|
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
|
handleMeteringUsageForApp,
|
|
);
|
|
|
|
extension.get(
|
|
'/metering/globalUsage',
|
|
{
|
|
subdomain: 'api',
|
|
adminOnly: true,
|
|
// Sums across every shard of the global aggregate. Admin-gated, so
|
|
// this is loop protection — but one accidental poll is an
|
|
// expensive minute.
|
|
rateLimit: {
|
|
scope: 'metering-global-usage',
|
|
limit: 10,
|
|
window: 60_000,
|
|
key: 'user',
|
|
},
|
|
},
|
|
handleMeteringGlobalUsage,
|
|
);
|
|
|
|
extension.get(
|
|
'/metering/allCosts',
|
|
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
|
handleMeteringAllCosts,
|
|
);
|