diff --git a/src/indexer/contractReader.ts b/src/indexer/contractReader.ts new file mode 100644 index 0000000..27659d5 --- /dev/null +++ b/src/indexer/contractReader.ts @@ -0,0 +1,133 @@ +import { + Account, + BASE_FEE, + Contract, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + type xdr, +} from '@stellar/stellar-sdk'; +import { environment } from '../config/environment.js'; +import { sorobanServer } from './sorobanClient.js'; + +/** + * Read-only contract calls, used to fill fields the contract's events omit. + * + * Some `#[contractevent]` payloads are narrower than the struct they describe — + * `SubscriptionPlanCreatedEvent` has no `description`, and + * `InvoiceCreatedEvent` has neither `description` nor `expires_at` — while our + * schema requires them. The values are read back from contract storage with a + * simulated (never submitted) invocation. + * + * Every read here is best-effort: callers fall back to a placeholder rather + * than throwing, because a handler that throws loses the event entirely (the + * poller advances its cursor past events whose handler failed), whereas a row + * with a placeholder description is visible and repairable. + */ + +// Simulation never submits, so the source account is only a structural +// requirement of the envelope and does not need to exist or hold a balance. +// The all-zero ed25519 key is the conventional stand-in. +const NULL_SOURCE_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +let cachedNetworkPassphrase: string | undefined; + +/** + * Read from the RPC rather than configured, so the indexer cannot end up + * simulating against a different network than the one it polls for events. + */ +const networkPassphrase = async (): Promise => { + cachedNetworkPassphrase ??= (await sorobanServer.getNetwork()).passphrase; + return cachedNetworkPassphrase; +}; + +const simulateRead = async (method: string, args: xdr.ScVal[]): Promise => { + const contractId = environment.stellar.contractId; + if (!contractId || contractId.trim() === '') { + throw new Error('STELLAR_CONTRACT_ID environment variable is unset or empty'); + } + + const transaction = new TransactionBuilder(new Account(NULL_SOURCE_ACCOUNT, '0'), { + fee: BASE_FEE, + networkPassphrase: await networkPassphrase(), + }) + .addOperation(new Contract(contractId).call(method, ...args)) + .setTimeout(30) + .build(); + + const simulation = await sorobanServer.simulateTransaction(transaction); + + if (rpc.Api.isSimulationError(simulation)) { + throw new Error(`${method} simulation failed: ${simulation.error}`); + } + if (!simulation.result?.retval) { + throw new Error(`${method} simulation returned no value`); + } + + return scValToNative(simulation.result.retval); +}; + +/** Reads one field off a `scValToNative`-decoded contract struct. */ +const structField = (value: unknown, field: string): unknown => { + if (value instanceof Map) return value.get(field); + if (typeof value === 'object' && value !== null) return (value as Record)[field]; + return undefined; +}; + +const optionalString = (value: unknown): string | null => + typeof value === 'string' && value.trim() !== '' ? value : null; + +export interface OnChainInvoiceDetails { + /** The description the contract stored; `InvoiceCreatedEvent` does not carry it. */ + description: string | null; + expiresAt: Date | null; +} + +/** + * Reads back the parts of `get_invoice` that `InvoiceCreatedEvent` leaves out. + * Returns null if the read fails for any reason, including the invoice having + * been pruned from contract storage — callers must cope with not knowing. + */ +export const fetchInvoiceDetails = async ( + invoiceId: number, +): Promise => { + try { + const invoice = await simulateRead('get_invoice', [nativeToScVal(invoiceId, { type: 'u64' })]); + const expiresAt = structField(invoice, 'expires_at'); + + return { + description: optionalString(structField(invoice, 'description')), + // `Option` decodes to undefined when None. + expiresAt: + typeof expiresAt === 'bigint' || typeof expiresAt === 'number' + ? new Date(Number(expiresAt) * 1000) + : null, + }; + } catch (error) { + console.warn( + `Could not read invoice ${invoiceId} from the contract:`, + error instanceof Error ? error.message : error, + ); + return null; + } +}; + +/** + * Reads back the `description` that `SubscriptionPlanCreatedEvent` omits. + * Returns null if the read fails; callers must cope with not knowing. + */ +export const fetchSubscriptionPlanDescription = async (planId: number): Promise => { + try { + const plan = await simulateRead('get_subscription_plan', [ + nativeToScVal(planId, { type: 'u64' }), + ]); + return optionalString(structField(plan, 'description')); + } catch (error) { + console.warn( + `Could not read subscription plan ${planId} from the contract:`, + error instanceof Error ? error.message : error, + ); + return null; + } +}; diff --git a/src/indexer/handlers/growth.ts b/src/indexer/handlers/growth.ts index f28fd7f..d1ac31b 100644 --- a/src/indexer/handlers/growth.ts +++ b/src/indexer/handlers/growth.ts @@ -1,47 +1,21 @@ 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; -}; +import { decodeMerchantRegisteredEventData, type DecodedEvent } from '../types.js'; 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. + * `MerchantRegistered` only moves PlatformDailyStats' "new merchants today" + * counter. The point-in-time total it feeds into (how many merchants exist) is + * counted live at request time off the existing table, so nothing else needs + * recording here. + * + * The other two growth events, `invoice_created_event` and `subscribed_event`, + * used to live here as stats-only handlers. They now project real rows as well, + * so they have moved to ./invoiceCreated.ts and ./subscribed.ts — each still + * increments the same daily counter it did here, from inside its service. */ 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 398ad43..1588529 100644 --- a/src/indexer/handlers/index.ts +++ b/src/indexer/handlers/index.ts @@ -7,14 +7,13 @@ import { TICKET_PURCHASED_TOPIC, TICKET_RESOLD_TOPIC, } from './ticketing.js'; +import { handleMerchantRegistered, MERCHANT_REGISTERED_TOPIC } from './growth.js'; +import { handleInvoiceCreated, INVOICE_CREATED_TOPIC } from './invoiceCreated.js'; import { - handleInvoiceCreated, - handleMerchantRegistered, - handleSubscribed, - INVOICE_CREATED_TOPIC, - MERCHANT_REGISTERED_TOPIC, - SUBSCRIBED_TOPIC, -} from './growth.js'; + handleSubscriptionPlanCreated, + SUBSCRIPTION_PLAN_CREATED_TOPIC, +} from './subscriptionPlanCreated.js'; +import { handleSubscribed, SUBSCRIBED_TOPIC } from './subscribed.js'; import { handleInvoicePartiallyRefunded, handleInvoiceRefunded, @@ -22,6 +21,9 @@ import { INVOICE_REFUNDED_TOPIC, } from './refunds.js'; +// One handler per topic: registerEventHandler overwrites on a repeated topic, +// so a second registration would silently replace the first, not run alongside it. + // Volume-moving events: they update MerchantAnalytics, TokenAnalytics and the // protocol-wide PlatformDailyStats rollup. registerEventHandler(INVOICE_PAID_TOPIC, handleInvoicePaid); @@ -29,9 +31,14 @@ 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. +// Growth event: only moves PlatformDailyStats' "new merchants today" counter. registerEventHandler(MERCHANT_REGISTERED_TOPIC, handleMerchantRegistered); + +// Creation events: they project the on-chain record into its own table and, for +// invoices and subscriptions, still move the same daily "new X today" counter +// they moved when they were stats-only handlers. registerEventHandler(INVOICE_CREATED_TOPIC, handleInvoiceCreated); +registerEventHandler(SUBSCRIPTION_PLAN_CREATED_TOPIC, handleSubscriptionPlanCreated); registerEventHandler(SUBSCRIBED_TOPIC, handleSubscribed); // Refund events: they adjust the invoice only. Volume is deliberately not @@ -40,10 +47,10 @@ 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. +// - event_created_event: a growth event with no dedicated daily counter in +// PlatformDailyStats. Event totals are point-in-time counts, and adding a +// daily counter 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/invoiceCreated.ts b/src/indexer/handlers/invoiceCreated.ts new file mode 100644 index 0000000..48aa20a --- /dev/null +++ b/src/indexer/handlers/invoiceCreated.ts @@ -0,0 +1,32 @@ +import { applyInvoiceCreated } from '../../services/invoice.services.js'; +import { fetchInvoiceDetails } from '../contractReader.js'; +import { ledgerCloseTime } from '../ledgerTime.js'; +import { decodeInvoiceCreatedEventData, type DecodedEvent } from '../types.js'; + +// Confirmed against a live testnet event: Soroban's `#[contractevent]` macro +// publishes a single fixed first topic, the struct name in lower snake case. +export const INVOICE_CREATED_TOPIC = 'invoice_created_event'; + +/** + * Normalizes the event at the indexer edge and delegates all persistence to + * applyInvoiceCreated. + * + * Part of that normalization is reading back what `InvoiceCreatedEvent` leaves + * out: it carries neither the invoice's description nor its expiry, and both + * matter downstream — the description is a required column and the strongest + * signal available for correlating this event with an off-chain invoice row. + * The read is best-effort and returns null on failure; keeping it here rather + * than in the service also keeps the Soroban RPC client out of the HTTP app's + * module graph, since nothing but the indexer reaches this path. + * + * `InvoiceCreatedEvent` is also one of the events the contract emits without a + * timestamp field, so the occurrence time comes from the close time of the + * ledger that contained it. Using the indexing time instead would attribute a + * historical replay to whatever day the replay happened to run. + */ +export const handleInvoiceCreated = async (event: DecodedEvent): Promise => { + const data = decodeInvoiceCreatedEventData(event.data); + const onChain = await fetchInvoiceDetails(data.invoiceId); + + await applyInvoiceCreated(data, event.txHash, ledgerCloseTime(event), onChain); +}; diff --git a/src/indexer/handlers/not-yet-implemented.ts b/src/indexer/handlers/not-yet-implemented.ts index a652761..64960f9 100644 --- a/src/indexer/handlers/not-yet-implemented.ts +++ b/src/indexer/handlers/not-yet-implemented.ts @@ -9,10 +9,13 @@ * there is no behavior change here, only documentation. * * None of these topic strings have been observed against a live deployment — - * they are inferred from the one confirmed convention in this codebase - * (`#[contractevent] InvoicePaidEvent` -> topic "InvoicePaid", see - * ../handlers/invoicePaid.ts). Do not build a decoder from this file alone; - * verify the actual event payload shape against the deployed contract first. + * they are inferred from the naming convention that the handled events do + * confirm (`#[contractevent] InvoicePaidEvent` -> topic "invoice_paid_event"). + * Do not build a decoder from this file alone; verify the actual event payload + * shape against a real testnet event first. That check is not a formality: the + * payload is routinely narrower than the struct it is named after — + * `SubscriptionPlanCreatedEvent` omits the plan's `description`, and + * `InvoiceCreatedEvent` omits both `description` and any timestamp. * * When wiring one of these for real: add a decoder to ../types.ts, a handler * to ../handlers/ (see invoicePaid.ts for the pattern), register it in @@ -43,7 +46,6 @@ * merchant.account_restricted <- AccountRestricted * * ---- On-chain events: Invoice Lifecycle (beyond InvoicePaid) ---- - * invoice.created (on-chain) <- InvoiceCreated * invoice.payment_split_routed <- PaymentSplitRouted * invoice.refunded / partially_refunded <- InvoiceRefunded / InvoicePartiallyRefunded * invoice.cancelled (on-chain) <- InvoiceCancelled @@ -52,12 +54,12 @@ * the real invoice.amended call site) * invoice.fiat_priced <- FiatInvoicePriced * - * ---- On-chain events: Subscription Lifecycle (no service exists at all) ---- - * subscription_plan.created <- SubscriptionPlanCreated + * ---- On-chain events: Subscription Lifecycle ---- * subscription_plan.deactivated <- PlanDeactivated - * subscription.created <- Subscribed - * subscription.charged <- SubscriptionCharged * subscription.cancelled <- SubscriptionCancelled + * (subscription_plan.created, subscription.created and subscription.charged are + * implemented — see ../handlers/subscriptionPlanCreated.ts, ./subscribed.ts and + * ./subscriptionCharged.ts) * * ---- On-chain events: Account Contract / Withdrawals ---- * account.initialized / verified <- AccountInitialized / AccountVerified diff --git a/src/indexer/handlers/subscribed.ts b/src/indexer/handlers/subscribed.ts new file mode 100644 index 0000000..2b8c937 --- /dev/null +++ b/src/indexer/handlers/subscribed.ts @@ -0,0 +1,9 @@ +import { applySubscribed } from '../../services/subscription.services.js'; +import { decodeSubscribedEventData, type DecodedEvent } from '../types.js'; + +// Confirmed against a live testnet event. +export const SUBSCRIBED_TOPIC = 'subscribed_event'; + +export const handleSubscribed = async (event: DecodedEvent): Promise => { + await applySubscribed(decodeSubscribedEventData(event.data), event.txHash); +}; diff --git a/src/indexer/handlers/subscriptionPlanCreated.ts b/src/indexer/handlers/subscriptionPlanCreated.ts new file mode 100644 index 0000000..5f763d1 --- /dev/null +++ b/src/indexer/handlers/subscriptionPlanCreated.ts @@ -0,0 +1,20 @@ +import { applySubscriptionPlanCreated } from '../../services/subscription.services.js'; +import { fetchSubscriptionPlanDescription } from '../contractReader.js'; +import { decodeSubscriptionPlanCreatedEventData, type DecodedEvent } from '../types.js'; + +// Confirmed against a live testnet event. +export const SUBSCRIPTION_PLAN_CREATED_TOPIC = 'subscription_plan_created_event'; + +/** + * `SubscriptionPlanCreatedEvent` omits the plan's `description`, which is a + * required column here and in the contract's own struct, so it is read back off + * the contract at the indexer edge. The read is best-effort and returns null on + * failure; the service stores a placeholder in that case rather than dropping + * the plan. + */ +export const handleSubscriptionPlanCreated = async (event: DecodedEvent): Promise => { + const data = decodeSubscriptionPlanCreatedEventData(event.data); + const description = await fetchSubscriptionPlanDescription(data.planId); + + await applySubscriptionPlanCreated(data, event.txHash, description); +}; diff --git a/src/indexer/ledgerTime.ts b/src/indexer/ledgerTime.ts new file mode 100644 index 0000000..091756c --- /dev/null +++ b/src/indexer/ledgerTime.ts @@ -0,0 +1,16 @@ +import type { DecodedEvent } from './types.js'; + +/** + * Close time of the ledger that contained an event. + * + * Used by handlers for the events the contract emits without their own + * timestamp field, so a historical replay attributes them to when they + * actually happened rather than to whenever the replay ran. Falls back to the + * indexing time only if the RPC response carried no ledger close time — every + * real `getEvents` response does. + */ +export 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; +}; diff --git a/src/indexer/types.ts b/src/indexer/types.ts index 779e3d4..0b665b2 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -72,7 +72,11 @@ export interface MerchantRegisteredEventData { timestamp: number; } -/** Note: the contract's `InvoiceCreatedEvent` carries no timestamp field. */ +/** + * Confirmed against a live testnet event (see decodeInvoiceCreatedEventData): + * `InvoiceCreatedEvent` carries neither a timestamp nor a description field. + * `merchant` is the merchant's Address (`G...`), not the numeric merchant_id. + */ export interface InvoiceCreatedEventData { invoiceId: number; merchant: string; @@ -80,6 +84,26 @@ export interface InvoiceCreatedEventData { token: string; } +/** + * Confirmed against a live testnet event: `SubscriptionPlanCreatedEvent` + * carries no `description`, even though the contract's own `SubscriptionPlan` + * struct (and our `SubscriptionPlan` model) has one as a required field. The + * description has to be read back separately with `get_subscription_plan` — + * see fetchSubscriptionPlanDescription in ./contractReader.ts. + * + * `merchant` is the merchant's Address (`G...`), not the plan's numeric + * `merchant_id`; the struct has both, and the event emits the Address. + */ +export interface SubscriptionPlanCreatedEventData { + planId: number; + merchant: string; + token: string; + amount: bigint; + /** Billing interval in seconds. */ + interval: number; + timestamp: number; +} + export interface SubscribedEventData { subscriptionId: number; planId: number; @@ -243,6 +267,11 @@ export const decodeMerchantRegisteredEventData = (data: unknown): MerchantRegist }; }; +/** + * Field list verified against a real `invoice_created_event` emitted on testnet + * and decoded with `scValToNative`: + * { invoice_id: bigint, merchant: 'G...', amount: bigint, token: 'C...' } + */ export const decodeInvoiceCreatedEventData = (data: unknown): InvoiceCreatedEventData => { const event = readEvent('InvoiceCreated', data); @@ -254,6 +283,34 @@ export const decodeInvoiceCreatedEventData = (data: unknown): InvoiceCreatedEven }; }; +/** + * Field list verified against a real `subscription_plan_created_event` emitted + * on testnet and decoded with `scValToNative`: + * { plan_id: bigint, merchant: 'G...', token: 'C...', amount: bigint, + * interval: bigint, timestamp: bigint } + * Note the absence of `description`. + */ +export const decodeSubscriptionPlanCreatedEventData = ( + data: unknown, +): SubscriptionPlanCreatedEventData => { + const event = readEvent('SubscriptionPlanCreated', data); + + return { + planId: event.number('plan_id'), + merchant: event.string('merchant'), + token: event.string('token'), + amount: event.bigint('amount'), + interval: event.number('interval'), + timestamp: event.number('timestamp'), + }; +}; + +/** + * Field list verified against a real `subscribed_event` emitted on testnet and + * decoded with `scValToNative`: + * { subscription_id: bigint, plan_id: bigint, customer: 'G...', timestamp: bigint } + * The event carries no merchant; it is resolved through the plan. + */ export const decodeSubscribedEventData = (data: unknown): SubscribedEventData => { const event = readEvent('Subscribed', data); diff --git a/src/services/invoice.services.ts b/src/services/invoice.services.ts index 90f4d5d..b796c42 100644 --- a/src/services/invoice.services.ts +++ b/src/services/invoice.services.ts @@ -9,11 +9,13 @@ import { parseAmount, } from '../utils/invoice.validation.js'; import type { + InvoiceCreatedEventData, InvoicePaidEventData, InvoicePartiallyRefundedEventData, InvoiceRefundedEventData, } from '../indexer/types.js'; -import { recordVolumeEvent } from './analytics.services.js'; +import type { OnChainInvoiceDetails } from '../indexer/contractReader.js'; +import { recordDailyStats, recordVolumeEvent } from './analytics.services.js'; import { recordAuditLog, ActorType } from './audit-log.services.js'; const SLUG_MAX_RETRIES = 5; @@ -67,30 +69,21 @@ const isUniqueSlugError = (error: unknown): boolean => { return code === 'P2002' && Array.isArray(meta?.target) && meta.target.includes('paymentSlug'); }; -export const createInvoice = async (merchantId: string, data: CreateInvoiceInput) => { - const amount = parseAmount(data.amount); - if (amount === null) { - throw new AppError(400, 'amount must be a positive integer'); - } - - const status: PrismaInvoiceStatus = data.isDraft ? InvoiceStatus.DRAFT : InvoiceStatus.PENDING; - const expiresAt = data.expiresAt ? new Date(data.expiresAt) : null; - +/** + * Creates an invoice, retrying on the (vanishingly unlikely) event that the + * generated payment slug collides with an existing one. `client` is either the + * root Prisma client or an interactive-transaction client. + */ +const createInvoiceWithUniqueSlug = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + data: Omit, +): Promise => { for (let attempt = 0; attempt < SLUG_MAX_RETRIES; attempt++) { try { - const invoice = await prisma.invoice.create({ - data: { - merchantId, - description: data.description.trim(), - amount, - token: data.token.trim(), - email: data.payerEmail?.trim() ?? null, - expiresAt, - status, - paymentSlug: generatePaymentSlug(), - }, + return await client.invoice.create({ + data: { ...data, paymentSlug: generatePaymentSlug() }, }); - return sanitizeInvoice(invoice); } catch (error) { if (isUniqueSlugError(error) && attempt < SLUG_MAX_RETRIES - 1) { continue; @@ -102,6 +95,28 @@ export const createInvoice = async (merchantId: string, data: CreateInvoiceInput throw new AppError(500, 'Failed to generate a unique payment slug'); }; +export const createInvoice = async (merchantId: string, data: CreateInvoiceInput) => { + const amount = parseAmount(data.amount); + if (amount === null) { + throw new AppError(400, 'amount must be a positive integer'); + } + + const status: PrismaInvoiceStatus = data.isDraft ? InvoiceStatus.DRAFT : InvoiceStatus.PENDING; + const expiresAt = data.expiresAt ? new Date(data.expiresAt) : null; + + const invoice = await createInvoiceWithUniqueSlug(prisma, { + merchantId, + description: data.description.trim(), + amount, + token: data.token.trim(), + email: data.payerEmail?.trim() ?? null, + expiresAt, + status, + }); + + return sanitizeInvoice(invoice); +}; + export const listInvoices = async ( merchantId: string, filters: InvoiceListFilters, @@ -246,6 +261,179 @@ export const voidInvoice = async (merchantId: string, id: string) => { return sanitizeInvoice(updated); }; +/** + * Applies an on-chain `InvoiceCreatedEvent` to the backend projection. + * + * --------------------------------------------------------------------------- + * WARNING: the correlation performed here is a best-effort heuristic, not a + * guaranteed-correct match. It is not a solved problem yet. + * --------------------------------------------------------------------------- + * + * Invoices in this backend are created off-chain first: POST /invoices writes + * an Invoice row with `invoiceId: null` before anything touches the chain, and + * nothing in this codebase currently submits `create_invoice` / + * `create_invoice_signed` on a merchant's behalf. So there is no established + * link between an off-chain row and the on-chain invoice id, and two different + * futures are both still open: + * + * 1. A relay path where this backend submits `create_invoice_signed` at the + * moment POST /invoices is called and captures `invoice_id` synchronously + * from the transaction result. If that lands, this function becomes a + * backup/reconciliation path rather than the primary way `invoiceId` gets + * set — and it already tolerates that: an invoice whose `invoiceId` the + * relay already filled in is left alone. + * 2. Invoices that originate entirely on-chain, from a merchant's own SDK + * integration calling `create_invoice` directly and bypassing our API. For + * those this handler is the only way the backend ever learns the invoice + * exists, so an unmatched event creates a row rather than being dropped. + * + * The clean fix is correlation by nonce: `create_invoice_signed` already takes + * a `nonce: BytesN<32>` for replay protection, and a future relay issue could + * set that nonce to the off-chain `Invoice.id` before submitting, turning this + * guesswork into an exact lookup. That depends on the relay existing first, so + * it is deliberately not implemented here. + * + * Until then the match is: an unlinked (`invoiceId: null`) invoice for the same + * merchant, amount, token and description. `InvoiceCreatedEvent` does not carry + * a description (verified against a live testnet event), so the description + * comes from reading `get_invoice` back off the contract; when that read fails + * the match falls back to merchant + amount + token alone, which is weaker, and + * says so in the log. Candidates are restricted to DRAFT/PENDING because the + * contract only emits this event for an invoice it has just put in `Pending` — + * linking a cancelled, paid or refunded off-chain row would be wrong. + * + * More than one candidate is ambiguous. Rather than guess, it logs loudly and + * creates a separate row, so the duplicate is visible and repairable instead of + * silently attached to the wrong invoice. + * + * The IndexerEvent table remains the only replay guard; the `invoiceId` lookup + * below is a natural-key existence check that makes "link or create" work, not + * a second idempotency mechanism. + */ +export const applyInvoiceCreated = async ( + event: InvoiceCreatedEventData, + txHash: string, + occurredAt: Date, + /** + * What `get_invoice` returned for this invoice, or null if the contract could + * not be read. Passed in by the handler rather than fetched here so the RPC + * round-trip stays at the indexer edge and this service remains pure + * persistence — see ../indexer/handlers/invoiceCreated.ts. + */ + onChain: OnChainInvoiceDetails | null, +) => { + // The event carries the merchant's Address, not the numeric merchant_id + // (verified against a live testnet event), so it resolves via Merchant.address. + const merchant = await prisma.merchant.findUnique({ + where: { address: event.merchant }, + }); + + const description = onChain?.description ?? null; + + const outcome = await prisma.$transaction(async (tx: any) => { + // The protocol-wide "new invoices today" counter is driven by the event + // itself, not by whether a row could be linked or created, so growth + // reporting is unchanged from when this event only recorded daily stats. + await recordDailyStats(tx, occurredAt, { newInvoices: 1 }); + + if (!merchant) { + console.warn( + `InvoiceCreated event for invoice ${event.invoiceId} (${txHash}) not applied: merchant ${event.merchant} is not in the database.`, + ); + return null; + } + + const alreadyLinked = await tx.invoice.findUnique({ + where: { invoiceId: event.invoiceId }, + }); + if (alreadyLinked) { + return { invoice: alreadyLinked as Invoice, outcome: 'already-linked' as const }; + } + + const candidates: Invoice[] = await tx.invoice.findMany({ + where: { + merchantId: merchant.id, + invoiceId: null, + amount: event.amount, + token: event.token, + status: { in: [InvoiceStatus.DRAFT, InvoiceStatus.PENDING] }, + ...(description === null ? {} : { description }), + }, + }); + + if (candidates.length === 1) { + const linked = await tx.invoice.update({ + where: { id: candidates[0].id }, + data: { invoiceId: event.invoiceId, status: InvoiceStatus.PENDING }, + }); + return { invoice: linked as Invoice, outcome: 'linked' as const }; + } + + if (candidates.length > 1) { + console.error( + `AMBIGUOUS InvoiceCreated correlation: on-chain invoice ${event.invoiceId} (${txHash}) matched ${candidates.length} unlinked invoices for merchant ${event.merchant} ` + + `[${candidates.map(candidate => candidate.id).join(', ')}]. ` + + 'Refusing to guess; creating a separate invoice row instead. ' + + 'These rows need manual reconciliation, and correlation needs the nonce-based fix described on applyInvoiceCreated.', + ); + } else if (description === null) { + console.warn( + `InvoiceCreated correlation for invoice ${event.invoiceId} (${txHash}) ran without an on-chain description; matched on merchant, amount and token only.`, + ); + } + + const created = await createInvoiceWithUniqueSlug(tx, { + invoiceId: event.invoiceId, + merchantId: merchant.id, + // The event has no description and the contract read did not produce one, + // so this placeholder marks the row as needing reconciliation rather than + // inventing a plausible-looking description. + description: description ?? `On-chain invoice #${event.invoiceId}`, + amount: event.amount, + token: event.token, + // The contract emits this event only for invoices it has just moved into + // `Pending`; `create_invoice_draft` deliberately emits nothing. + status: InvoiceStatus.PENDING, + expiresAt: onChain?.expiresAt ?? null, + // Backdated to the ledger close time so a historical replay does not + // report every on-chain invoice as created on the day of the replay. + createdAt: occurredAt, + }); + + return { + invoice: created, + outcome: (candidates.length > 1 ? 'created-ambiguous' : 'created') as + | 'created' + | 'created-ambiguous', + }; + }); + + if (!outcome || outcome.outcome === 'already-linked') { + return outcome; + } + + await recordAuditLog({ + action: 'invoice.created', + actorType: ActorType.MERCHANT, + actorId: merchant?.id, + actorLabel: event.merchant, + targetType: 'Invoice', + targetId: outcome.invoice.id, + metadata: { + // Distinguishes this from the off-chain POST /invoices call site, which + // records the same action. + source: 'on-chain', + correlation: outcome.outcome, + invoiceId: event.invoiceId, + amount: event.amount.toString(), + token: event.token, + txHash, + }, + }); + + return outcome; +}; + /** * Applies a confirmed on-chain invoice payment to the backend projection. * diff --git a/src/services/subscription.services.ts b/src/services/subscription.services.ts index 77b0aec..7e8a1ae 100644 --- a/src/services/subscription.services.ts +++ b/src/services/subscription.services.ts @@ -1,14 +1,199 @@ +import type { + SubscriptionPlan, + SubscriptionStatus as PrismaSubscriptionStatus, +} from '@prisma/client'; import prisma from '../config/prisma.js'; -import type { SubscriptionChargedEventData } from '../indexer/types.js'; -import { recordVolumeEvent } from './analytics.services.js'; +import type { + SubscribedEventData, + SubscriptionChargedEventData, + SubscriptionPlanCreatedEventData, +} from '../indexer/types.js'; +import { recordDailyStats, recordVolumeEvent } from './analytics.services.js'; +import { recordAuditLog, ActorType } from './audit-log.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). +// String constants matching the Prisma enums. 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; +const SubscriptionStatus = { + ACTIVE: 'ACTIVE', + CANCELLED: 'CANCELLED', +} as const satisfies Record; + +/** + * Applies an on-chain `SubscriptionPlanCreatedEvent` to the backend projection. + * + * Unlike invoices, plans have no off-chain-first creation path anywhere in this + * backend — nothing writes a SubscriptionPlan row except this function — so + * there is no correlation problem to solve. The plan is keyed on the on-chain + * `planId`, which is unique, making this a plain create-if-not-exists. + * + * `SubscriptionPlanCreatedEvent` does not carry the plan's `description` + * (verified against a live testnet event: its fields are plan_id, merchant, + * token, amount, interval, timestamp), but `SubscriptionPlan.description` is + * required here and in the contract's own struct. It is therefore read back + * with `get_subscription_plan`; if that read fails the row is still created, + * with a placeholder description that marks it as needing reconciliation. + * + * The IndexerEvent table remains the only replay guard; the `planId` lookup is + * a natural-key existence check, not a second idempotency mechanism. + */ +export const applySubscriptionPlanCreated = async ( + event: SubscriptionPlanCreatedEventData, + txHash: string, + /** + * What `get_subscription_plan` returned for this plan's description, or null + * if the contract could not be read. Passed in by the handler rather than + * fetched here so the RPC round-trip stays at the indexer edge and this + * service remains pure persistence — see + * ../indexer/handlers/subscriptionPlanCreated.ts. + */ + description: string | null, +) => { + const existing = await prisma.subscriptionPlan.findUnique({ + where: { planId: event.planId }, + }); + if (existing) { + return existing; + } + + // The event carries the merchant's Address, not the plan's numeric + // merchant_id (the contract struct has both; the event emits the Address). + const merchant = await prisma.merchant.findUnique({ + where: { address: event.merchant }, + }); + + if (!merchant) { + console.warn( + `SubscriptionPlanCreated event for plan ${event.planId} (${txHash}) skipped: merchant ${event.merchant} is not in the database.`, + ); + return null; + } + + if (description === null) { + console.warn( + `SubscriptionPlanCreated event for plan ${event.planId} (${txHash}) stored a placeholder description: the event omits it and get_subscription_plan could not be read.`, + ); + } + + const plan: SubscriptionPlan = await prisma.subscriptionPlan.create({ + data: { + planId: event.planId, + merchantId: merchant.id, + description: description ?? `On-chain plan #${event.planId}`, + token: event.token, + amount: event.amount, + interval: event.interval, + active: true, + // Backdated to the on-chain timestamp so a historical replay does not + // report every plan as created on the day of the replay. + createdAt: new Date(event.timestamp * 1000), + }, + }); + + await recordAuditLog({ + action: 'subscription_plan.created', + actorType: ActorType.MERCHANT, + actorId: merchant.id, + actorLabel: event.merchant, + targetType: 'SubscriptionPlan', + targetId: plan.id, + metadata: { + source: 'on-chain', + planId: event.planId, + amount: event.amount.toString(), + token: event.token, + interval: event.interval, + txHash, + }, + }); + + return plan; +}; + +/** + * Applies an on-chain `SubscribedEvent` to the backend projection. + * + * Like plans, subscriptions have no off-chain-first creation path, so this is a + * plain create-if-not-exists keyed on the unique on-chain `subscriptionId`. + * + * `merchantId` is taken from the resolved plan, never from the event (which + * carries no merchant at all): the schema's composite FK requires a + * subscription's merchant to match its plan's merchant, so the plan is the only + * correct source. A subscription for a plan the backend has not indexed yet is + * skipped rather than guessed at, matching applySubscriptionCharge. + * + * The IndexerEvent table remains the only replay guard; the `subscriptionId` + * lookup is a natural-key existence check, not a second idempotency mechanism. + */ +export const applySubscribed = async (event: SubscribedEventData, txHash: string) => { + const subscribedAt = new Date(event.timestamp * 1000); + + const existing = await prisma.subscription.findUnique({ + where: { subscriptionId: event.subscriptionId }, + }); + + const plan = existing + ? null + : await prisma.subscriptionPlan.findUnique({ where: { planId: event.planId } }); + + const subscription = await prisma.$transaction(async (tx: any) => { + // The protocol-wide "new subscriptions today" counter is driven by the + // event itself, not by whether a row could be created, so growth reporting + // is unchanged from when this event only recorded daily stats. + await recordDailyStats(tx, subscribedAt, { newSubscriptions: 1 }); + + if (existing) { + return null; + } + + if (!plan) { + console.warn( + `Subscribed event for subscription ${event.subscriptionId} (${txHash}) skipped: plan ${event.planId} is not in the database.`, + ); + return null; + } + + return tx.subscription.create({ + data: { + subscriptionId: event.subscriptionId, + planId: plan.id, + // Taken from the plan, not the event — the composite FK on Subscription + // rejects a merchant that differs from the plan's. + merchantId: plan.merchantId, + customer: event.customer, + status: SubscriptionStatus.ACTIVE, + createdAt: subscribedAt, + }, + }); + }); + + if (!subscription) { + return existing ?? null; + } + + await recordAuditLog({ + action: 'subscription.created', + // The subscriber is an on-chain customer address, not a merchant or admin + // of this backend. + actorType: ActorType.ANONYMOUS, + actorLabel: event.customer, + targetType: 'Subscription', + targetId: subscription.id, + metadata: { + source: 'on-chain', + subscriptionId: event.subscriptionId, + planId: event.planId, + txHash, + }, + }); + + return subscription; +}; + /** * Applies a confirmed on-chain subscription charge to the backend projection. * diff --git a/tests/unit/analytics.indexer.test.ts b/tests/unit/analytics.indexer.test.ts index eb54855..e5f8fbf 100644 --- a/tests/unit/analytics.indexer.test.ts +++ b/tests/unit/analytics.indexer.test.ts @@ -494,9 +494,11 @@ describe('growth handlers', () => { }); test('status and governance events stay unhandled', async () => { + // subscription_plan_created_event used to belong on this list. It now has a + // handler of its own (see creation-events.indexer.test.ts), so only the + // genuinely unhandled topics remain. 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(); diff --git a/tests/unit/creation-events.indexer.test.ts b/tests/unit/creation-events.indexer.test.ts new file mode 100644 index 0000000..fe6f3e4 --- /dev/null +++ b/tests/unit/creation-events.indexer.test.ts @@ -0,0 +1,316 @@ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { applyInvoiceCreated } = await import('../../src/services/invoice.services.js'); +const { applySubscriptionPlanCreated, applySubscribed } = await import( + '../../src/services/subscription.services.js' +); +const { decodeSubscriptionPlanCreatedEventData } = await import('../../src/indexer/types.js'); +const { dispatch } = await import('../../src/indexer/registry.js'); +const { INVOICE_CREATED_TOPIC } = await import('../../src/indexer/handlers/invoiceCreated.js'); +const { SUBSCRIPTION_PLAN_CREATED_TOPIC } = await import( + '../../src/indexer/handlers/subscriptionPlanCreated.js' +); +const { SUBSCRIBED_TOPIC } = await import('../../src/indexer/handlers/subscribed.js'); +await import('../../src/indexer/handlers/index.js'); + +const MERCHANT_UUID = 'merchant-uuid'; +const MERCHANT_ADDRESS = 'GAWCXMWXOEEY4R3L62FT744VGZBDZ6NLWD2ILW6VIMNCNCWQC6KAN3AA'; +const TOKEN = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const TIMESTAMP = 1_787_343_000; // 2026-08-21T20:10:00Z +const LEDGER_CLOSE = new Date('2026-08-21T20:27:17.000Z'); +const DAY = new Date('2026-08-21T00:00:00.000Z'); + +const merchant = { id: MERCHANT_UUID, merchantId: 7, address: MERCHANT_ADDRESS }; + +let consoleError: any; +let consoleWarn: any; + +beforeEach(() => { + mockReset(prismaMock); + prismaMock.$transaction.mockImplementation(async (callback: any) => callback(prismaMock)); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + consoleError.mockRestore(); + consoleWarn.mockRestore(); +}); + +describe('event shapes confirmed against testnet', () => { + test('decodes the real subscription_plan_created_event payload', () => { + // Exactly what scValToNative produced for a live testnet event. + expect( + decodeSubscriptionPlanCreatedEventData({ + amount: 2_500_000_000n, + interval: 2_592_000n, + merchant: MERCHANT_ADDRESS, + plan_id: 77n, + timestamp: 1_787_343_000n, + token: TOKEN, + }), + ).toEqual({ + planId: 77, + merchant: MERCHANT_ADDRESS, + token: TOKEN, + amount: 2_500_000_000n, + interval: 2_592_000, + timestamp: 1_787_343_000, + }); + }); + + test('registers the topic symbols the contract actually publishes', () => { + expect(INVOICE_CREATED_TOPIC).toBe('invoice_created_event'); + expect(SUBSCRIPTION_PLAN_CREATED_TOPIC).toBe('subscription_plan_created_event'); + expect(SUBSCRIBED_TOPIC).toBe('subscribed_event'); + }); + + test('each creation topic reaches its own handler', async () => { + // A wrong or duplicated registration would surface as the wrong decoder's + // error message here. + await expect( + dispatch({ id: 'a', topic: INVOICE_CREATED_TOPIC, ledger: 1, txHash: 'tx', data: null }), + ).rejects.toThrow('InvoiceCreated event data must be a decoded map'); + await expect( + dispatch({ + id: 'b', + topic: SUBSCRIPTION_PLAN_CREATED_TOPIC, + ledger: 1, + txHash: 'tx', + data: null, + }), + ).rejects.toThrow('SubscriptionPlanCreated event data must be a decoded map'); + await expect( + dispatch({ id: 'c', topic: SUBSCRIBED_TOPIC, ledger: 1, txHash: 'tx', data: null }), + ).rejects.toThrow('Subscribed event data must be a decoded map'); + }); +}); + +describe('applyInvoiceCreated', () => { + const event = { + invoiceId: 4242, + merchant: MERCHANT_ADDRESS, + amount: 1_500_000_000n, + token: TOKEN, + }; + + // What fetchInvoiceDetails returns for this invoice; the event itself carries + // neither field. + const onChain = { description: 'Design retainer', expiresAt: null }; + + const unlinked = (id: string) => ({ + id, + invoiceId: null, + merchantId: MERCHANT_UUID, + amount: event.amount, + token: TOKEN, + description: 'Design retainer', + status: 'PENDING', + }); + + beforeEach(() => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.invoice.findUnique.mockResolvedValue(null); + prismaMock.invoice.findMany.mockResolvedValue([]); + prismaMock.invoice.update.mockImplementation(async ({ where }: any) => ({ + ...unlinked(where.id), + invoiceId: event.invoiceId, + })); + prismaMock.invoice.create.mockResolvedValue({ id: 'new-invoice-uuid' }); + }); + + test('links the single unlinked invoice that matches', async () => { + prismaMock.invoice.findMany.mockResolvedValue([unlinked('off-chain-uuid')]); + + const result = await applyInvoiceCreated(event, 'tx-hash', LEDGER_CLOSE, onChain); + + expect(result?.outcome).toBe('linked'); + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: 'off-chain-uuid' }, + data: { invoiceId: 4242, status: 'PENDING' }, + }); + expect(prismaMock.invoice.create).not.toHaveBeenCalled(); + // The description read back off-chain narrows the candidate query. + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ description: 'Design retainer', invoiceId: null }), + }); + }); + + test('refuses to guess when more than one candidate matches, and says so loudly', async () => { + prismaMock.invoice.findMany.mockResolvedValue([ + unlinked('candidate-a'), + unlinked('candidate-b'), + ]); + + const result = await applyInvoiceCreated(event, 'tx-hash', LEDGER_CLOSE, onChain); + + expect(prismaMock.invoice.update).not.toHaveBeenCalled(); + expect(result?.outcome).toBe('created-ambiguous'); + const logged = consoleError.mock.calls.map((call: any[]) => String(call[0])).join('\n'); + expect(logged).toContain('AMBIGUOUS'); + expect(logged).toContain('candidate-a'); + expect(logged).toContain('candidate-b'); + }); + + test('creates a row when nothing matches, rather than dropping the event', async () => { + const result = await applyInvoiceCreated(event, 'tx-hash', LEDGER_CLOSE, onChain); + + expect(result?.outcome).toBe('created'); + expect(prismaMock.invoice.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + invoiceId: 4242, + merchantId: MERCHANT_UUID, + description: 'Design retainer', + amount: event.amount, + token: TOKEN, + status: 'PENDING', + createdAt: LEDGER_CLOSE, + }), + }); + }); + + test('falls back to a placeholder description when the contract read fails', async () => { + await applyInvoiceCreated(event, 'tx-hash', LEDGER_CLOSE, null); + + // Match must not be narrowed by a description we do not have. + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: expect.not.objectContaining({ description: expect.anything() }), + }); + expect(prismaMock.invoice.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ description: 'On-chain invoice #4242' }), + }); + }); + + test('leaves an invoice whose invoiceId is already set alone', async () => { + prismaMock.invoice.findUnique.mockResolvedValue({ id: 'already', invoiceId: 4242 }); + + const result = await applyInvoiceCreated(event, 'tx-hash', LEDGER_CLOSE, onChain); + + expect(result?.outcome).toBe('already-linked'); + expect(prismaMock.invoice.create).not.toHaveBeenCalled(); + expect(prismaMock.invoice.update).not.toHaveBeenCalled(); + }); + + test('still moves the daily new-invoice counter when the merchant is unknown', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const result = await applyInvoiceCreated(event, 'tx-hash', LEDGER_CLOSE, onChain); + + expect(result).toBeNull(); + expect(prismaMock.invoice.create).not.toHaveBeenCalled(); + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: DAY }, + create: { date: DAY, newInvoices: 1 }, + update: { newInvoices: { increment: 1 } }, + }); + }); +}); + +describe('applySubscriptionPlanCreated', () => { + const event = { + planId: 77, + merchant: MERCHANT_ADDRESS, + token: TOKEN, + amount: 2_500_000_000n, + interval: 2_592_000, + timestamp: TIMESTAMP, + }; + + beforeEach(() => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(null); + prismaMock.subscriptionPlan.create.mockResolvedValue({ id: 'plan-uuid', planId: 77 }); + }); + + test('creates the plan with the description read back off-chain', async () => { + const plan = await applySubscriptionPlanCreated(event, 'tx-hash', 'Pro monthly'); + + expect(plan).toEqual({ id: 'plan-uuid', planId: 77 }); + expect(prismaMock.subscriptionPlan.create).toHaveBeenCalledWith({ + data: { + planId: 77, + merchantId: MERCHANT_UUID, + description: 'Pro monthly', + token: TOKEN, + amount: 2_500_000_000n, + interval: 2_592_000, + active: true, + createdAt: new Date(TIMESTAMP * 1000), + }, + }); + }); + + test('replaying the same event does not create a second plan', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue({ id: 'plan-uuid', planId: 77 }); + + const plan = await applySubscriptionPlanCreated(event, 'tx-hash', 'Pro monthly'); + + expect(plan).toEqual({ id: 'plan-uuid', planId: 77 }); + expect(prismaMock.subscriptionPlan.create).not.toHaveBeenCalled(); + }); + + test('skips a plan whose merchant is unknown', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + expect(await applySubscriptionPlanCreated(event, 'tx-hash', 'Pro monthly')).toBeNull(); + expect(prismaMock.subscriptionPlan.create).not.toHaveBeenCalled(); + }); +}); + +describe('applySubscribed', () => { + const event = { + subscriptionId: 909, + planId: 77, + customer: MERCHANT_ADDRESS, + timestamp: TIMESTAMP, + }; + const plan = { id: 'plan-uuid', planId: 77, merchantId: MERCHANT_UUID }; + + beforeEach(() => { + prismaMock.subscription.findUnique.mockResolvedValue(null); + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(plan); + prismaMock.subscription.create.mockResolvedValue({ id: 'subscription-uuid' }); + }); + + test('takes merchantId from the resolved plan, not the event', async () => { + await applySubscribed(event, 'tx-hash'); + + expect(prismaMock.subscription.create).toHaveBeenCalledWith({ + data: { + subscriptionId: 909, + planId: 'plan-uuid', + merchantId: MERCHANT_UUID, + customer: MERCHANT_ADDRESS, + status: 'ACTIVE', + createdAt: new Date(TIMESTAMP * 1000), + }, + }); + }); + + test('replaying the same event does not create a second subscription', async () => { + prismaMock.subscription.findUnique.mockResolvedValue({ id: 'subscription-uuid' }); + + await applySubscribed(event, 'tx-hash'); + + expect(prismaMock.subscription.create).not.toHaveBeenCalled(); + }); + + test('skips a subscription whose plan is not indexed yet', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(null); + + expect(await applySubscribed(event, 'tx-hash')).toBeNull(); + expect(prismaMock.subscription.create).not.toHaveBeenCalled(); + }); + + test('moves the daily new-subscription counter', async () => { + await applySubscribed(event, 'tx-hash'); + + expect(prismaMock.platformDailyStats.upsert).toHaveBeenCalledWith({ + where: { date: DAY }, + create: { date: DAY, newSubscriptions: 1 }, + update: { newSubscriptions: { increment: 1 } }, + }); + }); +});