Repository navigation
feat(revenue): billing hand-off — outbound events on closed-won and contract activation - #1089
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
huangyiirene
marked this pull request as ready for review
August 11, 2026 15:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsbilling_handoff_closed_wonandbilling_handoff_contract_activatedsrc/flows/_billing-endpoint.tscontent/docs/revenue/billing-handoff.mdx+.zh-Hans+.zh-Hanttest/flow-billing-handoff.test.tshttpexecutorEach flow POSTs one JSON document on the transition into the state —
crm.opportunity.closed_woncarries the deal + account + line items;crm.contract.activatedcarries the contract (includingbilling_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
httpnode — 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-automationbuiltin/http-nodes.ts:durable: truecallsmessaging.enqueueHttp({ source: 'flow', refId, dedupKey, label, url, method, headers, signingSecret, timeoutMs, payload }). Its descriptor declaresneedsOutbox: true, supportsRetry: true.service-messaging:enqueueHttpwrites asys_http_deliveryrow;HttpDispatcherdrains it;classifyAttemptretries on408/429/5xx/ network / timeout with the backoff schedule1s, 10s, 1m, 10m, 1h, 6h, 24h(jittered, 7 attempts) and dead-letters otherwise;sendOncesigns the body HMAC-SHA256 intoX-Objectstack-Signaturewhen asigningSecretis present, and stampsX-Objectstack-Delivery/X-Objectstack-Attempt/X-Objectstack-Event.messagingis inPLATFORM_ALWAYS_ON_CAPABILITIES(measured:queue, job, cache, settings, email, storage, sms, sharing, messaging, analytics), so norequireschange is needed — unlike thewebhooksroute, whose dispatch is gated onrealtime.isHttpDeliveryReady() === true— without it adurable: truenode passes while firing a live request off the runner.crm_contract.statusre-confirmed against source:draft / in_approval / activated / expired / terminated. The card'sactivedoes not exist; the flow usesactivated, and a test pins that a payload spellingactiveproduces no delivery."Exactly once", proved rather than asserted
Both start conditions test the transition (
record.x == v && previous.x != v), reusingsrc/flows/opportunity-won-alert.flow.ts's idiom verbatim — thehas(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
previousterm 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 wherepreviousarrives 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:What is real in those runs: the real
AutomationEngine, the realinstallBuiltinNodeshttpexecutor taking its own durable branch (proved bysource: 'flow', which onlyenqueueHttpsets), the real start-condition CEL, the realget_recordfilter resolution. What is stubbed, and why: the outbox itself (retry/backoff/dead-letter/HMAC areservice-messaging's own code and its tests to run — this app proves it reaches the enqueue, once, with the right body) and the driver'sfieldsprojection (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: bothhttpnodes carrydurable: true,method: POST, the shared endpoint, and nosigningSecretkey 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-editablewebhookssurface 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.tsgained the two Chinese row labels — that ledger is authored by design, flows carry no locale-pack entry.README.md(3 sites) anddocs/STATUS.md: the flow count, pinned bytest/docs-drift.test.ts.test/runtime-coverage.test.ts: the new runtime test file registered.content/docs/revenue/meta.json+ siblings, and theindex.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: capturesenqueueHttpand answersisHttpDeliveryReady(), per the inline-fetch caveat above.Nothing under
src/objects/**,src/translations/**,content/docs/sales/quotes*orsrc/objects/forecast.*is touched — #599 and #1008 are in flight there.Deliberately out of scope
Inventory decrement (
crm_product.quantity_on_handstays 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