import { z } from 'zod' import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory' import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' import { BillingAccountUsage } from '../../data/entities' import { billingEntityIds } from '../../data/entityIds' import { billingUsageCreateSchema, billingUsageListQuerySchema, } from '../../data/validators' import { createBillingCrudOpenApi, createPagedListResponseSchema, } from '../openapi' // ─── Auth / RBAC ───────────────────────────────────────────────── // // Usage is append-only — no update or delete. The narrow scope here // lets us mint API keys for external metering systems with ONLY // `billing.usage.ingest`, eliminating an entire blast radius. const routeMetadata = { GET: { requireAuth: true, requireFeatures: ['billing.usage.view'] }, POST: { requireAuth: true, requireFeatures: ['billing.usage.ingest'] }, } export const metadata = routeMetadata const billingUsageListItemSchema = z.object({ id: z.string().uuid(), billAccountId: z.string().uuid(), uomCode: z.string(), quantity: z.string(), // numeric(18,4) → string periodStart: z.string(), periodEnd: z.string(), lineDescription: z.string().nullable(), sourceRef: z.string().nullable(), ratedInBillRunId: z.string().uuid().nullable(), isActive: z.boolean(), organizationId: z.string().uuid(), tenantId: z.string().uuid(), createdAt: z.string(), updatedAt: z.string(), }) const usageCreateResponseSchema = z.object({ id: z.string().uuid().nullable(), deduplicated: z.boolean(), // Prepaid accounts (SPEC-002) get the real-time balance body. Absent for // postpaid accounts (the legacy append-only response is unchanged). ratedAmount: z.string().optional(), currencyCode: z.string().optional(), balance: z.string().optional(), balanceStatus: z.enum(['ok', 'low', 'exhausted']).optional(), creditStatus: z.enum(['within_limit', 'near_limit', 'over_limit']).optional(), tierBreakdown: z.array(z.record(z.string(), z.unknown())).optional(), warning: z.string().nullable().optional(), }) const rawBodySchema = z.object({}).passthrough() const crud = makeCrudRoute({ metadata: routeMetadata, orm: { entity: BillingAccountUsage, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt', }, list: { schema: billingUsageListQuerySchema, entityId: billingEntityIds.usage, fields: [ 'id', 'bill_account_id', 'uom_code', 'quantity', 'period_start', 'period_end', 'line_description', 'source_ref', 'rated_in_bill_run_id', 'is_active', 'organization_id', 'tenant_id', 'created_at', 'updated_at', ], sortFieldMap: { createdAt: 'created_at', periodStart: 'period_start', periodEnd: 'period_end', }, buildFilters: async (query) => { const filters: Record = {} if (typeof query.billAccountId === 'string' && query.billAccountId) { filters.bill_account_id = { $eq: query.billAccountId } } if (typeof query.uomCode === 'string' && query.uomCode) { filters.uom_code = { $eq: query.uomCode } } if (typeof query.rated === 'boolean') { filters.rated_in_bill_run_id = query.rated ? { $ne: null } : { $eq: null } } if (query.periodEndFrom instanceof Date) { filters.period_end = Object.assign( (filters.period_end as Record) ?? {}, { $gte: query.periodEndFrom }, ) } if (query.periodEndTo instanceof Date) { filters.period_end = Object.assign( (filters.period_end as Record) ?? {}, { $lte: query.periodEndTo }, ) } return filters }, }, actions: { create: { commandId: 'billing.usage.create', schema: rawBodySchema, mapInput: async ({ raw, ctx }) => { const { translate } = await resolveTranslations() const scoped = withScopedPayload(raw ?? {}, ctx, translate) return billingUsageCreateSchema.parse(scoped) }, response: ({ result }) => { const base = { id: result?.id ?? null, deduplicated: Boolean(result?.deduplicated), } // Pass through the prepaid balance body when present (prepaid // accounts only); postpaid responses keep the legacy 2-field shape. if (result && 'balance' in result && result.balance !== undefined) { return { ...base, ratedAmount: result.ratedAmount, currencyCode: result.currencyCode, balance: result.balance, balanceStatus: result.balanceStatus, creditStatus: result.creditStatus, tierBreakdown: result.tierBreakdown ?? [], warning: result.warning ?? null, } } return base }, status: 200, }, // Usage has no update or delete by design — see file header. }, }) const { POST } = crud export { POST } export const GET = crud.GET export const openApi = createBillingCrudOpenApi({ resourceName: 'BillingUsageRecord', pluralName: 'BillingUsageRecords', querySchema: billingUsageListQuerySchema, listResponseSchema: createPagedListResponseSchema(billingUsageListItemSchema), create: { schema: billingUsageCreateSchema, responseSchema: usageCreateResponseSchema, description: 'Ingests a pre-aggregated usage record. Idempotent via `sourceRef`. ' + 'Usage records are immutable once persisted — to correct a mis-aggregation, ' + "post a `one_time` Billing Item credit (the spec's refund model). " + 'For a PREPAID account (SPEC-002) the upload is rated synchronously, ' + 'debited from the balance, and the response carries `ratedAmount`, the ' + 'remaining `balance`, `balanceStatus` (ok/low/exhausted), `creditStatus`, ' + 'and the `tierBreakdown`. Usage is never rejected — the balance may go ' + 'negative and the client is expected to throttle itself.', }, })