diff --git a/prisma/migrations/20260821120000_add_platform_daily_stats/migration.sql b/prisma/migrations/20260821120000_add_platform_daily_stats/migration.sql new file mode 100644 index 0000000..fd122a3 --- /dev/null +++ b/prisma/migrations/20260821120000_add_platform_daily_stats/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "PlatformDailyStats" ( + "id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "totalVolume" BIGINT NOT NULL DEFAULT 0, + "totalFees" BIGINT NOT NULL DEFAULT 0, + "transactionCount" BIGINT NOT NULL DEFAULT 0, + "newInvoices" INTEGER NOT NULL DEFAULT 0, + "newMerchants" INTEGER NOT NULL DEFAULT 0, + "newSubscriptions" INTEGER NOT NULL DEFAULT 0, + "newTickets" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlatformDailyStats_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PlatformDailyStats_date_key" ON "PlatformDailyStats"("date"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a25adc..85bafe1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -318,3 +318,19 @@ model DepositAccount { invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Restrict) } + +// Protocol-wide daily rollup. One row per UTC calendar day, upserted by the +// indexer as events arrive (no scheduled batch job), mirroring how +// MerchantAnalytics/TokenAnalytics are maintained. +model PlatformDailyStats { + id String @id @default(uuid()) + date DateTime @unique + totalVolume BigInt @default(0) + totalFees BigInt @default(0) + transactionCount BigInt @default(0) + newInvoices Int @default(0) + newMerchants Int @default(0) + newSubscriptions Int @default(0) + newTickets Int @default(0) + updatedAt DateTime @updatedAt +} diff --git a/src/controllers/admin-analytics.controllers.ts b/src/controllers/admin-analytics.controllers.ts new file mode 100644 index 0000000..a1af1b5 --- /dev/null +++ b/src/controllers/admin-analytics.controllers.ts @@ -0,0 +1,50 @@ +import { Request, Response } from 'express'; +import { + getAnalyticsSummary, + getAnalyticsTimeseries, + getTopTokensByVolume, +} from '../services/analytics.services.js'; +import { AppError } from '../utils/errors.js'; + +const handleError = (error: unknown, req: Request, res: Response, action: string): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error(`Failed to ${action}`, { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export const getAnalyticsSummaryController = async (req: Request, res: Response): Promise => { + try { + res.status(200).json(await getAnalyticsSummary()); + } catch (error) { + handleError(error, req, res, 'load the analytics summary'); + } +}; + +export const getAnalyticsTimeseriesController = async ( + req: Request, + res: Response, +): Promise => { + try { + const result = await getAnalyticsTimeseries(req.query as Record); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'load the analytics timeseries'); + } +}; + +export const getAnalyticsTokensController = async (req: Request, res: Response): Promise => { + try { + const result = await getTopTokensByVolume(req.query as Record); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'load the token analytics'); + } +}; diff --git a/src/indexer/handlers/growth.ts b/src/indexer/handlers/growth.ts new file mode 100644 index 0000000..f28fd7f --- /dev/null +++ b/src/indexer/handlers/growth.ts @@ -0,0 +1,47 @@ +import prisma from '../../config/prisma.js'; +import { recordDailyStats } from '../../services/analytics.services.js'; +import { + decodeInvoiceCreatedEventData, + decodeMerchantRegisteredEventData, + decodeSubscribedEventData, + type DecodedEvent, +} from '../types.js'; + +/** + * Falls back to the indexing time only if the RPC response carried no ledger + * close time — every real `getEvents` response does. + */ +const ledgerCloseTime = (event: DecodedEvent): Date => { + if (!event.ledgerClosedAt) return new Date(); + const closedAt = new Date(event.ledgerClosedAt); + return Number.isNaN(closedAt.getTime()) ? new Date() : closedAt; +}; + +export const MERCHANT_REGISTERED_TOPIC = 'merchant_registered_event'; +export const INVOICE_CREATED_TOPIC = 'invoice_created_event'; +export const SUBSCRIBED_TOPIC = 'subscribed_event'; + +/** + * Growth events only move PlatformDailyStats' "new X today" counters. The + * point-in-time totals they feed into (how many merchants exist, how many + * invoices are in each status) are counted live at request time off the + * existing tables, so nothing else needs recording here. + */ +export const handleMerchantRegistered = async (event: DecodedEvent): Promise => { + const data = decodeMerchantRegisteredEventData(event.data); + await recordDailyStats(prisma, new Date(data.timestamp * 1000), { newMerchants: 1 }); +}; + +export const handleInvoiceCreated = async (event: DecodedEvent): Promise => { + // `InvoiceCreatedEvent` is the one growth event the contract emits without a + // timestamp field, so the day comes from the close time of the ledger that + // contained it. Using the indexing time instead would bucket a historical + // replay into whatever day the replay happened to run. + decodeInvoiceCreatedEventData(event.data); + await recordDailyStats(prisma, ledgerCloseTime(event), { newInvoices: 1 }); +}; + +export const handleSubscribed = async (event: DecodedEvent): Promise => { + const data = decodeSubscribedEventData(event.data); + await recordDailyStats(prisma, new Date(data.timestamp * 1000), { newSubscriptions: 1 }); +}; diff --git a/src/indexer/handlers/index.ts b/src/indexer/handlers/index.ts index fe67499..398ad43 100644 --- a/src/indexer/handlers/index.ts +++ b/src/indexer/handlers/index.ts @@ -1,4 +1,49 @@ import { registerEventHandler } from '../registry.js'; import { handleInvoicePaid, INVOICE_PAID_TOPIC } from './invoicePaid.js'; +import { handleSubscriptionCharged, SUBSCRIPTION_CHARGED_TOPIC } from './subscriptionCharged.js'; +import { + handleTicketPurchased, + handleTicketResold, + TICKET_PURCHASED_TOPIC, + TICKET_RESOLD_TOPIC, +} from './ticketing.js'; +import { + handleInvoiceCreated, + handleMerchantRegistered, + handleSubscribed, + INVOICE_CREATED_TOPIC, + MERCHANT_REGISTERED_TOPIC, + SUBSCRIBED_TOPIC, +} from './growth.js'; +import { + handleInvoicePartiallyRefunded, + handleInvoiceRefunded, + INVOICE_PARTIALLY_REFUNDED_TOPIC, + INVOICE_REFUNDED_TOPIC, +} from './refunds.js'; +// Volume-moving events: they update MerchantAnalytics, TokenAnalytics and the +// protocol-wide PlatformDailyStats rollup. registerEventHandler(INVOICE_PAID_TOPIC, handleInvoicePaid); +registerEventHandler(SUBSCRIPTION_CHARGED_TOPIC, handleSubscriptionCharged); +registerEventHandler(TICKET_PURCHASED_TOPIC, handleTicketPurchased); +registerEventHandler(TICKET_RESOLD_TOPIC, handleTicketResold); + +// Growth events: they only move PlatformDailyStats' "new X today" counters. +registerEventHandler(MERCHANT_REGISTERED_TOPIC, handleMerchantRegistered); +registerEventHandler(INVOICE_CREATED_TOPIC, handleInvoiceCreated); +registerEventHandler(SUBSCRIBED_TOPIC, handleSubscribed); + +// Refund events: they adjust the invoice only. Volume is deliberately not +// netted down — see applyInvoiceRefund. +registerEventHandler(INVOICE_REFUNDED_TOPIC, handleInvoiceRefunded); +registerEventHandler(INVOICE_PARTIALLY_REFUNDED_TOPIC, handleInvoicePartiallyRefunded); + +// Intentionally unhandled, and left to log "no handler registered": +// - subscription_plan_created_event / event_created_event: growth events with +// no dedicated daily counter in PlatformDailyStats. Plan and event totals +// are point-in-time counts, and adding daily counters for them is a +// follow-up if a dashboard ever asks for the trend. +// - status and governance events (merchant_status_changed_event, +// role_granted_event, contract_paused_event, fee_set_event, ...): out of +// scope for analytics indexing. diff --git a/src/indexer/handlers/invoicePaid.ts b/src/indexer/handlers/invoicePaid.ts index 2d0faa7..4ab995a 100644 --- a/src/indexer/handlers/invoicePaid.ts +++ b/src/indexer/handlers/invoicePaid.ts @@ -1,8 +1,10 @@ import { applyInvoicePayment } from '../../services/invoice.services.js'; import { decodeInvoicePaidEventData, type DecodedEvent } from '../types.js'; -// The `#[contractevent] InvoicePaidEvent` macro emits this first topic symbol. -export const INVOICE_PAID_TOPIC = 'InvoicePaid'; +// Soroban's `#[contractevent]` macro publishes a single fixed first topic: the +// struct name in lower snake case. `InvoicePaidEvent` therefore arrives as +// "invoice_paid_event". +export const INVOICE_PAID_TOPIC = 'invoice_paid_event'; /** * Keeps contract-specific payload normalization at the indexer edge; all diff --git a/src/indexer/handlers/refunds.ts b/src/indexer/handlers/refunds.ts new file mode 100644 index 0000000..0646ab1 --- /dev/null +++ b/src/indexer/handlers/refunds.ts @@ -0,0 +1,17 @@ +import { applyInvoiceRefund } from '../../services/invoice.services.js'; +import { + decodeInvoicePartiallyRefundedEventData, + decodeInvoiceRefundedEventData, + type DecodedEvent, +} from '../types.js'; + +export const INVOICE_REFUNDED_TOPIC = 'invoice_refunded_event'; +export const INVOICE_PARTIALLY_REFUNDED_TOPIC = 'invoice_partially_refunded_event'; + +export const handleInvoiceRefunded = async (event: DecodedEvent): Promise => { + await applyInvoiceRefund(decodeInvoiceRefundedEventData(event.data), event.txHash); +}; + +export const handleInvoicePartiallyRefunded = async (event: DecodedEvent): Promise => { + await applyInvoiceRefund(decodeInvoicePartiallyRefundedEventData(event.data), event.txHash); +}; diff --git a/src/indexer/handlers/subscriptionCharged.ts b/src/indexer/handlers/subscriptionCharged.ts new file mode 100644 index 0000000..ff61b3d --- /dev/null +++ b/src/indexer/handlers/subscriptionCharged.ts @@ -0,0 +1,8 @@ +import { applySubscriptionCharge } from '../../services/subscription.services.js'; +import { decodeSubscriptionChargedEventData, type DecodedEvent } from '../types.js'; + +export const SUBSCRIPTION_CHARGED_TOPIC = 'subscription_charged_event'; + +export const handleSubscriptionCharged = async (event: DecodedEvent): Promise => { + await applySubscriptionCharge(decodeSubscriptionChargedEventData(event.data), event.txHash); +}; diff --git a/src/indexer/handlers/ticketing.ts b/src/indexer/handlers/ticketing.ts new file mode 100644 index 0000000..98de04a --- /dev/null +++ b/src/indexer/handlers/ticketing.ts @@ -0,0 +1,17 @@ +import { applyTicketPurchase, applyTicketResale } from '../../services/ticket.services.js'; +import { + decodeTicketPurchasedEventData, + decodeTicketResoldEventData, + type DecodedEvent, +} from '../types.js'; + +export const TICKET_PURCHASED_TOPIC = 'ticket_purchased_event'; +export const TICKET_RESOLD_TOPIC = 'ticket_resold_event'; + +export const handleTicketPurchased = async (event: DecodedEvent): Promise => { + await applyTicketPurchase(decodeTicketPurchasedEventData(event.data), event.txHash); +}; + +export const handleTicketResold = async (event: DecodedEvent): Promise => { + await applyTicketResale(decodeTicketResoldEventData(event.data), event.txHash); +}; diff --git a/src/indexer/poller.ts b/src/indexer/poller.ts index 91164d4..6821f1c 100644 --- a/src/indexer/poller.ts +++ b/src/indexer/poller.ts @@ -85,6 +85,7 @@ export async function tick(): Promise { topic: decodedTopic, ledger: event.ledger, txHash: event.txHash, + ledgerClosedAt: event.ledgerClosedAt, data: decodedValue, }); diff --git a/src/indexer/types.ts b/src/indexer/types.ts index ba07372..779e3d4 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -3,15 +3,21 @@ export interface DecodedEvent { topic: string; ledger: number; txHash: string; + /** + * ISO close time of the event's ledger, as reported by `getEvents`. Handlers + * for events the contract emits without their own timestamp field use this, + * so a historical replay buckets by when the event actually happened. + */ + ledgerClosedAt?: string; data: any; } /** - * Normalized payload for the Shade contract's `InvoicePaidEvent`. + * Normalized payloads for the Shade contract's events. * * Soroban's `scValToNative` preserves the Rust event-map field names - * (`invoice_id`, `merchant_id`, and so on), so the handler converts that - * payload to this application-facing shape before calling the invoice service. + * (`invoice_id`, `merchant_id`, and so on), so handlers convert that payload to + * these application-facing shapes before calling a service. */ export interface InvoicePaidEventData { invoiceId: number; @@ -24,59 +30,262 @@ export interface InvoicePaidEventData { timestamp: number; } +export interface SubscriptionChargedEventData { + subscriptionId: number; + planId: number; + customer: string; + merchant: string; + amount: bigint; + fee: bigint; + token: string; + timestamp: number; +} + +export interface TicketPurchasedEventData { + ticketId: number; + eventId: number; + merchantId: number; + buyer: string; + amount: bigint; + fee: bigint; + merchantAmount: bigint; + token: string; + timestamp: number; +} + +export interface TicketResoldEventData { + ticketId: number; + eventId: number; + merchantId: number; + seller: string; + buyer: string; + resalePrice: bigint; + royalty: bigint; + sellerProceeds: bigint; + token: string; + timestamp: number; +} + +export interface MerchantRegisteredEventData { + merchant: string; + merchantId: number; + timestamp: number; +} + +/** Note: the contract's `InvoiceCreatedEvent` carries no timestamp field. */ +export interface InvoiceCreatedEventData { + invoiceId: number; + merchant: string; + amount: bigint; + token: string; +} + +export interface SubscribedEventData { + subscriptionId: number; + planId: number; + customer: string; + timestamp: number; +} + +export interface InvoiceRefundedEventData { + invoiceId: number; + merchant: string; + amount: bigint; + timestamp: number; +} + +export interface InvoicePartiallyRefundedEventData { + invoiceId: number; + merchant: string; + amount: bigint; + totalAmountRefunded: bigint; + timestamp: number; +} + type EventRecord = Record | Map; const isEventRecord = (value: unknown): value is EventRecord => value instanceof Map || (typeof value === 'object' && value !== null); -const readField = (data: EventRecord, camelCase: string, snakeCase: string): unknown => { +const toCamelCase = (snakeCase: string): string => + snakeCase.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); + +/** + * Reads one contract-event field. `scValToNative` returns a plain object for + * some SDK versions and a `Map` for others, and callers may already have + * camel-cased keys, so both spellings are accepted. + */ +const readField = (data: EventRecord, snakeCase: string): unknown => { + const camelCase = toCamelCase(snakeCase); if (data instanceof Map) { return data.get(camelCase) ?? data.get(snakeCase); } - return data[camelCase] ?? data[snakeCase]; + return ( + (data as Record)[camelCase] ?? (data as Record)[snakeCase] + ); }; -const toBigInt = (value: unknown, field: string): bigint => { - try { - return typeof value === 'bigint' ? value : BigInt(value as string | number | boolean); - } catch { - throw new Error(`InvoicePaid event field "${field}" must be an integer`); +/** + * Field-level accessors for a single decoded event, carrying the event name so + * validation failures name the event they came from. + */ +class EventFieldReader { + constructor( + private readonly eventName: string, + private readonly data: EventRecord, + ) {} + + bigint(field: string): bigint { + const value = readField(this.data, field); + try { + return typeof value === 'bigint' ? value : BigInt(value as string | number | boolean); + } catch { + throw new Error(`${this.eventName} event field "${field}" must be an integer`); + } } -}; -const toSafeNumber = (value: unknown, field: string): number => { - const parsed = toBigInt(value, field); - if (parsed < 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new Error( - `InvoicePaid event field "${field}" is outside JavaScript's safe integer range`, - ); + number(field: string): number { + const parsed = this.bigint(field); + if (parsed < 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error( + `${this.eventName} event field "${field}" is outside JavaScript's safe integer range`, + ); + } + return Number(parsed); } - return Number(parsed); -}; -const toString = (value: unknown, field: string): string => { - if (value === null || value === undefined) { - throw new Error(`InvoicePaid event field "${field}" is required`); + string(field: string): string { + const value = readField(this.data, field); + if (value === null || value === undefined) { + throw new Error(`${this.eventName} event field "${field}" is required`); + } + return String(value); } - return String(value); -}; +} -export const decodeInvoicePaidEventData = (data: unknown): InvoicePaidEventData => { +const readEvent = (eventName: string, data: unknown): EventFieldReader => { if (!isEventRecord(data)) { - throw new Error('InvoicePaid event data must be a decoded map'); + throw new Error(`${eventName} event data must be a decoded map`); } + return new EventFieldReader(eventName, data); +}; + +export const decodeInvoicePaidEventData = (data: unknown): InvoicePaidEventData => { + const event = readEvent('InvoicePaid', data); + + return { + invoiceId: event.number('invoice_id'), + merchantId: event.number('merchant_id'), + payer: event.string('payer'), + amount: event.bigint('amount'), + fee: event.bigint('fee'), + merchantAmount: event.bigint('merchant_amount'), + token: event.string('token'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeSubscriptionChargedEventData = (data: unknown): SubscriptionChargedEventData => { + const event = readEvent('SubscriptionCharged', data); + + return { + subscriptionId: event.number('subscription_id'), + planId: event.number('plan_id'), + customer: event.string('customer'), + merchant: event.string('merchant'), + amount: event.bigint('amount'), + fee: event.bigint('fee'), + token: event.string('token'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeTicketPurchasedEventData = (data: unknown): TicketPurchasedEventData => { + const event = readEvent('TicketPurchased', data); + + return { + ticketId: event.number('ticket_id'), + eventId: event.number('event_id'), + merchantId: event.number('merchant_id'), + buyer: event.string('buyer'), + amount: event.bigint('amount'), + fee: event.bigint('fee'), + merchantAmount: event.bigint('merchant_amount'), + token: event.string('token'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeTicketResoldEventData = (data: unknown): TicketResoldEventData => { + const event = readEvent('TicketResold', data); + + return { + ticketId: event.number('ticket_id'), + eventId: event.number('event_id'), + merchantId: event.number('merchant_id'), + seller: event.string('seller'), + buyer: event.string('buyer'), + resalePrice: event.bigint('resale_price'), + royalty: event.bigint('royalty'), + sellerProceeds: event.bigint('seller_proceeds'), + token: event.string('token'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeMerchantRegisteredEventData = (data: unknown): MerchantRegisteredEventData => { + const event = readEvent('MerchantRegistered', data); + + return { + merchant: event.string('merchant'), + merchantId: event.number('merchant_id'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeInvoiceCreatedEventData = (data: unknown): InvoiceCreatedEventData => { + const event = readEvent('InvoiceCreated', data); + + return { + invoiceId: event.number('invoice_id'), + merchant: event.string('merchant'), + amount: event.bigint('amount'), + token: event.string('token'), + }; +}; + +export const decodeSubscribedEventData = (data: unknown): SubscribedEventData => { + const event = readEvent('Subscribed', data); + + return { + subscriptionId: event.number('subscription_id'), + planId: event.number('plan_id'), + customer: event.string('customer'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeInvoiceRefundedEventData = (data: unknown): InvoiceRefundedEventData => { + const event = readEvent('InvoiceRefunded', data); + + return { + invoiceId: event.number('invoice_id'), + merchant: event.string('merchant'), + amount: event.bigint('amount'), + timestamp: event.number('timestamp'), + }; +}; + +export const decodeInvoicePartiallyRefundedEventData = ( + data: unknown, +): InvoicePartiallyRefundedEventData => { + const event = readEvent('InvoicePartiallyRefunded', data); return { - invoiceId: toSafeNumber(readField(data, 'invoiceId', 'invoice_id'), 'invoice_id'), - merchantId: toSafeNumber(readField(data, 'merchantId', 'merchant_id'), 'merchant_id'), - payer: toString(readField(data, 'payer', 'payer'), 'payer'), - amount: toBigInt(readField(data, 'amount', 'amount'), 'amount'), - fee: toBigInt(readField(data, 'fee', 'fee'), 'fee'), - merchantAmount: toBigInt( - readField(data, 'merchantAmount', 'merchant_amount'), - 'merchant_amount', - ), - token: toString(readField(data, 'token', 'token'), 'token'), - timestamp: toSafeNumber(readField(data, 'timestamp', 'timestamp'), 'timestamp'), + invoiceId: event.number('invoice_id'), + merchant: event.string('merchant'), + amount: event.bigint('amount'), + totalAmountRefunded: event.bigint('total_amount_refunded'), + timestamp: event.number('timestamp'), }; }; diff --git a/src/routes/admin/analytics.routes.ts b/src/routes/admin/analytics.routes.ts new file mode 100644 index 0000000..ce2f144 --- /dev/null +++ b/src/routes/admin/analytics.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { + getAnalyticsSummaryController, + getAnalyticsTimeseriesController, + getAnalyticsTokensController, +} from '../../controllers/admin-analytics.controllers.js'; +import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; + +const router = Router(); + +// Read-only dashboard data: any authenticated admin, no superadmin requirement. +router.use(authenticateAdmin); + +router.get('/summary', getAnalyticsSummaryController); +router.get('/timeseries', getAnalyticsTimeseriesController); +router.get('/tokens', getAnalyticsTokensController); + +export default router; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index f1833d3..f4e31cd 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import authRoutes from './auth.routes.js'; +import analyticsRoutes from './analytics.routes.js'; import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; @@ -9,6 +10,11 @@ const router = Router(); // Public: issues the wallet challenge/verify pair, no admin session yet. router.use('/auth', authRoutes); +// Protected: the router applies authenticateAdmin to every route it owns. +router.use('/analytics', analyticsRoutes); + +// Sibling routers added by later issues (merchant.routes.ts, invoice.routes.ts, ...) +// are mounted here behind authenticateAdmin. router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); diff --git a/src/services/analytics.services.ts b/src/services/analytics.services.ts new file mode 100644 index 0000000..c4496d0 --- /dev/null +++ b/src/services/analytics.services.ts @@ -0,0 +1,301 @@ +import prisma from '../config/prisma.js'; +import { AppError } from '../utils/errors.js'; + +/** + * Prisma's interactive-transaction client is structurally identical to the + * root client for the models used here, but its generated type does not + * compose with the deep-mocked client used in tests. Handlers therefore pass + * it through untyped, matching `applyInvoicePayment`'s existing `tx: any`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnalyticsWriteClient = any; + +const DEFAULT_TIMESERIES_DAYS = 30; +const DEFAULT_TOKEN_LIMIT = 10; +const MAX_TOKEN_LIMIT = 100; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Truncates an instant to midnight UTC, the key for a PlatformDailyStats row. */ +export const startOfUtcDay = (date: Date): Date => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + +export interface DailyStatsDelta { + totalVolume?: bigint; + totalFees?: bigint; + transactionCount?: bigint; + newInvoices?: number; + newMerchants?: number; + newSubscriptions?: number; + newTickets?: number; +} + +/** + * Upserts today's (by UTC calendar day) PlatformDailyStats row, incrementing + * only the fields present in `delta`. Rows are created on demand as events + * arrive rather than by a scheduled batch job, consistent with how + * MerchantAnalytics/TokenAnalytics are maintained. + */ +export const recordDailyStats = async ( + client: AnalyticsWriteClient, + occurredAt: Date, + delta: DailyStatsDelta, +): Promise => { + const create: Record = { date: startOfUtcDay(occurredAt) }; + const update: Record = {}; + + for (const [field, value] of Object.entries(delta)) { + if (value === undefined) continue; + create[field] = value; + update[field] = { increment: value }; + } + + await client.platformDailyStats.upsert({ + where: { date: startOfUtcDay(occurredAt) }, + create, + update, + }); +}; + +export interface VolumeEvent { + /** Merchant.id (uuid), not the on-chain numeric merchantId. */ + merchantId: string; + token: string; + /** Gross amount, matching what the contract feeds its own analytics. */ + volume: bigint; + /** Platform fee taken from the gross amount; 0 where no fee applies. */ + fee: bigint; + occurredAt: Date; +} + +/** + * Applies one volume-moving payment (invoice, subscription charge, ticket sale) + * to every analytics projection: the merchant's per-token counters, the + * protocol's per-token counters, and the protocol-wide daily rollup. + * + * Must be called inside the same transaction as the caller's own writes so a + * failure cannot leave the projections ahead of the source records. + */ +export const recordVolumeEvent = async ( + tx: AnalyticsWriteClient, + { merchantId, token, volume, fee, occurredAt }: VolumeEvent, +): Promise => { + // Read before the upsert: an existing row means this merchant has already + // been counted against this token's uniqueMerchants tally. + const existingMerchantAnalytics = await tx.merchantAnalytics.findUnique({ + where: { merchantId_token: { merchantId, token } }, + select: { id: true }, + }); + const isMerchantNewForToken = !existingMerchantAnalytics; + + await tx.merchantAnalytics.upsert({ + where: { merchantId_token: { merchantId, token } }, + create: { + merchantId, + token, + totalVolume: volume, + totalFees: fee, + transactionCount: 1n, + }, + update: { + totalVolume: { increment: volume }, + totalFees: { increment: fee }, + transactionCount: { increment: 1n }, + }, + }); + + await tx.tokenAnalytics.upsert({ + where: { token }, + create: { + token, + totalVolume: volume, + totalFees: fee, + transactionCount: 1n, + // Creating the row implies its first merchant has just transacted. + uniqueMerchants: 1, + }, + update: { + totalVolume: { increment: volume }, + totalFees: { increment: fee }, + transactionCount: { increment: 1n }, + ...(isMerchantNewForToken ? { uniqueMerchants: { increment: 1 } } : {}), + }, + }); + + await recordDailyStats(tx, occurredAt, { + totalVolume: volume, + totalFees: fee, + transactionCount: 1n, + }); +}; + +// ── Read side (admin dashboard) ─────────────────────────────────────────────── + +const toStringAmount = (value: bigint | number | null | undefined): string => + (value ?? 0n).toString(); + +const countByKey = ( + groups: { _count: { _all: number } }[], + key: (group: any) => T, // eslint-disable-line @typescript-eslint/no-explicit-any +): Record => { + const counts: Record = {}; + for (const group of groups) { + counts[key(group)] = group._count._all; + } + return counts; +}; + +const sumCounts = (counts: Record): number => + Object.values(counts).reduce((total, count) => total + count, 0); + +/** + * Protocol-wide current totals. Volume/fee/transaction totals come from + * TokenAnalytics (the same rows the per-token endpoint serves, so the two can + * never disagree); everything else is a live count at request time. + * + * Refunds are reported as a separate total rather than netted against volume: + * the contract's `record_merchant_payment` is never called from a refund path, + * so on-chain `total_volume` is not reduced by a refund either. + */ +export const getAnalyticsSummary = async () => { + const [ + tokenTotals, + merchantsWithVolume, + refundTotals, + merchantCount, + activeMerchantCount, + verifiedMerchantCount, + invoicesByStatus, + subscriptionsByStatus, + ] = await Promise.all([ + prisma.tokenAnalytics.aggregate({ + _sum: { totalVolume: true, totalFees: true, transactionCount: true }, + _count: { _all: true }, + }), + // Prisma's typed API has no scalar COUNT(DISTINCT ...), and groupBy would + // stream back one row per merchant just to be counted. Same convention as + // the advisory lock in auth.services.ts: drop to raw SQL where the typed + // API cannot express the query. + prisma.$queryRaw<{ count: number }[]>` + SELECT COUNT(DISTINCT "merchantId")::int AS count FROM "MerchantAnalytics" + `, + prisma.invoice.aggregate({ _sum: { amountRefunded: true } }), + prisma.merchant.count(), + prisma.merchant.count({ where: { active: true } }), + prisma.merchant.count({ where: { verified: true } }), + prisma.invoice.groupBy({ by: ['status'], _count: { _all: true } }), + prisma.subscription.groupBy({ by: ['status'], _count: { _all: true } }), + ]); + + const invoiceCounts = countByKey(invoicesByStatus, group => group.status); + const subscriptionCounts = countByKey(subscriptionsByStatus, group => group.status); + + return { + totals: { + totalVolume: toStringAmount(tokenTotals._sum?.totalVolume), + totalFees: toStringAmount(tokenTotals._sum?.totalFees), + transactionCount: toStringAmount(tokenTotals._sum?.transactionCount), + totalRefunded: toStringAmount(refundTotals._sum?.amountRefunded), + tokens: tokenTotals._count?._all ?? 0, + merchantsWithVolume: merchantsWithVolume[0]?.count ?? 0, + }, + merchants: { + total: merchantCount, + active: activeMerchantCount, + verified: verifiedMerchantCount, + }, + invoices: { + total: sumCounts(invoiceCounts), + byStatus: invoiceCounts, + }, + subscriptions: { + total: sumCounts(subscriptionCounts), + byStatus: subscriptionCounts, + }, + }; +}; + +const parseDateParam = (value: unknown, field: string): Date | null => { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string') { + throw new AppError(400, `${field} must be an ISO date string`); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new AppError(400, `${field} must be an ISO date string`); + } + return parsed; +}; + +/** + * Daily PlatformDailyStats rows for the requested range, oldest first. + * + * Both bounds are optional and inclusive; the default window is the last 30 + * days. A range with no activity is an empty `data` array, not an error — the + * indexer only creates rows for days that saw events. + */ +export const getAnalyticsTimeseries = async (query: Record) => { + const to = parseDateParam(query.to, 'to') ?? new Date(); + const from = + parseDateParam(query.from, 'from') ?? + new Date(startOfUtcDay(to).getTime() - (DEFAULT_TIMESERIES_DAYS - 1) * MS_PER_DAY); + + const fromDay = startOfUtcDay(from); + const toDay = startOfUtcDay(to); + + if (fromDay > toDay) { + throw new AppError(400, 'from must not be after to'); + } + + const rows = await prisma.platformDailyStats.findMany({ + where: { date: { gte: fromDay, lte: toDay } }, + orderBy: { date: 'asc' }, + }); + + return { + from: fromDay.toISOString(), + to: toDay.toISOString(), + data: rows.map(row => ({ + date: row.date.toISOString(), + totalVolume: row.totalVolume.toString(), + totalFees: row.totalFees.toString(), + transactionCount: row.transactionCount.toString(), + newInvoices: row.newInvoices, + newMerchants: row.newMerchants, + newSubscriptions: row.newSubscriptions, + newTickets: row.newTickets, + })), + }; +}; + +/** + * Top tokens by volume, served from TokenAnalytics rather than the contract's + * `get_top_tokens_by_volume` — the numbers are the same projection and a + * frequently refreshed dashboard should not pay for a live contract call. + */ +export const getTopTokensByVolume = async (query: Record) => { + let limit = DEFAULT_TOKEN_LIMIT; + + if (query.limit !== undefined && query.limit !== '') { + const parsed = Number(query.limit); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_TOKEN_LIMIT) { + throw new AppError(400, `limit must be an integer between 1 and ${MAX_TOKEN_LIMIT}`); + } + limit = parsed; + } + + const tokens = await prisma.tokenAnalytics.findMany({ + orderBy: { totalVolume: 'desc' }, + take: limit, + }); + + return { + data: tokens.map(token => ({ + token: token.token, + totalVolume: token.totalVolume.toString(), + totalFees: token.totalFees.toString(), + transactionCount: token.transactionCount.toString(), + uniqueMerchants: token.uniqueMerchants, + lastUpdated: token.lastUpdated.toISOString(), + })), + }; +}; diff --git a/src/services/invoice.services.ts b/src/services/invoice.services.ts index f276870..90f4d5d 100644 --- a/src/services/invoice.services.ts +++ b/src/services/invoice.services.ts @@ -8,7 +8,12 @@ import { InvoicePagination, parseAmount, } from '../utils/invoice.validation.js'; -import type { InvoicePaidEventData } from '../indexer/types.js'; +import type { + InvoicePaidEventData, + InvoicePartiallyRefundedEventData, + InvoiceRefundedEventData, +} from '../indexer/types.js'; +import { recordVolumeEvent } from './analytics.services.js'; import { recordAuditLog, ActorType } from './audit-log.services.js'; const SLUG_MAX_RETRIES = 5; @@ -23,6 +28,8 @@ const InvoiceStatus = { PAID: 'PAID', PARTIALLY_PAID: 'PARTIALLY_PAID', CANCELLED: 'CANCELLED', + REFUNDED: 'REFUNDED', + PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED', } as const satisfies Record; const TransactionType = { @@ -310,6 +317,16 @@ export const applyInvoicePayment = async (event: InvoicePaidEventData, txHash: s }, }); + await recordVolumeEvent(tx, { + merchantId: merchant.id, + token: event.token, + // The contract feeds its own analytics the gross amount and the fee taken + // out of it, so the projection mirrors that rather than merchantAmount. + volume: event.amount, + fee: event.fee, + occurredAt: paidAt, + }); + return { invoice: updatedInvoice, transaction }; }); @@ -330,3 +347,62 @@ export const applyInvoicePayment = async (event: InvoicePaidEventData, txHash: s return result; }; + +const clampRefund = (reported: bigint, alreadyRefunded: bigint, invoiceAmount: bigint): bigint => { + if (reported > invoiceAmount) return invoiceAmount; + if (reported < alreadyRefunded) return alreadyRefunded; + return reported; +}; + +/** + * Applies a confirmed on-chain refund to the backend projection. + * + * Refunds deliberately do not touch MerchantAnalytics/TokenAnalytics/ + * PlatformDailyStats: the contract's `record_merchant_payment` is only reached + * from the payment paths (invoice payment, subscription charge, ticket sale) + * and never from `refund_invoice`/`refund_invoice_partial`, so on-chain + * `total_volume` is not reduced by a refund. Refund totals are read back + * separately from `Invoice.amountRefunded`. + */ +export const applyInvoiceRefund = async ( + event: InvoiceRefundedEventData | InvoicePartiallyRefundedEventData, + txHash: string, +) => { + const invoice = await prisma.invoice.findUnique({ + where: { invoiceId: event.invoiceId }, + }); + + if (!invoice) { + console.warn( + `Refund event for invoice ${event.invoiceId} (${txHash}) skipped: invoice is not in the database.`, + ); + return null; + } + + return prisma.$transaction(async (tx: any) => { + const current = await tx.invoice.findUniqueOrThrow({ + where: { invoiceId: event.invoiceId }, + }); + + // Both refund events report an absolute total, never an increment: the + // partial event carries `total_amount_refunded`, and the full event's + // `amount` is the invoice's whole amount — `refund_invoice` and the + // completing branch of `refund_invoice_partial` both pass `invoice.amount`, + // not the chunk just refunded. + const reportedRefund = + 'totalAmountRefunded' in event ? event.totalAmountRefunded : event.amount; + + // Clamped to the invoice total and never allowed to move backwards, so a + // replayed or out-of-order refund event cannot skew the refund total that + // /admin/analytics/summary reports. + const amountRefunded = clampRefund(reportedRefund, current.amountRefunded, current.amount); + + const status: PrismaInvoiceStatus = + amountRefunded >= current.amount ? InvoiceStatus.REFUNDED : InvoiceStatus.PARTIALLY_REFUNDED; + + return tx.invoice.update({ + where: { id: current.id }, + data: { amountRefunded, status }, + }); + }); +}; diff --git a/src/services/subscription.services.ts b/src/services/subscription.services.ts new file mode 100644 index 0000000..77b0aec --- /dev/null +++ b/src/services/subscription.services.ts @@ -0,0 +1,75 @@ +import prisma from '../config/prisma.js'; +import type { SubscriptionChargedEventData } from '../indexer/types.js'; +import { recordVolumeEvent } from './analytics.services.js'; + +// String constant matching the Prisma `TransactionType` enum. Defined locally so +// this module never imports a runtime value from `@prisma/client` (the generated +// client is mocked in tests and not generated in CI). +const TransactionType = { + SUBSCRIPTION_CHARGE: 'SUBSCRIPTION_CHARGE', +} as const; + +/** + * Applies a confirmed on-chain subscription charge to the backend projection. + * + * The merchant is resolved through the stored Subscription rather than the + * event's `merchant` address, so a charge can only ever be attributed to the + * merchant the backend already has linked to that subscription. A charge for a + * subscription the backend has not indexed yet is skipped rather than guessed + * at; the indexer's IndexerEvent table remains the only replay guard. + */ +export const applySubscriptionCharge = async ( + event: SubscriptionChargedEventData, + txHash: string, +) => { + const subscription = await prisma.subscription.findUnique({ + where: { subscriptionId: event.subscriptionId }, + }); + + if (!subscription) { + console.warn( + `SubscriptionCharged event for subscription ${event.subscriptionId} (${txHash}) skipped: subscription is not in the database.`, + ); + return null; + } + + const chargedAt = new Date(event.timestamp * 1000); + const description = `Subscription #${event.subscriptionId} charge${txHash ? ` (${txHash})` : ''}`; + + return prisma.$transaction(async (tx: any) => { + const updatedSubscription = await tx.subscription.update({ + where: { id: subscription.id }, + data: { + // A replayed or out-of-order charge must not walk lastCharged backwards. + lastCharged: + subscription.lastCharged && subscription.lastCharged > chargedAt + ? subscription.lastCharged + : chargedAt, + }, + }); + + const transaction = await tx.transaction.create({ + data: { + transactionType: TransactionType.SUBSCRIPTION_CHARGE, + refId: event.subscriptionId, + amount: event.amount, + token: event.token, + description, + merchantId: subscription.merchantId, + date: chargedAt, + }, + }); + + await recordVolumeEvent(tx, { + merchantId: subscription.merchantId, + token: event.token, + // The contract records the gross plan amount as volume and the fee taken + // out of it, so the projection mirrors that. + volume: event.amount, + fee: event.fee, + occurredAt: chargedAt, + }); + + return { subscription: updatedSubscription, transaction }; + }); +}; diff --git a/src/services/ticket.services.ts b/src/services/ticket.services.ts new file mode 100644 index 0000000..164390f --- /dev/null +++ b/src/services/ticket.services.ts @@ -0,0 +1,65 @@ +import prisma from '../config/prisma.js'; +import type { TicketPurchasedEventData, TicketResoldEventData } from '../indexer/types.js'; +import { recordDailyStats, recordVolumeEvent } from './analytics.services.js'; + +/** + * Event ticketing has no backend models yet — there is no `Event` or `Ticket` + * table to write a sale into, and building one is a separate piece of work. + * What these handlers can do without it is keep the analytics projections + * honest: a ticket sale moves real volume for a merchant that *is* in the + * database (the event carries the on-chain `merchant_id`), so it is folded into + * MerchantAnalytics/TokenAnalytics/PlatformDailyStats exactly like an invoice + * payment. No Transaction row is written: the Prisma `TransactionType` enum has + * no ticketing member, and adding one belongs with the ticketing models. + */ +const findMerchant = async (merchantId: number, label: string, txHash: string) => { + const merchant = await prisma.merchant.findUnique({ where: { merchantId } }); + + if (!merchant) { + console.warn( + `${label} event (${txHash}) skipped: merchant ${merchantId} is not in the database.`, + ); + return null; + } + + return merchant; +}; + +export const applyTicketPurchase = async (event: TicketPurchasedEventData, txHash: string) => { + const merchant = await findMerchant(event.merchantId, 'TicketPurchased', txHash); + if (!merchant) return null; + + const purchasedAt = new Date(event.timestamp * 1000); + + return prisma.$transaction(async (tx: any) => { + await recordVolumeEvent(tx, { + merchantId: merchant.id, + token: event.token, + volume: event.amount, + fee: event.fee, + occurredAt: purchasedAt, + }); + + await recordDailyStats(tx, purchasedAt, { newTickets: 1 }); + }); +}; + +export const applyTicketResale = async (event: TicketResoldEventData, txHash: string) => { + const merchant = await findMerchant(event.merchantId, 'TicketResold', txHash); + if (!merchant) return null; + + const resoldAt = new Date(event.timestamp * 1000); + + return prisma.$transaction(async (tx: any) => { + // A resale is peer-to-peer: the merchant's take is the royalty and the + // contract charges no platform fee on it, so the royalty is the volume and + // the fee is zero. `newTickets` is not incremented — no ticket was minted. + await recordVolumeEvent(tx, { + merchantId: merchant.id, + token: event.token, + volume: event.royalty, + fee: 0n, + occurredAt: resoldAt, + }); + }); +}; diff --git a/tests/integration/admin.analytics.routes.test.ts b/tests/integration/admin.analytics.routes.test.ts new file mode 100644 index 0000000..5f8a6ec --- /dev/null +++ b/tests/integration/admin.analytics.routes.test.ts @@ -0,0 +1,207 @@ +import { beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMIN', + active: true, + isSuperAdmin: false, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +}; + +const adminToken = () => + jwt.sign({ sub: admin.id, address: admin.address, type: 'admin' }, environment.jwtSecret, { + expiresIn: '15m', + }); + +const ENDPOINTS = [ + '/api/v1/admin/analytics/summary', + '/api/v1/admin/analytics/timeseries', + '/api/v1/admin/analytics/tokens', +]; + +const stubSummaryQueries = () => { + prismaMock.tokenAnalytics.aggregate.mockResolvedValue({ + _sum: { totalVolume: 5_000_000n, totalFees: 50_000n, transactionCount: 7n }, + _count: { _all: 2 }, + }); + prismaMock.$queryRaw.mockResolvedValue([{ count: 1 }]); + prismaMock.invoice.aggregate.mockResolvedValue({ _sum: { amountRefunded: 0n } }); + prismaMock.merchant.count.mockResolvedValue(4); + prismaMock.invoice.groupBy.mockResolvedValue([{ status: 'PAID', _count: { _all: 6 } }]); + prismaMock.subscription.groupBy.mockResolvedValue([{ status: 'ACTIVE', _count: { _all: 3 } }]); +}; + +describe('Admin analytics routes', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + describe('authentication', () => { + test.each(ENDPOINTS)('%s rejects an unauthenticated request', async endpoint => { + const response = await request(app).get(endpoint); + + expect(response.status).toBe(401); + }); + + test.each(ENDPOINTS)('%s rejects a merchant JWT', async endpoint => { + const merchantToken = jwt.sign({ sub: 'merchant-uuid' }, environment.jwtSecret, { + expiresIn: '15m', + }); + + const response = await request(app) + .get(endpoint) + .set('Authorization', `Bearer ${merchantToken}`); + + expect(response.status).toBe(401); + }); + + test.each(ENDPOINTS)('%s serves a non-superadmin admin', async endpoint => { + stubSummaryQueries(); + prismaMock.platformDailyStats.findMany.mockResolvedValue([]); + prismaMock.tokenAnalytics.findMany.mockResolvedValue([]); + + const response = await request(app) + .get(endpoint) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + }); + }); + + describe('GET /admin/analytics/summary', () => { + test('returns protocol totals and live counts', async () => { + stubSummaryQueries(); + + const response = await request(app) + .get('/api/v1/admin/analytics/summary') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(response.body.totals).toEqual({ + totalVolume: '5000000', + totalFees: '50000', + transactionCount: '7', + totalRefunded: '0', + tokens: 2, + merchantsWithVolume: 1, + }); + expect(response.body.invoices).toEqual({ total: 6, byStatus: { PAID: 6 } }); + expect(response.body.subscriptions).toEqual({ total: 3, byStatus: { ACTIVE: 3 } }); + }); + }); + + describe('GET /admin/analytics/timeseries', () => { + test('returns the daily rows for the requested range', async () => { + prismaMock.platformDailyStats.findMany.mockResolvedValue([ + { + id: 'daily-uuid', + date: new Date('2026-08-20T00:00:00.000Z'), + totalVolume: 1_000_000n, + totalFees: 10_000n, + transactionCount: 2n, + newInvoices: 3, + newMerchants: 1, + newSubscriptions: 0, + newTickets: 4, + updatedAt: new Date('2026-08-20T10:00:00.000Z'), + }, + ]); + + const response = await request(app) + .get('/api/v1/admin/analytics/timeseries?from=2026-08-19&to=2026-08-21') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([ + { + date: '2026-08-20T00:00:00.000Z', + totalVolume: '1000000', + totalFees: '10000', + transactionCount: '2', + newInvoices: 3, + newMerchants: 1, + newSubscriptions: 0, + newTickets: 4, + }, + ]); + }); + + test('returns an empty array, not an error, for a range with no activity', async () => { + prismaMock.platformDailyStats.findMany.mockResolvedValue([]); + + const response = await request(app) + .get('/api/v1/admin/analytics/timeseries?from=2020-01-01&to=2020-01-31') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + }); + + test('rejects an inverted range', async () => { + const response = await request(app) + .get('/api/v1/admin/analytics/timeseries?from=2026-08-21&to=2026-08-19') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(400); + expect(prismaMock.platformDailyStats.findMany).not.toHaveBeenCalled(); + }); + }); + + describe('GET /admin/analytics/tokens', () => { + test('returns tokens ordered by volume descending', async () => { + prismaMock.tokenAnalytics.findMany.mockResolvedValue([ + { + id: 'token-1', + token: 'CUSDC', + totalVolume: 9_000_000n, + totalFees: 90_000n, + transactionCount: 12n, + uniqueMerchants: 3, + lastUpdated: new Date('2026-08-21T10:00:00.000Z'), + }, + { + id: 'token-2', + token: 'CXLM', + totalVolume: 1_000_000n, + totalFees: 10_000n, + transactionCount: 2n, + uniqueMerchants: 1, + lastUpdated: new Date('2026-08-21T10:00:00.000Z'), + }, + ]); + + const response = await request(app) + .get('/api/v1/admin/analytics/tokens') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(prismaMock.tokenAnalytics.findMany).toHaveBeenCalledWith({ + orderBy: { totalVolume: 'desc' }, + take: 10, + }); + expect(response.body.data.map((token: { token: string }) => token.token)).toEqual([ + 'CUSDC', + 'CXLM', + ]); + expect(response.body.data[0].totalVolume).toBe('9000000'); + }); + + test('rejects an out-of-range limit', async () => { + const response = await request(app) + .get('/api/v1/admin/analytics/tokens?limit=500') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(400); + expect(prismaMock.tokenAnalytics.findMany).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/analytics.indexer.test.ts b/tests/unit/analytics.indexer.test.ts new file mode 100644 index 0000000..eb54855 --- /dev/null +++ b/tests/unit/analytics.indexer.test.ts @@ -0,0 +1,560 @@ +import { jest, beforeEach, afterEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { applyInvoicePayment, applyInvoiceRefund } = await import( + '../../src/services/invoice.services.js' +); +const { applySubscriptionCharge } = await import('../../src/services/subscription.services.js'); +const { applyTicketPurchase, applyTicketResale } = await import( + '../../src/services/ticket.services.js' +); +const { + decodeSubscriptionChargedEventData, + decodeTicketResoldEventData, + decodeInvoicePartiallyRefundedEventData, +} = await import('../../src/indexer/types.js'); +const { dispatch } = await import('../../src/indexer/registry.js'); +await import('../../src/indexer/handlers/index.js'); + +const MERCHANT_UUID = 'merchant-uuid'; +const TOKEN = 'CABC...TOKEN'; +const TIMESTAMP = 1_787_318_100; // 2026-08-21T13:15:00Z +const OCCURRED_AT = new Date(TIMESTAMP * 1000); +const DAY = new Date('2026-08-21T00:00:00.000Z'); + +const merchant = { id: MERCHANT_UUID, merchantId: 7 }; + +const dailyStatsCallFor = (field: string) => + prismaMock.platformDailyStats.upsert.mock.calls.find( + ([args]: [any]) => args.update[field] !== undefined, + )?.[0]; + +beforeEach(() => { + mockReset(prismaMock); + prismaMock.$transaction.mockImplementation(async (callback: any) => callback(prismaMock)); + prismaMock.merchantAnalytics.findUnique.mockResolvedValue(null); +}); + +describe('applyInvoicePayment analytics retrofit', () => { + const paymentEvent = { + invoiceId: 101, + merchantId: 7, + payer: 'GPAYER', + amount: 2000n, + fee: 20n, + merchantAmount: 1980n, + token: TOKEN, + timestamp: TIMESTAMP, + }; + + const invoice = { + id: 'invoice-uuid', + invoiceId: 101, + amount: 5000n, + amountPaid: 0n, + amountRefunded: 0n, + merchantId: MERCHANT_UUID, + }; + + beforeEach(() => { + prismaMock.invoice.findUnique.mockResolvedValue(invoice); + prismaMock.invoice.findUniqueOrThrow.mockResolvedValue(invoice); + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.invoice.update.mockResolvedValue(invoice); + prismaMock.transaction.create.mockResolvedValue({ id: 'transaction-uuid' }); + }); + + test('updates all three analytics projections inside the payment transaction', async () => { + await applyInvoicePayment(paymentEvent, 'tx-hash'); + + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1); + expect(prismaMock.merchantAnalytics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { merchantId_token: { merchantId: MERCHANT_UUID, token: TOKEN } }, + update: { + totalVolume: { increment: 2000n }, + totalFees: { increment: 20n }, + transactionCount: { increment: 1n }, + }, + }), + ); + expect(prismaMock.tokenAnalytics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { token: TOKEN } }), + ); + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: DAY }, + create: { date: DAY, totalVolume: 2000n, totalFees: 20n, transactionCount: 1n }, + update: { + totalVolume: { increment: 2000n }, + totalFees: { increment: 20n }, + transactionCount: { increment: 1n }, + }, + }); + }); + + test('records the gross amount as volume, not the merchant net', async () => { + await applyInvoicePayment(paymentEvent, 'tx-hash'); + + const merchantUpsert = prismaMock.merchantAnalytics.upsert.mock.calls[0][0]; + expect(merchantUpsert.update.totalVolume).toEqual({ increment: paymentEvent.amount }); + expect(merchantUpsert.update.totalVolume).not.toEqual({ + increment: paymentEvent.merchantAmount, + }); + }); + + test('writes no analytics when the invoice is unknown', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(null); + + expect(await applyInvoicePayment(paymentEvent, 'tx-hash')).toBeNull(); + expect(prismaMock.merchantAnalytics.upsert).not.toHaveBeenCalled(); + expect(prismaMock.platformDailyStats.upsert).not.toHaveBeenCalled(); + }); +}); + +describe('applySubscriptionCharge', () => { + const chargeEvent = { + subscriptionId: 501, + planId: 12, + customer: 'GCUSTOMER', + merchant: 'GMERCHANT', + amount: 10_000n, + fee: 100n, + token: TOKEN, + timestamp: TIMESTAMP, + }; + + const subscription = { + id: 'subscription-uuid', + subscriptionId: 501, + planId: 'plan-uuid', + merchantId: MERCHANT_UUID, + }; + + test('decodes the contract event map emitted by scValToNative', () => { + expect( + decodeSubscriptionChargedEventData({ + subscription_id: 501n, + plan_id: 12n, + customer: 'GCUSTOMER', + merchant: 'GMERCHANT', + amount: 10_000n, + fee: 100n, + token: TOKEN, + timestamp: BigInt(TIMESTAMP), + }), + ).toEqual(chargeEvent); + }); + + test('attributes the charge to the subscription owner, not the event merchant', async () => { + prismaMock.subscription.findUnique.mockResolvedValue(subscription); + prismaMock.subscription.update.mockResolvedValue(subscription); + prismaMock.transaction.create.mockResolvedValue({ id: 'transaction-uuid' }); + + await applySubscriptionCharge(chargeEvent, 'tx-hash'); + + expect(prismaMock.subscription.findUnique).toHaveBeenCalledWith({ + where: { subscriptionId: 501 }, + }); + expect(prismaMock.subscription.update).toHaveBeenCalledWith({ + where: { id: subscription.id }, + data: { lastCharged: OCCURRED_AT }, + }); + expect(prismaMock.transaction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + transactionType: 'SUBSCRIPTION_CHARGE', + refId: 501, + amount: 10_000n, + token: TOKEN, + merchantId: MERCHANT_UUID, + date: OCCURRED_AT, + }), + }); + expect(prismaMock.merchantAnalytics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { merchantId_token: { merchantId: MERCHANT_UUID, token: TOKEN } }, + }), + ); + expect(dailyStatsCallFor('totalVolume').update).toEqual({ + totalVolume: { increment: 10_000n }, + totalFees: { increment: 100n }, + transactionCount: { increment: 1n }, + }); + }); + + test('skips a charge for a subscription the backend has not indexed', async () => { + prismaMock.subscription.findUnique.mockResolvedValue(null); + + expect(await applySubscriptionCharge(chargeEvent, 'tx-hash')).toBeNull(); + expect(prismaMock.$transaction).not.toHaveBeenCalled(); + }); + + test('is registered for the contract topic', async () => { + prismaMock.subscription.findUnique.mockResolvedValue(null); + + await dispatch({ + id: 'event-1', + topic: 'subscription_charged_event', + ledger: 1, + txHash: 'tx-hash', + data: { + subscription_id: 501n, + plan_id: 12n, + customer: 'GCUSTOMER', + merchant: 'GMERCHANT', + amount: 10_000n, + fee: 100n, + token: TOKEN, + timestamp: BigInt(TIMESTAMP), + }, + }); + + expect(prismaMock.subscription.findUnique).toHaveBeenCalled(); + }); +}); + +describe('ticketing handlers', () => { + const purchaseEvent = { + ticketId: 9, + eventId: 3, + merchantId: 7, + buyer: 'GBUYER', + amount: 4000n, + fee: 40n, + merchantAmount: 3960n, + token: TOKEN, + timestamp: TIMESTAMP, + }; + + const resaleEvent = { + ticketId: 9, + eventId: 3, + merchantId: 7, + seller: 'GSELLER', + buyer: 'GBUYER', + resalePrice: 6000n, + royalty: 300n, + sellerProceeds: 5700n, + token: TOKEN, + timestamp: TIMESTAMP, + }; + + test('a purchase moves volume and bumps the daily ticket counter', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + + await applyTicketPurchase(purchaseEvent, 'tx-hash'); + + expect(prismaMock.merchantAnalytics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { + totalVolume: { increment: 4000n }, + totalFees: { increment: 40n }, + transactionCount: { increment: 1n }, + }, + }), + ); + expect(dailyStatsCallFor('newTickets')).toEqual({ + where: { date: DAY }, + create: { date: DAY, newTickets: 1 }, + update: { newTickets: { increment: 1 } }, + }); + }); + + test('a resale counts the royalty as volume with no platform fee and no new ticket', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + + await applyTicketResale(resaleEvent, 'tx-hash'); + + expect(prismaMock.merchantAnalytics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { + totalVolume: { increment: 300n }, + totalFees: { increment: 0n }, + transactionCount: { increment: 1n }, + }, + }), + ); + expect(dailyStatsCallFor('newTickets')).toBeUndefined(); + }); + + test('decodes the resale event map emitted by scValToNative', () => { + expect( + decodeTicketResoldEventData({ + ticket_id: 9n, + event_id: 3n, + merchant_id: 7n, + seller: 'GSELLER', + buyer: 'GBUYER', + resale_price: 6000n, + royalty: 300n, + seller_proceeds: 5700n, + token: TOKEN, + timestamp: BigInt(TIMESTAMP), + }), + ).toEqual(resaleEvent); + }); + + test('skips a sale for a merchant the backend does not know', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + expect(await applyTicketPurchase(purchaseEvent, 'tx-hash')).toBeNull(); + expect(prismaMock.$transaction).not.toHaveBeenCalled(); + }); +}); + +describe('refund handlers', () => { + const invoice = { + id: 'invoice-uuid', + invoiceId: 101, + amount: 5000n, + amountPaid: 5000n, + amountRefunded: 1000n, + merchantId: MERCHANT_UUID, + }; + + beforeEach(() => { + prismaMock.invoice.findUnique.mockResolvedValue(invoice); + prismaMock.invoice.findUniqueOrThrow.mockResolvedValue(invoice); + prismaMock.invoice.update.mockResolvedValue(invoice); + }); + + test('a partial refund trusts the running total on the event', async () => { + await applyInvoiceRefund( + decodeInvoicePartiallyRefundedEventData({ + invoice_id: 101n, + merchant: 'GMERCHANT', + amount: 2000n, + total_amount_refunded: 3000n, + timestamp: BigInt(TIMESTAMP), + }), + 'tx-hash', + ); + + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: invoice.id }, + data: { amountRefunded: 3000n, status: 'PARTIALLY_REFUNDED' }, + }); + }); + + test('a full refund takes the whole invoice amount the event reports', async () => { + // Both refund_invoice and the completing branch of refund_invoice_partial + // publish invoice.amount here, not the chunk just refunded. + await applyInvoiceRefund( + { + invoiceId: 101, + merchant: 'GMERCHANT', + amount: 5000n, + timestamp: TIMESTAMP, + }, + 'tx-hash', + ); + + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: invoice.id }, + data: { amountRefunded: 5000n, status: 'REFUNDED' }, + }); + }); + + test('clamps a refund total that would exceed the invoice amount', async () => { + await applyInvoiceRefund( + decodeInvoicePartiallyRefundedEventData({ + invoice_id: 101n, + merchant: 'GMERCHANT', + amount: 9000n, + total_amount_refunded: 9000n, + timestamp: BigInt(TIMESTAMP), + }), + 'tx-hash', + ); + + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: invoice.id }, + data: { amountRefunded: 5000n, status: 'REFUNDED' }, + }); + }); + + test('never walks the refund total backwards on a replayed event', async () => { + // 1000n is already refunded; replaying an earlier partial must not undo it. + await applyInvoiceRefund( + decodeInvoicePartiallyRefundedEventData({ + invoice_id: 101n, + merchant: 'GMERCHANT', + amount: 500n, + total_amount_refunded: 500n, + timestamp: BigInt(TIMESTAMP), + }), + 'tx-hash', + ); + + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: invoice.id }, + data: { amountRefunded: 1000n, status: 'PARTIALLY_REFUNDED' }, + }); + }); + + test('never nets a refund off the volume projections', async () => { + await applyInvoiceRefund( + { invoiceId: 101, merchant: 'GMERCHANT', amount: 5000n, timestamp: TIMESTAMP }, + 'tx-hash', + ); + + expect(prismaMock.merchantAnalytics.upsert).not.toHaveBeenCalled(); + expect(prismaMock.tokenAnalytics.upsert).not.toHaveBeenCalled(); + expect(prismaMock.platformDailyStats.upsert).not.toHaveBeenCalled(); + }); + + test('skips a refund for an invoice the backend does not have', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(null); + + expect( + await applyInvoiceRefund( + { invoiceId: 101, merchant: 'GMERCHANT', amount: 5000n, timestamp: TIMESTAMP }, + 'tx-hash', + ), + ).toBeNull(); + expect(prismaMock.$transaction).not.toHaveBeenCalled(); + }); +}); + +describe('growth handlers', () => { + const dispatchGrowth = (topic: string, data: Record) => + dispatch({ id: `${topic}-1`, topic, ledger: 1, txHash: 'tx-hash', data }); + + test('a merchant registration bumps newMerchants for the event day', async () => { + await dispatchGrowth('merchant_registered_event', { + merchant: 'GMERCHANT', + merchant_id: 7n, + timestamp: BigInt(TIMESTAMP), + }); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: DAY }, + create: { date: DAY, newMerchants: 1 }, + update: { newMerchants: { increment: 1 } }, + }); + }); + + test('a subscription bumps newSubscriptions for the event day', async () => { + await dispatchGrowth('subscribed_event', { + subscription_id: 501n, + plan_id: 12n, + customer: 'GCUSTOMER', + timestamp: BigInt(TIMESTAMP), + }); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: DAY }, + create: { date: DAY, newSubscriptions: 1 }, + update: { newSubscriptions: { increment: 1 } }, + }); + }); + + describe('invoice creation', () => { + const invoiceCreatedData = { + invoice_id: 101n, + merchant: 'GMERCHANT', + amount: 5000n, + token: TOKEN, + }; + + beforeEach(() => { + // A replay running on a much later day: the indexing time is the wrong + // bucket, so only the ledger close time can put the event on 2026-08-21. + jest.useFakeTimers({ now: new Date('2027-03-04T10:00:00.000Z') }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('buckets by ledger close time, since the event carries no timestamp', async () => { + await dispatch({ + id: 'invoice-created-1', + topic: 'invoice_created_event', + ledger: 1, + txHash: 'tx-hash', + ledgerClosedAt: '2026-08-21T13:15:00Z', + data: invoiceCreatedData, + }); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: DAY }, + create: { date: DAY, newInvoices: 1 }, + update: { newInvoices: { increment: 1 } }, + }); + }); + + test('falls back to the indexing time when no ledger close time is present', async () => { + await dispatchGrowth('invoice_created_event', invoiceCreatedData); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { date: new Date('2027-03-04T00:00:00.000Z') } }), + ); + }); + }); + + test('status and governance events stay unhandled', async () => { + await dispatchGrowth('merchant_status_changed_event', { merchant_id: 7n, active: false }); + await dispatchGrowth('role_granted_event', { admin: 'GADMIN', user: 'GUSER' }); + await dispatchGrowth('subscription_plan_created_event', { plan_id: 12n }); + await dispatchGrowth('event_created_event', { event_id: 3n }); + + expect(prismaMock.platformDailyStats.upsert).not.toHaveBeenCalled(); + }); +}); + +describe('topic registration', () => { + const dispatchTopic = (topic: string, data: Record) => + dispatch({ id: `${topic}-1`, topic, ledger: 1, txHash: 'tx-hash', data }); + + const ticketData = { + ticket_id: 9n, + event_id: 3n, + merchant_id: 7n, + buyer: 'GBUYER', + amount: 4000n, + fee: 40n, + merchant_amount: 3960n, + token: TOKEN, + timestamp: BigInt(TIMESTAMP), + }; + + const resaleData = { + ...ticketData, + seller: 'GSELLER', + resale_price: 6000n, + royalty: 300n, + seller_proceeds: 5700n, + }; + + const refundData = { + invoice_id: 101n, + merchant: 'GMERCHANT', + amount: 5000n, + timestamp: BigInt(TIMESTAMP), + }; + + // Each handler is reached only if its topic string matches what the contract + // actually publishes, so these guard against a topic typo going unnoticed. + test.each([ + ['ticket_purchased_event', ticketData], + ['ticket_resold_event', resaleData], + ])('%s reaches the ticketing handler', async (topic, data) => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + await dispatchTopic(topic, data); + + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ where: { merchantId: 7 } }); + }); + + test.each([ + ['invoice_refunded_event', refundData], + ['invoice_partially_refunded_event', { ...refundData, total_amount_refunded: 5000n }], + ])('%s reaches the refund handler', async (topic, data) => { + prismaMock.invoice.findUnique.mockResolvedValue(null); + + await dispatchTopic(topic, data); + + expect(prismaMock.invoice.findUnique).toHaveBeenCalledWith({ where: { invoiceId: 101 } }); + }); +}); diff --git a/tests/unit/analytics.services.test.ts b/tests/unit/analytics.services.test.ts new file mode 100644 index 0000000..c61ffab --- /dev/null +++ b/tests/unit/analytics.services.test.ts @@ -0,0 +1,318 @@ +import { beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { + startOfUtcDay, + recordDailyStats, + recordVolumeEvent, + getAnalyticsSummary, + getAnalyticsTimeseries, + getTopTokensByVolume, +} = await import('../../src/services/analytics.services.js'); +const { AppError } = await import('../../src/utils/errors.js'); + +const MERCHANT_ID = 'merchant-uuid'; +const TOKEN = 'CABC...TOKEN'; + +describe('startOfUtcDay', () => { + test('truncates an instant to midnight UTC', () => { + expect(startOfUtcDay(new Date('2026-08-21T23:59:59.999Z')).toISOString()).toBe( + '2026-08-21T00:00:00.000Z', + ); + }); + + test('buckets by UTC day, not local day', () => { + // 00:30 UTC on the 21st is still the 20th in every negative offset. + expect(startOfUtcDay(new Date('2026-08-21T00:30:00.000Z')).toISOString()).toBe( + '2026-08-21T00:00:00.000Z', + ); + }); +}); + +describe('recordDailyStats', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('increments only the fields present in the delta', async () => { + await recordDailyStats(prismaMock, new Date('2026-08-21T09:15:00.000Z'), { newMerchants: 1 }); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: new Date('2026-08-21T00:00:00.000Z') }, + create: { date: new Date('2026-08-21T00:00:00.000Z'), newMerchants: 1 }, + update: { newMerchants: { increment: 1 } }, + }); + }); +}); + +describe('recordVolumeEvent', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + const event = { + merchantId: MERCHANT_ID, + token: TOKEN, + volume: 1_000_000n, + fee: 10_000n, + occurredAt: new Date('2026-08-21T09:15:00.000Z'), + }; + + test('upserts merchant, token and daily projections for a payment', async () => { + prismaMock.merchantAnalytics.findUnique.mockResolvedValue(null); + + await recordVolumeEvent(prismaMock, event); + + expect(prismaMock.merchantAnalytics.upsert).toHaveBeenCalledWith({ + where: { merchantId_token: { merchantId: MERCHANT_ID, token: TOKEN } }, + create: { + merchantId: MERCHANT_ID, + token: TOKEN, + totalVolume: 1_000_000n, + totalFees: 10_000n, + transactionCount: 1n, + }, + update: { + totalVolume: { increment: 1_000_000n }, + totalFees: { increment: 10_000n }, + transactionCount: { increment: 1n }, + }, + }); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: new Date('2026-08-21T00:00:00.000Z') }, + create: { + date: new Date('2026-08-21T00:00:00.000Z'), + totalVolume: 1_000_000n, + totalFees: 10_000n, + transactionCount: 1n, + }, + update: { + totalVolume: { increment: 1_000_000n }, + totalFees: { increment: 10_000n }, + transactionCount: { increment: 1n }, + }, + }); + }); + + test('bumps uniqueMerchants the first time a merchant transacts in a token', async () => { + prismaMock.merchantAnalytics.findUnique.mockResolvedValue(null); + + await recordVolumeEvent(prismaMock, event); + + const tokenUpsert = prismaMock.tokenAnalytics.upsert.mock.calls[0][0]; + expect(tokenUpsert.create.uniqueMerchants).toBe(1); + expect(tokenUpsert.update.uniqueMerchants).toEqual({ increment: 1 }); + }); + + test('leaves uniqueMerchants alone for a merchant already seen in that token', async () => { + prismaMock.merchantAnalytics.findUnique.mockResolvedValue({ id: 'analytics-uuid' }); + + await recordVolumeEvent(prismaMock, event); + + const tokenUpsert = prismaMock.tokenAnalytics.upsert.mock.calls[0][0]; + expect(tokenUpsert.update.uniqueMerchants).toBeUndefined(); + expect(tokenUpsert.update.totalVolume).toEqual({ increment: 1_000_000n }); + }); +}); + +describe('getAnalyticsSummary', () => { + beforeEach(() => { + mockReset(prismaMock); + + prismaMock.tokenAnalytics.aggregate.mockResolvedValue({ + _sum: { totalVolume: 5_000_000n, totalFees: 50_000n, transactionCount: 7n }, + _count: { _all: 2 }, + }); + prismaMock.$queryRaw.mockResolvedValue([{ count: 3 }]); + prismaMock.invoice.aggregate.mockResolvedValue({ _sum: { amountRefunded: 250_000n } }); + prismaMock.merchant.count + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(9) + .mockResolvedValueOnce(4); + prismaMock.invoice.groupBy.mockResolvedValue([ + { status: 'PAID', _count: { _all: 6 } }, + { status: 'PENDING', _count: { _all: 2 } }, + ]); + prismaMock.subscription.groupBy.mockResolvedValue([ + { status: 'ACTIVE', _count: { _all: 3 } }, + { status: 'CANCELLED', _count: { _all: 1 } }, + ]); + }); + + test('reports TokenAnalytics totals alongside live counts', async () => { + const summary = await getAnalyticsSummary(); + + expect(summary).toEqual({ + totals: { + totalVolume: '5000000', + totalFees: '50000', + transactionCount: '7', + totalRefunded: '250000', + tokens: 2, + merchantsWithVolume: 3, + }, + merchants: { total: 10, active: 9, verified: 4 }, + invoices: { total: 8, byStatus: { PAID: 6, PENDING: 2 } }, + subscriptions: { total: 4, byStatus: { ACTIVE: 3, CANCELLED: 1 } }, + }); + }); + + test('reports zeroes rather than nulls on an empty protocol', async () => { + mockReset(prismaMock); + prismaMock.tokenAnalytics.aggregate.mockResolvedValue({ + _sum: { totalVolume: null, totalFees: null, transactionCount: null }, + _count: { _all: 0 }, + }); + prismaMock.$queryRaw.mockResolvedValue([{ count: 0 }]); + prismaMock.invoice.aggregate.mockResolvedValue({ _sum: { amountRefunded: null } }); + prismaMock.merchant.count.mockResolvedValue(0); + prismaMock.invoice.groupBy.mockResolvedValue([]); + prismaMock.subscription.groupBy.mockResolvedValue([]); + + const summary = await getAnalyticsSummary(); + + expect(summary.totals).toEqual({ + totalVolume: '0', + totalFees: '0', + transactionCount: '0', + totalRefunded: '0', + tokens: 0, + merchantsWithVolume: 0, + }); + expect(summary.invoices).toEqual({ total: 0, byStatus: {} }); + }); +}); + +describe('getAnalyticsTimeseries', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + const row = { + id: 'daily-uuid', + date: new Date('2026-08-20T00:00:00.000Z'), + totalVolume: 1_000_000n, + totalFees: 10_000n, + transactionCount: 2n, + newInvoices: 3, + newMerchants: 1, + newSubscriptions: 0, + newTickets: 4, + updatedAt: new Date('2026-08-20T10:00:00.000Z'), + }; + + test('queries the requested UTC day range in ascending date order', async () => { + prismaMock.platformDailyStats.findMany.mockResolvedValue([row]); + + const result = await getAnalyticsTimeseries({ + from: '2026-08-19', + to: '2026-08-21T18:00:00.000Z', + }); + + expect(prismaMock.platformDailyStats.findMany).toHaveBeenCalledWith({ + where: { + date: { + gte: new Date('2026-08-19T00:00:00.000Z'), + lte: new Date('2026-08-21T00:00:00.000Z'), + }, + }, + orderBy: { date: 'asc' }, + }); + expect(result.data).toEqual([ + { + date: '2026-08-20T00:00:00.000Z', + totalVolume: '1000000', + totalFees: '10000', + transactionCount: '2', + newInvoices: 3, + newMerchants: 1, + newSubscriptions: 0, + newTickets: 4, + }, + ]); + }); + + test('returns an empty array for a range with no activity', async () => { + prismaMock.platformDailyStats.findMany.mockResolvedValue([]); + + const result = await getAnalyticsTimeseries({ from: '2020-01-01', to: '2020-01-31' }); + + expect(result.data).toEqual([]); + }); + + test('rejects an unparseable date', async () => { + await expect(getAnalyticsTimeseries({ from: 'not-a-date' })).rejects.toBeInstanceOf(AppError); + }); + + test('rejects an inverted range', async () => { + await expect( + getAnalyticsTimeseries({ from: '2026-08-21', to: '2026-08-19' }), + ).rejects.toThrow('from must not be after to'); + }); + + test('defaults to a 30-day window ending today', async () => { + prismaMock.platformDailyStats.findMany.mockResolvedValue([]); + + const result = await getAnalyticsTimeseries({ to: '2026-08-21T12:00:00.000Z' }); + + expect(result.from).toBe('2026-07-23T00:00:00.000Z'); + expect(result.to).toBe('2026-08-21T00:00:00.000Z'); + }); +}); + +describe('getTopTokensByVolume', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.tokenAnalytics.findMany.mockResolvedValue([]); + }); + + test('orders by volume descending with a default limit', async () => { + await getTopTokensByVolume({}); + + expect(prismaMock.tokenAnalytics.findMany).toHaveBeenCalledWith({ + orderBy: { totalVolume: 'desc' }, + take: 10, + }); + }); + + test('honours an explicit limit', async () => { + await getTopTokensByVolume({ limit: '3' }); + + expect(prismaMock.tokenAnalytics.findMany).toHaveBeenCalledWith({ + orderBy: { totalVolume: 'desc' }, + take: 3, + }); + }); + + test('rejects a limit outside the allowed range', async () => { + await expect(getTopTokensByVolume({ limit: '0' })).rejects.toBeInstanceOf(AppError); + await expect(getTopTokensByVolume({ limit: '500' })).rejects.toBeInstanceOf(AppError); + }); + + test('serializes BigInt counters to strings', async () => { + prismaMock.tokenAnalytics.findMany.mockResolvedValue([ + { + id: 'token-analytics-uuid', + token: TOKEN, + totalVolume: 9_000_000n, + totalFees: 90_000n, + transactionCount: 12n, + uniqueMerchants: 2, + lastUpdated: new Date('2026-08-21T10:00:00.000Z'), + }, + ]); + + const result = await getTopTokensByVolume({}); + + expect(result.data[0]).toMatchObject({ + token: TOKEN, + totalVolume: '9000000', + totalFees: '90000', + transactionCount: '12', + uniqueMerchants: 2, + lastUpdated: '2026-08-21T10:00:00.000Z', + }); + }); +}); diff --git a/tests/unit/invoice-paid.handler.test.ts b/tests/unit/invoice-paid.handler.test.ts index f6d052f..d7b7164 100644 --- a/tests/unit/invoice-paid.handler.test.ts +++ b/tests/unit/invoice-paid.handler.test.ts @@ -30,7 +30,8 @@ describe('InvoicePaid indexer handler', () => { }); test('registers the contract event symbol used by InvoicePaidEvent', async () => { - expect(INVOICE_PAID_TOPIC).toBe('InvoicePaid'); + // #[contractevent] publishes the struct name in lower snake case. + expect(INVOICE_PAID_TOPIC).toBe('invoice_paid_event'); await expect( dispatch({ id: 'invoice-paid-invalid-payload',