Skip to content

feat(revenue): billing hand-off — outbound events on closed-won and contract activation - #1089

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-600-billing-handoff-flows
Aug 11, 2026
Merged

huangyiirene merged 1 commit into
mainfrom
claude/issue-600-billing-handoff-flows

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #600

Two record-change flows hand a won deal and an activated contract to an external billing system, and one trilingual docs page states where HotCRM's revenue scope ends. No Order / Invoice / Payment object was added — the 2026-08-02 maintainer decision stands.

What lands

src/flows/billing-handoff.flow.ts billing_handoff_closed_won and billing_handoff_contract_activated
src/flows/_billing-endpoint.ts the one place the endpoint, signing secret, timeout, event names and payload version live
content/docs/revenue/billing-handoff.mdx + .zh-Hans + .zh-Hant the boundary, the payload contract, the delivery guarantees, and how to repoint the endpoint
test/flow-billing-handoff.test.ts 14 cases on the real engine and the real builtin http executor

Each flow POSTs one JSON document on the transition into the state — crm.opportunity.closed_won carries the deal + account + line items; crm.contract.activated carries the contract (including billing_frequency / payment_terms, the two fields the card names as having no consumer) + account + the originating deal's line items.

Mechanism: PM ruling Option B, and its premise measured

The PM's ruling was Option B — record_change flows plus the builtin durable http node — and the stated reason was that this node inherits the platform's reliability. That premise was the one thing this round had to falsify or confirm before building. Confirmed, on the installed 17.0.0-rc.6:

  • service-automation builtin/http-nodes.ts: durable: true calls messaging.enqueueHttp({ source: 'flow', refId, dedupKey, label, url, method, headers, signingSecret, timeoutMs, payload }). Its descriptor declares needsOutbox: true, supportsRetry: true.
  • service-messaging: enqueueHttp writes a sys_http_delivery row; HttpDispatcher drains it; classifyAttempt retries on 408 / 429 / 5xx / network / timeout with the backoff schedule 1s, 10s, 1m, 10m, 1h, 6h, 24h (jittered, 7 attempts) and dead-letters otherwise; sendOnce signs the body HMAC-SHA256 into X-Objectstack-Signature when a signingSecret is present, and stamps X-Objectstack-Delivery / X-Objectstack-Attempt / X-Objectstack-Event.
  • messaging is in PLATFORM_ALWAYS_ON_CAPABILITIES (measured: queue, job, cache, settings, email, storage, sms, sharing, messaging, analytics), so no requires change is needed — unlike the webhooks route, whose dispatch is gated on realtime.
  • One honest caveat, documented rather than hidden: when no HTTP outbox is wired the node logs a warning and degrades to an inline fetch. That is why the test harness answers isHttpDeliveryReady() === true — without it a durable: true node passes while firing a live request off the runner.

crm_contract.status re-confirmed against source: draft / in_approval / activated / expired / terminated. The card's active does not exist; the flow uses activated, and a test pins that a payload spelling active produces no delivery.

"Exactly once", proved rather than asserted

Both start conditions test the transition (record.x == v && previous.x != v), reusing src/flows/opportunity-won-alert.flow.ts's idiom verbatim — the has(previous.stage) && previous.stage != "closed_won" term, fail-closed, whose header explains that without it every later edit of a won deal re-fires. Reused deliberately: a value-equality condition is exactly the duplicate-order shape that got Option A rejected.

Reverse verification, direction predicted before running: strip the previous term and the "later edit of an already-won deal" case flips from 0 deliveries to 1. Asserted in-test (the transition term is what makes it once), not described. Also pinned: no delivery on a bulk update where previous arrives null, and none on a stage move between two non-won values.

Acceptance evidence

pnpm test — 87 files, 2118 passed, 1 skipped. The 14 new cases assert against the captured outbox call, not against metadata:

✓ enqueues exactly one DURABLE delivery on the transition into closed_won
✓ the payload carries the deal, its account and its line items
✓ does NOT fire on a later edit of an already-won deal
✓ does NOT fire when the stage moves between two non-won values
✓ does NOT fire on a bulk update, where `previous` arrives null
✓ the transition term is what makes it once — remove it and the same edit re-bills
✓ enqueues one delivery on the transition into `activated`
✓ `activated`, not `active` — the value the card named does not exist
✓ still hands off a contract with no originating deal, with an empty item list
✓ does NOT fire on a later edit of an already-activated contract

