logicspike/docs

Engagement

Engagement — Billing

Last Updated: 2026-09-05 · Status: Active

What costs money

Action Cost Reason code Mechanism
A comment becomes published 0.1 credit (ENGAGEMENT_COMMENT_THOUSANDTHS: 100) engagement.comment.publish Accumulator
AI comment summary 5 credits (ENGAGEMENT_AI_SUMMARY) engagement.ai.summary chargedCall, flat
Views, reads, reactions, ratings free — soft plan cap only
Owner replies free — never touches the metering path

Costs live in packages/core-types/src/credit-costs.ts and reasons in packages/core-billing/src/reasons.ts. Nothing else may define them; no-direct-writes.test.ts scans apps/*/src to make sure nothing writes tenant_credits or credit_transactions by hand.

Why comments are metered on publish, not submit

A submitted comment might be spam, might be rejected, might sit in a queue forever. Charging for it would mean a tenant pays for a spammer's output — the exact thing they installed moderation to avoid. Charging on publish means a tenant pays only for comments they actually chose to show.

The two pre-flight gates run at submit time anyway:

  • Out of credits → 402 insufficient_credits
  • Monthly plan cap reached → 402 plan_limit_reached

Both check at submit even though the charge lands at publish, because accepting a comment the tenant could never afford to publish just builds a moderation queue that can never be cleared.

The visitor-facing copy for both is deliberately vague — "Comments are temporarily unavailable on this site." A blog comment box faces the open internet, and the tenant's billing state is not a passing reader's business. The machine-readable error code still distinguishes them for the tenant's own tooling.

The accumulator

One published comment is 0.1 credits, which is not a number the ledger can move. So publishes accumulate in thousandths against tenant_engagement_usage, and the ledger fires only when the total crosses a whole credit — the 10th comment settles 1 credit. Same pattern as forms' submissions and comms' emails.

keyPrefix: "engagement-settle". Unbilled remainders are swept by billing-crons' daily flush_engagement_accumulator job → POST /cron/flush-accumulator.

Metering fires at most once per comment

Keyed on published_at already being set, checked inside publishComment().

This matters because a comment can legitimately be published more than once. The five-minute edit window re-screens the new body; an edit that trips a rule pulls the comment back to pending, and a moderator may then approve it again. Without the guard that round trip would bill the tenant a second time for one comment.

published_at is preserved (never cleared) across an unpublish precisely so it can serve as this marker.

When billing is absent

Surface Behaviour without CORE_DATABASE_URL Why
Comment publish runs unbilled, logs a warning Matches forms. A missing config in local dev must not break the feature under test.
AI summary 500, refuses to run A flat AI charge silently becoming free is a different and worse failure — it costs real money on every call.

Refunds

chargedCall refunds automatically when the work throws, and when refundWhen says the work was a no-op. For the AI summary that's an empty thread: nobody should pay to be told there is nothing to summarise.

Parity tests that will catch you

Test What it enforces
packages/core-billing/test/credit-costs.test.ts Every cost is a positive number
packages/service-catalog/test/billing-parity.test.ts The set of tools declaring billing matches a hand-maintained list
apps/engagement-service/test/catalog-parity.test.ts Only moderate_comment and summarize_comments declare billing — replies, deletes and bans stay free
no-direct-writes.test.ts No hand-rolled ledger writes

Adding a billed tool means updating the hand-lists in the second and third. That friction is deliberate: it makes "this now costs the customer money" a visible line in a diff.

Plan limits

Key What it caps
engagement.events_monthly Views/reads/reactions/ratings. Soft — counting never stops; the dashboard hides breakdowns over cap.
engagement.comments_monthly Published comments. Hard — a 402 at submit.

Rows live in both seeds (packages/core-database/src/seed.ts and apps/manager/src/seed.ts) across all five plan tiers. A service with no plan_service_limits rows is never provisioned by rebuildEntitlements, which is why the limits and the registry entry had to ship together.

Internal-ops visibility

apps/internal-api/src/services/spend.ts has an engagement.% arm in svcCase, so engagement spend shows as its own line in the cross-tenant revenue view rather than being lumped into other.

Engagement