Background
Three handlers, following the exact pattern established (decode → dedicated service function → thin handler → registerEventHandler). Two of them are straightforward; one is not, and that one needs to be flagged honestly rather than papered over.
InvoiceCreatedEvent has a real correlation problem. Invoices in this backend are created off-chain first — POST /invoices writes an Invoice row with invoiceId: null before anything touches the chain. Nothing currently in this codebase submits create_invoice/create_invoice_signed on-chain on a merchant's behalf, so it isn't yet established how an off-chain-created invoice gets its on-chain invoiceId filled in, or how this handler is supposed to match an incoming InvoiceCreatedEvent back to the right DB row. Two real possibilities, not resolved here:
- A future issue has this backend relay
create_invoice_signed (Manager-role, using the merchant's Ed25519 key ) at the moment POST /invoices is called, and captures the resulting invoice_id synchronously from the transaction result — in which case this handler is a backup/reconciliation path, not the primary way invoiceId gets set.
- Invoices can also originate entirely on-chain (a merchant's own SDK integration calling
create_invoice directly, bypassing our API) — in which case this handler is the only way our backend learns about that invoice at all, and must create a new Invoice row from scratch.
- The contract's
create_invoice_signed already takes a nonce for replay protection — that nonce is a natural correlation key if a future issue sets it to the off-chain Invoice.id before submitting. That's the clean fix, but it depends on the relay issue above existing first, so it isn't implemented here.
Given that, this handler's proposed behavior is a best-effort heuristic, not a guaranteed-correct match — call this out in code comments, don't present it as solved: on InvoiceCreatedEvent, look for an existing Invoice with invoiceId: null and matching merchantId + amount + token + description; if found, set its invoiceId; if not found (or if more than one candidate matches — ambiguous, don't guess), create a new Invoice row from the event data instead. Log loudly on the ambiguous-match case so it's visible, not silently wrong.
SubscriptionPlanCreatedEvent and SubscribedEvent have no such problem — plans and subscriptions have no off-chain-first creation path anywhere in this backend, so their handlers are plain create-if-not-exists.
One more thing to verify, not assume: the Events Reference table lists SubscriptionPlanCreatedEvent's key fields as plan_id, merchant, token, amount, interval, timestamp — no description, even though SubscriptionPlan.description is a required (non-nullable) field in our schema and in the contract's own struct. Either the event's actual decoded payload includes description despite the summary table omitting it (likely, if #[contractevent] emits the full struct), or it doesn't and this field must be fetched separately via get_subscription_plan(plan_id). Confirm which against a real testnet event — same discipline already established for topic names — before writing the handler as if it's settled.
Proposed Steps
- Confirm real topic symbols and decoded field shapes for all three events against testnet — including the
description question above for SubscriptionPlanCreatedEvent, and whether SubscriptionPlanCreatedEvent's merchant field is an Address or a merchant_id integer (the contract's SubscriptionPlan struct has both merchant_id: u64 and merchant: Address; the event's listed field is ambiguous as to which it carries).
applyInvoiceCreated(event, txHash) in invoice.services.ts — implements the heuristic-match-or-create logic described above.
applySubscriptionPlanCreated(event, txHash) in a new subscription.services.ts — resolves merchantId (via whichever of address/int step 1 confirms), creates a SubscriptionPlan row keyed on planId (unique).
applySubscribed(event, txHash) in subscription.services.ts — resolves the SubscriptionPlan by planId, creates a Subscription row with merchantId taken from the resolved plan (not from the event — matches the schema's existing comment about the composite FK requiring plan/subscription merchant consistency).
- Three thin handler files under
src/indexer/handlers/, each registered via registerEventHandler, same as invoicePaid.ts.
- Wire
recordAuditLog calls per Action Catalog for all three.
Acceptance Criteria
Background
Three handlers, following the exact pattern established (decode → dedicated service function → thin handler →
registerEventHandler). Two of them are straightforward; one is not, and that one needs to be flagged honestly rather than papered over.InvoiceCreatedEventhas a real correlation problem. Invoices in this backend are created off-chain first —POST /invoiceswrites anInvoicerow withinvoiceId: nullbefore anything touches the chain. Nothing currently in this codebase submitscreate_invoice/create_invoice_signedon-chain on a merchant's behalf, so it isn't yet established how an off-chain-created invoice gets its on-chaininvoiceIdfilled in, or how this handler is supposed to match an incomingInvoiceCreatedEventback to the right DB row. Two real possibilities, not resolved here:create_invoice_signed(Manager-role, using the merchant's Ed25519 key ) at the momentPOST /invoicesis called, and captures the resultinginvoice_idsynchronously from the transaction result — in which case this handler is a backup/reconciliation path, not the primary wayinvoiceIdgets set.create_invoicedirectly, bypassing our API) — in which case this handler is the only way our backend learns about that invoice at all, and must create a newInvoicerow from scratch.create_invoice_signedalready takes anoncefor replay protection — that nonce is a natural correlation key if a future issue sets it to the off-chainInvoice.idbefore submitting. That's the clean fix, but it depends on the relay issue above existing first, so it isn't implemented here.Given that, this handler's proposed behavior is a best-effort heuristic, not a guaranteed-correct match — call this out in code comments, don't present it as solved: on
InvoiceCreatedEvent, look for an existingInvoicewithinvoiceId: nulland matchingmerchantId+amount+token+description; if found, set itsinvoiceId; if not found (or if more than one candidate matches — ambiguous, don't guess), create a newInvoicerow from the event data instead. Log loudly on the ambiguous-match case so it's visible, not silently wrong.SubscriptionPlanCreatedEventandSubscribedEventhave no such problem — plans and subscriptions have no off-chain-first creation path anywhere in this backend, so their handlers are plain create-if-not-exists.One more thing to verify, not assume: the Events Reference table lists
SubscriptionPlanCreatedEvent's key fields asplan_id, merchant, token, amount, interval, timestamp— nodescription, even thoughSubscriptionPlan.descriptionis a required (non-nullable) field in our schema and in the contract's own struct. Either the event's actual decoded payload includesdescriptiondespite the summary table omitting it (likely, if#[contractevent]emits the full struct), or it doesn't and this field must be fetched separately viaget_subscription_plan(plan_id). Confirm which against a real testnet event — same discipline already established for topic names — before writing the handler as if it's settled.Proposed Steps
descriptionquestion above forSubscriptionPlanCreatedEvent, and whetherSubscriptionPlanCreatedEvent'smerchantfield is anAddressor amerchant_idinteger (the contract'sSubscriptionPlanstruct has bothmerchant_id: u64andmerchant: Address; the event's listed field is ambiguous as to which it carries).applyInvoiceCreated(event, txHash)ininvoice.services.ts— implements the heuristic-match-or-create logic described above.applySubscriptionPlanCreated(event, txHash)in a newsubscription.services.ts— resolvesmerchantId(via whichever of address/int step 1 confirms), creates aSubscriptionPlanrow keyed onplanId(unique).applySubscribed(event, txHash)insubscription.services.ts— resolves theSubscriptionPlanbyplanId, creates aSubscriptionrow withmerchantIdtaken from the resolved plan (not from the event — matches the schema's existing comment about the composite FK requiring plan/subscription merchant consistency).src/indexer/handlers/, each registered viaregisterEventHandler, same asinvoicePaid.ts.recordAuditLogcalls per Action Catalog for all three.Acceptance Criteria
InvoiceCreatedEventhandler's ambiguous-match case is logged loudly and does not silently pick a wrong invoiceInvoiceCreatedEventhandler creates a newInvoicerow when no off-chain match exists, rather than dropping the eventSubscriptionPlanCreatedEventandSubscribedEventhandlers create rows idempotently keyed on their on-chain unique ids (planId,subscriptionId) — replaying the same event (pre-IndexerEvent-dedupe, as a sanity check) does not create duplicates