What is real in those runs: the real AutomationEngine, the real installBuiltinNodes http executor taking its own durable branch (proved by source: 'flow', which only enqueueHttp sets), the real start-condition CEL, the real get_record filter resolution. What is stubbed, and why: the outbox itself (retry/backoff/dead-letter/HMAC are service-messaging's own code and its tests to run — this app proves it reaches the enqueue, once, with the right body) and the driver's fields projection (the in-memory engine ignores it, so the projection is asserted as metadata instead). Both are named in the test header rather than left for a reader to discover.

Gates run locally, all green: pnpm validate (Validation passed; no new warnings — the new flows produce none), pnpm typecheck, pnpm lint (0 findings mentioning the new files), pnpm hygiene, pnpm build (Logic: 26 Flows), pnpm test. Built artifact spot-checked: both http nodes carry durable: true, method: POST, the shared endpoint, and no signingSecret key when the env var is unset.

The cost, stated where a reader will hit it

Option B puts the endpoint and the signing secret in flow metadata. Repointing them is a build-time change, not a Setup edit — the card's scope item 2 assumed an admin-editable endpoint and that assumption does not survive. The docs page says so in its own section rather than teaching a Setup screen that does not apply, names the module (src/flows/_billing-endpoint.ts) and the two env overrides (HOTCRM_BILLING_ENDPOINT, HOTCRM_BILLING_SIGNING_SECRET), and carries a callout explaining why the admin-editable webhooks surface was not used. The secret is deliberately not defaulted to a literal; the endpoint defaults to the RFC 2606 example host, so an unconfigured install enqueues, retries and dead-letters visibly instead of quietly doing nothing.

Surface beyond the dispatch list, and why

The card scoped content/docs/** to the new page and its two siblings. Several ledgers count flows and went red the moment the stack shipped 26 instead of 24, so they moved with it — a consequence of adding flows, not scope creep:

  • content/docs/administration/automation.mdx + both siblings: two table rows and the (24) numeral (the scheduled-flow count is untouched; these are record-change flows). test/automation-docs-coverage.test.ts gained the two Chinese row labels — that ledger is authored by design, flows carry no locale-pack entry.
  • README.md (3 sites) and docs/STATUS.md: the flow count, pinned by test/docs-drift.test.ts.
  • test/runtime-coverage.test.ts: the new runtime test file registered.
  • content/docs/revenue/meta.json + siblings, and the index.mdx "What's included" row, in all three languages — without the meta entry the new page has no navigation at all.
  • test/helpers/flow-harness.ts: captures enqueueHttp and answers isHttpDeliveryReady(), per the inline-fetch caveat above.

Nothing under src/objects/**, src/translations/**, content/docs/sales/quotes* or src/objects/forecast.* is touched — #599 and #1008 are in flight there.

Deliberately out of scope

Inventory decrement (crm_product.quantity_on_hand stays display-only), revenue schedules, payment-status write-back, and any inbound direction. The docs page states each as a boundary rather than leaving it to be discovered.


Generated by Claude Code

…illing

Two record-change flows POST one shaped JSON document to the billing endpoint
on the TRANSITION into `closed_won` / `activated`, through the platform's
durable HTTP outbox (`http` node with `durable: true` -> `sys_http_delivery`,
with retry/backoff, dead-letter and HMAC signing).

- src/flows/billing-handoff.flow.ts — the two flows
- src/flows/_billing-endpoint.ts — the one place the endpoint + secret live
- content/docs/revenue/billing-handoff.mdx (+ zh-Hans / zh-Hant) — the
  lead-to-contract boundary, the payload contract, the delivery guarantees,
  and that repointing the endpoint is a build-time change, not a Setup edit
- test/flow-billing-handoff.test.ts — real engine + real builtin http
  executor; one delivery per crossing, none on a later edit, with the
  transition term's removal pinned as reverse verification

Stack `webhooks` cannot express either event (strict schema rejects
condition/body, triggers are object+action only, dispatch gated on `realtime`),
which is why this is a flow. Details in the file headers and the changeset.

Fixes #600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM6o28jmBgsyTRQHutn7LC
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 11, 2026 3:30pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Billing hand-off: declare outbound webhooks on closed-won and contract activation — CRM scope ends at the contract

1 participant