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: 1000 tokens/day + Gate-->>App: Allow or Deny + App-->>Client: 200 or 429 +``` + +### Flow + +1. User makes authenticated request +2. Feature gate reads entitlement limit from cache +3. Gate compares usage vs limit +4. Request continues or returns 429 + +```typescript +// Check the user's entitlement for a particular feature +const limit = await cache.get(`entitlement:${subscriptionId}:${featureId}`); + +if (usage >= limit) { + throw new ForbiddenError("Usage limit exceeded"); +} +``` + +## 2. Feature Types And Enforcement Rules + +| Type | What it means | How to check | +|------|---------------|--------------| +| switch | Boolean on/off | `value === true` | +| quantity | Numeric limit | `usage < value or value === "unlimited"` | +| range | Number in range | Same as quantity | +| custom | Text labels | `value === "premium"` | +| + + +```typescript +// Switch feature (e.g., SSO enabled) +if (entitlement.value !== true) { + throw new ForbiddenError("SSO not available on your plan"); +} +// Quantity feature (e.g., API rate limit) +const limit = entitlement.value === "unlimited" ? Infinity : entitlement.value; +if (currentUsage >= limit) { + throw new TooManyRequestsError("Rate limit exceeded"); +} +// Custom feature (e.g., model tier) +if (entitlement.value !== "gpt-4") { + throw new ForbiddenError("Upgrade to access GPT-4"); +} +``` + +## 3. Syncing Entitlements In The Worker + +Since the app reads the cached entitlements only from Redis while serving requests, it's important to keep the data in sync with Chargebee any time the entitlements are updated. This is handled asynchronously in the background worker, which reads the queued webhook event, and updates the database with the entitlement snapshot + +```mermaid +sequenceDiagram + box rgba(0,0,0,0.1) External + participant Chargebee + end + participant App + participant Queue + participant Worker + participant DB + participant Cache + + Chargebee->>App: subscription_entitlements_updated + App->>Queue: Store webhook + App-->>Chargebee: 200 OK + Queue->>Worker: Process event + Worker->>Chargebee: List entitlements + Chargebee-->>Worker: Features + limits + Worker->>DB: Upsert snapshot + Worker->>Cache: Refresh +``` + +```typescript +// Worker handler (simplified) +async function handleEntitlementsUpdated(event) { + const { subscription_id } = event.content.subscription; + + // Fetch full entitlement list + const entitlements = await chargebee.subscriptionEntitlement.subscriptionEntitlementsForSubscription({ subscription_id, limit: 100 }); + + // Store locally + await db.upsertEntitlements(subscription_id, entitlements.list); + + // Refresh cache + await cache.del(`entitlements:${subscription_id}`); +} +``` + +**Key points**: + +* Worker processes webhooks asynchronously +* Update the snapshot for the customer/subscription +* Invalidate cache after DB write + +✅ **Recommended**: Webhooks must be processed near real-time to ensure entitlements aren't exceeded + +⚠️ **Not recommended**: Calling Chargebee API during the user request, which will use up your API quota quickly and degrade the experience of your customer + + +## 4. Handling Subscription Upgrades and Downgrades + +When a customer upgrades or downgrades their subscription, Chargebee will trigger the `subscription_entitlements_updated` webhook event. However, since the event can take a few seconds to be processed by our worker, our customer may experience the following: + +* When upgrading, they will not immediately have access to the new upgraded entitlements which means they can't use their subscription features right away + +* When downgrading, they may still continue to use features they no longer should have access to + +To avoid a sub-par user experience, then entitlements for the new subscription can be fetched once the checkout flow is completed, so that the customer can use his new limits right away. + +```mermaid +sequenceDiagram + participant Customer + participant App + participant Chargebee + + Customer->>Chargebee: Complete checkout + Chargebee-->>Customer: Success + Customer->>App: Use Pro feature + App-->>Customer: 403 (cache still on Free) + Note over App,Chargebee: Receive & process
webhook + Customer->>App: Retry + App-->>Customer: 200 OK +``` + +```typescript +// After successful upgrade +async function onCheckoutSuccess(subscriptionId) { + // Optimistically refresh before webhook arrives + const entitlements = await chargebee.subscriptionEntitlement + .subscriptionEntitlementsForSubscription(subscription_id); + + await db.upsertEntitlements(subscriptionId, entitlements.list); + await cache.del(`entitlements:${subscriptionId}`); +} +``` + +⚠️ **Not recommended**: Waiting for the webhook to trigger fetching the updated entitlements (causes upgrade lag) + +⚠️ **Not recommended**: Calling Chargebee in the feature gate (adds latency to every request) + +⚠️ **Not recommended**: Letting cache naturally expire when downgrading (customer gets free access for anywhere between a few seconds to minutes) + +## 5. Entitlement Overrides + +In some scenarios, certain customers may have custom limits provisioned via [entitlement overrides](https://apidocs.chargebee.com/docs/api/entitlement_overrides). For example, the sales team might provision custom limits to an enterprise customer as a part of the deal. + +In such cases, the API sets the `subscription_entitlement.is_overridden` flag to `true`, and the response will automatically reflect the overridden value. + +```typescript +// Gate should check the resolved value, not plan defaults +const entitlement = await getEntitlement(subscriptionId, "f_api_rate"); + +// ✅ Correct: uses Chargebee's computed value (includes overrides) +const limit = entitlement.value; + +// ❌ Wrong: static plan table misses overrides +const limit = PLAN_LIMITS[planId].api_rate; +``` + +Events to watch: `entitlement_overrides_updated`, `entitlement_overrides_removed` + +## 6. Fail-Safe Behavior + +To avoid undefined behaviour, your app has to be designed around the idea that any component can fail. In the case of entitlements, the following failure modes have to be handled: + +| Failure scenario | Impact | Remediation options | +|------------------|--------|---------------| +| Redis cache unreachable | Entitlements cannot be determined | 1. Fallback to DB snapshot
2. Use a in-memory cache with short TTL | +| Chargebee API limit exceeded (`api_request_limit_exceeded`, HTTP 429) | Refresh fails, snapshot goes stale | 1. Honour `Retry-After`, then exponential backoff with jitter
2 Retry in the background
3. Avoid duplicate requests for same subscription | +| Chargebee returns 5xx (`internal_temporary_error`, `site_read_only_mode`) | Refresh fails | 1. Treat as retryable and keep the last-known-good snapshot
2. Never write a partial or empty snapshot on a failed fetch | +| Webhook endpoint down | Chargebee retries 7 times over ~3 days 7 hours, then the event is lost | 1. Return 200 as soon as the event is durably queued
2. Reconcile on a schedule so a lost event self-heals | +| Worker backlog | Snapshots silently age | Alert on queue lag and on snapshot age, not just on errors | + +Depending on how expensive it is to serve a user's request, you may broadly choose to deny or allow access in the case of a component failure. + +```mermaid +flowchart TD + A[Get entitlements] --> B{Cache
reachable?} + B -->|Yes| OK[Allow or deny based on cached value] + B -->|No| C{DB
reachable?} + C -->|Yes| D[Read from DB
Cache in-memory] --> OK + C -->|No| E{Feature impacts
revenue?} + E -->|Yes| F[Fail closed
503 + Alert] + E -->|No| G[Fail open
Fallback limit, overage] +``` + + +**Option 1: Fail closed (deny access)** + +```typescript +try { + const limit = await cache.get(`entitlement:${subId}:${featureId}`); +} catch (err) { + throw new ServiceUnavailableError("Try again later"); +} +``` + +✅ Use for: Paid features (SSO, advanced models) + +⚠️ Risk: Blocks paying customers during outage + +**Option 2: Fail open (allow access)** + +```typescript +try { + const limit = await cache.get(`entitlement:${subId}:${featureId}`); +} catch (err) { + return FALLBACK_LIMIT; // Allow with basic limits +} +``` + +✅ Use for: Soft metering, usage that bills later + +⚠️ Risk: Free usage during outage + +## 7. Customer vs Subscription Entitlements + +Chargebee exposed two APIs to fetch the entitlements for a customer: + +* [List subscription entitlements](https://apidocs.chargebee.com/docs/api/subscription_entitlements/list-subscription-entitlements) returns entitlements for a particular subscription, regardless of its status + +* [List customer entitlements](https://apidocs.chargebee.com/docs/api/customer_entitlements/list-customer-entitlements) returns entitlements across *all* of a customer's active subscriptions, plus those granted directly to the customer (for example a one-time charge) + +| | Subscription entitlements | Customer entitlements | +|---|---|---| +| Scope | One subscription | `active` + `non_renewing` subscriptions, plus customer-level grants | +| Consolidation | By your app | By Chargebee API when `consolidate_entitlements=true` +| Extra fields | `feature_name`, `feature_type`, `is_overridden`, `expires_at` | `customer_id`, `subscription_id` | +| Webhook Events | `subscription_entitlements_created`, `subscription_entitlements_updated` | `customer_entitlements_updated` | + +The choice of which method to use to fetch and determine user entitlements is dependent on various product and business factors. However, in simple terms: + +* If your app allows a single subscription per user with a fixed set of entitlements that cannot be topped up, subscription entitlements will be easier to configure and manage +* If a user can have multiple subscriptions, or they can be provided additional entitlements via overrides, fetching customer entitlements with `consolidate_entitlements=true` can make it simpler and more accurate since Chargebee handles the logic to merge the entitlements + + +### Feature Value Consolidation + +| Feature type | Consolidated value | +|--------------|--------------------| +| switch | `true` if any subscription grants `true` | +| quantity | Sum of all values; `unlimited` if any value is `unlimited` | +| range | Sum, capped at `levels[1].value` unless that level is unlimited; `unlimited` wins | +| custom | The highest level held | + +```typescript +async function loadCustomerEntitlements(customerId: string) { + const entitlements = []; + let offset: string | undefined; + + do { + const page = await chargebee.customerEntitlement.entitlementsForCustomer(customerId, { + limit: 100, + consolidate_entitlements: true, + offset, + }); + + entitlements.push( + ...page.list + .map((entry) => entry.customer_entitlement) + .filter((e) => e.is_enabled) + ); + offset = page.next_offset; + } while (offset); + + return entitlements; +} +``` + +**Key points**: + +* Both endpoints are eventually consistent, so a read immediately after a write may return the old value +* If `entitlement.is_enabled === false`, the customer _does not_ have access to the entitlement, regardless of the value returned +* Customer-level entitlements do not carry over to a new subscription, so a plan change will not move them + +## Demo App Implementation Notes + +Entitlements are used across the app to provide various features to the end-users. For example, a user with a free subscription gets access to a smaller list of LLM models along with a limited set of tokens and requests per month. Subscription upgrades trigger the refresh of the customer's entitlement snapshot, and the hot-path avoids hitting the Chargebee API. + +Using the [`@chargebee/entitlements`](https://npmx.dev/@chargebee/entitlements) library, the app maintains the cached entitlements in Redis, which is backed by the source of truth in Postgres which is kept in sync via webhooks and the background worker. + +Some relevant files to look into the hood: + +- [`pointer/lib/entitlements/provider.ts`](../pointer/lib/entitlements/provider.ts) — entitlement lookup via Redis -> Postgres -> Chargebee API. A request never waits on Chargebee: a subscription with no snapshot defaults to the free-tier limits while the refresh runs in the background. Cache TTL defaults to 300 seconds and snapshot TTL to 24 hours, both configurable. + +- [`pointer/lib/entitlements/gate.ts`](../pointer/lib/entitlements/gate.ts) — Feature specific gates. Throws `EntitlementGateError` with `429` and `retryAfterSeconds` for a rate limit, `402` for an exhausted quota or a model the plan does not include. + +- [`pointer/lib/entitlements/sync.ts`](../pointer/lib/entitlements/sync.ts), [`pointer/lib/entitlements/queue.ts`](../pointer/lib/entitlements/queue.ts) — Background sync logic. + +- [`pointer/app/api/entitlements/checkout-complete/route.ts`](../pointer/app/api/entitlements/checkout-complete/route.ts) — Optimistic refresh after subscription checkout succeeds, rather than waiting for the webhook event. + +- [`pointer/scripts/catalog.ts`](../pointer/scripts/catalog.ts) — the script to create the Chargebee catalog, including entitlements, features and per-plan limits. + +## Go-Live Checklist + +- [ ] Is the site on Product Catalog 2.0 with every gated feature defined in Chargebee? + +- [ ] Does every gate read the local snapshot, with no Chargebee API call on the request path? + +- [ ] Does the worker refresh the snapshot on `subscription_entitlements_created` and `subscription_entitlements_updated`? + +- [ ] Does it also refresh on the plan-item events, such as `subscription_changed` and `subscription_renewed`? + +- [ ] Does it refresh on `entitlement_overrides_updated` and `entitlement_overrides_removed`? + +- [ ] Does the gate use the resolved `value` from Chargebee rather than a static plan table, so overrides are honoured? + +- [ ] Does the gate deny access when `is_enabled` is `false`, whatever the value says? + +- [ ] Is `unlimited` handled as unlimited, and not as the string `"unlimited"` compared against a number? + +- [ ] Are per-seat limits multiplied by the subscription's quantity? + +- [ ] Does the app refresh entitlements when checkout returns, instead of waiting for the webhook? + +- [ ] Is the worst-case downgrade window bounded by a cache TTL you have chosen deliberately? + +- [ ] Is the fail-open or fail-closed decision made per feature, and do product and finance agree with it? + +- [ ] Does a request behave as intended at exactly the limit, not just above and below it? + +- [ ] Is there a reconciliation job that compares local snapshots against Chargebee and reports drift? + +- [ ] Can support tell whether a customer's snapshot is stale, without reading logs? + diff --git a/how-to/integrating-chargebee-webhooks.md b/how-to/integrating-chargebee-webhooks.md deleted file mode 100644 index de758fd..0000000 --- a/how-to/integrating-chargebee-webhooks.md +++ /dev/null @@ -1,159 +0,0 @@ -# How to integrate Chargebee Webhooks - -One of the first steps when integrating Chargebee using the SDK/API is to setup webhooks. You will need wekhooks to: - -* Keep your app data in sync with your Chargebee site -* Listen to events related to payments, subscriptions, etc for your system to trigger additional processes -* To verify and reconcile the outcome of a billing flow in case of errors - - -## 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: Webhook Event - activate app - note over app: Validate basic auth - app->>queue: Store message - note over app: Message stored
successfully? - alt Error - app->>cb: HTTP 5xx (Retry) - cb-->>app: Scheduled retry - else Success - app->>cb: HTTP 2xx (Success) - end - deactivate app - - note over queue,worker: Poll or push messages - queue-)worker: Receive messages - activate worker - note over worker: Process by
event type - worker-->>db: Update DB
entities - note over worker: Processed successfully? - 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 - deactivate worker -``` - - -## Best Practices - -* The incoming webhook handler should do the bare minimum synchronously: validate and store the message delivered from Chargebee. It should: - - - Validate the basic authentication credentials from the request headers - - Check the `event_type` to ensure it's a relevant event that has to be acted upon - - 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 - - Respond to the webhook request with a HTTP `2xx` response indicating that the webhook was handled successfully - - In case of errors, respond with a HTTP `5xx` error, in which case Chargebee will attempt to deliver the message again - -* __Background workers__ should consume the messages from the queue and process them as required. Since any operation performed by the worker is asynchronous, this can include all error prone or retryable actions. Once processed successfully, acknowledge the message so the queue doesn't redeliver it. - -* If a message cannot be processed due to non-retryable errors (e.g. payload not valid JSON), move them to the equivalent of a [__dead letter queue__](https://en.wikipedia.org/wiki/Dead_letter_queue) for further analysis and auditing. - -* __Idempotent operations__: Since asynchronous systems have different message delivery semantics (at-least-once, at-most-once, exactly-once), ensure the messages are processed with idempotency in mind. In practical terms, this can include: - - - Storing important events in a audit table along with the message `id` - - Checking for duplicate message IDs when receiving the webhook event from Chargebee - - Ensuring SQL queries ignore changes in case of insert/update (e.g. `ON CONFLICT DO NOTHING`) - -* If multiple webhooks are configured for an environment, ensure they don't cause duplication. For example, one endpoint may be for the primary application to respond to billing events, and the other may be for auditing or reporting. - -* Since webhook events can be [delivered out of order](https://apidocs.chargebee.com/docs/api/events/event-object#out-of-order-delivery), store and compare the `resource_version` returned in the webhook `content`. Note that `resource_version` has to be individually checked for all resources returned in the webhook content (e.g. `content.customer.resource_version`, `content.subscription.resource_version`). Out-of-order delivery also means an event can arrive **before another event it depends on** (for example, a `payment_succeeded` referencing a customer whose `customer_created` hasn't been processed yet). Your worker must be able to handle these dependency gaps — see [Handling out-of-order and dependent events](#handling-out-of-order-and-dependent-events) below. - - -### Handling out-of-order and dependent events - -Chargebee delivers events with **at-least-once** semantics and **no ordering guarantee**. Most of the time events arrive in roughly the order they occurred, but network retries and independent delivery mean a later event can overtake an earlier one. This becomes a correctness problem when events have **dependencies** between resources. - -The classic case: a `payment_succeeded` (or `subscription_created`) arrives and references a customer whose `customer_created` event **hasn't been processed yet**. The worker cannot attach the payment to a customer that doesn't exist in your database. - -To handle such cases, the webhook handler must implement the following rules: - -- **Don't drop the event.** The dependency will likely arrive moments later. -- **Don't return a 5xx to Chargebee** just because your worker isn't ready — you've already durably stored the message, so acknowledge receipt and let the worker retry from the queue. - -Instead, let the message **stay in the queue in a retry state** until its prerequisite lands. The worker treats "dependency not ready" as a *retryable* error: it does not acknowledge the message, so the queue redelivers it after a delay. Meanwhile the queue keeps making progress on other messages, so the blocked event is never head-of-line blocking the rest. - -```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.
Retention keeps the message alive. - deactivate worker - - cb->>app: customer_created (arrives late) - app->>queue: Store message - app->>cb: HTTP 2xx - queue-)worker: customer_created - activate worker - worker-->>db: Upsert customer - worker-->>queue: Ack - 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 -``` - -#### Retry semantics - -- **Retry with backoff — how many attempts.** On each unacknowledged delivery the message becomes available again after a *backoff delay*, and its delivery/receive count is incremented. After a configured **maximum delivery count**, the message is routed to a [dead letter queue](https://en.wikipedia.org/wiki/Dead_letter_queue) instead of being retried forever. Prefer an **increasing backoff** keyed off the delivery count so a dependency-not-ready message waits progressively longer (seconds → minutes) between attempts rather than burning all its retries in a burst. - -- **Retention — how long overall.** The message survives in the queue for the whole **retention period** regardless of individual retries. This is the real safety margin for out-of-order delivery: as long as the prerequisite event arrives within the retention window (and the message hasn't exhausted its max receive count), the dependent event will eventually process. Out-of-order gaps are usually seconds, so a retention period of hours-to-days is ample headroom. - -Aim to **distinguish a "dependency not ready" error (retry patiently) from a "poison" message (malformed / permanently un-processable, fail fast to the DLQ)** so that transient ordering gaps don't share the same retry budget as genuinely broken payloads. Whatever lands in the DLQ should be monitored, retained long enough for investigation, and re-drivable back onto the main queue once the root cause is fixed. - -## Implementation notes - -The [`pointer`](../pointer/) app implements webhooks using the Better Auth Chargebee plugin. The HTTP webhook endpoint is setup by the plugin at `/api/auth/chargebee/webhook`. It validates basic auth, parses the event, and submits the event to the `webhookEventBus` instead of running DB sync inline. The bus enqueues the raw event to an SQS queue and returns `2xx`. A standalone worker long-polls that queue and runs the plugin's DB-sync hooks per event. - -- [`pointer/lib/webhooks.ts`](../pointer/lib/webhooks.ts) — the `webhookEventBus.publish` implementation. Runs synchronously in the request path: sends the validated event to SQS (using the event `id` as the FIFO dedupe key) so the endpoint can respond fast and durably. - -- [`pointer/workers/chargebee-webhook-worker.ts`](../pointer/workers/chargebee-webhook-worker.ts) — the async worker. Uses `sqs-consumer` to poll the queue and `createChargebeeWebhookProcessor` to apply DB sync. Returning from the handler acks (deletes) the message; throwing leaves it to become visible again and, after `maxReceiveCount`, routes it to the DLQ. Scales horizontally by running more processes. - -- [`pointer/infra/sqs.tf`](../pointer/infra/sqs.tf) — the durable queue, dead-letter queue, and redrive policy (`maxReceiveCount = 5`). Main queue retention is 4 days; DLQ retention is 14 days — the out-of-order safety margin. - -Idempotency and out-of-order handling come from the queue semantics (SQS at-least-once + visibility timeout prevents double processing) plus the plugin's `resource_version`-aware upserts. A dependency-not-ready case is handled by throwing so SQS redelivers after the visibility timeout, and poison messages fall through to the DLQ after 5 attempts. - -## Go-live checklist - -- [ ] Has basic authentication been configured for the webhook in the Chargebee dashboard? - -- [ ] Do you save all validated webhook events to a durable queue or database so it can be processed asynchronously? - -- [ ] Are all messages processed with idempotency in mind so that duplicate messages are handled gracefully and in a predictable manner. - -- [ ] Can your worker tolerate out-of-order and dependent events (e.g. a payment before its customer) by retrying rather than dropping or failing them? - -- [ ] Is the queue retention period comfortably longer than the largest realistic out-of-order gap, and is a max-retry / dead-letter policy configured for messages that never succeed? diff --git a/how-to/webhooks.md b/how-to/webhooks.md new file mode 100644 index 0000000..a6d71ac --- /dev/null +++ b/how-to/webhooks.md @@ -0,0 +1,272 @@ +# How To Integrate Chargebee Webhooks + +Webhooks are usually the first thing you build after the Chargebee SDK is wired up. This topic covers using them to: + +* Keep your app's copy of customers, subscriptions and invoices in sync with your Chargebee site +* Trigger your own jobs when a payment, renewal or plan change happens +* Recover from a partial or failed user checkout + +**Important**: Chargebee is the source of truth for billing state. Your app keeps a local mirror of it. The webhook endpoint's only job is to accept the event and make it durable. Everything else happens in a background worker. + +## Setup + +- A webhook configured in Chargebee under **Settings > Configure Chargebee > API Keys and Webhooks** +- An HTTPS endpoint protected with [basic authentication](https://www.chargebee.com/docs/billing/2.0/site-configuration/webhook_settings) +- A durable queue (or a table) the endpoint can write to +- A background worker that consumes from that queue +- A table to record which event `id` and `resource_version` you have already applied + +## 1. How Webhook Delivery Works + +```mermaid +sequenceDiagram + box rgba(0,0,0,0.1) Third-party service + participant Chargebee + end + participant Endpoint + participant Queue + participant Worker + participant DB + + Chargebee->>Endpoint: POST event + Endpoint->>Queue: Store event + Endpoint-->>Chargebee: 200 OK + Queue->>Worker: Deliver event + Worker->>DB: Apply change + Worker-->>Queue: Ack +``` + +### Flow + +1. Something changes in Chargebee (a subscription is created, a payment succeeds) +2. Chargebee POSTs the event as JSON to your endpoint +3. The endpoint validates basic auth and writes the event to the queue +4. The endpoint returns `200`, which tells Chargebee the event was delivered +5. The worker picks the event up later and updates the database + +Delivery is **at-least-once** and **unordered**. The same event can arrive twice, and a later event can arrive before an earlier one. Once you answer `200`, Chargebee stops tracking the event, so finishing the work is entirely your app's problem. + +## 2. What The Endpoint Does + +The endpoint has one responsibility: make the event durable, fast. Chargebee's execution timeout on a live site is 60 seconds, and a timeout counts as a failure, so any real work belongs in the worker. + +```typescript +// Validate, enqueue, answer. No DB writes on this path. +await queue.send({ MessageBody: JSON.stringify(event), MessageDeduplicationId: event.id }); +return new Response(null, { status: 200 }); +``` + +If the enqueue fails, return a `5xx`. Chargebee then [retries the delivery](https://www.chargebee.com/docs/billing/2.0/site-configuration/webhook_settings), so nothing is lost. + +✅ **Recommended**: Return `200` as soon as the event is durably stored + +✅ **Recommended**: Return `5xx` when you could not store it, and let Chargebee redeliver + +⚠️ **Not recommended**: Updating your database, calling the Chargebee API, or sending email inside the endpoint. A slow dependency turns into a webhook timeout and a retry storm. + +⚠️ **Not recommended**: Returning `200` on an error to "keep Chargebee quiet". The event is then gone for good. + +## 3. What The Worker Does + +The worker is where the actual sync happens. It is a separate process from your web app, so a burst of webhooks never competes with customer traffic. It runs one pipeline per message and its exit path decides the message's fate. + +```mermaid +sequenceDiagram + participant Queue + participant Worker + participant DB + participant DLQ + + Queue->>Worker: Deliver event + Worker->>DB: Read applied version + alt Already applied + Worker-->>Queue: Ack (skip) + else Ready to apply + Worker->>DB: Upsert entity + version + Worker-->>Queue: Ack + else Dependency missing + Worker-->>Queue: Retry with backoff + else Malformed payload + Worker->>DLQ: Move message + end +``` + +The pipeline, in order: + +1. Parse the body. A body that will never parse is a poison message (section 7) +2. Skip the event if you have already applied an equal or newer `resource_version` (section 5) +3. Requeue the event if a parent record it references is missing (section 6) +4. Apply the change to the database +5. Record the applied `resource_version`, then acknowledge the message + +```typescript +// Ack only after the version is committed, so a crash mid-apply replays the event +await applyEvent(event); +await commitVersions(event); +``` + +**Key points**: + +* Acknowledging is the worker's way of saying "done", not "received" +* Throwing (not acknowledging) puts the message back on the queue for another attempt +* Scaling out means running more worker processes — the queue's visibility timeout stops two workers from applying the same event + +✅ **Recommended**: One worker per queue, scaled horizontally, with the queue's retry and dead-letter policy doing the error handling + +⚠️ **Not recommended**: Doing the sync in a `setTimeout` or fire-and-forget promise in the web process. A deploy or a crash drops the event silently. + +## 4. Handling Duplicate Deliveries + +Retries mean the same event can arrive more than once. The event `id` is unique per event, so use it as the deduplication key. + +```sql +-- A replay of an already-processed event falls straight through +INSERT INTO webhook_event (id) VALUES ($1) ON CONFLICT (id) DO NOTHING; +``` + +Chargebee's [last retry lands about 3 days and 7 hours](https://apidocs.chargebee.com/docs/api/events) after the original event, so keep event IDs for at least that long before purging them. + +✅ **Recommended**: Writing every applied event `id` to a table, and making your upserts idempotent so a duplicate is a no-op + +⚠️ **Not recommended**: Deduplicating on `(event_type, subscription_id)`. Two legitimate changes to the same subscription share that key. + +## 5. Handling Out-Of-Order Events + +Network delays and retries mean a newer event can arrive before an older one. Every Chargebee resource carries a `resource_version` that increases on each change, so compare it against what you have stored before writing. + +```typescript +// The incoming snapshot is older than what's already applied — ignore it +if (event.content.subscription.resource_version <= stored.resourceVersion) return; +``` + +`resource_version` is per resource, not per event. An event's `content` can hold a customer, a subscription and an invoice, each with its own version, so check each one you intend to write. + +The payload is a point-in-time snapshot taken when the change happened, and it does not change on retries. When you need the current state instead of the snapshot, call the resource's retrieve endpoint. + +✅ **Recommended**: Storing the applied `resource_version` per resource, and only advancing it forward + +⚠️ **Not recommended**: Ordering events by `occurred_at`. It tells you when the change happened, not whether your row is newer. + +## 6. Handling Dependent Events That Arrive Early + +The classic case: `payment_succeeded` arrives referencing a customer whose `customer_created` has not been processed yet. There is no row to attach the payment to. + +```mermaid +sequenceDiagram + participant Queue + participant Worker + participant DB + + note over Queue,DB: payment_succeeded arrives before customer_created + + Queue->>Worker: payment_succeeded + Worker->>DB: Customer missing + Worker-->>Queue: Retry with backoff + Queue->>Worker: customer_created + Worker->>DB: Upsert customer + Queue->>Worker: payment_succeeded (redelivery) + Worker->>DB: Apply payment +``` + +Treat "dependency not ready" as a *retryable* error. The worker does not acknowledge the message, the queue redelivers it after a delay, and other messages keep flowing past it in the meantime. + +```typescript +// Not an error to alert on — the parent event is usually seconds behind +if (!(await customerExists(event.content.subscription.customer_id))) { + throw new RetryableWebhookError("customer not in DB yet"); +} +``` + +Exempt the resource the event itself creates. A `customer_created` event must not wait for the customer to already exist. + +✅ **Recommended**: Retrying the dependent event with increasing backoff until its parent lands + +⚠️ **Not recommended**: Dropping the event, or returning `5xx` to Chargebee. You already stored it, so the retry belongs to your queue. + +⚠️ **Not recommended**: Fetching the missing parent from the Chargebee API inline. It works, but it hides genuine ordering problems and burns API quota on every gap. + +## 7. Handling Poison Messages + +Some messages will never succeed: an unparseable body, a missing event `id`, a payload your code cannot interpret. Retrying these wastes the retry budget that dependent events need. + +```typescript +// Skip the retries and send it to the dead letter queue immediately +if (!event.id) throw new PoisonWebhookError("webhook body is missing an event id"); +``` + +Send them to a [dead letter queue](https://en.wikipedia.org/wiki/Dead_letter_queue), alert when it is non-empty, and keep a way to replay messages back onto the main queue once the bug is fixed. Chargebee can also resend a webhook manually from **Logs > Events**, which is the fallback when a message is lost entirely. + +✅ **Recommended**: Separating "retry patiently" from "fail fast", so a malformed payload and a late parent event get different treatment + +⚠️ **Not recommended**: A single `catch` that retries everything. Poison messages then retry for days and bury the real failures. + +## 8. Retry And Retention Settings + +Two independent retry loops are in play. Chargebee's loop gets the event to your endpoint; your queue's loop gets it through your worker. + +| | Chargebee to endpoint | Queue to worker | +|---|---|---| +| Retries on | Non-`2xx` response or timeout | Unacknowledged message | +| Attempts | 7 | Your `maxReceiveCount` | +| Schedule | 2m, 6m, 30m, 1h, 5h, 1d, 2d | Your backoff policy | +| Total window | ~3 days 7 hours | Your retention period | +| Gives up by | Failure email, manual resend | Dead letter queue | + +Back off progressively using the delivery count, rather than retrying at a fixed interval. + +```typescript +// 30s -> 1m -> 2m -> ... capped at 15m +const backoff = Math.min(30 * 2 ** (receiveCount - 1), 900); +``` + +Set the queue's retention period well above the largest out-of-order gap you expect. Gaps are normally seconds, so hours-to-days of retention is ample headroom. + +## 9. Choosing Events And Endpoints + +A Chargebee site allows up to five webhooks. Subscribe each one to only the events it acts on, instead of leaving **All Events** selected — fewer deliveries means less noise and less retry load. + +If you configure more than one endpoint, give each a distinct job. One for the app's billing sync, one for auditing or analytics. Two endpoints both writing the same tables produces duplicate work that your deduplication has to absorb. + +Check the event's `api_version` against the API version your client library targets. A mismatch means the `content` is structured differently from what your code expects. + +If you already run on AWS and your event volume is high, [Event Streaming via AWS EventBridge](https://www.chargebee.com/docs/billing/2.0/site-configuration/webhook_settings) delivers the same events without an HTTP endpoint to operate. The endpoint in section 2 disappears; everything from section 3 onwards still applies. + +✅ **Recommended**: One endpoint owning the billing mirror, subscribed to the specific event types your app handles + +⚠️ **Not recommended**: Pointing several services at the same endpoint and fanning out from there without a queue. One slow consumer then times out the webhook for everyone. + +## See It Running In The Demo App + +- [`pointer/lib/webhooks.ts`](../pointer/lib/webhooks.ts) — the endpoint's publish step. Sends the validated event to SQS using the event `id` as the dedupe key, then returns. + +- [`pointer/workers/chargebee-webhook-worker.ts`](../pointer/workers/chargebee-webhook-worker.ts) — the consumer loop. Long-polls the queue; returning from the handler acknowledges the message, throwing leaves it for redelivery. + +- [`pointer/workers/chargebee-webhook-processor.ts`](../pointer/workers/chargebee-webhook-processor.ts) — the per-message pipeline: stale check, dependency check, apply, commit versions, ack. + +- [`pointer/lib/webhooks/webhook-guards.ts`](../pointer/lib/webhooks/webhook-guards.ts) — the `resource_version` and dependency guards, backed by the `chargebee_resource_version` table. + +- [`pointer/infra/sqs.tf`](../pointer/infra/sqs.tf) — the queue and DLQ. `maxReceiveCount = 5`, main queue retention 4 days, DLQ retention 14 days, with a CloudWatch alarm when the DLQ is non-empty. + +## Go-Live Checklist + +- [ ] Is the webhook URL served over HTTPS and protected with basic authentication? + +- [ ] Does the endpoint return `2xx` only after the event is durably stored, and `5xx` when it is not? + +- [ ] Does the endpoint stay clear of database writes and outbound API calls? + +- [ ] Is every applied event `id` recorded, and kept for at least 3 days and 7 hours? + +- [ ] Does the worker compare `resource_version` per resource before writing, and only advance it forward? + +- [ ] Does the worker retry a missing-dependency event instead of dropping or failing it? + +- [ ] Do malformed payloads reach the dead letter queue without consuming the retry budget? + +- [ ] Is the dead letter queue alerted on, and can its messages be replayed onto the main queue? + +- [ ] Is the queue's retention period longer than the largest out-of-order gap you expect? + +- [ ] Is each webhook subscribed to only the event types it handles, rather than **All Events**? + +- [ ] Does the event `api_version` match the API version your client library targets? diff --git a/implementation.md b/implementation.md deleted file mode 100644 index a5ccadb..0000000 --- a/implementation.md +++ /dev/null @@ -1,57 +0,0 @@ -# Goals - -* Demonstrate how Chargebee features are used at scale and with best practices in mind - -* Generate a detailed reference architecture with enough implementation details for an LLM to generate a working implementation in any stack - -* Implement the reference architecture with an opinionated, scaleable and modern stack which can be made deployable as a demonstration of a SaaS using Chargebee at scale. - -* Load test the implementation with generated data against a live Chargebee environment (dev/perf-test/etc) - -# Implementation Notes - -* Local dev environment should use docker compose to setup the required components like Postgres, Redis, etc - -* Redis streams can be used in place of Kafka as an event bus to reduce complexity - -* Clickhouse is the OLAP of choice as it scales well and offers usage event logging/filtering/aggregation over the billing period, and exposes it in the user analytics dashboard - -# TODO - -- [] Metered feature definitions + billing model (Fixed fee + overages) (https://www.chargebee.com/docs/billing/2.0/usage-based-billing/link-pricing) - -- [] Usage based alert thresholds (https://apidocs.chargebee.com/docs/api/alerts/create-an-alert) - -- [] Credit based usage?? (once feature is rolled out) - -- [] Which CB environment do we do real load testing on? - -- [] Pooled usage limits for teams/enterprise offerings (https://apidocs.chargebee.com/docs/api/usage_summary/usage-summary-object) - - - -## Best practices broken down by module - -- How to handle webhooks -- How do you cache entitlements? -- When to use a feature vs "how" to use features -- Create a how-tos -- Set of questions to help user make decisions on how to design/handle scenarios -- Easy to build out deployable app - - -## Preferred Stack - -* Better Auth with the Chargebee Better Auth plugin - -* Express or Next.js?? - -* ai-sdk for external LLM invocation, token usage tracking, etc - -* Load testing - realistic seeding of data and basic load testing to ensure our SLOs/SLAs are achievable - -Karthik: Pricing model: modify agent prompt 3 times for basic/ 10 time for middle/ unlimited for max - -Retention: Do we use chargebee retention if the customer cancels the plan? What do we offer?? - -Do we offer this as an AWS SAM template?? or any one click deployment options? diff --git a/pointer/.gitignore b/pointer/.gitignore index 5ef6a52..3efb5a7 100644 --- a/pointer/.gitignore +++ b/pointer/.gitignore @@ -19,6 +19,12 @@ # production /build +/dist +/vendor/* +!/vendor/README.md + +# terraform local data +**/.terraform*/ # misc .DS_Store @@ -39,3 +45,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +logs/ diff --git a/pointer/AGENTS.md b/pointer/AGENTS.md index 6e8d23c..b719b13 100644 --- a/pointer/AGENTS.md +++ b/pointer/AGENTS.md @@ -1,3 +1,45 @@ +# Important information for agents + +- When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more. + +- Avoid superlatives and praise. Stop telling me I am absolutely right. Give me the cold hard truth. + +- Avoid magic numbers and strings by extracting recurring or meaningful values into descriptive constants (const) or enums. Keep self-explanatory, one-off values inline to avoid clutter. If a value comes from a spec (e.g. HTTP 200 OK), use a constant regardless. + +- Reduce code indentation. Avoid Arrow Anti-Pattern. Leverage early return and continue. + +- Keep function names short. Less than 30 characters. + +- Use enums instead of booleans for function parameters. + +- Let the reader of the code breathe. Add empty lines between logical blocks of code. + +- Add a small, to the point, comment to explain *what* the block does and *why*. Use examples when possible. Propose ASCII drawings to explain complete systems. + +- Treat member visibility changes as a breaking design shift. Keep all fields and functions private unless external access is strictly required by the design. Prompt the user for explicit approval before changing any access modifier from private to internal or public. + +- Program to levels of abstraction. Lower-level mechanics (e.g., raw hardware I/O, sector parsing, direct socket streams) must be encapsulated in a dedicated driver/abstraction layer. Expose clean, high-level APIs to the rest of the application so calling code works with domain concepts, not raw implementation details. + +- Don't touch blocks of code unrelated to the feature you implement. e.g. Don't add comments to a block of code if you did not create it or modify it. As much as possible try to minimize the number of changed lines when implementing a feature. + +- Strictly adhere to the layered boundary hierarchy: each layer may only communicate with its immediate neighbor directly below it. Never "punch holes" through layers (e.g., controllers or UI components must never directly call database queries, raw hardware drivers, or low-level network clients; always route through the intermediate service/abstraction layer). + +- Always use {}, even on a one-line "if" statement. + +When you write a commit message, follow these 7 rules: +Rule 1: Separate the subject line from the body with a single blank line. +Rule 2: Limit the subject line to 50 characters (72 is the absolute hard limit). +Rule 3: Capitalize the first letter of the subject line. +Rule 4: Do not end the subject line with a period. +Rule 5: Use the imperative mood in the subject line (e.g., "Fix bug," "Add feature," + not "Fixed" or "Adds"). Test formula: It must complete the sentence: "If applied, + this commit will [your subject line here]". +Rule 6: Wrap the body text manually at 72 characters to prevent Git formatting issues. +Rule 7: Use the body to explain what and why vs. how. Assume the code explains the how; + the message must explain the context and reasoning. + +- If the prompt indicates that a bug is being fixed, don't write the fix right away. First write the test. Observe it failing. Then write the fix. And observe the test passing. + # Pointer - Chargebee Reference Architecture App This app is an opinionated implementation of the Chargebee Reference Architecture for a medium to large SaaS company. The tech stack is as follows: @@ -7,15 +49,19 @@ This app is an opinionated implementation of the Chargebee Reference Architectur * Chargebee Better Auth plugin - Managing App <-> Chargebee sync, including processing webhooks * PostgreSQL - Primary data store for relational data * Redis - App cache, and usage streams -* Clickhouse - Event ingestion, aggregation and metrics -* BullMQ - Async jobs backed by Redis +* AWS SQS - Queue +* LocalStack - AWS services for local development # Planning IMPORTANT: Save all plans to `plans` directory for auditing. + # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/pointer/ARCHITECTURE.md b/pointer/ARCHITECTURE.md new file mode 100644 index 0000000..11b8edc --- /dev/null +++ b/pointer/ARCHITECTURE.md @@ -0,0 +1,189 @@ +# Pointer — Architecture Overview + +Pointer is a reference SaaS application which integrates with Chargebee for subscription billing and OpenRouter for AI text generation. + +This document outlines the high level architecture of the app, including it's tech stack and infrastructure. + +## Overview + +```mermaid +flowchart TB + User([Browser User]) + Admin([Browser Admin]) + + subgraph Platform["Pointer Platform"] + App["pointer-app
(Next.js 16 + Better Auth)"] + Worker["pointer-worker
(SQS Consumer + Usage Loop)"] + Queue[["AWS SQS
(Main Queue + DLQ)"]] + PG[("PostgreSQL 18
(Mirror & Archive)")] + Redis[("Redis 8
(Cache, Counters & Streams)")] + end + + subgraph ThirdParty["Third-Party Services"] + CB["Chargebee
(Billing Engine)"] + OR["OpenRouter
(Model Provider)"] + end + + style ThirdParty fill:#DDDDDD,stroke:#faad14,stroke-width:1.5px,stroke-dasharray: 4 4 + style CB fill:#fff7e6,stroke:#d46b08,stroke-width:1.5px + style OR fill:#fff7e6,stroke:#d46b08,stroke-width:1.5px + + User -->|Web & API requests| App + Admin -->|Live flow UI| App + App -->|Create customer / portal session| CB + CB -->|Webhook events| App + App -->|Enqueue webhooks| Queue + Queue -->|Consume events| Worker + Worker -->|Sync billing & archive usage| PG + Worker -->|Batch ingest usage| CB + App -->|Stream generation| OR + App <-->|App state & billing mirror| PG + App <-->|Quota checks & usage buffer| Redis + Worker <-->|Drain buffer & emit events| Redis +``` + + +## 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 | +| Model Provider | **OpenRouter** + `@openrouter/ai-sdk-provider` | External streaming text generation provider | +| Primary DB | **PostgreSQL 18** (via `pg` + Kysely) | Users, sessions, orgs, subscription mirror, usage archive | +| Cache + streams | **Redis 8** (`ioredis`) | Idempotency helpers, usage counters, the usage-event buffer, and the live event stream | +| Async Queue | **AWS SQS** (+ DLQ), `sqs-consumer` | Buffers Chargebee webhooks for the worker | +| Background jobs | Standalone Node process (`tsx`) | Consumes SQS and applies DB sync | + +## Components + +### 3.1 `pointer-app` + +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 + home page 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 `/admin/flow` visualization. + +### 3.2 `pointer-worker` + +The worker owns all webhook-driven database sync and can run as either an ECS service (the +default) or an SQS-triggered Lambda function, selected by Terraform's `worker_runtime` +variable. The runtimes are mutually exclusive and consume the same durable queue. Both call +the same per-message 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** — ECS scales tasks from queue-depth alarms. Lambda uses a bounded SQS event + source concurrency so its warm connection pools cannot overwhelm PostgreSQL or Redis. + SQS visibility prevents simultaneous processing; no leader election is required. +- **Lambda batches** — partial batch responses retry only failed records. A cold Lambda loads + the existing application and database secrets from Secrets Manager before constructing the + Better Auth processor; warm environments reuse the processor and connection pools. +- **Networking** — Lambda runs in private subnets for RDS/Redis and reaches Chargebee and AWS + APIs through a NAT gateway. The POC uses one NAT; production should use one per AZ. +- **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. +- **Usage flush** — a second loop drains the Redis usage buffer into Postgres and then + Chargebee, up to 500 events per pass (Chargebee's batch ceiling). Both sinks are fed from + one pass rather than a second consumer group, because settling an entry deletes it from the + stream: whichever group acknowledged first would take it away from the other. A failed + Postgres write abandons the pass without acknowledging anything, since history is the read + path and a silent gap would be visible to the subscriber. It rides this process rather than + a service of its own: the work is a periodic drain and this is already a long-running task in the VPC + with a Redis connection. Redis consumer groups spread entries across however many tasks + autoscaling creates, so no leader election is needed — the same property SQS provides for + webhooks. The loop requires the ECS runtime; `lib/usage/flush.ts` is runtime-agnostic so a + scheduled Lambda could drive it instead. + +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. + It also holds `usage_event`, the durable usage archive. That table is range-partitioned by + ISO week so a period's worth of history is a handful of contiguous partitions and ageing + data can be dropped by detaching one; `pg_cron` provisions the coming fortnight's + partitions nightly, with a `DEFAULT` partition catching anything it misses. A single + unique index on `("subscriptionId", "usageTimestamp", "deduplicationId")` does double duty + — it is the history query's range scan and the conflict target that makes the flush loop's + at-least-once replay a no-op — so the write path pays for exactly one index. The CLI cannot + express partitioning, so `lib/usage/partitions.ts` owns that DDL while + `plugins/usage-plugin.ts` keeps the columns under CLI management. +- **Redis** — hosts the Redis-Streams event bus for the live flow view, the quota and rate + counters, and the usage-event buffer awaiting its next Chargebee batch. The event bus is a + best-effort observation tap: if it is down, publishing fails silently and the caller is + unaffected. The usage buffer is held to a higher bar — it is read through a consumer group + so an unacknowledged batch survives a worker crash — but it is not as durable as SQS. A node + loss drops at most one flush interval of events. Production should enable AOF or add a + replica; the POC runs a single `cache.t4g.micro`. + +### 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.chargebee-labs.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.chargebee-labs.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) | + +**Deployment:** 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. + +**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** 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. diff --git a/pointer/Dockerfile b/pointer/Dockerfile index 636c722..b758af6 100644 --- a/pointer/Dockerfile +++ b/pointer/Dockerfile @@ -12,6 +12,7 @@ FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + # Keep the pnpm store on the same filesystem as node_modules so pnpm can # hardlink instead of copying (avoids ~2x disk usage during install). RUN pnpm config set store-dir /app/.pnpm-store \ @@ -29,6 +30,23 @@ ENV NEXT_TELEMETRY_DISABLED=1 \ RUN pnpm build +# ---------- Lambda worker bundle ---------- +FROM deps AS lambda-builder +WORKDIR /app + +COPY . . +RUN pnpm build:lambda + +# ---------- Lambda worker runtime ---------- +FROM public.ecr.aws/lambda/nodejs:22 AS lambda-worker + +ENV NODE_ENV=production \ + NODE_OPTIONS=--enable-source-maps + +COPY --from=lambda-builder /app/dist/lambda/ ${LAMBDA_TASK_ROOT}/ + +CMD ["index.handler"] + # ---------- Runner ---------- FROM node:22-alpine AS runner WORKDIR /app diff --git a/pointer/README.md b/pointer/README.md index e215bc4..628f181 100644 --- a/pointer/README.md +++ b/pointer/README.md @@ -1,36 +1,20 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Pointer -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Requirements -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +* Node.js >= v22 +* PNPM >= 11 +* Docker +* Terraform (for deploying infra) -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: +## Getting Started -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +1. Copy `.env.example` to `.env.local` and fill out the environment variables to configure the app -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +2. Run `docker compose up -d` to bring up the required services -## Deploy on Vercel +3. Run `pnpm run db:migrate:local` to create the required schema in PostgreSQL -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +4. `pnpm dev` to start the server -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Open [http://localhost:3000](http://localhost:3000) in your browser to play around! diff --git a/pointer/app/(auth)/_components/form-ui.tsx b/pointer/app/(auth)/_components/form-ui.tsx index b19e7f3..12e24c0 100644 --- a/pointer/app/(auth)/_components/form-ui.tsx +++ b/pointer/app/(auth)/_components/form-ui.tsx @@ -1,97 +1,97 @@ import type { ComponentProps, ReactNode } from "react"; export function AuthHeading({ - title, - subtitle, + title, + subtitle, }: { - title: string; - subtitle?: ReactNode; + title: string; + subtitle?: ReactNode; }) { - return ( -
-

