logicspike/docs

Blog Engine

Blog Service — Billing & Credits

Last Updated: 2026-06-28 Status: Active Companion docs: architecture.md · blog-service.md · permissions.md


1. What Is Billed

Publishing a post costs 1 credit.

The credit is charged at the moment POST /admin/posts/:id/publish is called and succeeds. No other blog operations (create, update, delete, schedule, list, fetch) cost credits.


2. Credit Guard Middleware

The creditGuard middleware wraps the publish handler and manages the full charge/refund lifecycle:

POST /admin/posts/:id/publish
  ├── creditGuard (before)
  │    └── CreditLedger.charge(tenantId, reason="blog.post.publish", amount=1)
  │         ├── OK → continue to handler
  │         └── Insufficient → 402 Payment Required (handler never runs)
  ├── Publish handler
  │    ├── Already published? → set c.set("creditGuardRefund", true) → middleware refunds
  │    ├── Success → 200 { status: "published" }
  │    └── Error → 4xx/5xx → middleware auto-refunds
  └── creditGuard (after)
       ├── creditGuardRefund = true → refund (idempotency: post was already published)
       └── handler returned 4xx/5xx → refund

Idempotency

If a post is already status=published when the publish endpoint is called:

  • The handler detects it and sets c.set("creditGuardRefund", true)
  • The credit guard automatically issues a full refund
  • The caller receives 200 { status: "published" } with no net credit change

This means calling publish twice on an already-published post is safe — the second call costs nothing.


3. 30-Minute Grace Window (Unpublish Refund)

Unpublishing a post within 30 minutes of publish triggers an automatic credit refund.

POST /admin/posts/:id/unpublish
  ├── Find most recent credit_transactions row WHERE reason = "blog.post.publish" AND post_id = :id
  ├── Is it within 30 minutes of now?
  │    ├── Yes → refund 1 credit (insert reverse transaction)
  │    └── No  → no refund (content existed publicly, was indexed, newsletter sent)
  └── Set status = "draft", clear published_at

After the grace window, unpublishing is free but does not recover the publish credit.

Edge case — multiple publishes in the grace window: Only the most recent publish transaction is refunded. If a post was published, unpublished, and re-published within 30 minutes, only the last publish transaction is eligible for a refund on the second unpublish.


4. MCP Differences

The MCP publish-post and unpublish-post tools behave differently from the REST endpoints:

Operation REST endpoint MCP tool
publish Charges credit ✅ + fans out newsletter ✅ Charges credit ✅ + no newsletter
unpublish Grace-window refund ✅ No refund

IMPORTANT

If you need full publish behavior (credit charge + newsletter fan-out), use the REST endpoint (POST /admin/posts/:id/publish). The MCP tool is designed for agent-driven content management where newsletter dispatch should be a separate explicit step.


5. Core Database Connection

Credit operations read and write the core database (CORE_DATABASE_URL), a separate Neon connection from the blog database (BLOG_DATABASE_URL):

Table Used for
tenant_credits Read: balance check before publish
credit_transactions Write: charge on publish; write: refund on grace-window unpublish
subscriptions Read: check subscription is active (also done by gateway)

CORE_DATABASE_URL is required for publish to work. If it is missing or unreachable, the credit guard will fail and publish will return an error.


6. Reason Strings

Credit transactions are tagged with reason = "blog.post.publish" (defined in @repo/core-billing/src/reasons.ts). These reason strings are used by:

  • The analytics dashboard to show monthly blog credit spend
  • The refund logic to find the matching transaction on unpublish
  • Billing reports and invoices

7. Error Codes

Status When
402 Payment Required Tenant has insufficient credits to publish
403 Forbidden Missing blog:posts.publish permission
409 Conflict Post is in a state that cannot be published (e.g., already published — but this is handled idempotently; only hard conflicts return 409)
Blog Engine