From 3ff3233c2df6fa95369bd0ea9d62c6cab1757bf2 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Mon, 27 Jul 2026 18:24:43 +1000 Subject: [PATCH 01/35] add authoring-how-to skill --- .cursor/skills/authoring-how-to/SKILL.md | 102 ++++ .../assets/how-to-template.md | 139 +++++ .../references/chargebee-sources.md | 98 ++++ .../authoring-how-to/references/diagrams.md | 180 ++++++ .../authoring-how-to/references/language.md | 136 +++++ .../authoring-how-to/scripts/check_how_to.py | 531 ++++++++++++++++++ .cursorignore | 2 + 7 files changed, 1188 insertions(+) create mode 100644 .cursor/skills/authoring-how-to/SKILL.md create mode 100644 .cursor/skills/authoring-how-to/assets/how-to-template.md create mode 100644 .cursor/skills/authoring-how-to/references/chargebee-sources.md create mode 100644 .cursor/skills/authoring-how-to/references/diagrams.md create mode 100644 .cursor/skills/authoring-how-to/references/language.md create mode 100755 .cursor/skills/authoring-how-to/scripts/check_how_to.py create mode 100644 .cursorignore diff --git a/.cursor/skills/authoring-how-to/SKILL.md b/.cursor/skills/authoring-how-to/SKILL.md new file mode 100644 index 0000000..4da96e4 --- /dev/null +++ b/.cursor/skills/authoring-how-to/SKILL.md @@ -0,0 +1,102 @@ +--- +name: authoring-how-to +description: Authors and revises in-depth Chargebee integration how-to guides in the reference-architecture repository, following the structure, mermaid-only diagram rule, and writing voice of how-to/integrating-chargebee-webhooks.md. Use when adding or editing any file under how-to/, when asked to write or review a Chargebee how-to or reference-architecture guide, or when documenting a Chargebee integration topic such as webhooks, subscription lifecycle, checkout, invoices, payments, dunning, entitlements, usage metering, tax, or reconciliation. +--- + +# Authoring reference-architecture how-tos + +The `how-to/` directory holds vetted, in-depth guides on integrating Chargebee into a high-traffic SaaS platform. Each guide gives an architect or developer a design they can build against, plus the operational detail needed to run it. `pointer/` is the working implementation those guides point at. + +Read [`how-to/integrating-chargebee-webhooks.md`](../../../how-to/integrating-chargebee-webhooks.md) before writing. It is the structural and tonal reference. Match its shape and voice; do not copy its sentences or reproduce its typos. + +## Four rules that override everything else + +1. **Everything is code, including diagrams.** Every diagram is a fenced `mermaid` code block committed in the markdown. No images, no screenshots, no ASCII art, no links to Lucid, Miro, Excalidraw, or draw.io. If a design can't be drawn in mermaid, describe it in prose instead of reaching for a picture. +2. **Verify Chargebee behavior against the live docs.** Never write API names, field names, event types, limits, or product behavior from memory. See [references/chargebee-sources.md](references/chargebee-sources.md). +3. **Go deep.** These are not quickstarts. A guide that only documents the happy path has failed. Cover the failure modes, the ordering and idempotency and consistency concerns, and the trade-offs behind each recommendation. +4. **Write like the reference guide, not like a model.** The language rules in [references/language.md](references/language.md) are enforceable, not advisory. Run the validator before finishing. + +## Workflow + +1. **Fix the scope.** Name the outcome the reader wants and the boundary of the system covered. One guide, one outcome. If the topic is really two outcomes, propose splitting it. +2. **Research.** Pull the current behavior from Chargebee's docs and API reference, then read the relevant `pointer/` code. Follow [references/chargebee-sources.md](references/chargebee-sources.md). Note the exact URLs you relied on; they become the guide's outbound links. +3. **Map the design.** Before writing prose, work out: the synchronous path, what moves to asynchronous work, the system of record for each piece of state, every failure path, who retries and what gets acknowledged, and how state is reconciled when things drift. +4. **Draft.** Copy [assets/how-to-template.md](assets/how-to-template.md) to `how-to/-.md` and fill it in. Keep the section order. +5. **Draw the diagrams.** Follow [references/diagrams.md](references/diagrams.md) for the sequence and flowchart conventions used across the repo. +6. **Ground the implementation notes.** Open every `pointer/` file you cite and confirm it still does what you say. Label opinionated choices (SQS, Better Auth, Next.js, Postgres) as `pointer`'s choices, not Chargebee requirements. +7. **Validate.** Run the checker, fix every error, and for each warning either fix it or be able to say why it is a false positive. + + ```bash + python3 .cursor/skills/author-ref-arch-how-to/scripts/check_how_to.py how-to/.md + ``` + +8. **Register the guide.** Add it to the numbered topic list in [`how-to/README.md`](../../../how-to/README.md). +9. **Self-review.** Walk the final checklist at the bottom of this file. + +## Required structure + +Keep this order. Drop a section only when the topic genuinely has nothing to put in it. + +| Section | Heading | Contains | +| --- | --- | --- | +| Title | `# How to ` | The outcome, phrased as a task | +| Intro | (no heading) | Why this matters and what the reader will build, in 2–4 sentences, optionally followed by a short list of concrete reasons | +| Architecture | `## High level architecture` | One mermaid diagram covering happy path and the failure paths that matter | +| Guidance | `## Best Practices` | Recommendations organized by responsibility, with `###` subsections for the hard correctness topics | +| Code | `## Implementation notes` | How `pointer` does it, with links to specific files and what each one does | +| Launch | `## Go-live checklist` | `- [ ]` questions that can be answered yes or no before deploying | + +`Best Practices` keeps its title-case capitalization to match the reference guide. Every other heading is sentence case. + +### Intro + +Open on the reader's situation, not on the document. The reference guide starts with `One of the first steps when integrating Chargebee using the SDK/API is to setup webhooks. You will need webhooks to:` and then lists three reasons. That is the pattern: a sentence that places the topic in a real integration sequence, then the concrete payoff. + +### High level architecture + +Lead with the diagram, then explain it in prose dense enough that a reader who skips the diagram still understands the design. Show where trust boundaries sit, where a hand-off becomes durable, what runs asynchronously, and what happens on error. + +### Best Practices + +Group by responsibility or design decision. For each recommendation, say what to do, why it matters, and what breaks if it is skipped. Where the topic involves retries, duplicates, ordering, concurrency, or partial failure, spell out the semantics rather than gesturing at them. + +Promote a genuinely hard correctness problem into its own `###` subsection with its own mermaid sequence. The reference guide does this for out-of-order and dependent events, and that subsection is the most useful part of the document. Drop to `####` for a finer breakdown inside it, as the reference guide does for retry semantics. + +### Implementation notes + +Open with a short paragraph on how `pointer` wires the topic together, then a bulleted list of files. Each bullet is a link to the file followed by its role and the runtime behavior that matters, for example what acknowledges a message or what happens on a thrown error. + +State trade-offs plainly when `pointer` diverges from the recommended production design. + +### Go-live checklist + +Write each item as a question with a testable answer: + +```markdown +- [ ] Does the worker retry transient failures without acknowledging the message? +``` + +Cover configuration, authentication, correctness, failure recovery, observability, security, scale, reconciliation, and ownership as the topic warrants. Reject anything as soft as "Is the integration production-ready?". + +## Reference files + +- [references/language.md](references/language.md) — the voice, sentence patterns, formatting conventions, and the banned-phrase list. Read this before drafting prose. +- [references/diagrams.md](references/diagrams.md) — mermaid conventions: participant naming, `alt` blocks for failure paths, when to use `flowchart` over `sequenceDiagram`. +- [references/chargebee-sources.md](references/chargebee-sources.md) — which documentation site to trust for what, verified entry-point URLs, product-version traps, and the facts that must always be verified rather than recalled. +- [assets/how-to-template.md](assets/how-to-template.md) — the skeleton to copy into `how-to/`. +- [scripts/check_how_to.py](scripts/check_how_to.py) — structural and language validator. Run with `--strict` to make warnings fail too. + +## Final checklist + +- [ ] The title names a concrete outcome, not a subject area. +- [ ] The intro states the reader's problem in its first sentence and contains no meta-commentary about the document. +- [ ] Every diagram is mermaid, and the diagram and the prose describe the same system. +- [ ] Failure paths appear in the architecture diagram, not just in the prose. +- [ ] Each recommendation carries its rationale and its failure mode. +- [ ] Delivery and consistency semantics are stated precisely, and "exactly once" is not claimed unless the design proves it. +- [ ] Chargebee behavior, event types, and field names were checked against the live documentation, and the guide links to the pages a reader would use to verify them. +- [ ] `pointer`'s technology choices are labelled as choices. +- [ ] Every repository link resolves and every cited file still plays the stated role. +- [ ] Checklist items are answerable yes or no before launch. +- [ ] The draft has no filler transitions, no bolded label on every bullet, no closing summary, and nothing that reads like a template. +- [ ] `check_how_to.py` reports no errors. diff --git a/.cursor/skills/authoring-how-to/assets/how-to-template.md b/.cursor/skills/authoring-how-to/assets/how-to-template.md new file mode 100644 index 0000000..14fa7ed --- /dev/null +++ b/.cursor/skills/authoring-how-to/assets/how-to-template.md @@ -0,0 +1,139 @@ +# How to with Chargebee + + + + You will need to: + +* +* +* + + +## High level architecture + + + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + participant worker as Worker + participant db as Database + + cb->>app: + activate app + note over app: + app->>queue: Store message + alt Error + app->>cb: HTTP 5xx (Retry) + cb-->>app: Scheduled retry + else Success + app->>cb: HTTP 2xx (Success) + end + deactivate app + + queue-)worker: Receive messages + activate worker + worker-->>db: + alt All OK + worker-->>queue: Ack + else Retryable error + worker->>queue: Error (Retry) + else Non-retryable error + note over queue: Push to Dead Letter
queue/store + end + deactivate worker +``` + + + + +## Best Practices + + + +* It should: + + - + - + - + +* Bold a **new term** on first use, then drop the bold. + +* + + +### + + + + + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant worker as Worker + + note over cb,worker: + + cb->>app: + app->>cb: HTTP 2xx +``` + + + + +## Implementation notes + +The [`pointer`](../pointer/) app implements using . + +- [`pointer/`](../pointer/) — + +- [`pointer/`](../pointer/) — + + + + +## Go-live checklist + + + +- [ ] + +- [ ] + +- [ ] + +- [ ] + +- [ ] diff --git a/.cursor/skills/authoring-how-to/references/chargebee-sources.md b/.cursor/skills/authoring-how-to/references/chargebee-sources.md new file mode 100644 index 0000000..d8ad9db --- /dev/null +++ b/.cursor/skills/authoring-how-to/references/chargebee-sources.md @@ -0,0 +1,98 @@ +# Chargebee sources + +These guides are read by people who will build against them, so a wrong field name or a stale limit is worse than no guide. Verify every Chargebee-specific claim against the live documentation while drafting. + +## Contents + +- [Which site for which question](#which-site-for-which-question) +- [Research procedure](#research-procedure) +- [Verified entry points](#verified-entry-points) +- [Facts that must never be written from memory](#facts-that-must-never-be-written-from-memory) +- [Product version traps](#product-version-traps) +- [Tooling that speeds up research](#tooling-that-speeds-up-research) + +## Which site for which question + +| Question | Site | +| --- | --- | +| What does this endpoint accept and return? What are the resource attributes, enums, and event types? | [apidocs.chargebee.com](https://apidocs.chargebee.com/docs/api) | +| How does the product behave? What does this setting do in the dashboard? How do these objects relate? | [chargebee.com/docs](https://www.chargebee.com/docs/billing/2.0/getting-started/object-relationship) | + +When product behavior and API reference appear to disagree, cite the API reference for the contract and the product docs for the behavior, and say which is which. + +## Research procedure + +1. Search the relevant site for the topic. The docs URL scheme is `https://www.chargebee.com/docs/billing/2.0/
/`; the API reference is `https://apidocs.chargebee.com/docs/api/`. +2. Read the page rather than the search snippet. Snippets routinely drop the qualifier that matters. +3. **Confirm the URL resolves before citing it.** Documentation pages get reorganized, and a plausible-looking path is often a 404: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -L "https://apidocs.chargebee.com/docs/api/" +``` + +The validator's `--external` flag checks every outbound link in a finished guide. + +4. Link to the specific section or anchor that supports the claim, not to the section landing page. The webhook guide links straight to the event object's out-of-order delivery anchor, which is what makes that claim checkable. +5. Cross-check against `pointer/`. If the code contradicts the docs, the code is probably behind — investigate before documenting either. + +## Verified entry points + +Confirmed reachable at the time of writing. Re-check before citing. + +**API reference** + +- Getting started, authentication, pagination, error model — `https://apidocs.chargebee.com/docs/api` +- Events and event types — `https://apidocs.chargebee.com/docs/api/events` +- Event object, including out-of-order delivery — `https://apidocs.chargebee.com/docs/api/events/event-object` +- Webhooks — `https://apidocs.chargebee.com/docs/api/webhooks` +- Idempotency — `https://apidocs.chargebee.com/docs/api/idempotency` +- Error handling and `api_error_code` — `https://apidocs.chargebee.com/docs/api/error-handling` +- Customers — `https://apidocs.chargebee.com/docs/api/customers` +- Subscriptions — `https://apidocs.chargebee.com/docs/api/subscriptions` +- Invoices — `https://apidocs.chargebee.com/docs/api/invoices` +- Hosted pages — `https://apidocs.chargebee.com/docs/api/hosted_pages` +- Payment intents — `https://apidocs.chargebee.com/docs/api/payment_intents` +- Usages — `https://apidocs.chargebee.com/docs/api/usages` +- Entitlements — `https://apidocs.chargebee.com/docs/api/entitlements` + +**Product documentation** + +- Object relationship model — `https://www.chargebee.com/docs/billing/2.0/getting-started/object-relationship` +- Product catalog — `https://www.chargebee.com/docs/billing/2.0/product-catalog/product-catalog` +- Subscriptions — `https://www.chargebee.com/docs/billing/2.0/subscriptions/subscriptions` +- Invoices and credit notes — `https://www.chargebee.com/docs/billing/2.0/invoices-credit-notes-and-quotes/invoice-overview` +- Entitlements — `https://www.chargebee.com/docs/billing/2.0/entitlements/entitlements` +- Tax — `https://www.chargebee.com/docs/billing/2.0/taxes/tax-overview` + +For anything not listed, search the site and verify the URL. Do not extrapolate a path from the pattern. + +## Facts that must never be written from memory + +- Event type names and the exact set of resources inside `content` for a given event. +- Attribute names, nesting, and enum values on any resource. +- Which parameters an endpoint accepts and which are required. +- Retry schedules, rate limits, page sizes, expiry windows, and any other number. +- Whether a feature is generally available, gated, or plan-dependent. +- Anything about a specific payment gateway's behavior. + +Stable enough to rely on, and still worth linking: + +- The API is REST over HTTPS at `https://{site}.chargebee.com/api/v2/`, authenticated with HTTP Basic using the API key as the username and an empty password. +- Timestamps are Unix epoch seconds. +- Single resources come back enveloped (`{"customer": {...}}`); lists come back as `{"list": [...]}` with `next_offset` for pagination. +- API keys are per-environment: a test-site key is not a live-site key. +- Webhook events are delivered at-least-once with no ordering guarantee, and `resource_version` is the tiebreaker for staleness. + +## Product version traps + +- Documentation paths carry the product version (`/docs/billing/2.0/`). Confirm you are reading 2.0 and not an older page surfaced by a search engine. +- Product Catalog exists in two versions. The newer model uses items and item prices; the older uses plans and addons. Which one applies changes the API calls, the event payloads, and the local schema. State which version a guide assumes. +- A Chargebee site can run in test or live mode with separate configuration, keys, and webhook endpoints. Guides should say which mode a step applies to. +- Chargebee sells more than Billing. If a topic touches Retention, Revenue Recognition, or Receivables, name the product explicitly instead of writing "Chargebee". + +## Tooling that speeds up research + +- API reference pages carry a **Copy for LLM** control that yields the page as clean markdown. +- The full API is published as an OpenAPI 3.0 document, which is the fastest way to confirm a parameter list exhaustively. +- Chargebee publishes an MCP server and official agent skills (`npx skills add chargebee/ai`). If either is available in the session, prefer it over scraping pages. +- **Time Machine** on a test site simulates time-based billing events. Mention it when a guide's testing scenarios depend on renewals, trial expiry, or dunning cycles. diff --git a/.cursor/skills/authoring-how-to/references/diagrams.md b/.cursor/skills/authoring-how-to/references/diagrams.md new file mode 100644 index 0000000..8148d05 --- /dev/null +++ b/.cursor/skills/authoring-how-to/references/diagrams.md @@ -0,0 +1,180 @@ +# Diagrams + +Everything is code, diagrams included. Every diagram in `how-to/` is a fenced `mermaid` block in the markdown file. No images, no screenshots, no exported PNGs, no ASCII art, no links to Lucid, Miro, Excalidraw, or draw.io. GitHub renders mermaid natively, so the diagram stays reviewable in a pull request diff. + +## Contents + +- [Choosing a diagram type](#choosing-a-diagram-type) +- [Sequence diagram conventions](#sequence-diagram-conventions) +- [Arrow vocabulary](#arrow-vocabulary) +- [Showing failure paths](#showing-failure-paths) +- [Scenario diagrams](#scenario-diagrams) +- [Flowchart conventions](#flowchart-conventions) +- [Syntax pitfalls](#syntax-pitfalls) +- [What the diagram must not do](#what-the-diagram-must-not-do) + +## Choosing a diagram type + +| Use | When | +| --- | --- | +| `sequenceDiagram` | Timing, ordering, acknowledgements, retries, or who-waits-for-whom is the point. This is the default for the `High level architecture` section. | +| `flowchart LR` / `flowchart TD` | Component boundaries, routing, and deployment topology matter more than timing. | +| `stateDiagram-v2` | The topic is a lifecycle with named states, such as subscription or invoice status transitions. | +| `erDiagram` | The topic is about how Chargebee resources map onto local tables. | + +One diagram in `High level architecture`. Additional diagrams belong inside the `Best Practices` subsection that needs them, next to the prose they clarify. + +## Sequence diagram conventions + +Declare every participant up front with a short lowercase alias and a readable display name. Use `
` to wrap long names. + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + participant worker as Worker + participant db as Database +``` + +Established aliases, reused across guides so diagrams read consistently: `cb` (Chargebee), `app` (your application), `web` (browser or client), `queue`, `worker`, `db`, `dlq`. + +Wrap the span where a component is doing work in `activate` / `deactivate`. It makes the synchronous portion of the flow visually obvious, which is usually the whole argument of the diagram. + +Use `note over` for the things that are not messages: validation steps, decisions, and state checks. + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + + cb->>app: Webhook Event + activate app + note over app: Validate basic auth + app->>queue: Store message + note over app: Message stored
successfully? + app->>cb: HTTP 2xx (Success) + deactivate app +``` + +Label messages with the real thing being sent. `payment_succeeded` beats `event`. `HTTP 2xx (Success)` beats `response`. + +## Arrow vocabulary + +Keep these consistent, because readers learn them across guides: + +| Arrow | Meaning | +| --- | --- | +| `->>` | A request or a call the sender waits on | +| `-->>` | A response, or an internal write the caller does not block a remote system on | +| `-)` | Asynchronous delivery where the sender does not wait, such as a queue handing a message to a worker | + +Annotate a push-or-poll ambiguity rather than picking one silently: `note over queue,worker: Poll or push messages`. + +## Showing failure paths + +The failure path is the reason the diagram exists. Put it in the diagram with `alt` / `else`, and name each branch by its outcome. + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + + app->>queue: Store message + alt Error + app->>cb: HTTP 5xx (Retry) + cb-->>app: Scheduled retry + else Success + app->>cb: HTTP 2xx (Success) + end +``` + +Where an error is not uniform, split the branches by how they are handled rather than by what threw: + +```mermaid +sequenceDiagram + participant queue as Durable
Queue/Store + participant worker as Worker + + alt All OK + worker-->>queue: Ack + else Retryable error + worker->>queue: Error (Retry) + queue-->>worker: Scheduled retry + else Non-retryable error + worker->>queue: Error + note over queue: Push to Dead Letter
queue/store for
reporting + end +``` + +## Scenario diagrams + +When a subsection explains one specific hard case, open the diagram with a note spanning the full participant range that states the scenario, then walk it in order. This is what makes the out-of-order subsection in the webhook guide legible: + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + participant worker as Worker + participant db as Database + + note over cb,db: payment_succeeded arrives BEFORE customer_created + + cb->>app: payment_succeeded + app->>queue: Store message + app->>cb: HTTP 2xx + + queue-)worker: payment_succeeded + activate worker + note over worker: customer not in DB yet + worker->>queue: Retryable error (dependency missing) + note over queue: Redeliver after
a backoff delay. + deactivate worker + + note over queue: retry delay elapses + queue-)worker: payment_succeeded (redelivery) + activate worker + note over worker: customer now exists + worker-->>db: Apply payment + worker-->>queue: Ack + deactivate worker +``` + +Use `note over` to mark the passage of time (`retry delay elapses`) rather than trying to draw it. + +## Flowchart conventions + +Use `flowchart LR` for a left-to-right pipeline, `flowchart TD` for a topology with layers. Give every node a stable id and quote any label containing punctuation. + +```mermaid +flowchart LR + web[Browser] -->|Hosted Checkout| cb[Chargebee] + cb -->|Webhook| app["App (/api/webhook)"] + app --> queue[(Durable queue)] + queue --> worker[Worker] + worker --> db[(Database)] + worker -.->|Exhausted retries| dlq[(Dead letter queue)] +``` + +Use `subgraph` to mark a trust or deployment boundary, and label the edge that crosses it. + +## Syntax pitfalls + +- Quote any label containing `(`, `)`, `:`, `,`, `#`, or `-`: `app["App (sync path)"]`. Unquoted parentheses break flowchart parsing. +- Use `
` for line breaks inside labels and notes. It works in both sequence diagrams and flowcharts. +- In a sequence diagram the first `:` after an arrow starts the message text; later colons are literal. +- Every `alt`, `loop`, `opt`, `par`, and `subgraph` needs a matching `end`. +- `note over a,b:` spans participants; `note over a:` sits on one. There is no `note between`. +- `%%` starts a comment line. +- Declare participants explicitly instead of letting them be inferred, so the left-to-right order is deliberate. +- Render the diagram before committing. A mermaid block that fails to parse shows as a raw error on GitHub, and the validator only checks that the block exists. + +## What the diagram must not do + +- Duplicate the prose. The diagram carries the flow; the prose carries the reasoning. +- Show only the happy path. +- Include so many participants that the failure branches stop fitting. Split into a topology flowchart plus a focused sequence instead. +- Encode `pointer`-specific technology in the `High level architecture` diagram. Say `Durable Queue/Store`, not `SQS`. SQS belongs in `Implementation notes`. diff --git a/.cursor/skills/authoring-how-to/references/language.md b/.cursor/skills/authoring-how-to/references/language.md new file mode 100644 index 0000000..9591f68 --- /dev/null +++ b/.cursor/skills/authoring-how-to/references/language.md @@ -0,0 +1,136 @@ +# Language patterns + +Every rule here is derived from [`how-to/integrating-chargebee-webhooks.md`](../../../../how-to/integrating-chargebee-webhooks.md). When in doubt, open that file and match it. + +## Contents + +- [Who you are writing for](#who-you-are-writing-for) +- [Voice and person](#voice-and-person) +- [Sentence patterns that match the reference guide](#sentence-patterns-that-match-the-reference-guide) +- [Paragraphs versus lists](#paragraphs-versus-lists) +- [Emphasis and code formatting](#emphasis-and-code-formatting) +- [Linking](#linking) +- [Precision about distributed-systems behavior](#precision-about-distributed-systems-behavior) +- [Banned phrases](#banned-phrases) +- [Structural tells to avoid](#structural-tells-to-avoid) +- [Before and after](#before-and-after) + +## Who you are writing for + +An architect or senior developer who has decided to integrate Chargebee and now has to design something that survives production. They know what a queue is. They do not know how Chargebee behaves at the edges. Write for that gap. + +Do not explain general computer science. Link to it. The reference guide links "dead letter queue" to Wikipedia rather than spending a paragraph defining it. + +## Voice and person + +Use **you** for guidance aimed at the reader: + +> Since webhook events can be delivered out of order, store and compare the `resource_version` returned in the webhook `content`. + +Name the **component** when describing runtime behavior, not "you": + +> The worker treats "dependency not ready" as a *retryable* error: it does not acknowledge the message, so the queue redelivers it after a delay. + +Use the imperative for instructions. `Store the event before returning 2xx.` Not `It is important to ensure that the event is stored.` + +Write in present tense and active voice. `Chargebee retries the webhook`, not `the webhook will be retried by Chargebee`. + +Never use "we" or "one". Never address a team. Never invent a scenario involving a fictional company. + +## Sentence patterns that match the reference guide + +**Lead with the action, then the reason.** The recommendation comes first; the justification follows in the same sentence or the next one. + +> Store the event payload in a **durable queue** or database with the event `id` as the unique identifier. This ensures events are not lost, and duplicate events can be ignored. + +**State the mechanism, not the vibe.** Prefer a sentence that a reader could implement from. + +> On each unacknowledged delivery the message becomes available again after a *backoff delay*, and its delivery/receive count is incremented. + +**Name the failure case directly.** The reference guide writes `The classic case:` and then describes an actual event pair. + +> The classic case: a `payment_succeeded` (or `subscription_created`) arrives and references a customer whose `customer_created` event **hasn't been processed yet**. + +**Use short sentences for constraints.** One clause, one rule. + +> Don't drop the event. The dependency will likely arrive moments later. + +**Keep transitions specific.** Tie the transition to the thing that just happened: `Once the event is durable, the endpoint can return 2xx.` Never open a sentence with `Additionally`, `Furthermore`, `Moreover`, or `It is worth noting that`. + +**Qualify honestly.** The reference guide writes `Most of the time events arrive in roughly the order they occurred` and `Out-of-order gaps are usually seconds`. Hedge where reality hedges; do not hedge to sound cautious. + +## Paragraphs versus lists + +Reasoning goes in paragraphs. Parallel items go in lists. A list of three items that are not actually parallel should have been a paragraph. Number a list only when the order is part of the instruction. + +Delete any sentence that can be removed without losing meaning. + +Top-level bullets in `Best Practices` carry real weight — often two or three sentences. Nested bullets carry the mechanics of the parent bullet, such as the ordered steps a handler performs. That nesting is how the reference guide handles the synchronous webhook path, and it works because the sub-bullets are genuinely a sequence. + +Contractions are fine where they read naturally (`doesn't redeliver it`, `hasn't been processed yet`). Do not force them. + +## Emphasis and code formatting + +Bold a term the first time you introduce it as a concept, then drop the bold: **durable queue**, **idempotent operations**, **at-least-once**. Bold a rule that a reader must not miss: **Don't drop the event.** + +Do not bold a two-word label at the front of every bullet. The reference guide does it in one place — the retry-semantics subsection, where `Retry with backoff` and `Retention` are genuine named concepts being contrasted. Mechanical bolding across a whole list is the single clearest sign that a model wrote the page. + +Use backticks for event types (`payment_succeeded`), field paths (`content.subscription.resource_version`), HTTP status families (`5xx`), config keys (`maxReceiveCount`), commands, and file paths. Write HTTP statuses as the reference guide does: `HTTP 2xx`, `HTTP 5xx`. + +Use italics sparingly, for a word being used in a precise technical sense: a *retryable* error, a *backoff delay*. + +No emoji. No decorative horizontal rules. + +## Linking + +- Repository files use relative links with the path as the link text: `` [`pointer/lib/webhooks.ts`](../pointer/lib/webhooks.ts) ``. +- Chargebee behavior links to the specific page and anchor that proves it, such as the event object's out-of-order delivery section. +- Standard concepts link out rather than being re-explained. +- Link text describes the destination. Never `click here` or a bare URL. + +## Precision about distributed-systems behavior + +These guides live or die on this section. + +- Name the delivery semantics: at-least-once, at-most-once. Do not claim exactly-once unless the whole design proves it. +- Separate **durable acceptance** from **successful processing**. The endpoint accepting an event is not the same as the work being done. +- Say who retries, what is acknowledged, when, and where a message goes when it runs out of attempts. +- Distinguish a transient failure worth retrying patiently from a permanently broken payload that should fail fast. +- Give concrete numbers when the implementation has them, and label them as the implementation's settings: `maxReceiveCount = 5`, `Main queue retention is 4 days; DLQ retention is 14 days`. +- Say which side is authoritative for each piece of state and how local state gets reconciled when it drifts. + +## Banned phrases + +The validator flags these. They are the vocabulary of generated marketing prose, and none of them survive in a technical guide. + +`seamless`, `seamlessly`, `robust`, `powerful`, `comprehensive`, `leverage` (use "use"), `utilize` (use "use"), `facilitate`, `delve`, `dive into`, `unpack`, `unlock`, `supercharge`, `elevate`, `empower`, `streamline`, `holistic`, `synergy`, `myriad`, `plethora`, `underscore(s)`, `paramount`, `harness` (as a verb), `foster`, `embark`, `bespoke`, `meticulous(ly)`, `boasts`, `game-changer`, `cutting-edge`, `best-in-class`, `state-of-the-art`, `world-class`, `tapestry`, `realm`, `landscape`, `testament to`, `navigate the complexities`, `in today's fast-paced`, `in the world of`, `when it comes to`, `ever-evolving`, `it is worth noting`, `it's worth noting`, `it is important to note`, `keep in mind that`, `needless to say`, `at the end of the day`, `in conclusion`, `in summary`, `to sum up`, `this guide will explore`, `this article will`, `let's dive in`, `we'll explore`, `crucial`, `vital role`, `pivotal role`, `key takeaway`, `rest assured`, `look no further`, `and more!`, `Certainly!`, `Great question`. + +Also cut vague intensifiers — `very`, `really`, `extremely`, `incredibly`. If a thing is slow, say how slow. + +Three notes on judgement: + +- `crucial`, `robust`, `powerful`, and `comprehensive` are banned as adjectives of praise. If a word has a precise technical meaning in context, rewrite to say the precise thing instead of arguing for the adjective. +- `enable` and `explore` are fine in their literal senses (enable a setting, explore the API reference) and banned as filler. +- Em dashes are fine, and the reference guide uses them for exactly the kind of aside they exist for. Do not ban punctuation to sound human, and do not lean on them either. + +## Structural tells to avoid + +- **No document meta-commentary.** No "This guide covers", no "In this section we will", no restating the heading in the first sentence beneath it. +- **No rhetorical questions in prose.** The go-live checklist is the one place a question belongs. Replace "So what happens when the customer doesn't exist yet?" with the answer. +- **No closing summary.** The reference guide ends on the last checklist item. Yours does too. +- **No rule of three everywhere.** If there are four practices, list four. If there are two, list two. +- **No symmetrical padding.** Do not add a sentence to a bullet just because the bullet above it is longer. +- **No hedged non-recommendations.** "Consider whether you might want to potentially validate" is not guidance. Pick a position and defend it. +- **No placeholder examples** where a real Chargebee name exists. Write `subscription_renewal_reminder`, not `some_event_type`. + +## Before and after + +| Rewrite this | To this | +| --- | --- | +| It is crucial to ensure that robust error handling is implemented. | If the handler throws before the event is stored, return `HTTP 5xx` so Chargebee redelivers it. | +| Additionally, it is worth noting that events may arrive out of order. | Chargebee delivers events with no ordering guarantee, so a `payment_succeeded` can arrive before the `customer_created` it depends on. | +| This guide will explore how to leverage Chargebee webhooks to build a seamless integration. | One of the first steps when integrating Chargebee is setting up webhooks. | +| **Idempotency:** Ensure idempotency. **Retries:** Ensure retries. **Monitoring:** Ensure monitoring. | Process every message as if it may arrive twice: key the audit table on the event `id` and let inserts fall through with `ON CONFLICT DO NOTHING`. | +| You might want to consider utilizing a durable queue. | Store the event in a durable queue. | +| This powerful feature enables seamless synchronization. | This keeps your app data in sync with your Chargebee site. | +| In conclusion, following these best practices will help you build a production-ready integration. | (delete it) | diff --git a/.cursor/skills/authoring-how-to/scripts/check_how_to.py b/.cursor/skills/authoring-how-to/scripts/check_how_to.py new file mode 100755 index 0000000..1ed018f --- /dev/null +++ b/.cursor/skills/authoring-how-to/scripts/check_how_to.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Validate a reference-architecture how-to guide. + +Checks structure, mermaid-only diagrams, link integrity, and the language rules +described in references/language.md. + + python3 check_how_to.py how-to/integrating-chargebee-webhooks.md + python3 check_how_to.py how-to/*.md --strict --external +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import urllib.error +import urllib.request + +REQUIRED_SECTIONS = [ + "High level architecture", + "Best Practices", + "Implementation notes", + "Go-live checklist", +] + +# Unambiguous marketing or generated-prose tells. +def inflect(verb: str) -> list[str]: + """Every form of a banned verb, so 'utilizes' is caught alongside 'utilize'.""" + if verb.endswith("e"): + return [verb, verb + "s", verb + "d", verb[:-1] + "ing"] + return [verb, verb + "s", verb + "ed", verb + "ing"] + + +BANNED_VERBS_ERROR = [ + "utilize", "facilitate", "delve", "elevate", "empower", "streamline", + "unlock", "supercharge", "unpack", +] + +BANNED_ERROR = [ + "seamless", "seamlessly", "robust", "powerful", "comprehensive", + "dive into", "holistic", + "synergy", "myriad", "plethora", "paramount", "bespoke", "meticulous", + "meticulously", "boasts", "game-changer", "game changer", "cutting-edge", + "best-in-class", "state-of-the-art", "world-class", "tapestry", + "testament to", "navigate the complexities", "in today's fast-paced", + "in the world of", "when it comes to", "ever-evolving", + "it is worth noting", "it's worth noting", "it is important to note", + "keep in mind that", "needless to say", "at the end of the day", + "in conclusion", "in summary", "to sum up", "this guide will explore", + "this guide will cover", "this article will", "let's dive in", + "we'll explore", "we will explore", "crucial", "vital role", + "pivotal role", "key takeaway", "rest assured", "look no further", + "and more!", "as an ai", "certainly!", "great question", + "might want to consider", +] + [form for verb in BANNED_VERBS_ERROR for form in inflect(verb)] + +# Legitimate in the right technical context, but usually a tell. +BANNED_WARN = [ + "harness", "foster", "embark", "underscore", "underscores", "realm", + "landscape", "very", "really", "extremely", "incredibly", +] + inflect("leverage") + inflect("explore") + +# Filler only when they open a sentence; fine mid-sentence. +BANNED_OPENERS = ["additionally", "furthermore", "moreover", "overall", "notably"] + +IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".pdf") + +DIAGRAM_HOSTS = ( + "lucid.app", "lucidchart", "miro.com", "excalidraw", "draw.io", + "diagrams.net", "figma.com", "whimsical.com", "mermaid.live", +) + +MERMAID_TYPES = ( + "sequenceDiagram", "flowchart", "graph", "stateDiagram-v2", "stateDiagram", + "erDiagram", "classDiagram", "gantt", "journey", "timeline", "block-beta", + "C4Context", +) + +MERMAID_BLOCK_OPENERS = ("alt", "loop", "opt", "par", "critical", "rect", "box", "subgraph") + +# pointer's technology choices, which do not belong in a neutral architecture diagram. +VENDOR_TERMS = ( + "SQS", "SNS", "Kinesis", "RabbitMQ", "Kafka", "Lambda", "DynamoDB", + "Postgres", "PostgreSQL", "MySQL", "Redis", "Next.js", "NextJS", + "Better Auth", "BetterAuth", "Vercel", "Cloudflare", "Drizzle", "Prisma", +) + +EMOJI = re.compile( + "[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\U00002B00-\U00002BFF]" +) + +LINK = re.compile(r"(!?)\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)") +HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +BULLET = re.compile(r"^(\s*)([-*+])\s+(.*)$") +CHECKBOX = re.compile(r"^\s*[-*+]\s+\[( |x|X)\]\s+(.*)$") + + +class Report: + def __init__(self, path: str) -> None: + self.path = path + self.errors: list[tuple[int, str]] = [] + self.warnings: list[tuple[int, str]] = [] + + def error(self, line: int, message: str) -> None: + self.errors.append((line, message)) + + def warn(self, line: int, message: str) -> None: + self.warnings.append((line, message)) + + def render(self) -> None: + print(f"\n{self.path}") + if not self.errors and not self.warnings: + print(" ok") + return + for line, message in sorted(self.errors): + print(f" ERROR {self.path}:{line}: {message}") + for line, message in sorted(self.warnings): + print(f" WARN {self.path}:{line}: {message}") + print(f" {len(self.errors)} error(s), {len(self.warnings)} warning(s)") + + +def normalize(text: str) -> str: + """Fold smart punctuation so phrase matching is not defeated by typography.""" + return ( + text.replace("\u2019", "'").replace("\u2018", "'") + .replace("\u201c", '"').replace("\u201d", '"') + ) + + +def find_fences(lines: list[str]) -> list[dict]: + """Return fenced code blocks as {lang, start, end, body} with 0-based indices.""" + fences: list[dict] = [] + open_fence: dict | None = None + for index, line in enumerate(lines): + match = re.match(r"^(\s*)(`{3,})(.*)$", line) + if not match: + continue + marker, info = match.group(2), match.group(3).strip() + if open_fence is None: + open_fence = {"lang": info.split()[0] if info else "", "start": index, + "marker": marker, "body": []} + elif marker[: len(open_fence["marker"])] == open_fence["marker"] and not info: + open_fence["end"] = index + fences.append(open_fence) + open_fence = None + if open_fence is not None: + open_fence["end"] = len(lines) - 1 + open_fence["unclosed"] = True + fences.append(open_fence) + for fence in fences: + fence["body"] = lines[fence["start"] + 1: fence["end"]] + return fences + + +def mask(lines: list[str], fences: list[dict]) -> list[str]: + """Blank out code so prose checks never fire on code samples.""" + masked = list(lines) + for fence in fences: + for index in range(fence["start"], min(fence["end"] + 1, len(masked))): + masked[index] = "" + for index, line in enumerate(masked): + masked[index] = re.sub(r"`[^`]*`", lambda m: " " * len(m.group(0)), line) + return masked + + +def slugify(title: str) -> str: + text = re.sub(r"`", "", title.lower()) + text = re.sub(r"[^\w\- ]", "", text) + return text.strip().replace(" ", "-") + + +def phrase_pattern(phrase: str) -> re.Pattern: + body = re.escape(phrase).replace(r"\ ", r"\s+").replace("'", "['\u2019]") + lead = r"\b" if phrase[0].isalnum() else "" + tail = r"\b" if phrase[-1].isalnum() else "" + return re.compile(lead + body + tail, re.IGNORECASE) + + +def opener_pattern(word: str) -> re.Pattern: + return re.compile(r"(?:^|[.!?]\s+)[\s\-*+>]*(?:\*\*|__)?" + re.escape(word) + r"\b", + re.IGNORECASE) + + +BANNED_ERROR_PATTERNS = [(p, phrase_pattern(p)) for p in BANNED_ERROR] +BANNED_WARN_PATTERNS = [(p, phrase_pattern(p)) for p in BANNED_WARN] +BANNED_OPENER_PATTERNS = [(p, opener_pattern(p)) for p in BANNED_OPENERS] + + +def check_headings(lines: list[str], masked: list[str], report: Report) -> list[dict]: + headings = [] + for index, line in enumerate(masked): + match = HEADING.match(line) + if match: + headings.append({"level": len(match.group(1)), "title": match.group(2), + "line": index}) + + h1s = [h for h in headings if h["level"] == 1] + if not h1s: + report.error(1, "no H1 title found") + elif len(h1s) > 1: + report.error(h1s[1]["line"] + 1, f"{len(h1s)} H1 headings; a guide has exactly one") + if h1s and not re.match(r"^How to \S", h1s[0]["title"]): + report.error(h1s[0]["line"] + 1, + f"title must start with 'How to ' and name an outcome: {h1s[0]['title']!r}") + + h2_titles = [h["title"] for h in headings if h["level"] == 2] + positions = [] + for required in REQUIRED_SECTIONS: + if required not in h2_titles: + report.error(1, f"missing required section '## {required}'") + else: + positions.append(h2_titles.index(required)) + if len(positions) == len(REQUIRED_SECTIONS) and positions != sorted(positions): + report.error(1, "required sections are out of order; expected " + + " then ".join(REQUIRED_SECTIONS)) + + for heading in headings: + if heading["level"] >= 2 and heading["title"] not in ("Best Practices",): + words = [w for w in heading["title"].split() if w.isalpha() and len(w) > 3] + capitalized = [w for w in words[1:] if w[0].isupper()] + if words and len(capitalized) >= max(2, len(words) // 2): + report.warn(heading["line"] + 1, + f"heading looks title-cased; use sentence case: {heading['title']!r}") + return headings + + +def section_bounds(headings: list[dict], title: str, total: int) -> tuple[int, int] | None: + for position, heading in enumerate(headings): + if heading["title"] == title: + start = heading["line"] + end = total + for later in headings[position + 1:]: + if later["level"] <= heading["level"]: + end = later["line"] + break + return start, end + return None + + +def check_diagrams(lines: list[str], fences: list[dict], headings: list[dict], + report: Report) -> None: + for fence in fences: + if fence.get("unclosed"): + report.error(fence["start"] + 1, "unclosed code fence") + + mermaid = [f for f in fences if f["lang"].lower() == "mermaid"] + if not mermaid: + report.error(1, "no mermaid diagram found; every diagram must be a mermaid code block") + + bounds = section_bounds(headings, "High level architecture", len(lines)) + if bounds: + start, end = bounds + in_section = [f for f in mermaid if start < f["start"] < end] + if not in_section: + report.error(start + 1, "'High level architecture' has no mermaid diagram") + else: + architecture = in_section[0] + for offset, line in enumerate(architecture["body"]): + for term in VENDOR_TERMS: + if re.search(rf"\b{re.escape(term)}\b", line): + report.warn(architecture["start"] + 2 + offset, + f"'{term}' is a pointer implementation choice; keep the " + "architecture diagram technology-neutral and move it to " + "'Implementation notes'") + prose = " ".join( + l.strip() for l in lines[architecture["end"] + 1: end] + if l.strip() and not l.strip().startswith("#") + ) + if len(prose) < 200: + report.warn(architecture["end"] + 1, + "the architecture diagram is not followed by enough prose to stand " + "on its own") + + for fence in mermaid: + body = [l for l in fence["body"] if l.strip() and not l.strip().startswith("%%")] + if not body: + report.error(fence["start"] + 1, "empty mermaid block") + continue + first = body[0].strip() + if not any(first.startswith(kind) for kind in MERMAID_TYPES): + report.warn(fence["start"] + 2, f"unrecognized mermaid diagram type: {first!r}") + + openers = 0 + ends = 0 + for line in body: + token = line.strip().split()[0] if line.strip().split() else "" + if token in MERMAID_BLOCK_OPENERS: + openers += 1 + elif token == "end": + ends += 1 + if openers != ends: + report.error(fence["start"] + 1, + f"mermaid block has {openers} block opener(s) but {ends} 'end'(s)") + + if first.startswith("sequenceDiagram"): + declared = set() + for line in body: + match = re.match(r"^\s*(?:participant|actor)\s+(\w+)", line) + if match: + declared.add(match.group(1)) + used: set[str] = set() + for line in body: + arrow = re.match(r"^\s*(\w+)\s*(?:-{1,2}>>?|-{1,2}\)|-{1,2}x|<<-->>)\s*\+?(\w+)", + line) + if arrow: + used.update(arrow.groups()) + for keyword in ("activate", "deactivate"): + single = re.match(rf"^\s*{keyword}\s+(\w+)", line) + if single: + used.add(single.group(1)) + undeclared = sorted(used - declared) + if undeclared: + report.warn(fence["start"] + 1, + "participant(s) used but never declared: " + ", ".join(undeclared)) + + +def check_images_and_links(lines: list[str], masked: list[str], headings: list[dict], + report: Report, doc_path: str, check_external: bool) -> None: + anchors = {slugify(h["title"]) for h in headings} + doc_dir = os.path.dirname(os.path.abspath(doc_path)) + external: list[tuple[int, str]] = [] + + for index, line in enumerate(masked): + line_number = index + 1 + if re.search(r" tag; diagrams must be mermaid code blocks") + if ">gate: Limits + overrides + else Cache miss + gate->>db: Load subscription mirror + db-->>gate: Plan + cached entitlements + gate-->>cache: Populate snapshot + end + alt Within limit + gate-->>app: Allow + app-->>web: HTTP 2xx + action + else Over limit + gate-->>app: Deny + app-->>web: HTTP 403 + upgrade hint + else Cache/DB unreachable + gate-->>app: Fail-open or fail-closed
(policy decision) + end + deactivate gate + deactivate app + + cb->>app: subscription_entitlements_updated + activate app + app->>queue: Store webhook + app->>cb: HTTP 2xx + deactivate app + + queue-)worker: subscription_entitlements_updated + activate worker + worker->>cb: List subscription entitlements
(paginate if has_next) + worker-->>db: Upsert entitlement snapshot + worker-->>cache: Invalidate or refresh + worker-->>queue: Ack + deactivate worker +``` + +Chargebee computes [subscription_entitlement](https://apidocs.chargebee.com/docs/api/subscription_entitlements) records from catalog entitlements, subscription items, and any [entitlement_override](https://apidocs.chargebee.com/docs/api/entitlement_overrides) records. Your app should not re-derive that math at runtime unless you have a strong reason — copy the resolved `value` fields into a local snapshot and enforce against that. Webhooks are **at-least-once** and [not ordered for time-critical work](https://apidocs.chargebee.com/docs/api/events); the entitlement cache therefore always lags Chargebee by at least one webhook delivery plus worker processing time. The feature gate sits **after** authentication and **before** the business handler, reading only the local snapshot and a usage counter — never the Chargebee API on the synchronous path. + + +## Best Practices + +* Treat [subscription_entitlement](https://apidocs.chargebee.com/docs/api/subscription_entitlements) as the enforcement source, not raw plan metadata. A subscription inherits entitlements from the items and item prices on it; [entitlement_override](https://apidocs.chargebee.com/docs/api/entitlement_overrides) records at the subscription, plan price, addon price, or charge level can change the effective value. The API exposes the resolved outcome on each subscription entitlement (`value`, `is_overridden`, `components`). Mirror that snapshot locally and key your gates on `feature_id`, not on `itemPriceId` alone. If you only map `itemPriceId → static plan limits`, subscription-level overrides and grandfathered catalog entitlements will be wrong. + +* Model Chargebee features explicitly before writing gate code. A [feature](https://apidocs.chargebee.com/docs/api/features) has a `type` of `switch`, `quantity`, `range`, or `custom`, plus optional `levels[]` and `unit` for numeric types. Catalog [entitlements](https://apidocs.chargebee.com/docs/api/entitlements) linking a feature to an `item` or `item_price` set the per-product values; [item_entitlements](https://apidocs.chargebee.com/docs/api/item_entitlements) is deprecated — migrate to the Entitlements API. Each gate should know how to interpret one feature type: + + - `switch`: treat `value` `true` / `false` (API docs also describe `available` on catalog entitlements) as on/off + - `quantity`: compare usage against a numeric limit; any component with `unlimited` makes the subscription entitlement `unlimited` + - `range`: same enforcement shape as quantity, but values can be any whole number in the configured range + - `custom`: compare against enumerated labels (for example `basic` vs `premium` model tiers) + +* Sync entitlements asynchronously from webhook events, not from the request path. Chargebee fires `subscription_entitlements_created` on new subscriptions and [subscription_entitlements_updated](https://apidocs.chargebee.com/docs/api/events/webhook/subscription_entitlements_updated) alongside `subscription_changed` when recurring or non-recurring items change. Override changes emit `entitlement_overrides_updated` and `entitlement_overrides_removed` (see [event types](https://apidocs.chargebee.com/docs/api/events/event-types)); expired overrides eventually emit `entitlement_overrides_auto_removed` (deletion can lag up to 12 hours after `expires_at`). Catalog-side edits emit `item_entitlements_updated`, `item_price_entitlements_updated`, and related types — these affect future subscription entitlement calculations but may not change existing subscriptions when [grandfathering](https://www.chargebee.com/docs/billing/2.0/entitlements/grandfathering-entitlements) is enabled. The worker should: + + - Persist the webhook durably, return HTTP `2xx`, then fetch the full entitlement set with the [List subscription entitlements](https://apidocs.chargebee.com/docs/api/subscription_entitlements) endpoint when the event payload's `has_next` flag is true (the webhook includes at most the first 100 records) + - Upsert by `(subscription_id, feature_id)` and store `is_overridden` so gates can audit override-driven access + - Invalidate or version the entitlement cache entry for that subscription after a successful write + +* Keep usage counters separate from entitlement snapshots. Entitlement records answer "what is the limit"; counters answer "how much has been consumed in this window". Store counters in a low-latency store (for example Redis sorted sets or atomic integers with TTL aligned to the billing period). Increment counters in the same code path that performs the gated action, after the gate allows the request. Do not write usage back to Chargebee on every API call unless you are on a metered/usage-based billing path — for included-quota features, local counters are enough for enforcement; Chargebee [Usage Alerts](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/usage-alerts) (Private Beta — contact Chargebee Support to enable) can fire `alert_status_changed` webhooks when metered usage crosses thresholds. + +* Apply Chargebee's aggregation rules when validating your snapshot. For `quantity` features (see [How subscription entitlements are determined](https://apidocs.chargebee.com/docs/api/subscription_entitlements)), the subscription entitlement is the sum of each subscription item's entitlement value multiplied by that item's `quantity`, unless any component is `unlimited`. For `switch` features, the subscription entitlement is `true` if any contributing item is `true`. Subscription-level overrides replace the computed value entirely. Per-seat plans (like `plan-team` in the reference catalog) rely on this multiplication — a per-seat limit times seat count must not be hard-coded from the base plan row alone. + +* Pick a single fail direction for cache and datastore outages and document it per feature class. There is no Chargebee-default answer — it is a product decision. Fail closed (deny when the snapshot is missing or stale) protects revenue and prevents complimentary access during outages, but risks blocking paying customers when Redis or Postgres blips. Fail open (allow when unreachable) keeps the product usable, but risks unlimited usage during an outage. A common split: fail closed for hard paid features (`switch` gates like SSO), fail open for soft metering where downstream billing can reconcile later — but only if your finance team accepts that trade-off. + + +### Enforcement latency versus billing truth + +The classic case: a customer upgrades from Pro to Max in Chargebee, completes checkout, and immediately hits an API endpoint. Chargebee has already updated the subscription, but your worker has not processed `subscription_entitlements_updated` yet, so the entitlement cache still holds Pro limits. The customer sees a false `403` or an upgrade prompt for a feature they just paid for. + +The mirror image is downgrade lag: the cache still shows Max limits for seconds (or longer if the worker is backed up) after a downgrade, so the customer keeps higher-tier access until the snapshot refreshes. + +```mermaid +sequenceDiagram + participant web as Browser + participant app as App + participant gate as Feature gate + participant cache as Entitlement
cache + participant cb as Chargebee + participant queue as Durable
Queue/Store + participant worker as Worker + participant db as Database + + note over web,db: Customer upgrades plan —
cache still on old entitlements + + web->>app: POST /api/generate + app->>gate: Check f_output_tokens_daily + gate->>cache: Read snapshot + cache-->>gate: Old plan limit (200000) + gate-->>app: Deny — over limit + app-->>web: HTTP 403 + + cb->>app: subscription_changed +
subscription_entitlements_updated + app->>queue: Store event + app->>cb: HTTP 2xx + + queue-)worker: subscription_entitlements_updated + activate worker + worker->>cb: List subscription entitlements + worker-->>db: Upsert new snapshot + worker-->>cache: Invalidate entry + worker-->>queue: Ack + deactivate worker + + web->>app: POST /api/generate (retry) + app->>gate: Check f_output_tokens_daily + gate->>cache: Cache miss → reload + cache-->>gate: New plan limit (2000000) + gate-->>app: Allow + app-->>web: HTTP 2xx +``` + +Rules that keep this predictable: + +- Never call the Chargebee API synchronously inside the gate to "fix" latency — webhook delivery is async and rate-limited; you will add tail latency and still race with concurrent changes. +- On upgrade paths you control, optimistically refresh the snapshot when checkout or `subscription.update` returns success, before the webhook arrives. Treat the webhook as reconciliation, not the first write. +- On downgrade paths, prefer letting the cache expire naturally or refreshing immediately — granting extra access briefly is usually cheaper than revoking paid access briefly. +- Expose cache version or `occurred_at` on entitlement rows so support can tell "customer is on new plan in Chargebee but cache timestamp is old" without guessing. +- For downgrades that must cut access immediately, pair webhook sync with a short TTL on cache entries (seconds to low minutes) so the worst-case stale window is bounded. + + +### Threshold prompts, hard caps, and overages + +Numeric features need two policies: what happens as the customer approaches the limit, and what happens at the limit. + +For soft thresholds (upgrade prompts, in-app banners), compare local usage counters against a percentage of the entitlement limit without blocking the action. Chargebee [Usage Alerts](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/usage-alerts) evaluates metered usage in near real time and sends `alert_status_changed` when status moves between `within_limit` and `in_alarm` — useful when usage events are already flowing to Chargebee. For non-metered included quotas enforced only in your app, implement the same threshold logic locally (for example fire an internal event at 80% of `f_credits_monthly`). + +For hard caps, the gate rejects the request when `usage >= limit` unless the subscription entitlement is `unlimited`. For overages, the gate allows the action and a separate pipeline records billable usage — Chargebee's usage-based billing path handles overage invoicing when configured. Mixing both on the same feature without documenting it produces customers who are blocked *and* billed, or neither. + +Pick one primary enforcement mode per feature and test the boundary: at limit minus one, at limit, and at limit plus one. + + +### Grandfathered and overridden entitlements + +Two mechanisms diverge a subscription from the plan's catalog defaults: + +[Grandfathering](https://www.chargebee.com/docs/billing/2.0/entitlements/grandfathering-entitlements) applies when enabled on a site: catalog entitlement changes can apply only to new subscriptions while existing subscriptions keep prior item entitlements. Sites with grandfathering enabled cannot use certain deprecated item entitlement upsert APIs. Your worker must consume `subscription_entitlements_updated` for existing customers — never assume a plan's current catalog row describes an old subscription. + +[Subscription entitlement overrides](https://apidocs.chargebee.com/docs/api/entitlement_overrides) cover sales-led deals: subscription-level overrides (`entity_id` omitted) or entity-level overrides on a specific plan price, addon price, or charge. The resulting `subscription_entitlement.is_overridden` flag (see [subscription entitlements API](https://apidocs.chargebee.com/docs/api/subscription_entitlements)) is `true` when an override drives the value. Subscription-level overrides support `effective_from` and `expires_at`; after expiry the override object disappears and `entitlement_overrides_auto_removed` fires on a delay (up to 12 hours). A gate that reads static plan limits from code will miss these entirely — including Enterprise contracts wired in the Chargebee dashboard with overrides while the base plan row shows `custom` pricing. + + +### Demo scenario: enforcing an API rate limit live + +The reference catalog defines `f_api_rate_per_minute` as a `quantity` [feature](https://apidocs.chargebee.com/docs/api/features) with per-plan values seeded in [`pointer/scripts/catalog.ts`](../pointer/scripts/catalog.ts) (30 requests/min on Free, 300 on Pro, 1000 on Max, 500 per seat on Team, 5000 on Enterprise). A minimal live gate for `POST /api/generate` would: + +1. Resolve the caller's active subscription reference (user or organization) to a `subscription_id` in the local mirror +2. Load `f_api_rate_per_minute` from the entitlement snapshot (not from `planLimits` alone — overrides can differ) +3. Increment a Redis key `rate:{subscription_id}:{minute_bucket}` with TTL 60 seconds +4. If the count exceeds the entitlement value (and the value is not `unlimited`), return HTTP `429` with a link to `/choose-plan`; otherwise proceed + +This keeps the hot path to two reads (subscription lookup + counter increment) and no Chargebee round trip. Pair it with a dashboard widget that reads the same counter so the `/flow` demo can show the gate firing in real time. + + +## Implementation notes + +TODO + +## Go-live checklist + +- [ ] Is the site on Product Catalog 2.0 with features and entitlements configured via the [entitlements API](https://apidocs.chargebee.com/docs/api/entitlements) (not legacy [item_entitlements](https://apidocs.chargebee.com/docs/api/item_entitlements) alone)? + +- [ ] Does the worker persist full `subscription_entitlement` snapshots (paginating when webhook `has_next` is true) on `subscription_entitlements_created` and `subscription_entitlements_updated`? + +- [ ] Does the worker refresh snapshots on `entitlement_overrides_updated`, `entitlement_overrides_removed`, and handle delayed `entitlement_overrides_auto_removed`? + +- [ ] Does every feature gate read the local entitlement snapshot rather than calling Chargebee synchronously? + +- [ ] Are `quantity` limits enforced using Chargebee's sum-of-items aggregation (including subscription item `quantity`), not a static plan table alone? + +- [ ] When a customer completes an upgrade in your checkout flow, does the app optimistically refresh entitlements before the webhook arrives? + +- [ ] Does enforcement fail safe — i.e., does the chosen fail-open/fail-closed policy match what product and finance expect when Redis or Postgres is down? + +- [ ] Are subscription-level and entity-level entitlement overrides reflected in the snapshot (`is_overridden` checked in tests)? + +- [ ] For hard-capped features, does usage at exactly the limit behave as intended (blocked or allowed) and match the overage policy if one exists? + +- [ ] Are threshold / upgrade prompts driven off the same counter the gate uses, so users are not prompted after already being blocked (or vice versa)? + +- [ ] Is there a reconciliation job or support query that compares local entitlement snapshots to Chargebee's [List subscription entitlements](https://apidocs.chargebee.com/docs/api/subscription_entitlements) API for drift detection? From 74c41f7be297786d1087b2e44bfa9ab46b12c00b Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Mon, 27 Jul 2026 18:33:42 +1000 Subject: [PATCH 03/35] initial draft of implementing ubb --- how-to/implementing-usage-based-billing.md | 188 +++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 how-to/implementing-usage-based-billing.md diff --git a/how-to/implementing-usage-based-billing.md b/how-to/implementing-usage-based-billing.md new file mode 100644 index 0000000..d8fa8c1 --- /dev/null +++ b/how-to/implementing-usage-based-billing.md @@ -0,0 +1,188 @@ +# How to implement usage-based billing with Chargebee + +Once subscriptions are live, the next billing problem is turning raw product consumption into invoice lines that reconcile back to source events. You will need a usage pipeline to: + +* Ingest high-volume consumption events without losing or double-counting them +* Aggregate usage into billable quantities that match Chargebee's metered features +* Rate aggregated usage against [Product Catalog 2.0](https://www.chargebee.com/docs/billing/2.0/product-catalog/product-catalog) item prices and produce invoice lines at period close +* Enforce included allowances and cut off or alert when a customer exceeds quota + +This guide assumes **Product Catalog 2.0** (`items` / `item_prices` / `features`), not the legacy plans/addons 1.0 model. Chargebee exposes two distinct usage surfaces; most new SaaS integrations should target **Advanced Usage-Based Billing (UBB)** via the [Usage Events API](https://apidocs.chargebee.com/docs/api/usage_events), not the older [Usages API](https://apidocs.chargebee.com/docs/api/usages) for Automated Metered Billing. + + +## High level architecture + +```mermaid +sequenceDiagram + participant svc as Product
Service + participant app as App + participant queue as Durable
Queue/Store + participant worker as Sync
Worker + participant db as Local
Event Store + participant ingest as Chargebee
Ingest API + participant cb as Chargebee
Billing + + svc->>app: Usage signal
(API call, token, GB) + activate app + note over app: Assign dedupe key
Resolve subscription_id + app->>queue: Enqueue raw event + app->>db: Insert raw event
(dedupe key unique) + alt Store failed + app->>svc: Reject or buffer
(caller retries) + else Stored + app->>svc: Accept request + end + deactivate app + + note over queue,worker: Poll or push messages + queue-)worker: Raw usage event + activate worker + worker->>ingest: POST usage_events
or batch + alt Ingest OK + worker-->>db: Mark synced_at + worker-->>queue: Ack + else Retryable error + worker->>queue: Error (Retry) + queue-->>worker: Scheduled retry + else Non-retryable error + note over queue: Push to Dead Letter
queue/store + end + deactivate worker + + note over cb: End of billing period + cb->>cb: Aggregate events →
metered features + cb->>cb: Apply entitlements +
item price tiers + cb->>app: invoice_generated
webhook + app->>cb: GET invoice +
usage_summaries + app->>db: Reconcile invoice line
to raw events +``` + +Your product emits a usage signal on every billable action. The synchronous path validates the caller, resolves the Chargebee `subscription_id`, and durably stores the raw event locally before returning success to the caller. A background worker pushes stored events to Chargebee's ingest host (`https://{site}.ingest.chargebee.com/api/v2/usage_events` for single events, or the [batch usage events endpoint](https://apidocs.chargebee.com/docs/api/usage_events/ingest-usages-in-batch) for batches). Chargebee holds the billing system of record: it aggregates schemaless event `properties` through [metered features](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/defining-metered-features), applies [linked pricing and entitlements](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/link-pricing), and generates the invoice at period end. Your reconciliation job compares the invoice line quantity and amount against your local raw-event roll-up and Chargebee's [usage summaries](https://apidocs.chargebee.com/docs/api/usage_summaries) for the same window. + +Keep real-time enforcement (rate limits, hard caps) in your app against local aggregates. Chargebee's [usage charges](https://apidocs.chargebee.com/docs/api/usage_charges) resource and [alert_status_changed](https://apidocs.chargebee.com/docs/api/events/webhook/alert_status_changed) webhooks inform billing state; they are not a substitute for blocking an API call at request time. + + +## Best Practices + +* Choose Advanced UBB over the legacy Usages API before writing ingestion code. Advanced UBB ingests schemaless events and decouples metering from the catalog — you define [metered features](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/defining-metered-features) that aggregate over event `properties` after ingestion. The legacy [Usages API](https://apidocs.chargebee.com/docs/api/usages) posts pre-aggregated quantities per `item_price_id` and `usage_date` (Unix seconds) and applies only when Automated Metered Billing is enabled with `metered: true` on the [item](https://apidocs.chargebee.com/docs/api/items). The legacy path also caps at 5,000 usage records per subscription lifetime. High-volume SaaS should use UBB; reserve the Usages API only for low-volume, pre-aggregated meters you control entirely in your app. + +* Store every raw event locally before acknowledging the caller. Chargebee ingest is at-least-once from your worker's perspective — network retries and queue redelivery can produce duplicate POST attempts. Your local store is the reconciliation anchor. Insert with a unique constraint on your dedupe key and treat conflicts as success. Without a local copy you cannot explain an invoice line six weeks later. + +* Mirror Chargebee's dedupe contract in your local store. For UBB, the unique event identity is `(subscription_id, usage_timestamp, deduplication_id)` — all three fields are required on ingest, and [deduplication_id](https://apidocs.chargebee.com/docs/api/usage_events) must disambiguate multiple events that share the same millisecond timestamp for one subscription. Generate `deduplication_id` from a stable idempotency key your product already has (request ID, message ID, UUID from the originating service). For POST retries to Chargebee's main API (including legacy usage creates), also send the [idempotency key header](https://apidocs.chargebee.com/docs/api/idempotency) (`chargebee-idempotency-key`) — replays within the 30-minute idempotency window return the original response with `chargebee-idempotency-replayed: true`. + +* Batch high-volume ingestion; preserve per-event ordering only where it matters. The [batch endpoint](https://apidocs.chargebee.com/docs/api/usage_events/ingest-usages-in-batch) accepts up to 500 events per request. Your worker should accumulate events by time or count, flush batches, and retry the whole batch on transient failures while relying on dedupe keys to absorb partial replays. Ordering between unrelated subscriptions does not matter. Ordering within one subscription only matters when your metered feature uses `max` or `last`-style logic — for `count` and `sum` aggregations, commutative batches are safe. + +* Keep event `properties` flat and forward-compatible. UBB ingestion is [schemaless](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/ingesting-usage-events-into-chargebee): you can add a new metric by emitting a new property field without redeploying the ingest pipeline. Avoid [reserved property names](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/ingesting-usage-events-into-chargebee#reserved-keywords) such as `subscription_id`, `usage_timestamp`, SQL keywords like `count` or `sum`, and unprefixed generic names like `status`. Prefer namespaced fields (`api_method`, `tokens_input`, `storage_bytes`). When you start billing on a new field, create a new metered feature in Chargebee that filters and aggregates that property — existing events in the stream already carry the data. + +* Configure catalog, metered features, and pricing as separate layers. In Product Catalog 2.0, a [feature](https://apidocs.chargebee.com/docs/api/features) with `metered: true` defines what to count and how ([sum, count, min, max, average, count distinct](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/defining-metered-features)). [Link pricing](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/link-pricing) attaches included usage (entitlements on non-metered plan/addon items) and on-demand usage (metered plan/addon [item prices](https://apidocs.chargebee.com/docs/api/item_prices) with `pricing_model` of `per_unit`, `tiered`, `volume`, or `stairstep`). For UBB, configure included quantities through entitlements on linked items — `free_quantity` on item prices is [not supported for UBB](https://apidocs.chargebee.com/docs/api/item_prices); included allowance lives in entitlements instead ([FAQ](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/usage-based-billing-faqs)). + +* Enforce allowance in your app; use Chargebee alerts for billing-side notification. Chargebee does not block your API at the edge. Read entitlements from the subscription (or mirror them locally from webhooks) and maintain a running counter in your local store for the current period. When usage crosses the included limit, reject or throttle at the gateway. For billing visibility, configure [usage alerts](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/usage-alerts) (private beta — contact Chargebee Support to enable) on the metered feature: Chargebee evaluates thresholds within seconds of processing ingested events and emits `alert_status_changed` when status moves between `within_limit` and `in_alarm`. Handle that webhook to notify the customer, trigger an upgrade flow, or flip a hard-cap flag in your app. Poll [usage charges](https://apidocs.chargebee.com/docs/api/usage_charges) for near-current `total_usage`, `included_usage`, and `on_demand_usage` when you need a billing-aligned view without waiting for period close. + +* Reconcile invoice lines to raw events every billing period. After `invoice_generated`, fetch the invoice and locate the metered line item. Compare its quantity to (a) your local roll-up of raw events for the subscription over `usage_from`/`usage_to` from usage charges or the invoice period, and (b) Chargebee's [usage summaries](https://apidocs.chargebee.com/docs/api/usage_summaries) for the same `feature_id` and window. Mismatches usually trace to late-arriving events, metered-feature filter drift, or timezone boundary errors — not to "Chargebee math being wrong." Persist reconciliation results; unresolved drift above a tolerance should page someone. + + +### Late and duplicate usage events + +Duplicate delivery is normal. Your ingest worker may POST the same event twice after a timeout; Chargebee's dedupe key prevents double counting on the billing side as long as you reuse the same `(subscription_id, usage_timestamp, deduplication_id)`. Locally, treat the dedupe key as the idempotency boundary: insert-first, sync-second, mark `synced_at` only after a successful ingest response. + +Late events are harder because billing period boundaries are enforced on `usage_timestamp`, not on when your worker finally delivers the event. Via the Usage Events API, `usage_timestamp` must fall within the [last 12 hours](https://apidocs.chargebee.com/docs/api/usage_events/create-a-usage-event) at ingest time, on both test and live sites. S3 and file upload accept a much wider window — Chargebee's [advisory limits](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/understanding-usages#usage-based-billing-limits) list **365 days** of backdating on a test site and **30 days** on a live site, with the API window configurable on request. An event whose true consumption time falls outside 12 hours needs the file/S3 path or a raised limit — do not assume you can POST arbitrarily old timestamps through the real-time API. + +For legacy Usages API integrations, [usage_date](https://apidocs.chargebee.com/docs/api/usages) is in Unix seconds and only one record may exist per `(subscription_id, item_price_id, usage_date)`. Chargebee bills usages whose `usage_date` falls while the subscription is `active` or `non_renewing`; usages whose date falls in an already-closed invoice period are stored but not invoiced unless the invoice is regenerated. + +```mermaid +sequenceDiagram + participant svc as Product
Service + participant app as App + participant queue as Durable
Queue/Store + participant db as Local
Event Store + participant worker as Sync
Worker + participant ingest as Chargebee
Ingest API + participant cb as Chargebee
Billing + + note over svc,cb: Usage at 23:58 UTC on last day of period —
ingest arrives after period close + + svc->>app: API call event
usage_timestamp = T + app->>db: Insert (dedupe key) + app->>svc: HTTP 2xx + + note over cb: Billing period ends at T+2min + cb->>cb: Generate invoice
(without late event) + + queue-)worker: Deliver buffered event + activate worker + worker->>ingest: POST usage_events
usage_timestamp = T + alt Within backdating window + ingest-->>worker: Accepted + worker-->>db: synced_at = now + note over cb: Event attributed to
closed period — may appear
on next invoice or
require regeneration + else Outside 12h API window + ingest-->>worker: Rejected + note over worker: Route to file/S3 path
or dead letter for manual fix + end + deactivate worker + + app->>cb: GET usage_summaries + invoice + app->>db: Compare local sum
to invoice line + note over app: Reconciliation flags
period-boundary gap +``` + +Clock skew between your servers and Chargebee adds edge cases around period boundaries. Emit `usage_timestamp` from the time the consumption occurred in your product (ideally from the originating service clock, corrected with NTP), not from the sync worker clock at delivery time. If your app clock runs ahead, events can land in the next period early; if it runs behind, valid events miss the backdating window. Monitor skew between `occurred_at` (your insert time) and `usage_timestamp` (billing time) — large deltas predict reconciliation pain. + +Chargebee subscription billing periods follow the site [time zone](https://www.chargebee.com/docs/billing/2.0/site-configuration/time-zone) (site-wide, not per user). Your local aggregation windows for enforcement should use the same timezone boundary as Chargebee, or convert explicitly. A customer in Tokyo on a site configured for US/Eastern can see invoice period cutoffs that do not match their local midnight. + + +### Demo scenario: API-call events through to an invoice line + +This walkthrough validates the full pipeline on a test site with [Advanced Usage-Based Billing enabled](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/understanding-usages#enable-advanced-usage-based-billing). + +1. **Catalog setup.** Create a plan item price (flat base fee) and a metered addon item price (`per_unit` at, say, $0.001 per call). Define a metered feature "API Calls" with aggregation `count` over property `api_method` (or count all events with no filter). [Link pricing](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/link-pricing): include 1,000 API calls on the plan, attach the metered addon for on-demand overage. + +2. **Subscribe a test customer** and note the `subscription_id`. + +3. **Emit events from your app.** On each API request, enqueue and store: + +```json +{ + "subscription_id": "sub_xxx", + "usage_timestamp": 1738732394123, + "deduplication_id": "req_abc123", + "properties": { + "event_type": "api_request", + "api_method": "get_usage", + "response_code": 200 + } +} +``` + +POST to `https://{site}.ingest.chargebee.com/api/v2/usage_events`, or batch via the worker. Use [Time Machine](https://www.chargebee.com/docs/billing/2.0/site-configuration/time-machine) on the test site to advance into the next billing period without waiting a month. + +4. **Observe aggregation.** In the Chargebee UI under Usages, confirm events appear and the metered feature count increases. Optionally call [usage charges](https://apidocs.chargebee.com/docs/api/usage_charges) to see `total_usage`, `included_usage`, and `on_demand_usage` before invoicing. + +5. **Close the period.** After Time Machine advances billing, open the generated invoice. The metered line should show overage quantity `(total_calls - 1000)` rated at the addon's unit price. Reconcile: `count(*)` of local raw events with `usage_timestamp` in the period should equal `total_usage` on the usage charge and the billed quantity on the line (modulo included allowance). + + +## Implementation notes + +TODO + +## Go-live checklist + +- [ ] Is Advanced Usage-Based Billing enabled on the target Chargebee site (test via Billing LogIQ settings; live via access request)? + +- [ ] Does the catalog use Product Catalog 2.0 (`items` / `item_prices`) with metered features, included entitlements, and on-demand item prices linked per the [link-pricing](https://www.chargebee.com/docs/billing/2.0/usage-based-billing/link-pricing) model you intend (PAYG, fixed fee + overage, or hybrid)? + +- [ ] Does every ingested event carry `subscription_id`, `usage_timestamp` (epoch milliseconds), `deduplication_id`, and flat `properties` with no reserved field names? + +- [ ] Are duplicate events handled idempotically locally (unique dedupe key) and at Chargebee (same `(subscription_id, usage_timestamp, deduplication_id)` on retry)? + +- [ ] Are late events handled — events buffered past the 12-hour API backdating window routed to file/S3 ingestion or operations review, not dropped silently? + +- [ ] Does the sync worker retry transient ingest failures without acknowledging the queue message, and route permanently rejected payloads to a dead-letter store? + +- [ ] Does real-time enforcement (rate limits, hard caps) read from local period counters aligned to the Chargebee site time zone, not only from post-hoc invoice data? + +- [ ] If usage alerts are enabled, is the webhook endpoint subscribed to `alert_status_changed` and wired to customer notification or lockout logic? + +- [ ] After a test billing period (Time Machine or natural), does usage reconcile to the invoice line — local raw-event count matches `usage_summaries` / invoice quantity for each metered feature within your tolerance? + +- [ ] Is there an on-call runbook for reconciliation drift (metered-feature filter change, entitlement grandfathering, period-boundary late events)? From 5efc840366fea631aea49b7260d5f77a9e38e780 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Mon, 27 Jul 2026 18:38:54 +1000 Subject: [PATCH 04/35] initial draft of syncing and reconciling entities --- ...cing-and-reconciling-chargebee-entities.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 how-to/syncing-and-reconciling-chargebee-entities.md diff --git a/how-to/syncing-and-reconciling-chargebee-entities.md b/how-to/syncing-and-reconciling-chargebee-entities.md new file mode 100644 index 0000000..061bc56 --- /dev/null +++ b/how-to/syncing-and-reconciling-chargebee-entities.md @@ -0,0 +1,244 @@ +# How to sync and reconcile Chargebee entities + +Once webhooks are flowing, the next integration step is deciding what billing state lives in your database and how you recover when it drifts. You will need entity sync and reconciliation to: + +* Treat Chargebee as the single source of truth while keeping a fast local mirror for auth, entitlements, and UI +* Rebuild local billing state from Chargebee after webhook gaps, a cold start, or a bad deploy +* Prevent split-brain where a local write and a webhook-driven write fight over the same row + +This guide assumes **Product Catalog 2.0** — items, item prices, features, and item entitlements — not the legacy plans/addons API. If your site still runs Catalog 1.0, the list endpoints, event payloads, and local schema differ; confirm your site's catalog version in the Chargebee dashboard before copying any API call here. + + +## High level architecture + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + participant worker as Worker + participant db as Database + participant reconcile as Reconciliation
Job + + cb->>app: Webhook event + activate app + app->>queue: Store message + app->>cb: HTTP 2xx (Success) + deactivate app + + note over queue,worker: Poll or push messages + queue-)worker: Receive messages + activate worker + note over worker: Upsert by resource_version + worker-->>db: Apply mirror + worker-->>queue: Ack + deactivate worker + + note over cb,reconcile: Scheduled or on-demand catch-up + reconcile->>cb: List / retrieve resources
or replay events + activate reconcile + alt Gap within retention + reconcile-->>db: Upsert missing / stale rows + reconcile-->>db: Advance resource_version cursors + else Gap beyond event retention + reconcile->>cb: List customers / subscriptions / catalog + reconcile-->>db: Full resource upsert from API + end + deactivate reconcile + + alt Local write races webhook + app->>cb: API mutation (authoritative) + cb-->>app: Updated resource + cb->>app: Webhook with newer resource_version + app->>queue: Store message + note over worker: Incoming rv > stored rv → apply.
Stale local row overwritten + end +``` + +Chargebee owns every billing fact — customers, subscriptions, invoices, payments, and the product catalog. Your app keeps a **mirror**: enough relational state to authorize requests, render dashboards, and enforce entitlements without calling Chargebee on every page view. Webhooks are the steady-state sync path: the HTTP handler validates and durably enqueues, a worker applies idempotent upserts keyed on `resource_version`, and only then acknowledges the message. + +Reconciliation is the safety net. A scheduled or manually triggered job compares local rows against Chargebee — either by replaying events from the [List events API](https://apidocs.chargebee.com/docs/api/events/list-events) or by listing resources directly — and repairs gaps that webhooks missed. When a local mutation must happen (sign-up provisioning, admin override), route it through the Chargebee API first; the webhook that follows carries the authoritative `resource_version` and wins any race with optimistic local writes. + + +## Best Practices + +* Draw a hard line between **system-of-record fields** and **app-owned fields**. Chargebee owns subscription status, billing period, item prices on a subscription, invoice totals, coupon redemptions, and catalog definitions. Your database owns the join keys (`chargebeeCustomerId` on a user or organization row), denormalized display fields you are willing to rebuild, and derived entitlement limits you compute from catalog metadata. Never treat a local column as authoritative for billing state unless you can reconstruct it entirely from a Chargebee API response. Skip mirroring resources your app never reads locally — invoices you only link out to in the billing portal do not need a table. + +* Mirror subscriptions, customers, and the catalog entities your checkout and entitlement paths depend on. At minimum for a self-serve SaaS app: + + - **Customers** — link Chargebee customer IDs to app users or organizations; mirror billing contact fields only if the UI needs them offline. + - **Subscriptions** — status, current term, plan item price, quantity, and cancellation schedule drive access control. + - **Product catalog** — items, item prices, features, and item entitlements (Catalog 2.0) or their legacy plan/addon equivalents; coupon definitions if you apply codes in-app. + - **Invoices and payments** — mirror when you render billing history in-app or reconcile revenue; otherwise fetch on demand. + + The [object relationship model](https://www.chargebee.com/docs/billing/2.0/getting-started/object-relationship) shows how these pieces connect: a subscription references one customer, one or more item prices, and optional coupons; invoices hang off subscriptions. + +* Use **two rebuild paths** and know when each applies: + + - **Event replay** — walk the [List events](https://apidocs.chargebee.com/docs/api/events/list-events) endpoint with `occurred_at[after]` / `occurred_at[before]` filters and `sort_by[asc] = "occurred_at"`, processing each event through the same handler the webhook worker uses. Works when you still have the events and your handler is deterministic. [Retrieve an event](https://apidocs.chargebee.com/docs/api/events/retrieve-an-event) notes that only events **less than 90 days old** can be retrieved; treat 90 days as the hard ceiling for event-based catch-up unless you have been archiving events yourself. + - **Resource list backfill** — paginate [List customers](https://apidocs.chargebee.com/docs/api/customers), [List subscriptions](https://apidocs.chargebee.com/docs/api/subscriptions), [List items](https://apidocs.chargebee.com/docs/api/items), [List item prices](https://apidocs.chargebee.com/docs/api/item_prices), and [List coupons](https://apidocs.chargebee.com/docs/api/coupons) with `limit` up to **100** and follow `next_offset` until exhausted ([pagination](https://apidocs.chargebee.com/docs/api)). Use this for cold starts, gaps older than event retention, or when you need the latest snapshot regardless of history. For large sites, [Export customers](https://apidocs.chargebee.com/docs/api/exports/export-customers) and [Export subscriptions](https://apidocs.chargebee.com/docs/api/exports/export-subscriptions) produce async ZIP exports — at most **five** export jobs can run concurrently ([Exports](https://apidocs.chargebee.com/docs/api/exports)). + + Apply the same `resource_version` guard on both paths. A replayed event or a listed resource with `resource_version` less than or equal to what you already stored is a no-op. + +* Run reconciliation on a **schedule** and after incidents. A daily job that samples recently active subscriptions and any row whose `updatedAt` lag exceeds a threshold catches slow drift; an on-demand job after webhook endpoint downtime or queue backlog clears repairs acute gaps. Store per-resource high-water marks (`resource_version` or last-seen `occurred_at`) so the job can skip work already applied. Emit metrics for rows repaired, events skipped as stale, and API pages fetched so you can tune frequency against [rate limits](https://apidocs.chargebee.com/docs/api/error-handling) (`HTTP 429` — back off and retry). + +* **Cache catalog and entitlement data** that is read on every request but changes rarely. A typical pattern: + + - Load item prices and plan entitlements into memory or Redis at process start and on a TTL (hours to a day for catalog; minutes for subscription status if you cache it at all). + - Invalidate on webhook events `item_updated`, `item_price_updated`, `item_entitlements_updated`, or on reconciliation diff. + - Prefer caching derived entitlement limits (tokens per day, seat caps) keyed by `item_price_id`, not whole subscription objects — subscription state should come from the database mirror updated by webhooks. + + Set cache TTL shorter than your reconciliation interval so a missed webhook expires before the cache lies to authorization code for long. + +* Store webhook event IDs for **at-least-once** deduplication separately from `resource_version`. Chargebee delivers webhooks with no ordering guarantee ([event object — out-of-order delivery](https://apidocs.chargebee.com/docs/api/events/event-object#out-of-order-delivery)) and retries for up to **two days** until it receives `HTTP 2xx`. The last retry can arrive around **three days and seven hours** after the original trigger — keep processed event IDs at least that long before purging. `resource_version` handles stale payloads; event `id` handles exact duplicates. + + +### Reconciliation after webhook gaps + +Webhook gaps happen when your endpoint returns `HTTP 5xx`, the queue loses messages, or processing exhausts retries and lands in a [dead letter queue](https://en.wikipedia.org/wiki/Dead_letter_queue). Chargebee keeps retrying webhooks for up to two days, but if your endpoint was down longer than that, those notifications are gone from Chargebee's delivery pipeline even though the billing change already happened on their side. + +The recovery sequence: + +1. Fix the ingress path so new webhooks durably enqueue again. +2. Determine the gap window — last successfully processed event timestamp, or the oldest unacked queue message. +3. If the gap is within **90 days**, replay events from the List events API filtered on `occurred_at`, reusing the webhook handler. +4. If the gap exceeds event retention, or you need an authoritative snapshot now, list (or export) resources from the API and upsert every row. +5. Drain the DLQ after the replay — redrive only messages whose events were not already applied via replay. + +```mermaid +sequenceDiagram + participant cb as Chargebee + participant app as App + participant queue as Durable
Queue/Store + participant worker as Worker + participant db as Database + participant reconcile as Reconciliation
Job + + note over cb,db: Endpoint was down 3 days — webhooks exhausted retries + + cb->>cb: subscription_changed
(never delivered) + note over app: No webhook received + + reconcile->>db: Read last occurred_at cursor + reconcile->>cb: List events occurred_at[after]=cursor + cb-->>reconcile: subscription_changed + others + loop Each event + reconcile->>reconcile: Same handler as worker + reconcile-->>db: Upsert if rv > stored + end + + alt Event still missing (outside retention) + reconcile->>cb: Retrieve subscription + cb-->>reconcile: Current subscription snapshot + reconcile-->>db: Upsert from API body + end + + note over queue: DLQ holds failed copies + reconcile->>db: Skip DLQ msgs whose event id already applied + reconcile->>queue: Redrive remaining DLQ msgs + queue-)worker: Redelivered event + worker-->>db: Stale guard → no-op + worker-->>queue: Ack +``` + +Do not delete local rows just because you never saw a `subscription_cancelled` webhook during an outage. Prefer fetching the subscription from Chargebee and mirroring its current `status` and `resource_version`; Chargebee's retrieve response is the tiebreaker. + + +### Ordering with resource_version + +`resource_version` is a per-resource monotonic counter — Chargebee updates it with a new **timestamp in milliseconds** on every change ([customers](https://apidocs.chargebee.com/docs/api/customers), [subscriptions](https://apidocs.chargebee.com/docs/api/subscriptions), [items](https://apidocs.chargebee.com/docs/api/items), [item prices](https://apidocs.chargebee.com/docs/api/item_prices), [coupons](https://apidocs.chargebee.com/docs/api/coupons)). It is **not** a global sequence: a subscription's version and its customer's version advance independently, and neither orders relative to the other. + +Rules: + +- Compare `resource_version` **per resource type and id** — store `customer:cb_abc → 1710000000123`, not one cursor for the whole site. +- On every webhook, inspect **each** object in `content` that carries `resource_version` (for example `content.customer.resource_version` and `content.subscription.resource_version` on a `subscription_changed` event). Apply updates only where incoming `rv1` > stored `rv2`; ignore the rest ([out-of-order delivery](https://apidocs.chargebee.com/docs/api/events/event-object#out-of-order-delivery)). +- Do not use event `occurred_at` as the staleness key across different resources — two events for different resources can arrive out of order even when their timestamps look ordered. +- Events whose `content` includes arrays (credit notes, unbilled charges) may carry multiple versioned objects; version each array element individually. +- After a successful apply, persist the new high-water mark in the same transaction as the mirror write so a crash cannot leave the version table ahead of the entity row. + +Cross-resource ordering is a dependency problem, not a version problem. A `payment_succeeded` referencing a customer id that is not in your database yet should retry until `customer_created` lands — see [Handling out-of-order and dependent events](integrating-chargebee-webhooks.md#handling-out-of-order-and-dependent-events) in the webhook guide. + + +### Back-fill on cold start + +An empty database or a fresh environment needs a bootstrap before webhooks alone can keep you current. + +1. **Push catalog to Chargebee** (greenfield) — create item families, features, items, item prices, and entitlements via API so the site matches your product definition. Catalog objects change infrequently; many teams treat the repo as canonical and run an idempotent bootstrap script on deploy rather than mirroring catalog rows from webhooks into Postgres. +2. **Pull runtime state** — paginate all customers and subscriptions (and any other mirrored resources) from Chargebee; upsert each with its `resource_version`. Link customers to app users by email or explicit metadata you set at creation time. +3. **Seed version cursors** — populate the `resource_version` store from the API bodies so the first live webhook does not replay history you already ingested. +4. **Enable webhooks** — from this point forward, the queue worker is the primary writer; reconciliation runs as a periodic diff. + +For Catalog 2.0 sites, list [items](https://apidocs.chargebee.com/docs/api/items) and [item prices](https://apidocs.chargebee.com/docs/api/item_prices) separately — subscriptions reference item price IDs, not raw item IDs. Include archived or deleted catalog objects if existing subscriptions still point at them (`deleted` resources remain retrievable via list APIs). + +`pointer` has no backfill script yet. When you add one, confirm whether listing subscriptions needs a `status[is]` filter or an unfiltered paginated list is acceptable for your site size. + + +### Avoiding split-brain between local DB and Chargebee + +Split-brain appears when your app writes billing state locally and Chargebee simultaneously sends a webhook reflecting a different truth — common when sign-up creates a customer through the API while a `customer_created` event is in flight, or when an admin edits a subscription in the Chargebee dashboard during a local "upgrade" request. + +Pick one writer per field: + +- **Chargebee wins** for every field Chargebee stores. Local updates that skip the API are bugs. +- **App wins** only for join columns Chargebee does not model (`userId`, organization membership) and for derived caches you can drop and rebuild. + +Concrete rules: + +- Create customers and subscriptions through Chargebee API calls (or the Better Auth plugin that wraps them), not by inserting mirror rows directly. Persist the returned IDs and wait for the webhook — or upsert immediately from the API response using the same code path the worker uses, then let the webhook no-op on matching `resource_version`. +- Never update subscription status locally "optimistically" after checkout; read from the mirror only after webhook processing or an explicit retrieve confirms the change. +- If you must patch metadata both sides use, write to Chargebee first (`customer.update`, subscription metadata fields), then mirror the response — not the other way around. +- Run reconciliation with compare-and-swap semantics: fetch the remote resource, apply only if remote `resource_version` > local, log and alert when local is ahead of remote (that indicates a local write bypassed Chargebee). + +```mermaid +sequenceDiagram + participant web as Browser + participant app as App + participant db as Database + participant cb as Chargebee + participant queue as Durable
Queue/Store + participant worker as Worker + + note over web,worker: Local API write races dashboard webhook + + web->>app: Upgrade subscription + app->>cb: subscription.update + cb-->>app: subscription rv=2000 + + par App path + app-->>db: Upsert rv=2000 from API response + and Dashboard path + cb->>app: subscription_changed rv=2001 + app->>queue: Store event + queue-)worker: subscription_changed + worker-->>db: rv 2001 > 2000 → apply + end + + note over db: Final row matches rv=2001 +``` + + +## Implementation notes + +TODO + +## Go-live checklist + +- [ ] Is every mirrored billing field documented as either Chargebee-owned (rebuildable from API) or app-owned (join keys only)? + +- [ ] Does a reconciliation job exist (scheduled or on-demand) that can repair webhook gaps without manual SQL? + +- [ ] Can you rebuild local subscription and customer mirrors from Chargebee alone — via event replay within 90 days or via list/export APIs — on an empty database? + +- [ ] Does every upsert compare `resource_version` per resource id and type, not per event or global cursor? + +- [ ] Are webhook event IDs deduplicated for at least three days and seven hours to cover Chargebee's final retry window? + +- [ ] Do app-initiated billing mutations go through the Chargebee API before (or atomically with) any local mirror write? + +- [ ] After a simulated webhook outage, does replay or API backfill converge the mirror to Chargebee's dashboard state? + +- [ ] Is catalog caching (if used) invalidated on catalog webhook events or TTL expiry shorter than the reconciliation interval? + +- [ ] Are DLQ messages monitored, and is there a documented redrive procedure that skips events already applied during replay? + +- [ ] Does the integration target Product Catalog 2.0 (`item` / `item_price` APIs) rather than legacy `plan` / `addon` endpoints? From 4f6424e02d56dae8a5eadc179be218b9c2bfa18c Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Wed, 12 Aug 2026 18:41:28 +1000 Subject: [PATCH 05/35] entitlements phase 1 --- pointer/ARCHITECTURE.md | 206 ++++ pointer/app/(auth)/sign-up/page.tsx | 10 +- .../entitlements/checkout-complete/route.ts | 87 ++ pointer/app/api/entitlements/status/route.ts | 36 + pointer/app/api/generate/route.ts | 170 ++++ pointer/app/api/usage/route.ts | 50 + .../choose-plan/_components/plan-picker.tsx | 3 +- .../_components/account-provisioning.tsx | 88 ++ .../dashboard/_components/generate-demo.tsx | 292 ++++++ pointer/app/dashboard/page.tsx | 16 +- pointer/app/flow/_components/EventLog.tsx | 17 + pointer/app/flow/_lib/mapping.ts | 28 + pointer/config/models.yaml | 20 + pointer/infra/locals.tf | 2 + pointer/lib/auth.ts | 16 +- pointer/lib/entitlements/features.ts | 203 ++++ pointer/lib/entitlements/gate.ts | 223 ++++ .../postgres-store.integration.test.ts | 69 ++ pointer/lib/entitlements/postgres-store.ts | 265 +++++ pointer/lib/entitlements/provider.ts | 71 ++ pointer/lib/entitlements/queue.test.ts | 37 + pointer/lib/entitlements/queue.ts | 84 ++ pointer/lib/entitlements/subject.ts | 94 ++ pointer/lib/entitlements/sync.test.ts | 181 ++++ pointer/lib/entitlements/sync.ts | 313 ++++++ pointer/lib/events/types.ts | 9 +- pointer/lib/free-subscription.test.ts | 85 ++ pointer/lib/free-subscription.ts | 72 ++ pointer/lib/generate/simulate.test.ts | 46 + pointer/lib/generate/simulate.ts | 62 ++ pointer/lib/models.test.ts | 24 + pointer/lib/models.ts | 59 ++ pointer/lib/queue.ts | 36 + pointer/lib/subscriptions.ts | 15 +- .../lib/usage/counters.integration.test.ts | 119 +++ pointer/lib/usage/counters.ts | 236 +++++ pointer/lib/webhooks.ts | 38 +- pointer/next.config.ts | 3 + pointer/package.json | 16 +- pointer/plugins/chargebee-plugin.ts | 21 - pointer/plugins/entitlements-plugin.ts | 53 + pointer/pnpm-lock.yaml | 953 +++++++++++++++--- pointer/scripts/catalog.test.ts | 33 + pointer/scripts/reconcile-entitlements.ts | 41 + pointer/vitest.config.mts | 14 + pointer/workers/chargebee-webhook-worker.ts | 82 +- 46 files changed, 4390 insertions(+), 208 deletions(-) create mode 100644 pointer/ARCHITECTURE.md create mode 100644 pointer/app/api/entitlements/checkout-complete/route.ts create mode 100644 pointer/app/api/entitlements/status/route.ts create mode 100644 pointer/app/api/generate/route.ts create mode 100644 pointer/app/api/usage/route.ts create mode 100644 pointer/app/dashboard/_components/account-provisioning.tsx create mode 100644 pointer/app/dashboard/_components/generate-demo.tsx create mode 100644 pointer/config/models.yaml create mode 100644 pointer/lib/entitlements/features.ts create mode 100644 pointer/lib/entitlements/gate.ts create mode 100644 pointer/lib/entitlements/postgres-store.integration.test.ts create mode 100644 pointer/lib/entitlements/postgres-store.ts create mode 100644 pointer/lib/entitlements/provider.ts create mode 100644 pointer/lib/entitlements/queue.test.ts create mode 100644 pointer/lib/entitlements/queue.ts create mode 100644 pointer/lib/entitlements/subject.ts create mode 100644 pointer/lib/entitlements/sync.test.ts create mode 100644 pointer/lib/entitlements/sync.ts create mode 100644 pointer/lib/free-subscription.test.ts create mode 100644 pointer/lib/free-subscription.ts create mode 100644 pointer/lib/generate/simulate.test.ts create mode 100644 pointer/lib/generate/simulate.ts create mode 100644 pointer/lib/models.test.ts create mode 100644 pointer/lib/models.ts create mode 100644 pointer/lib/queue.ts create mode 100644 pointer/lib/usage/counters.integration.test.ts create mode 100644 pointer/lib/usage/counters.ts create mode 100644 pointer/plugins/entitlements-plugin.ts create mode 100644 pointer/scripts/catalog.test.ts create mode 100644 pointer/scripts/reconcile-entitlements.ts create mode 100644 pointer/vitest.config.mts diff --git a/pointer/ARCHITECTURE.md b/pointer/ARCHITECTURE.md new file mode 100644 index 0000000..5cf492f --- /dev/null +++ b/pointer/ARCHITECTURE.md @@ -0,0 +1,206 @@ +# Pointer — Architecture Overview + +This document outlines the high level architecture of the app, including it's tech stack and infrastructure. + + +## 1. Tech Stack + +| Layer | Technology | Role | +| --- | --- | --- | +| Web + API | **Next.js 16** (App Router, standalone build), **React 19** | Renders the UI and hosts all HTTP endpoints | +| Auth | **Better Auth** | Email/password, 2FA, bearer tokens, organizations, admin | +| Billing | **Chargebee** + [@chargebee/better-auth](https://npmx.dev/@chargebee/better-auth) plugin | Customer/subscription lifecycle and webhook sync | +| Primary DB | **PostgreSQL 18** (via `pg` + Kysely) | Users, sessions, orgs, subscription mirror | +| Cache + streams | **Redis 8** (`ioredis`) | Idempotency helpers and the live event stream | +| Async transport | **AWS SQS** (+ DLQ), `sqs-consumer` | Buffers Chargebee webhooks for the worker | +| Background jobs | Standalone Node process (`tsx`) | Consumes SQS and applies DB sync | + +--- + +## 2. Component Diagram + +```mermaid +flowchart LR + User(["Browser / User"]) + CB["Chargebee
(billing system of record)"] + + subgraph AWS["AWS (ECS Fargate)"] + subgraph App["pointer-app (Next.js)"] + Web["Web UI + Auth
(Better Auth)"] + WHIn["Webhook endpoint
validate + enqueue"] + SSE["/api/events/stream
(SSE)"] + end + Worker["pointer-worker
SQS consumer +
correctness pipeline"] + end + + Queue[["SQS queue"]] + DLQ[["SQS DLQ"]] + PG[("PostgreSQL
RDS")] + Redis[("Redis
streams + cache")] + + User -->|"HTTPS (via ALB)"| Web + Web -->|"create customer / subscription"| CB + Web --> PG + Web -->|"publish events"| Redis + SSE -->|"subscribe"| Redis + User -->|"live flow view"| SSE + + CB -->|"webhook (Basic Auth)"| WHIn + WHIn -->|"enqueue"| Queue + Queue -->|"long-poll"| Worker + Worker -->|"plugin DB-sync hooks"| PG + Worker -->|"poison / exhausted"| DLQ + Worker -->|"publish events"| Redis +``` + +**Reading the diagram** + +- **Synchronous path (blue in your head):** the browser hits the app through the ALB. + Better Auth handles sign-up/sign-in and, on sign-up, calls Chargebee to create a matching + customer plus an idempotent free subscription. Upgrades and billing-portal actions also go + straight to Chargebee via the SDK. +- **Asynchronous path:** Chargebee calls back with webhooks. The app endpoint authenticates + and enqueues them to SQS, then returns `2xx` immediately. The worker picks them up and + applies changes to Postgres. Once `subscription_created` has established the local + subscription row, the worker queues a second job to fetch and persist its entitlements. +- **Observation tap:** every meaningful step emits an event onto a Redis stream. The `/flow` + page subscribes over Server-Sent Events to animate the system live — it is purely for + demonstration and never on the critical path. + +--- + +## 3. Core Components + +### 3.1 `pointer-app` (Next.js) + +The single web/API application. It is responsible for: + +- **Authentication & accounts** — Better Auth with the organization plugin. Personal accounts + bill against the user; Team accounts bill against the organization. +- **Chargebee provisioning** — on user creation a Chargebee customer is created (or reused), + its ID is stored on the `user` row, and an idempotent free subscription is created. The + dashboard waits for the subscription webhook before opening, then uses free-tier defaults + while the queued entitlement snapshot finishes loading. +- **Webhook ingress** — the Chargebee webhook endpoint validates HTTP Basic Auth, parses the + payload, and enqueues it to SQS. It intentionally does **no** database work, so a webhook + storm can never degrade the web tier. +- **Live event stream** — publishes domain events to a Redis stream and exposes them over SSE + for the `/flow` visualization. + +### 3.2 `pointer-worker` (SQS consumer) + +A long-running process that owns all webhook-driven database sync. For each message it runs a +correctness pipeline that the plugin alone can't provide: + +``` +parse → stale/duplicate guard → dependency pre-check → process + → verify-after-process → commit versions → ack +``` + +- **Idempotency without an inbox** — SQS is at-least-once, but the sync hooks are idempotent + and a `resource_version` guard turns a stale or redelivered event into a no-op. +- **Error handling** — transient/dependency-not-ready errors get an increasing backoff (via + `ChangeMessageVisibility`) and are left on the queue; after 5 attempts SQS auto-routes them + to the DLQ. Malformed "poison" messages are sent straight to the DLQ. +- **Scaling** — more load simply means more worker tasks; SQS fans messages out and the + visibility timeout prevents double-processing. No leader election. +- **Entitlement jobs** — after the subscription-created webhook writes the subscription and + item rows, the same queue receives a job for its entitlement snapshot. Jobs skip the webhook + pipeline, since there is no additional Chargebee event to order or verify, but reuse the + backoff and DLQ. + +See [`docs/plans/5-webhook-correctness.md`](docs/plans/5-webhook-correctness.md) for the full rationale. + +### 3.3 Data stores + +- **PostgreSQL** — the primary store. Better Auth manages the core schema (users, sessions, + organizations, members) plus the Chargebee plugin's tables and a small + `chargebee_resource_version` table used for out-of-order protection. Schema changes are + applied by Better Auth's migration CLI, run as a dedicated one-off task. +- **Redis** — hosts the Redis-Streams event bus for the live flow view and short-lived + idempotency/cache helpers. It is a best-effort observation tap: if it is down, publishing + fails silently and the caller is unaffected. + +### 3.4 Chargebee + +The external billing **system of record**. The app never treats its local Postgres data as +authoritative for billing state — it mirrors Chargebee via webhooks and reconciles using +resource versions. See [`docs/03-chargebee-source-of-truth.md`](docs/03-chargebee-source-of-truth.md). + +--- + +## 4. Infrastructure + +Everything is defined as **Terraform** under [`infra/`](infra/) and runs on **AWS**. The stack +is intentionally lean for a proof-of-concept, with production trade-offs documented in the +infra README. + +```mermaid +flowchart TB + Route53["Route53
pointer.localcblabs.com"] --> ALB["ALB
HTTPS, TLS 1.2+"] + ALB --> AppSvc["ECS service: pointer-app
Fargate (Next.js :3000)"] + subgraph ECS["ECS Fargate cluster (pointer-cluster)"] + AppSvc + WorkerSvc["ECS service: pointer-worker
queue-depth autoscaling"] + Migrate["one-off task: pointer-app-migrate"] + end + AppSvc --> RDS[("RDS Postgres
encrypted, TLS")] + WorkerSvc --> RDS + AppSvc --> SQS[["SQS queue + DLQ"]] + WorkerSvc --> SQS + Secrets["Secrets Manager
db + app secrets"] -.-> AppSvc + Secrets -.-> WorkerSvc + ECR["ECR: pointer-app"] -.image.-> ECS +``` + +| Area | What runs | +| --- | --- | +| **Network** | `pointer-vpc` (10.20.0.0/16), Internet Gateway, 2 public subnets across 2 AZs | +| **Edge** | ALB (HTTPS only, HTTP→HTTPS redirect); Route53 alias to `pointer.localcblabs.com`; ACM cert looked up | +| **Compute** | ECS Fargate cluster with `pointer-app` (web), `pointer-worker` (webhook consumer), and a short-lived `pointer-app-migrate` task | +| **Images** | ECR repo `pointer-app`; a slim standalone `:latest` for the app, a `builder` image for worker + migrations | +| **Data** | RDS Postgres (`db.t4g.micro`, single-AZ, encrypted at rest, TLS enforced) | +| **Async** | SQS main queue + DLQ (SSE on both, `maxReceiveCount=5`) | +| **Secrets** | Secrets Manager holds DB credentials and app/Chargebee secrets, injected into tasks at start | +| **Logs** | CloudWatch log groups per service (14-day retention) | + +**Deploy shape.** A new image is built and pushed to ECR, Better Auth migrations run as a +one-off Fargate task against the new schema, then the `pointer-app` and `pointer-worker` +services are rolled onto the new image. + +**Worker autoscaling.** The worker scales on SQS backlog +(`ApproximateNumberOfMessagesVisible`): scale out when the queue is deep, scale in when it +drains, within configurable min/max bounds — so webhook bursts are absorbed without touching +the web tier. + +**Security baseline.** Encryption at rest (RDS/SQS/Secrets/ECR) and in transit (ALB TLS 1.2+, +RDS `force_ssl`), security groups that reference each other rather than open CIDRs, RDS not +publicly reachable, and IAM scoped to specific secret and queue ARNs. + +--- + +## 5. Local Development + +`docker-compose.yaml` brings up the full dependency set offline: + +- **Postgres 18** on `:5432` +- **Redis 8** on `:6379` +- **LocalStack** on `:4566` providing SQS, with the webhook queue + DLQ created on startup + (mirroring `infra/sqs.tf`) + +The app runs with `next dev`, and the worker with `pnpm worker:chargebee`. This mirrors the +production topology closely enough that the same code paths — enqueue, consume, sync — are +exercised end to end without any AWS account. + +--- + +## 6. Where to Go Next + +| Topic | Document | +| --- | --- | +| Component boundaries & ports | [`docs/01-architecture.md`](docs/01-architecture.md) | +| Data model | [`docs/02-data-architecture.md`](docs/02-data-architecture.md) | +| Chargebee as source of truth | [`docs/03-chargebee-source-of-truth.md`](docs/03-chargebee-source-of-truth.md) | +| Sequence flows | [`docs/04-sequence-flows.md`](docs/04-sequence-flows.md) | +| Scaling & deployment | [`docs/05-scaling-and-deployment.md`](docs/05-scaling-and-deployment.md) | +| Infrastructure (Terraform) | [`infra/README.md`](infra/README.md) | diff --git a/pointer/app/(auth)/sign-up/page.tsx b/pointer/app/(auth)/sign-up/page.tsx index a8ee2e9..367b9a8 100644 --- a/pointer/app/(auth)/sign-up/page.tsx +++ b/pointer/app/(auth)/sign-up/page.tsx @@ -29,14 +29,14 @@ export default function SignUpPage() { const email = String(formData.get("email") ?? ""); const password = String(formData.get("password") ?? ""); - // Email verification is disabled — Better Auth signs the user in - // immediately and the session cookie is set, so we send them to plan - // selection before the dashboard. + // Email verification is disabled, so Better Auth signs the user in + // immediately. The dashboard waits for the automatically-created free + // subscription webhook and entitlement mirror before rendering. const { error: signUpError } = await authClient.signUp.email({ name, email, password, - callbackURL: "/choose-plan", + callbackURL: "/dashboard?provisioning=1", }); if (signUpError) { @@ -45,7 +45,7 @@ export default function SignUpPage() { return; } - router.push("/choose-plan"); + router.push("/dashboard?provisioning=1"); router.refresh(); } diff --git a/pointer/app/api/entitlements/checkout-complete/route.ts b/pointer/app/api/entitlements/checkout-complete/route.ts new file mode 100644 index 0000000..df609a7 --- /dev/null +++ b/pointer/app/api/entitlements/checkout-complete/route.ts @@ -0,0 +1,87 @@ +import { headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/lib/auth"; +import { getPool } from "@/lib/db"; +import { syncSubscriptionEntitlements } from "@/lib/entitlements/sync"; +import { resolveBillingSubject } from "@/lib/entitlements/subject"; +import { chargebeeClient } from "@/plugins/chargebee-plugin"; + +function safeCallback(value: string | null): string { + return value?.startsWith("/") && !value.startsWith("//") + ? value + : "/dashboard"; +} + +export async function GET(request: NextRequest): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.redirect( + new URL("/sign-in?from=/dashboard", request.url), + ); + } + + const callbackURL = safeCallback( + request.nextUrl.searchParams.get("callbackURL"), + ); + const subject = await resolveBillingSubject(session, { customerType: "user" }); + const requestedSubscriptionId = + request.nextUrl.searchParams.get("subscriptionId"); + const pool = await getPool(); + const result = await pool.query<{ + id: string; + chargebeeSubscriptionId: string | null; + }>( + `SELECT id, "chargebeeSubscriptionId" + FROM subscription + WHERE "referenceId" = $1 + AND ($2::text IS NULL OR id = $2) + ORDER BY "periodStart" DESC NULLS LAST + LIMIT 1`, + [subject.referenceId, requestedSubscriptionId], + ); + const local = result.rows[0]; + if (!local) { + return new Response("Subscription not found", { status: 404 }); + } + + try { + let chargebeeSubscriptionId = local.chargebeeSubscriptionId; + if (!chargebeeSubscriptionId && subject.chargebeeCustomerId) { + const subscriptions = await chargebeeClient.subscription.list({ + customer_id: { is: subject.chargebeeCustomerId }, + limit: 10, + }); + const active = subscriptions.list + .map((entry) => entry.subscription) + .filter((subscription) => + ["active", "in_trial", "non_renewing", "future"].includes( + subscription.status ?? "", + ), + ) + .sort((a, b) => (b.updated_at ?? 0) - (a.updated_at ?? 0))[0]; + chargebeeSubscriptionId = active?.id ?? null; + if (chargebeeSubscriptionId) { + await pool.query( + `UPDATE subscription + SET "chargebeeSubscriptionId" = $1 + WHERE id = $2 AND "referenceId" = $3 + AND "chargebeeSubscriptionId" IS NULL`, + [chargebeeSubscriptionId, local.id, subject.referenceId], + ); + } + } + + if (chargebeeSubscriptionId) { + await syncSubscriptionEntitlements(chargebeeSubscriptionId, { + trigger: "checkout", + }); + } + } catch (error) { + // The webhook worker remains the correctness path. Do not strand a paid + // customer on the callback if the optimistic refresh is temporarily down. + console.error("[entitlements] checkout refresh failed", error); + } + + return NextResponse.redirect(new URL(callbackURL, request.url)); +} diff --git a/pointer/app/api/entitlements/status/route.ts b/pointer/app/api/entitlements/status/route.ts new file mode 100644 index 0000000..925627c --- /dev/null +++ b/pointer/app/api/entitlements/status/route.ts @@ -0,0 +1,36 @@ +import { type NextRequest } from "next/server"; + +import { auth } from "@/lib/auth"; +import { getEntitlementSnapshotDiagnostics } from "@/lib/entitlements/postgres-store"; +import { resolveEntitlementSubject } from "@/lib/entitlements/subject"; + +/** + * Readiness probe for the local entitlement mirror. The dashboard polls this + * while a snapshot is still loading so it can reload once the subscriber's real + * plan replaces the free-tier defaults. + */ +export async function GET(request: NextRequest): Promise { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json( + { error: "unauthenticated", message: "Sign in to read entitlements" }, + { status: 401 }, + ); + } + + const subject = await resolveEntitlementSubject(session, { + customerType: "user", + }); + if (!subject) { + return Response.json({ ready: false, reason: "no_subscription" }); + } + + const snapshot = await getEntitlementSnapshotDiagnostics( + subject.chargebeeSubscriptionId, + ); + return Response.json({ + ready: snapshot !== null, + subscriptionId: subject.chargebeeSubscriptionId, + snapshot, + }); +} diff --git a/pointer/app/api/generate/route.ts b/pointer/app/api/generate/route.ts new file mode 100644 index 0000000..a788e1a --- /dev/null +++ b/pointer/app/api/generate/route.ts @@ -0,0 +1,170 @@ +import { type NextRequest } from "next/server"; + +import { auth } from "@/lib/auth"; +import { resolveEntitlements } from "@/lib/entitlements/features"; +import { + EntitlementGateError, + enforceGeneration, + getUsageSnapshot, +} from "@/lib/entitlements/gate"; +import { resolveEntitlementSubject } from "@/lib/entitlements/subject"; +import { emit } from "@/lib/events/emit"; +import { + simulateGeneration, + validateGenerateInput, +} from "@/lib/generate/simulate"; +import { claimUsageThreshold } from "@/lib/usage/counters"; + +function upgradeHint(action: "upgrade" | "buy_credits" = "upgrade") { + return { action, href: "/choose-plan" as const }; +} + +export async function POST(request: NextRequest): Promise { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json( + { error: "unauthenticated", message: "Sign in to generate" }, + { status: 401 }, + ); + } + + let input; + try { + input = validateGenerateInput(await request.json()); + } catch (error) { + return Response.json( + { + error: "invalid_request", + message: error instanceof Error ? error.message : "Invalid request", + }, + { status: 400 }, + ); + } + + const subject = await resolveEntitlementSubject(session, { + customerType: "user", + }); + if (!subject) { + return Response.json( + { + error: "no_subscription", + message: "Choose a plan before generating", + upgradeHint: upgradeHint(), + }, + { status: 403 }, + ); + } + + const simulated = simulateGeneration(input); + await emit( + "app.generate_requested", + { + subscription_id: subject.chargebeeSubscriptionId, + model: input.model, + input_tokens: simulated.inputTokens, + requested_output_tokens: simulated.outputTokens, + }, + { source: "app" }, + ); + + let entitlementsPending = false; + try { + // While the snapshot loads, entitlements resolve to the free-tier floor so + // a new subscriber can generate without waiting on Chargebee. + const entitlements = await resolveEntitlements(subject); + entitlementsPending = entitlements.pending; + const consumed = await enforceGeneration(subject, entitlements, { + model: input.model, + inputTokens: simulated.inputTokens, + outputTokens: simulated.outputTokens, + }); + const limits = await getUsageSnapshot(subject, entitlements); + await emit( + "app.generate_completed", + { + subscription_id: subject.chargebeeSubscriptionId, + model: input.model, + input_tokens: simulated.inputTokens, + output_tokens: simulated.outputTokens, + credits_consumed: consumed.creditsConsumed, + usage_source: consumed.source, + }, + { source: "app", trace_id: simulated.id }, + ); + for (const crossed of limits.thresholds) { + if (!(await claimUsageThreshold(subject, crossed.featureId))) continue; + await emit( + "app.usage_threshold", + { + subscription_id: subject.chargebeeSubscriptionId, + feature_id: crossed.featureId, + percent: crossed.percent, + }, + { source: "app", trace_id: simulated.id }, + ); + } + + return Response.json({ + id: simulated.id, + model: simulated.model, + output: simulated.output, + usage: { + inputTokens: simulated.inputTokens, + outputTokens: simulated.outputTokens, + creditsConsumed: consumed.creditsConsumed, + source: consumed.source, + }, + limits, + }); + } catch (error) { + if (error instanceof EntitlementGateError) { + await emit( + "app.generate_denied", + { + subscription_id: subject.chargebeeSubscriptionId, + model: input.model, + error: error.code, + feature_id: error.featureId, + }, + { source: "app", trace_id: simulated.id }, + ); + const headers = new Headers(); + if (error.retryAfterSeconds) { + headers.set("Retry-After", String(error.retryAfterSeconds)); + } + return Response.json( + { + error: error.code, + message: entitlementsPending + ? `${error.message}. Your plan entitlements are still loading.` + : error.message, + featureId: error.featureId, + entitlementsPending, + upgradeHint: upgradeHint(), + ...(error.retryAfterSeconds + ? { retryAfterSeconds: error.retryAfterSeconds } + : {}), + }, + { status: error.status, headers }, + ); + } + + console.error("[generate] entitlement enforcement failed", error); + await emit( + "app.generate_denied", + { + subscription_id: subject.chargebeeSubscriptionId, + model: input.model, + error: "enforcement_unavailable", + }, + { source: "app", trace_id: simulated.id }, + ); + return Response.json( + { + error: "enforcement_unavailable", + message: "Local entitlement enforcement is temporarily unavailable", + }, + { status: 503 }, + ); + } +} diff --git a/pointer/app/api/usage/route.ts b/pointer/app/api/usage/route.ts new file mode 100644 index 0000000..8d9bc80 --- /dev/null +++ b/pointer/app/api/usage/route.ts @@ -0,0 +1,50 @@ +import { type NextRequest } from "next/server"; + +import { auth } from "@/lib/auth"; +import { resolveEntitlements } from "@/lib/entitlements/features"; +import { getUsageSnapshot } from "@/lib/entitlements/gate"; +import { getEntitlementSnapshotDiagnostics } from "@/lib/entitlements/postgres-store"; +import { resolveEntitlementSubject } from "@/lib/entitlements/subject"; + +export async function GET(request: NextRequest): Promise { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json( + { error: "unauthenticated", message: "Sign in to view usage" }, + { status: 401 }, + ); + } + const subject = await resolveEntitlementSubject(session, { + customerType: "user", + }); + if (!subject) { + return Response.json( + { + error: "no_subscription", + message: "Choose a plan to view usage", + upgradeHint: { action: "upgrade", href: "/choose-plan" }, + }, + { status: 403 }, + ); + } + + try { + // A pending snapshot is not an error: the response reports free-tier limits + // and `entitlementsPending` so the client can re-check shortly. + const entitlements = await resolveEntitlements(subject); + const [usage, entitlementSnapshot] = await Promise.all([ + getUsageSnapshot(subject, entitlements), + getEntitlementSnapshotDiagnostics(subject.chargebeeSubscriptionId), + ]); + return Response.json({ ...usage, entitlementSnapshot }); + } catch (error) { + console.error("[usage] entitlement enforcement failed", error); + return Response.json( + { + error: "enforcement_unavailable", + message: "Local entitlement enforcement is temporarily unavailable", + }, + { status: 503 }, + ); + } +} diff --git a/pointer/app/choose-plan/_components/plan-picker.tsx b/pointer/app/choose-plan/_components/plan-picker.tsx index e5c63d8..0310e94 100644 --- a/pointer/app/choose-plan/_components/plan-picker.tsx +++ b/pointer/app/choose-plan/_components/plan-picker.tsx @@ -38,7 +38,8 @@ export function PlanPicker({ const payload = { itemPriceId: plan.itemPriceId, - successUrl: "/dashboard", + successUrl: + "/api/entitlements/checkout-complete?callbackURL=%2Fdashboard", cancelUrl: mode === "switch" ? "/dashboard" : "/choose-plan", }; diff --git a/pointer/app/dashboard/_components/account-provisioning.tsx b/pointer/app/dashboard/_components/account-provisioning.tsx new file mode 100644 index 0000000..f66f936 --- /dev/null +++ b/pointer/app/dashboard/_components/account-provisioning.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +const POLL_INTERVAL_MS = 1_500; +const POLL_ATTEMPTS = 40; + +async function localSubscriptionReady(): Promise { + const response = await fetch("/api/entitlements/status", { + cache: "no-store", + }); + if (!response.ok) return false; + const body = (await response.json()) as { subscriptionId?: unknown }; + return typeof body.subscriptionId === "string"; +} + +export function AccountProvisioning() { + const router = useRouter(); + const [attempt, setAttempt] = useState(0); + const [timedOut, setTimedOut] = useState(false); + + useEffect(() => { + let active = true; + + void (async () => { + for (let index = 0; index < POLL_ATTEMPTS; index += 1) { + try { + if (await localSubscriptionReady()) { + if (!active) return; + router.replace("/dashboard"); + router.refresh(); + return; + } + } catch { + // The webhook worker or local mirror may still be starting. + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (!active) return; + } + if (active) setTimedOut(true); + })(); + + return () => { + active = false; + }; + }, [attempt, router]); + + return ( +
+
+
+

+ Setting up your free plan +

+

+ Chargebee is creating your subscription. Your dashboard will open as + soon as its local record arrives; free-tier defaults apply while the + entitlement snapshot finishes loading. +

+ + {timedOut ? ( +
+

+ Provisioning is taking longer than expected. +

+ +
+ ) : null} +
+
+ ); +} diff --git a/pointer/app/dashboard/_components/generate-demo.tsx b/pointer/app/dashboard/_components/generate-demo.tsx new file mode 100644 index 0000000..28b0177 --- /dev/null +++ b/pointer/app/dashboard/_components/generate-demo.tsx @@ -0,0 +1,292 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import type { UsageSnapshot } from "@/lib/entitlements/gate"; + +type ApiError = { + error: string; + message: string; + retryAfterSeconds?: number; + upgradeHint?: { action: string; href: string }; +}; + +const STATUS_POLL_MS = 2_000; +const STATUS_POLL_ATTEMPTS = 15; + +type GenerateResult = { + output: string; + usage: { + inputTokens: number; + outputTokens: number; + creditsConsumed: number; + source: "plan_quota" | "credits"; + }; + limits: UsageSnapshot; +}; + +function formatLimit(value: number | "unlimited"): string { + return value === "unlimited" ? "Unlimited" : value.toLocaleString(); +} + +async function fetchUsage(): Promise { + const response = await fetch("/api/usage", { cache: "no-store" }); + const body = await response.json(); + if (!response.ok) throw body; + return body as UsageSnapshot; +} + +/** Polls the mirror until the background Chargebee refresh has landed. */ +async function waitForEntitlements(active: () => boolean): Promise { + for (let attempt = 0; attempt < STATUS_POLL_ATTEMPTS; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS)); + if (!active()) return false; + try { + const response = await fetch("/api/entitlements/status", { + cache: "no-store", + }); + if (!response.ok) continue; + const body = (await response.json()) as { ready: boolean }; + if (body.ready) return true; + } catch { + // Keep polling; the refresh is retried on the next request anyway. + } + } + return false; +} + +function Meter({ + label, + used, + limit, +}: { + label: string; + used: number; + limit: number | "unlimited"; +}) { + const percent = + limit === "unlimited" || limit === 0 + ? 0 + : Math.min(100, Math.round((used / limit) * 100)); + const color = + percent >= 100 + ? "bg-red-500" + : percent >= 80 + ? "bg-amber-500" + : "bg-[#6E56CF]"; + return ( +
+
+ {label} + + {used.toLocaleString()} / {formatLimit(limit)} + +
+
+
+
+
+ ); +} + +export function GenerateDemo() { + const router = useRouter(); + const [usage, setUsage] = useState(null); + const [prompt, setPrompt] = useState("Explain runtime entitlements in one paragraph."); + const [model, setModel] = useState(""); + const [output, setOutput] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + const applyUsage = useCallback((snapshot: UsageSnapshot) => { + setUsage(snapshot); + setModel((current) => + snapshot.features.models.allowedModels.includes(current) + ? current + : (snapshot.features.models.allowedModels[0] ?? ""), + ); + }, []); + + useEffect(() => { + let active = true; + void (async () => { + try { + const snapshot = await fetchUsage(); + if (!active) return; + applyUsage(snapshot); + if (!snapshot.entitlementsPending) return; + if (!(await waitForEntitlements(() => active))) return; + applyUsage(await fetchUsage()); + // Server components read entitlements too, so refresh the whole route + // once the subscriber's real plan replaces the free-tier defaults. + router.refresh(); + } catch (reason) { + if (!active) return; + const apiError = reason as ApiError; + setError({ + error: apiError.error ?? "usage_unavailable", + message: apiError.message ?? "Unable to load usage", + }); + } + })(); + return () => { + active = false; + }; + }, [applyUsage, router]); + + async function generate() { + setPending(true); + setError(null); + setOutput(""); + try { + const response = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt, model, maxOutputTokens: 256 }), + }); + const body = (await response.json()) as GenerateResult | ApiError; + if (!response.ok) throw body; + const result = body as GenerateResult; + setOutput(result.output); + applyUsage(result.limits); + } catch (reason) { + const apiError = reason as ApiError; + setError({ + error: apiError.error ?? "request_failed", + message: apiError.message ?? "Generation failed", + retryAfterSeconds: apiError.retryAfterSeconds, + upgradeHint: apiError.upgradeHint, + }); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

+ Entitlement-gated generation +

+

+ Simulated generation uses live Chargebee entitlement snapshots and + local Redis usage counters. +

+
+ {usage ? ( + + {usage.features.models.tier} models + + ) : null} +
+ + {usage?.entitlementsPending ? ( +
+ Loading your plan entitlements from Chargebee. Free-tier limits apply + until they arrive, then this page refreshes automatically. +
+ ) : null} + + {error ? ( +
+ {error.message} + {error.retryAfterSeconds + ? ` Try again in ${error.retryAfterSeconds}s.` + : null} + {error.upgradeHint ? ( + + View plans + + ) : null} +
+ ) : null} + +
+ +