- {title} -

- {subtitle ? ( -

{subtitle}

- ) : null} -
- ); + return ( +
+

+ {title} +

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+ ); } export function Field({ - label, - htmlFor, - children, - hint, + label, + htmlFor, + children, + hint, }: { - label: string; - htmlFor: string; - children: ReactNode; - hint?: ReactNode; + label: string; + htmlFor: string; + children: ReactNode; + hint?: ReactNode; }) { - return ( - - ); + return ( + + ); } export function Input(props: ComponentProps<"input">) { - return ( - - ); + return ( + + ); } export function SubmitButton({ - pending, - children, + pending, + children, }: { - pending?: boolean; - children: ReactNode; + pending?: boolean; + children: ReactNode; }) { - return ( - - ); + return ( + + ); } export function ErrorText({ children }: { children: ReactNode }) { - if (!children) return null; - return ( -

- {children} -

- ); + if (!children) return null; + return ( +

+ {children} +

+ ); } export function SuccessText({ children }: { children: ReactNode }) { - if (!children) return null; - return ( -

- {children} -

- ); + if (!children) return null; + return ( +

+ {children} +

+ ); } diff --git a/pointer/app/(auth)/forgot-password/page.tsx b/pointer/app/(auth)/forgot-password/page.tsx index 5f8d5fb..d390946 100644 --- a/pointer/app/(auth)/forgot-password/page.tsx +++ b/pointer/app/(auth)/forgot-password/page.tsx @@ -6,77 +6,77 @@ import { useState, type FormEvent } from "react"; import { authClient } from "@/lib/auth-client"; import { - AuthHeading, - ErrorText, - Field, - Input, - SubmitButton, - SuccessText, + AuthHeading, + ErrorText, + Field, + Input, + SubmitButton, + SuccessText, } from "../_components/form-ui"; export default function ForgotPasswordPage() { - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); - const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + const [pending, setPending] = useState(false); - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - setInfo(null); - setPending(true); + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setInfo(null); + setPending(true); - const formData = new FormData(event.currentTarget); - const email = String(formData.get("email") ?? ""); + const formData = new FormData(event.currentTarget); + const email = String(formData.get("email") ?? ""); - const { error: requestError } = await authClient.requestPasswordReset({ - email, - redirectTo: "/reset-password", - }); + const { error: requestError } = await authClient.requestPasswordReset({ + email, + redirectTo: "/reset-password", + }); - setPending(false); + setPending(false); - if (requestError) { - setError(requestError.message ?? "Unable to send reset email"); - return; - } + if (requestError) { + setError(requestError.message ?? "Unable to send reset email"); + return; + } - setInfo( - "If an account exists for that email, a reset link has been sent. Check the dev console.", - ); - } + setInfo( + "If an account exists for that email, a reset link has been sent. Check the dev console.", + ); + } - return ( - <> - + return ( + <> + -
- - - + + + + - {error} - {info} + {error} + {info} - Send reset link -
+ Send reset link + -

- - Back to sign in - -

- - ); +

+ + Back to sign in + +

+ + ); } diff --git a/pointer/app/(auth)/layout.tsx b/pointer/app/(auth)/layout.tsx index 01b483a..69af7b4 100644 --- a/pointer/app/(auth)/layout.tsx +++ b/pointer/app/(auth)/layout.tsx @@ -2,19 +2,19 @@ import Link from "next/link"; import type { ReactNode } from "react"; export default function AuthLayout({ children }: { children: ReactNode }) { - return ( -
-
- - ← Back home - -
- {children} -
-
-
- ); + return ( +
+
+ + ← Back home + +
+ {children} +
+
+
+ ); } diff --git a/pointer/app/(auth)/reset-password/page.tsx b/pointer/app/(auth)/reset-password/page.tsx index f0d6fe0..ccf260f 100644 --- a/pointer/app/(auth)/reset-password/page.tsx +++ b/pointer/app/(auth)/reset-password/page.tsx @@ -7,102 +7,102 @@ import { Suspense, useState, type FormEvent } from "react"; import { authClient } from "@/lib/auth-client"; import { - AuthHeading, - ErrorText, - Field, - Input, - SubmitButton, - SuccessText, + AuthHeading, + ErrorText, + Field, + Input, + SubmitButton, + SuccessText, } from "../_components/form-ui"; function ResetPasswordForm() { - const router = useRouter(); - const params = useSearchParams(); - const token = params.get("token"); - - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); - const [pending, setPending] = useState(false); - - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - setInfo(null); - - if (!token) { - setError("Missing or invalid reset token."); - return; - } - - setPending(true); - - const formData = new FormData(event.currentTarget); - const password = String(formData.get("password") ?? ""); - - const { error: resetError } = await authClient.resetPassword({ - newPassword: password, - token, - }); - - setPending(false); - - if (resetError) { - setError(resetError.message ?? "Unable to reset password"); - return; - } - - setInfo("Password updated. Redirecting to sign in…"); - setTimeout(() => router.push("/sign-in"), 1200); - } - - if (!token) { - return ( - - This link is missing a token. Request a new reset email. - - ); - } - - return ( -
- - - - - {error} - {info} - - Update password -
- ); + const router = useRouter(); + const params = useSearchParams(); + const token = params.get("token"); + + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + const [pending, setPending] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setInfo(null); + + if (!token) { + setError("Missing or invalid reset token."); + return; + } + + setPending(true); + + const formData = new FormData(event.currentTarget); + const password = String(formData.get("password") ?? ""); + + const { error: resetError } = await authClient.resetPassword({ + newPassword: password, + token, + }); + + setPending(false); + + if (resetError) { + setError(resetError.message ?? "Unable to reset password"); + return; + } + + setInfo("Password updated. Redirecting to sign in…"); + setTimeout(() => router.push("/sign-in"), 1200); + } + + if (!token) { + return ( + + This link is missing a token. Request a new reset email. + + ); + } + + return ( +
+ + + + + {error} + {info} + + Update password +
+ ); } export default function ResetPasswordPage() { - return ( - <> - - - }> - - - -

- - Back to sign in - -

- - ); + return ( + <> + + + }> + + + +

