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.