+ + Back to sign in + +

+ + ); } diff --git a/pointer/app/(auth)/sign-in/page.tsx b/pointer/app/(auth)/sign-in/page.tsx index b84601d..e06c8a1 100644 --- a/pointer/app/(auth)/sign-in/page.tsx +++ b/pointer/app/(auth)/sign-in/page.tsx @@ -7,110 +7,110 @@ import { Suspense, useState, type FormEvent } from "react"; import { authClient } from "@/lib/auth-client"; import { - AuthHeading, - ErrorText, - Field, - Input, - SubmitButton, + AuthHeading, + ErrorText, + Field, + Input, + SubmitButton, } from "../_components/form-ui"; function SignInForm() { - const router = useRouter(); - const params = useSearchParams(); - const callbackUrl = params.get("from") ?? "/dashboard"; - - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - setPending(true); - - const formData = new FormData(event.currentTarget); - const email = String(formData.get("email") ?? ""); - const password = String(formData.get("password") ?? ""); - - const { data, error: signInError } = await authClient.signIn.email({ - email, - password, - callbackURL: callbackUrl, - }); - - if (signInError) { - setPending(false); - setError(signInError.message ?? "Unable to sign in"); - return; - } - - if ((data as { twoFactorRedirect?: boolean } | null)?.twoFactorRedirect) { - router.push("/two-factor"); - return; - } - - router.push(callbackUrl); - router.refresh(); - } - - return ( -
- - - - - - - - - {error} - - Sign in -
- ); + const router = useRouter(); + const params = useSearchParams(); + const callbackUrl = params.get("from") ?? "/"; + + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setPending(true); + + const formData = new FormData(event.currentTarget); + const email = String(formData.get("email") ?? ""); + const password = String(formData.get("password") ?? ""); + + const { data, error: signInError } = await authClient.signIn.email({ + email, + password, + callbackURL: callbackUrl, + }); + + if (signInError) { + setPending(false); + setError(signInError.message ?? "Unable to sign in"); + return; + } + + if ((data as { twoFactorRedirect?: boolean } | null)?.twoFactorRedirect) { + router.push("/two-factor"); + return; + } + + router.push(callbackUrl); + router.refresh(); + } + + return ( +
+ + + + + + + + + {error} + + Sign in +
+ ); } export default function SignInPage() { - return ( - <> - - New here?{" "} - - Create an account - - - } - /> - - }> - - - -

- - Forgot password? - -

- - ); + return ( + <> + + New here?{" "} + + Create an account + + + } + /> + + }> + + + +

+ + Forgot password? + +

+ + ); } diff --git a/pointer/app/(auth)/sign-up/page.tsx b/pointer/app/(auth)/sign-up/page.tsx index a8ee2e9..6b4f00f 100644 --- a/pointer/app/(auth)/sign-up/page.tsx +++ b/pointer/app/(auth)/sign-up/page.tsx @@ -1,106 +1,138 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState, type FormEvent } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState, type FormEvent } from "react"; import { authClient } from "@/lib/auth-client"; +import { findSelfServicePlan } from "@/lib/self-service-plans"; import { - AuthHeading, - ErrorText, - Field, - Input, - SubmitButton, + AuthHeading, + ErrorText, + Field, + Input, + SubmitButton, } from "../_components/form-ui"; +function SignUpForm() { + const router = useRouter(); + const params = useSearchParams(); + const selectedPlan = findSelfServicePlan(params.get("plan") ?? undefined); + + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + // A paid choice rides along to the home page, which hands off to Chargebee + // once the free subscription every account starts on has been provisioned. + const callbackURL = selectedPlan?.paid + ? `/?provisioning=1&plan=${selectedPlan.id}` + : "/?provisioning=1"; + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setPending(true); + + const formData = new FormData(event.currentTarget); + const name = String(formData.get("name") ?? ""); + const email = String(formData.get("email") ?? ""); + const password = String(formData.get("password") ?? ""); + + // Email verification is disabled, so Better Auth signs the user in + // immediately. The home page waits for the automatically-created free + // subscription webhook and entitlement mirror before rendering. + const { error: signUpError } = await authClient.signUp.email({ + name, + email, + password, + callbackURL, + }); + + if (signUpError) { + setPending(false); + setError(signUpError.message ?? "Unable to sign up"); + return; + } + + router.push(callbackURL); + router.refresh(); + } + + return ( + <> + {selectedPlan?.paid ? ( +

+ You picked{" "} + + {selectedPlan.name} + {" "} + at {selectedPlan.priceLabel} + {selectedPlan.cadence}. We'll take you to Chargebee checkout + right after your account is created. +

+ ) : null} + +
+ + + + + + + + + + + + + {error} + + Create account +
+ + ); +} + export default function SignUpPage() { - const router = useRouter(); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - setPending(true); - - const formData = new FormData(event.currentTarget); - const name = String(formData.get("name") ?? ""); - 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. - const { error: signUpError } = await authClient.signUp.email({ - name, - email, - password, - callbackURL: "/choose-plan", - }); - - if (signUpError) { - setPending(false); - setError(signUpError.message ?? "Unable to sign up"); - return; - } - - router.push("/choose-plan"); - router.refresh(); - } - - return ( - <> - - Already have one?{" "} - - Sign in - - - } - /> - -
- - - - - - - - - - - - - {error} - - Create account -
- - ); + return ( + <> + + Already have one?{" "} + + Sign in + + + } + /> + + }> + + + + ); } diff --git a/pointer/app/(auth)/two-factor/page.tsx b/pointer/app/(auth)/two-factor/page.tsx index 4b287fb..3dbb896 100644 --- a/pointer/app/(auth)/two-factor/page.tsx +++ b/pointer/app/(auth)/two-factor/page.tsx @@ -7,75 +7,75 @@ import { useState, type FormEvent } from "react"; import { authClient } from "@/lib/auth-client"; import { - AuthHeading, - ErrorText, - Field, - Input, - SubmitButton, + AuthHeading, + ErrorText, + Field, + Input, + SubmitButton, } from "../_components/form-ui"; export default function TwoFactorPage() { - const router = useRouter(); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); + const router = useRouter(); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - setPending(true); + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setPending(true); - const formData = new FormData(event.currentTarget); - const code = String(formData.get("code") ?? ""); + const formData = new FormData(event.currentTarget); + const code = String(formData.get("code") ?? ""); - const { error: verifyError } = await authClient.twoFactor.verifyTotp({ - code, - }); + const { error: verifyError } = await authClient.twoFactor.verifyTotp({ + code, + }); - setPending(false); + setPending(false); - if (verifyError) { - setError(verifyError.message ?? "Invalid code"); - return; - } + if (verifyError) { + setError(verifyError.message ?? "Invalid code"); + return; + } - router.push("/"); - router.refresh(); - } + router.push("/"); + router.refresh(); + } - return ( - <> - + return ( + <> + -
- - - + + + + - {error} + {error} - Verify -
+ Verify + -

- - Back to sign in - -

- - ); +

+ + Back to sign in + +

+ + ); } diff --git a/pointer/app/(auth)/verify-email/page.tsx b/pointer/app/(auth)/verify-email/page.tsx index ecd07e7..eb290fa 100644 --- a/pointer/app/(auth)/verify-email/page.tsx +++ b/pointer/app/(auth)/verify-email/page.tsx @@ -6,79 +6,79 @@ import { useState, type FormEvent } from "react"; import { authClient } from "@/lib/auth-client"; import { - AuthHeading, - ErrorText, - Field, - Input, - SubmitButton, - SuccessText, + AuthHeading, + ErrorText, + Field, + Input, + SubmitButton, + SuccessText, } from "../_components/form-ui"; export default function VerifyEmailPage() { - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); - const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + const [pending, setPending] = useState(false); - async function onSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - setInfo(null); - setPending(true); + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setInfo(null); + setPending(true); - const formData = new FormData(event.currentTarget); - const email = String(formData.get("email") ?? ""); + const formData = new FormData(event.currentTarget); + const email = String(formData.get("email") ?? ""); - const { error: resendError } = await authClient.sendVerificationEmail({ - email, - callbackURL: "/", - }); + const { error: resendError } = await authClient.sendVerificationEmail({ + email, + callbackURL: "/", + }); - setPending(false); + setPending(false); - if (resendError) { - setError(resendError.message ?? "Unable to resend verification email"); - return; - } + if (resendError) { + setError(resendError.message ?? "Unable to resend verification email"); + return; + } - setInfo("Verification email sent. Check the dev console for the link."); - } + setInfo("Verification email sent. Check the dev console for the link."); + } - return ( - <> - + return ( + <> + -
- - - + + + + - {error} - {info} + {error} + {info} - Resend verification email -
+ Resend verification email + -

- - Back to sign in - -

- - ); +

+ + Back to sign in + +

+ + ); } diff --git a/pointer/app/_components/account-provisioning.tsx b/pointer/app/_components/account-provisioning.tsx new file mode 100644 index 0000000..56ab8e5 --- /dev/null +++ b/pointer/app/_components/account-provisioning.tsx @@ -0,0 +1,135 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { authClient } from "@/lib/auth-client"; +import { checkoutReturnUrls } from "@/lib/checkout-urls"; +import type { SelfServicePlan } from "@/lib/self-service-plans"; + +const POLL_INTERVAL_MS = 1_500; +const POLL_ATTEMPTS = 40; + +async function readyChargebeeSubscriptionId(): Promise { + const response = await fetch("/api/entitlements/status", { + cache: "no-store", + }); + if (!response.ok) return null; + const body = (await response.json()) as { subscriptionId?: unknown }; + return typeof body.subscriptionId === "string" ? body.subscriptionId : null; +} + +export function AccountProvisioning({ + checkoutPlan, +}: { + checkoutPlan?: SelfServicePlan; +}) { + const router = useRouter(); + const [attempt, setAttempt] = useState(0); + const [timedOut, setTimedOut] = useState(false); + const [error, setError] = useState(null); + + const checkoutItemPriceId = checkoutPlan?.itemPriceId; + + useEffect(() => { + let active = true; + + void (async () => { + for (let index = 0; index < POLL_ATTEMPTS; index += 1) { + try { + const subscriptionId = await readyChargebeeSubscriptionId(); + if (subscriptionId) { + if (!active) return; + if (!checkoutItemPriceId) { + router.replace("/"); + router.refresh(); + return; + } + + // Every account starts on free, so buying a paid plan is a switch + // on the existing subscription. A successful call hands the + // browser to Chargebee's hosted page. + const { error: checkoutError } = + await authClient.subscription.update({ + subscriptionId, + itemPriceId: checkoutItemPriceId, + ...checkoutReturnUrls("/"), + }); + + if (!active) return; + if (checkoutError) { + setError(checkoutError.message ?? "Unable to open checkout"); + } + 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, checkoutItemPriceId, router]); + + return ( +
+
+
+

+ {checkoutPlan + ? `Setting up ${checkoutPlan.name}` + : "Setting up your free plan"} +

+

+ {checkoutPlan + ? `Chargebee is creating your account. We'll open the ${checkoutPlan.name} checkout as soon as it is ready.` + : "Chargebee is creating your subscription. Pointer will open as soon as its local record arrives; free-tier defaults apply while the entitlement snapshot finishes loading."} +

+ + {error ? ( +
+

+ {error} +

+ + Pick a plan + +
+ ) : null} + + {timedOut ? ( +
+

+ Provisioning is taking longer than expected. +

+ +
+ ) : null} +
+
+ ); +} diff --git a/pointer/app/_components/app-header.tsx b/pointer/app/_components/app-header.tsx new file mode 100644 index 0000000..b46fa44 --- /dev/null +++ b/pointer/app/_components/app-header.tsx @@ -0,0 +1,66 @@ +import { headers } from "next/headers"; +import Image from "next/image"; +import Link from "next/link"; + +import { isAdminRequest } from "@/lib/admin"; + +import { SignOutButton } from "./sign-out-button"; + +/** + * The chrome every signed-in page wears. Signed-out pages have their own + * header, since none of these targets exist without a session. + */ + +/** Each page names its own nav target, which spares the nav a client hook. */ +export type AppNavTarget = "/admin" | "/usage" | "/choose-plan"; + +type NavItem = { href: AppNavTarget; label: string }; + +const NAV: NavItem[] = [ + { href: "/usage", label: "Usage" }, + { href: "/choose-plan", label: "Manage plan" }, +]; + +const ADMIN_NAV: NavItem = { href: "/admin", label: "Admin" }; + +function NavLink({ item, active }: { item: NavItem; active: boolean }) { + return ( + + {item.label} + + ); +} + +export async function AppHeader({ active }: { active?: AppNavTarget }) { + const isAdmin = await isAdminRequest(await headers()); + const items = isAdmin ? [ADMIN_NAV, ...NAV] : NAV; + + return ( +
+ + Pointer + + + +
+ ); +} diff --git a/pointer/app/_components/ask-panel.tsx b/pointer/app/_components/ask-panel.tsx new file mode 100644 index 0000000..47fdee6 --- /dev/null +++ b/pointer/app/_components/ask-panel.tsx @@ -0,0 +1,283 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState, type KeyboardEvent } from "react"; + +import type { GenerateFrame } from "@/app/api/generate/frames"; +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; + +const SUGGESTIONS = [ + "Explain runtime entitlements in one paragraph.", + "How should I handle a failed Chargebee webhook?", + "Draft a plan comparison for Starter vs Pro.", +]; + +/** Splits the NDJSON body into frames as they arrive, one JSON object per line. */ +async function* readFrames(body: ReadableStream) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let pending = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + pending += decoder.decode(value, { stream: true }); + const lines = pending.split("\n"); + // A trailing fragment is the start of the next frame, not a whole one. + pending = lines.pop() ?? ""; + + for (const line of lines) { + if (!line) continue; + yield JSON.parse(line) as GenerateFrame; + } + } +} + +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; +} + +export function AskPanel() { + const router = useRouter(); + const [usage, setUsage] = useState(null); + const [prompt, setPrompt] = useState(""); + const [model, setModel] = useState(""); + const [output, setOutput] = useState(""); + const [asked, setAsked] = 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() { + const question = prompt.trim(); + if (!(question && model) || pending) return; + setPending(true); + setError(null); + setOutput(""); + setAsked(question); + try { + const response = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: question, model, maxOutputTokens: 256 }), + }); + // Denials raised before the stream opens are still plain JSON. + if (!(response.ok && response.body)) throw await response.json(); + + for await (const frame of readFrames(response.body)) { + if (frame.type === "delta") { + setOutput((current) => current + frame.text); + continue; + } + // Both terminal frames carry a fresh snapshot, so the meters stay + // correct even when generation was cut short. + if (frame.limits) applyUsage(frame.limits); + if (frame.type === "error") throw frame; + } + } 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); + } + } + + function onComposerKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void generate(); + } + } + + return ( +
+
+

+ What can Pointer help with? +

+

+ Ask anything — get pointed straight to the answer. +

+
+ + {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} + +
+ +