Vibecode Grow or Die
track this build4 phases, 12 steps, beginner friendly0%An agent can build the honest personal version: a first-party tracker, a Stripe webhook, an OpenAI usage wrapper, and a SQLite join keyed by your own account ID. That is enough to show which customers make money. The paid product starts earning its fee when the inputs stop being tidy. Analytics exports drift, refunds arrive late, model prices change, streaming calls fail after consuming tokens, and anonymous visitors do not naturally become the same people who paid. A weekend build can give one founder a useful ledger for one stack. Matching Grow or Die's connector coverage, SDKs, missing-data discipline, retention, and production reliability is ongoing analytics infrastructure, not a prompt.
You are building a lean indie version of Grow or Die. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Grow or Die · indie build A one-product profit dashboard whose hard problems are recording model usage that the provider only reports when asked, absorbing payment events that arrive duplicated and out of order, and carrying unknown data through every calculation instead of rounding it to zero. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and Fastify, server-rendered pages, one process | a dashboard for one person does not need a build pipeline and a client framework | | Storage | SQLite in one file, append-only ledgers, no destructive updates | every number on the page must be traceable to rows that were never rewritten | | Payments | Stripe webhooks with signature verification and event-id idempotency | delivery is at least once and order is not guaranteed, which the schema has to assume | | Model usage | A thin wrapper returning the provider response unchanged, recording usage beside it | if the wrapper changes the response people stop using it and the data stops arriving | | Prices | A versioned catalogue with effective dates, cost computed on the server | prices change and yesterday's calls must keep yesterday's cost | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the dashboard, the tracker endpoint and the webhook receiver. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A Stripe account with webhooks and a signing secret** · free to set up; transaction fees apply to real payments - Why: Revenue arrives as events, and the whole ledger design exists because those events repeat and arrive out of order. - Get it: Create a webhook endpoint for the events you care about and copy the signing secret. - Verify: The command line tool forwards a test event that your endpoint verifies - [ ] **A model provider key for the usage wrapper** · pay per token - Why: The wrapper is the only place token counts can be observed, and the streaming behaviour is the reason this phase exists. - Get it: A key with a spend limit set in the provider console. - Verify: A streamed call records the same token counts as the same call unstreamed - [ ] **Your own stable account identifier** · free - Why: Every join in this build is on this column, and choosing it after the tracker ships means reprocessing everything. - Get it: Decide what an account is in your product, how the identifier is generated, and where your application knows it. - Verify: The identify call is wired into your product and the column never contains an address - [ ] **A price catalogue you maintain by hand** · free - Why: Costs are computed from it and nobody is going to tell you when a figure changes. - Get it: Copy the current published prices for the models you use, with the date and the source URL, into the catalogue file. - Verify: Every model appearing in usage has a price entry covering that date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. - ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors - maintained OpenAI and Anthropic pricing rules, including cached tokens - seven language SDKs with streaming, failure, timeout, and cancellation tracking - careful unknown, unpriced, unmatched, and incomplete data states - managed retention, backups, source health, and production reliability If one of those is essential to you, that is the reason to keep paying for Grow or Die, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Grow or Die The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-tenant profit dashboard that joins product activity, payments and model spend on one account identifier and refuses to guess to replace Grow or Die. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule decides this build: a missing number is never a zero. Not in the ledger, not in the usage table, not in the total at the top of the page. Unknown revenue, an unpriced model, an unmatched visitor and a partial import all propagate as unknown, and any figure computed from them is shown with the unknown count beside it. A profit number that quietly treats absent data as nothing is not conservative, it is wrong in the direction that makes you feel better. The sharpest example is in the usage wrapper, so get it right first. When you stream a completion, the token usage is not returned unless you ask for it: OpenAI's reference states that the usage field is present only when stream_options include_usage is set in the request, that it is null on every chunk except the last, and that the final chunk carries the totals for the whole request. A wrapper that reads response.usage from a stream without setting it records nothing, your code helpfully turns nothing into zero, and every margin on the dashboard is too good. Worse, an interrupted stream may never deliver that final chunk at all, so a cancelled or failed call has consumed tokens you will never see a figure for. Record that call with status failed and usage unknown, not with zero tokens. Second rule, smaller but load-bearing: the join key is an account identifier you control and issue. Never an email address. People change them, share them, type them differently at checkout, and a join on an email is a join that silently merges two customers or splits one. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and Fastify, server-rendered pages, one process · a dashboard for one person does not need a build pipeline and a client framework - Storage: SQLite in one file, append-only ledgers, no destructive updates · every number on the page must be traceable to rows that were never rewritten - Payments: Stripe webhooks with signature verification and event-id idempotency · delivery is at least once and order is not guaranteed, which the schema has to assume - Model usage: A thin wrapper returning the provider response unchanged, recording usage beside it · if the wrapper changes the response people stop using it and the data stops arriving - Prices: A versioned catalogue with effective dates, cost computed on the server · prices change and yesterday's calls must keep yesterday's cost ### Data model (create this before Phase 1) - visitors(id, first_seen, last_seen) · a random identifier, no address, no fingerprint - identities(visitor_id, account_id, bound_at) · set by your own identify call, never derived from an email - events(id, visitor_id, account_id, name, occurred_at, props_json) · account_id stays null until a binding exists and null means unmatched, not anonymous - revenue(id, stripe_event_id, account_id, kind, amount_minor, currency, occurred_at, received_at, raw_json) · append only, unique on the event id - usage(id, account_id, model_id, input_tokens, cached_input_tokens, output_tokens, status, usage_known, latency_ms, occurred_at) · usage_known is false for interrupted or failed calls - prices(model_id, effective_from, effective_to, input_minor_per_million, cached_input_minor_per_million, output_minor_per_million, source_note) - windows(window, finalised_through, unmatched_events, unknown_usage_calls, unpriced_calls) · the holes, computed and displayed with every total ### Phase 1 · An identifier you issue, and honest gaps Build: Ship a small first-party tracker that records page views and named events against a random visitor identifier. Add an endpoint your application calls to bind that visitor to your own stable account identifier. Backfill earlier events for that visitor when the binding arrives, and record the backfill. Leave everything still unbound counted as unmatched. Trap: Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. Done when: An event recorded before identification is bound to the account when identification happens, and the backfill is recorded, tested. Events never bound are counted as unmatched and that count appears with every total, not in a footnote. No address, agent string or network address is stored against a visitor, verified in the database. The tracker sends nothing to any third party, verified by watching the network. Do not build yet: No payments, model usage, pricing or dashboard yet. ### Phase 2 · A revenue ledger that survives duplicates and disorder Build: Verify the webhook signature, store the raw event keyed by its event identifier, and return success before doing anything else. Process asynchronously into an append-only revenue ledger. Read your account identifier from the payment metadata. Treat a refund as another ledger row rather than an edit, and reconstruct balances by summing. Trap: Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. Done when: Replaying the same event twenty times produces exactly one ledger row and no change to any total, tested. A refund processed before its charge arrives produces a correct balance once both are present, tested by delivering them in reverse. The endpoint responds before any processing, and a signature failure stores nothing. Every ledger row keeps the raw payload it came from. Do not build yet: No model usage, pricing or dashboard yet. ### Phase 3 · Usage you actually measured Build: Wrap the provider client so it returns the response unchanged and records a usage row beside it. For streaming calls, request usage explicitly and read it from the final chunk. If the stream ends without that chunk, or the call errors after tokens were consumed, write the row with status failed and usage unknown. Never record prompts or outputs, only counts. Trap: Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. Done when: A streamed call records the same token counts as the equivalent non-streamed call, tested against a recorded fixture. A stream cut off before its final chunk produces a row with usage unknown and never a row with zero tokens, tested. Cached input tokens are stored in their own column rather than folded into input tokens. No prompt or completion text appears anywhere in the database, verified by inspection. Do not build yet: No pricing, joins or dashboard yet. ### Phase 4 · Prices that were true at the time Build: Keep prices in a versioned file with effective dates per model and per token kind, including cached input. Compute cost at query time by matching the call's timestamp against the effective range. A model with no matching price produces no cost figure and increments an unpriced count that travels with every total that includes it. Trap: Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. Done when: Changing a price with a new effective date changes the cost of calls after that date and leaves earlier calls untouched, tested at the boundary. A call to a model absent from the catalogue produces no cost and increments the unpriced count shown with the total. No usage row stores a money value. Each price entry records where the figure came from and when it was checked. Do not build yet: No dashboard, demo data or retention yet. ### Phase 5 · A dashboard that shows its own holes Build: Join activity, revenue and usage only on the account identifier and only across the same finalised window. Show visitors, paying accounts, revenue, model cost, contribution and margin, each with unmatched, unknown-usage and unpriced counts beside it. Every total links to its rows. Protect the page with one token, ship deletable demo data, and back up on a schedule. Trap: Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. Done when: Every headline figure carries its unmatched, unknown-usage and unpriced counts and links to the underlying rows, checked by review. A window inside the settlement lag is labelled as still filling everywhere it appears, tested. Demo data is removable with one command and cannot coexist with real data, tested. A restore from backup reproduces every total exactly, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Connectors for hosted analytics products, which are OAuth maintenance rather than a feature and drift the moment their export schema changes. - Automatic discovery of model prices, because a wrong price discovered automatically is worse than a missing one you noticed. - Cross-device and cross-browser identity stitching, which is guesswork dressed as data and breaks the one rule this build has. - Multi-currency reporting, which needs a rate source with dated history and is a project of its own. ### README must contain - That a missing value is carried as unknown everywhere and never rendered as zero, with the counts that appear beside every total - That streamed model calls only report token usage when the request asks for it, and that an interrupted stream records unknown rather than zero - That payment events may arrive more than once and out of order, and that the ledger is keyed on the event identifier - That the join key is an account identifier you issue, and why an email address is not acceptable - Where prices come from, when they were last checked, and that cost is computed on read rather than stored ===== AGENTS.md ===== # Agent instructions · Grow or Die indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and Fastify, server-rendered pages, one process, SQLite in one file, append-only ledgers, no destructive updates, Stripe webhooks with signature verification and event-id idempotency, A thin wrapper returning the provider response unchanged, recording usage beside it, A versioned catalogue with effective dates, cost computed on the server. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ===== BUILD_PLAN.md ===== # Build plan · Grow or Die A one-product profit dashboard whose hard problems are recording model usage that the provider only reports when asked, absorbing payment events that arrive duplicated and out of order, and carrying unknown data through every calculation instead of rounding it to zero. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An identifier you issue, and honest gaps Product activity is recorded and bound to your own account identifier, with unbound activity visibly unbound. ### Steps 1. Issue the account identifier yourself It is the only column every other table will join on. Files: `src/identify.ts`, `tests/identity.test.ts` 2. Backfill on binding and record that you did Otherwise the same visitor appears as two customers. Files: `src/events.ts` 3. Count unmatched activity as unmatched It is a real number and it belongs on the page. Files: `src/windows.ts` ### Done when - [ ] Pre-identification events bind and the backfill is recorded - [ ] Unmatched events are counted and displayed - [ ] No address or fingerprint is stored per visitor - [ ] The tracker makes no third-party request ### Watch out - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. ## Phase 2 · A revenue ledger that survives duplicates and disorder Payment events can arrive twice, out of order or late without changing any total. ### Steps 1. Unique on the event identifier, not on anything else It is the only duplicate detector the provider gives you. Files: `src/webhooks.ts`, `tests/idempotency.test.ts` 2. Store raw, respond, then process The endpoint has a deadline and your processing does not. Files: `src/webhooks.ts` 3. Make refunds rows, never edits An edited ledger cannot be reconciled against anything. Files: `src/revenue.ts`, `tests/out-of-order.test.ts` ### Done when - [ ] Twenty replays produce one row and no total change - [ ] Reversed delivery still reconciles correctly - [ ] The endpoint responds before processing - [ ] Raw payloads are retained per row ### Watch out - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. ## Phase 3 · Usage you actually measured Every model call records real token counts or is marked as unknown, and never as zero. ### Steps 1. Ask for usage on every streaming call It is not returned otherwise, and the default looks like success. Files: `src/llm/wrapper.ts`, `tests/streaming-usage.test.ts` 2. Record unknown on interruption, never zero Those tokens were spent whether or not you were told. Files: `src/llm/wrapper.ts` 3. Keep cached input tokens in their own column They price differently, so folding them in breaks the cost later. Files: `src/usage.ts` ### Done when - [ ] Streamed and non-streamed calls record the same counts - [ ] An interrupted stream records unknown, not zero - [ ] Cached input tokens have their own column - [ ] No prompt or output text is stored ### Watch out - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. ## Phase 4 · Prices that were true at the time Cost is computed on the server from a dated catalogue, and an unpriced model stays unpriced. ### Steps 1. Store tokens, compute cost on read A stored cost is a mistake you cannot correct. Files: `src/pricing.ts`, `tests/pricing.test.ts` 2. Match the call time against the effective range Yesterday's calls keep yesterday's price. Files: `src/pricing.ts` 3. Make an unpriced model visible, not free Free is the most expensive default in this build. Files: `src/windows.ts` ### Done when - [ ] A new effective date changes only later calls - [ ] An unknown model produces no cost and a visible count - [ ] No money value is stored on a usage row - [ ] Every price records its source and check date ### Watch out - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ## Not in this build - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. ## After v1, if you want it - A daily snapshot of every headline figure, so you can see when a number changed and which rows arrived late - A second payment processor behind the same ledger interface, which is mostly a test of whether the ledger was general enough - Per-feature cost attribution, once you have enough call sites that one model bill stops being one answer ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, revenue, usage and windows. Back it up; none of it can be refetched. DATABASE_PATH=./data/profit.db # Required · secret. Verifies every incoming payment event before it is stored. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Used to fetch objects an out-of-order event referenced but did not contain. STRIPE_API_KEY=sk_live_...redacted # Required · secret. Passed straight through by the wrapper and never recorded anywhere. MODEL_API_KEY=sk-...redacted # Required · secret. The only credential on the dashboard. Rotate it when you share a screenshot. ADMIN_TOKEN=a long random string # Required. A window is not finalised until this much time has passed. SETTLEMENT_LAG_DAYS=3 # Required. Dated prices per model. The source and check date live in the file. PRICE_CATALOGUE_PATH=./config/prices.json
You are building a lean indie version of Grow or Die. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Grow or Die · indie build A one-product profit dashboard whose hard problems are recording model usage that the provider only reports when asked, absorbing payment events that arrive duplicated and out of order, and carrying unknown data through every calculation instead of rounding it to zero. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and Fastify, server-rendered pages, one process | a dashboard for one person does not need a build pipeline and a client framework | | Storage | SQLite in one file, append-only ledgers, no destructive updates | every number on the page must be traceable to rows that were never rewritten | | Payments | Stripe webhooks with signature verification and event-id idempotency | delivery is at least once and order is not guaranteed, which the schema has to assume | | Model usage | A thin wrapper returning the provider response unchanged, recording usage beside it | if the wrapper changes the response people stop using it and the data stops arriving | | Prices | A versioned catalogue with effective dates, cost computed on the server | prices change and yesterday's calls must keep yesterday's cost | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the dashboard, the tracker endpoint and the webhook receiver. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A Stripe account with webhooks and a signing secret** · free to set up; transaction fees apply to real payments - Why: Revenue arrives as events, and the whole ledger design exists because those events repeat and arrive out of order. - Get it: Create a webhook endpoint for the events you care about and copy the signing secret. - Verify: The command line tool forwards a test event that your endpoint verifies - [ ] **A model provider key for the usage wrapper** · pay per token - Why: The wrapper is the only place token counts can be observed, and the streaming behaviour is the reason this phase exists. - Get it: A key with a spend limit set in the provider console. - Verify: A streamed call records the same token counts as the same call unstreamed - [ ] **Your own stable account identifier** · free - Why: Every join in this build is on this column, and choosing it after the tracker ships means reprocessing everything. - Get it: Decide what an account is in your product, how the identifier is generated, and where your application knows it. - Verify: The identify call is wired into your product and the column never contains an address - [ ] **A price catalogue you maintain by hand** · free - Why: Costs are computed from it and nobody is going to tell you when a figure changes. - Get it: Copy the current published prices for the models you use, with the date and the source URL, into the catalogue file. - Verify: Every model appearing in usage has a price entry covering that date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. - ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors - maintained OpenAI and Anthropic pricing rules, including cached tokens - seven language SDKs with streaming, failure, timeout, and cancellation tracking - careful unknown, unpriced, unmatched, and incomplete data states - managed retention, backups, source health, and production reliability If one of those is essential to you, that is the reason to keep paying for Grow or Die, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Grow or Die The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-tenant profit dashboard that joins product activity, payments and model spend on one account identifier and refuses to guess to replace Grow or Die. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule decides this build: a missing number is never a zero. Not in the ledger, not in the usage table, not in the total at the top of the page. Unknown revenue, an unpriced model, an unmatched visitor and a partial import all propagate as unknown, and any figure computed from them is shown with the unknown count beside it. A profit number that quietly treats absent data as nothing is not conservative, it is wrong in the direction that makes you feel better. The sharpest example is in the usage wrapper, so get it right first. When you stream a completion, the token usage is not returned unless you ask for it: OpenAI's reference states that the usage field is present only when stream_options include_usage is set in the request, that it is null on every chunk except the last, and that the final chunk carries the totals for the whole request. A wrapper that reads response.usage from a stream without setting it records nothing, your code helpfully turns nothing into zero, and every margin on the dashboard is too good. Worse, an interrupted stream may never deliver that final chunk at all, so a cancelled or failed call has consumed tokens you will never see a figure for. Record that call with status failed and usage unknown, not with zero tokens. Second rule, smaller but load-bearing: the join key is an account identifier you control and issue. Never an email address. People change them, share them, type them differently at checkout, and a join on an email is a join that silently merges two customers or splits one. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and Fastify, server-rendered pages, one process · a dashboard for one person does not need a build pipeline and a client framework - Storage: SQLite in one file, append-only ledgers, no destructive updates · every number on the page must be traceable to rows that were never rewritten - Payments: Stripe webhooks with signature verification and event-id idempotency · delivery is at least once and order is not guaranteed, which the schema has to assume - Model usage: A thin wrapper returning the provider response unchanged, recording usage beside it · if the wrapper changes the response people stop using it and the data stops arriving - Prices: A versioned catalogue with effective dates, cost computed on the server · prices change and yesterday's calls must keep yesterday's cost ### Data model (create this before Phase 1) - visitors(id, first_seen, last_seen) · a random identifier, no address, no fingerprint - identities(visitor_id, account_id, bound_at) · set by your own identify call, never derived from an email - events(id, visitor_id, account_id, name, occurred_at, props_json) · account_id stays null until a binding exists and null means unmatched, not anonymous - revenue(id, stripe_event_id, account_id, kind, amount_minor, currency, occurred_at, received_at, raw_json) · append only, unique on the event id - usage(id, account_id, model_id, input_tokens, cached_input_tokens, output_tokens, status, usage_known, latency_ms, occurred_at) · usage_known is false for interrupted or failed calls - prices(model_id, effective_from, effective_to, input_minor_per_million, cached_input_minor_per_million, output_minor_per_million, source_note) - windows(window, finalised_through, unmatched_events, unknown_usage_calls, unpriced_calls) · the holes, computed and displayed with every total ### Phase 1 · An identifier you issue, and honest gaps Build: Ship a small first-party tracker that records page views and named events against a random visitor identifier. Add an endpoint your application calls to bind that visitor to your own stable account identifier. Backfill earlier events for that visitor when the binding arrives, and record the backfill. Leave everything still unbound counted as unmatched. Trap: Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. Done when: An event recorded before identification is bound to the account when identification happens, and the backfill is recorded, tested. Events never bound are counted as unmatched and that count appears with every total, not in a footnote. No address, agent string or network address is stored against a visitor, verified in the database. The tracker sends nothing to any third party, verified by watching the network. Do not build yet: No payments, model usage, pricing or dashboard yet. ### Phase 2 · A revenue ledger that survives duplicates and disorder Build: Verify the webhook signature, store the raw event keyed by its event identifier, and return success before doing anything else. Process asynchronously into an append-only revenue ledger. Read your account identifier from the payment metadata. Treat a refund as another ledger row rather than an edit, and reconstruct balances by summing. Trap: Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. Done when: Replaying the same event twenty times produces exactly one ledger row and no change to any total, tested. A refund processed before its charge arrives produces a correct balance once both are present, tested by delivering them in reverse. The endpoint responds before any processing, and a signature failure stores nothing. Every ledger row keeps the raw payload it came from. Do not build yet: No model usage, pricing or dashboard yet. ### Phase 3 · Usage you actually measured Build: Wrap the provider client so it returns the response unchanged and records a usage row beside it. For streaming calls, request usage explicitly and read it from the final chunk. If the stream ends without that chunk, or the call errors after tokens were consumed, write the row with status failed and usage unknown. Never record prompts or outputs, only counts. Trap: Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. Done when: A streamed call records the same token counts as the equivalent non-streamed call, tested against a recorded fixture. A stream cut off before its final chunk produces a row with usage unknown and never a row with zero tokens, tested. Cached input tokens are stored in their own column rather than folded into input tokens. No prompt or completion text appears anywhere in the database, verified by inspection. Do not build yet: No pricing, joins or dashboard yet. ### Phase 4 · Prices that were true at the time Build: Keep prices in a versioned file with effective dates per model and per token kind, including cached input. Compute cost at query time by matching the call's timestamp against the effective range. A model with no matching price produces no cost figure and increments an unpriced count that travels with every total that includes it. Trap: Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. Done when: Changing a price with a new effective date changes the cost of calls after that date and leaves earlier calls untouched, tested at the boundary. A call to a model absent from the catalogue produces no cost and increments the unpriced count shown with the total. No usage row stores a money value. Each price entry records where the figure came from and when it was checked. Do not build yet: No dashboard, demo data or retention yet. ### Phase 5 · A dashboard that shows its own holes Build: Join activity, revenue and usage only on the account identifier and only across the same finalised window. Show visitors, paying accounts, revenue, model cost, contribution and margin, each with unmatched, unknown-usage and unpriced counts beside it. Every total links to its rows. Protect the page with one token, ship deletable demo data, and back up on a schedule. Trap: Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. Done when: Every headline figure carries its unmatched, unknown-usage and unpriced counts and links to the underlying rows, checked by review. A window inside the settlement lag is labelled as still filling everywhere it appears, tested. Demo data is removable with one command and cannot coexist with real data, tested. A restore from backup reproduces every total exactly, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Connectors for hosted analytics products, which are OAuth maintenance rather than a feature and drift the moment their export schema changes. - Automatic discovery of model prices, because a wrong price discovered automatically is worse than a missing one you noticed. - Cross-device and cross-browser identity stitching, which is guesswork dressed as data and breaks the one rule this build has. - Multi-currency reporting, which needs a rate source with dated history and is a project of its own. ### README must contain - That a missing value is carried as unknown everywhere and never rendered as zero, with the counts that appear beside every total - That streamed model calls only report token usage when the request asks for it, and that an interrupted stream records unknown rather than zero - That payment events may arrive more than once and out of order, and that the ledger is keyed on the event identifier - That the join key is an account identifier you issue, and why an email address is not acceptable - Where prices come from, when they were last checked, and that cost is computed on read rather than stored ===== AGENTS.md ===== # Agent instructions · Grow or Die indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and Fastify, server-rendered pages, one process, SQLite in one file, append-only ledgers, no destructive updates, Stripe webhooks with signature verification and event-id idempotency, A thin wrapper returning the provider response unchanged, recording usage beside it, A versioned catalogue with effective dates, cost computed on the server. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ===== BUILD_PLAN.md ===== # Build plan · Grow or Die A one-product profit dashboard whose hard problems are recording model usage that the provider only reports when asked, absorbing payment events that arrive duplicated and out of order, and carrying unknown data through every calculation instead of rounding it to zero. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An identifier you issue, and honest gaps Product activity is recorded and bound to your own account identifier, with unbound activity visibly unbound. ### Steps 1. Issue the account identifier yourself It is the only column every other table will join on. Files: `src/identify.ts`, `tests/identity.test.ts` 2. Backfill on binding and record that you did Otherwise the same visitor appears as two customers. Files: `src/events.ts` 3. Count unmatched activity as unmatched It is a real number and it belongs on the page. Files: `src/windows.ts` ### Done when - [ ] Pre-identification events bind and the backfill is recorded - [ ] Unmatched events are counted and displayed - [ ] No address or fingerprint is stored per visitor - [ ] The tracker makes no third-party request ### Watch out - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. ## Phase 2 · A revenue ledger that survives duplicates and disorder Payment events can arrive twice, out of order or late without changing any total. ### Steps 1. Unique on the event identifier, not on anything else It is the only duplicate detector the provider gives you. Files: `src/webhooks.ts`, `tests/idempotency.test.ts` 2. Store raw, respond, then process The endpoint has a deadline and your processing does not. Files: `src/webhooks.ts` 3. Make refunds rows, never edits An edited ledger cannot be reconciled against anything. Files: `src/revenue.ts`, `tests/out-of-order.test.ts` ### Done when - [ ] Twenty replays produce one row and no total change - [ ] Reversed delivery still reconciles correctly - [ ] The endpoint responds before processing - [ ] Raw payloads are retained per row ### Watch out - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. ## Phase 3 · Usage you actually measured Every model call records real token counts or is marked as unknown, and never as zero. ### Steps 1. Ask for usage on every streaming call It is not returned otherwise, and the default looks like success. Files: `src/llm/wrapper.ts`, `tests/streaming-usage.test.ts` 2. Record unknown on interruption, never zero Those tokens were spent whether or not you were told. Files: `src/llm/wrapper.ts` 3. Keep cached input tokens in their own column They price differently, so folding them in breaks the cost later. Files: `src/usage.ts` ### Done when - [ ] Streamed and non-streamed calls record the same counts - [ ] An interrupted stream records unknown, not zero - [ ] Cached input tokens have their own column - [ ] No prompt or output text is stored ### Watch out - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. ## Phase 4 · Prices that were true at the time Cost is computed on the server from a dated catalogue, and an unpriced model stays unpriced. ### Steps 1. Store tokens, compute cost on read A stored cost is a mistake you cannot correct. Files: `src/pricing.ts`, `tests/pricing.test.ts` 2. Match the call time against the effective range Yesterday's calls keep yesterday's price. Files: `src/pricing.ts` 3. Make an unpriced model visible, not free Free is the most expensive default in this build. Files: `src/windows.ts` ### Done when - [ ] A new effective date changes only later calls - [ ] An unknown model produces no cost and a visible count - [ ] No money value is stored on a usage row - [ ] Every price records its source and check date ### Watch out - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ## Not in this build - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. ## After v1, if you want it - A daily snapshot of every headline figure, so you can see when a number changed and which rows arrived late - A second payment processor behind the same ledger interface, which is mostly a test of whether the ledger was general enough - Per-feature cost attribution, once you have enough call sites that one model bill stops being one answer ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, revenue, usage and windows. Back it up; none of it can be refetched. DATABASE_PATH=./data/profit.db # Required · secret. Verifies every incoming payment event before it is stored. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Used to fetch objects an out-of-order event referenced but did not contain. STRIPE_API_KEY=sk_live_...redacted # Required · secret. Passed straight through by the wrapper and never recorded anywhere. MODEL_API_KEY=sk-...redacted # Required · secret. The only credential on the dashboard. Rotate it when you share a screenshot. ADMIN_TOKEN=a long random string # Required. A window is not finalised until this much time has passed. SETTLEMENT_LAG_DAYS=3 # Required. Dated prices per model. The source and check date live in the file. PRICE_CATALOGUE_PATH=./config/prices.json
You are building a production product version of Grow or Die. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Grow or Die · product brief ## Problem An agent can build the honest personal version: a first-party tracker, a Stripe webhook, an OpenAI usage wrapper, and a SQLite join keyed by your own account ID. That is enough to show which customers make money. The paid product starts earning its fee when the inputs stop being tidy. Analytics exports drift, refunds arrive late, model prices change, streaming calls fail after consuming tokens, and anonymous visitors do not naturally become the same people who paid. A weekend build can give one founder a useful ledger for one stack. Matching Grow or Die's connector coverage, SDKs, missing-data discipline, retention, and production reliability is ongoing analytics infrastructure, not a prompt. ## Product outcome A profit dashboard for one product where every total names what is missing from it, model usage is measured rather than assumed, and a payment event arriving three times changes nothing. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - VPS with a domain and TLS - Stripe webhook secret - OpenAI API key - first-party tracker and identify call - SQLite backups ## Explicit non-goals for v1 - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. - ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors - maintained OpenAI and Anthropic pricing rules, including cached tokens - seven language SDKs with streaming, failure, timeout, and cancellation tracking - careful unknown, unpriced, unmatched, and incomplete data states - managed retention, backups, source health, and production reliability ## Success criteria - A replayed payment event is confirmed to change no total - An interrupted stream is confirmed to record unknown rather than zero - No money value is confirmed stored on a usage row - Unknown counts are confirmed present beside every headline figure - Demo data is confirmed unable to coexist with real data ===== BRIEF.md ===== # Build brief · Grow or Die The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-tenant profit dashboard that joins product activity, payments and model spend on one account identifier and refuses to guess to replace Grow or Die. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule decides this build: a missing number is never a zero. Not in the ledger, not in the usage table, not in the total at the top of the page. Unknown revenue, an unpriced model, an unmatched visitor and a partial import all propagate as unknown, and any figure computed from them is shown with the unknown count beside it. A profit number that quietly treats absent data as nothing is not conservative, it is wrong in the direction that makes you feel better. The sharpest example is in the usage wrapper, so get it right first. When you stream a completion, the token usage is not returned unless you ask for it: OpenAI's reference states that the usage field is present only when stream_options include_usage is set in the request, that it is null on every chunk except the last, and that the final chunk carries the totals for the whole request. A wrapper that reads response.usage from a stream without setting it records nothing, your code helpfully turns nothing into zero, and every margin on the dashboard is too good. Worse, an interrupted stream may never deliver that final chunk at all, so a cancelled or failed call has consumed tokens you will never see a figure for. Record that call with status failed and usage unknown, not with zero tokens. Second rule, smaller but load-bearing: the join key is an account identifier you control and issue. Never an email address. People change them, share them, type them differently at checkout, and a join on an email is a join that silently merges two customers or splits one. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and Fastify, server-rendered pages, one process · a dashboard for one person does not need a build pipeline and a client framework - Storage: SQLite in one file, append-only ledgers, no destructive updates · every number on the page must be traceable to rows that were never rewritten - Payments: Stripe webhooks with signature verification and event-id idempotency · delivery is at least once and order is not guaranteed, which the schema has to assume - Model usage: A thin wrapper returning the provider response unchanged, recording usage beside it · if the wrapper changes the response people stop using it and the data stops arriving - Prices: A versioned catalogue with effective dates, cost computed on the server · prices change and yesterday's calls must keep yesterday's cost ### Data model (create this before Phase 1) - visitors(id, first_seen, last_seen) · a random identifier, no address, no fingerprint - identities(visitor_id, account_id, bound_at) · set by your own identify call, never derived from an email - events(id, visitor_id, account_id, name, occurred_at, props_json) · account_id stays null until a binding exists and null means unmatched, not anonymous - revenue(id, stripe_event_id, account_id, kind, amount_minor, currency, occurred_at, received_at, raw_json) · append only, unique on the event id - usage(id, account_id, model_id, input_tokens, cached_input_tokens, output_tokens, status, usage_known, latency_ms, occurred_at) · usage_known is false for interrupted or failed calls - prices(model_id, effective_from, effective_to, input_minor_per_million, cached_input_minor_per_million, output_minor_per_million, source_note) - windows(window, finalised_through, unmatched_events, unknown_usage_calls, unpriced_calls) · the holes, computed and displayed with every total ### Phase 1 · An identifier you issue, and honest gaps Build: Ship a small first-party tracker that records page views and named events against a random visitor identifier. Add an endpoint your application calls to bind that visitor to your own stable account identifier. Backfill earlier events for that visitor when the binding arrives, and record the backfill. Leave everything still unbound counted as unmatched. Trap: Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. Done when: An event recorded before identification is bound to the account when identification happens, and the backfill is recorded, tested. Events never bound are counted as unmatched and that count appears with every total, not in a footnote. No address, agent string or network address is stored against a visitor, verified in the database. The tracker sends nothing to any third party, verified by watching the network. Do not build yet: No payments, model usage, pricing or dashboard yet. ### Phase 2 · A revenue ledger that survives duplicates and disorder Build: Verify the webhook signature, store the raw event keyed by its event identifier, and return success before doing anything else. Process asynchronously into an append-only revenue ledger. Read your account identifier from the payment metadata. Treat a refund as another ledger row rather than an edit, and reconstruct balances by summing. Trap: Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. Done when: Replaying the same event twenty times produces exactly one ledger row and no change to any total, tested. A refund processed before its charge arrives produces a correct balance once both are present, tested by delivering them in reverse. The endpoint responds before any processing, and a signature failure stores nothing. Every ledger row keeps the raw payload it came from. Do not build yet: No model usage, pricing or dashboard yet. ### Phase 3 · Usage you actually measured Build: Wrap the provider client so it returns the response unchanged and records a usage row beside it. For streaming calls, request usage explicitly and read it from the final chunk. If the stream ends without that chunk, or the call errors after tokens were consumed, write the row with status failed and usage unknown. Never record prompts or outputs, only counts. Trap: Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. Done when: A streamed call records the same token counts as the equivalent non-streamed call, tested against a recorded fixture. A stream cut off before its final chunk produces a row with usage unknown and never a row with zero tokens, tested. Cached input tokens are stored in their own column rather than folded into input tokens. No prompt or completion text appears anywhere in the database, verified by inspection. Do not build yet: No pricing, joins or dashboard yet. ### Phase 4 · Prices that were true at the time Build: Keep prices in a versioned file with effective dates per model and per token kind, including cached input. Compute cost at query time by matching the call's timestamp against the effective range. A model with no matching price produces no cost figure and increments an unpriced count that travels with every total that includes it. Trap: Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. Done when: Changing a price with a new effective date changes the cost of calls after that date and leaves earlier calls untouched, tested at the boundary. A call to a model absent from the catalogue produces no cost and increments the unpriced count shown with the total. No usage row stores a money value. Each price entry records where the figure came from and when it was checked. Do not build yet: No dashboard, demo data or retention yet. ### Phase 5 · A dashboard that shows its own holes Build: Join activity, revenue and usage only on the account identifier and only across the same finalised window. Show visitors, paying accounts, revenue, model cost, contribution and margin, each with unmatched, unknown-usage and unpriced counts beside it. Every total links to its rows. Protect the page with one token, ship deletable demo data, and back up on a schedule. Trap: Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. Done when: Every headline figure carries its unmatched, unknown-usage and unpriced counts and links to the underlying rows, checked by review. A window inside the settlement lag is labelled as still filling everywhere it appears, tested. Demo data is removable with one command and cannot coexist with real data, tested. A restore from backup reproduces every total exactly, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Connectors for hosted analytics products, which are OAuth maintenance rather than a feature and drift the moment their export schema changes. - Automatic discovery of model prices, because a wrong price discovered automatically is worse than a missing one you noticed. - Cross-device and cross-browser identity stitching, which is guesswork dressed as data and breaks the one rule this build has. - Multi-currency reporting, which needs a rate source with dated history and is a project of its own. ### README must contain - That a missing value is carried as unknown everywhere and never rendered as zero, with the counts that appear beside every total - That streamed model calls only report token usage when the request asks for it, and that an interrupted stream records unknown rather than zero - That payment events may arrive more than once and out of order, and that the ledger is keyed on the event identifier - That the join key is an account identifier you issue, and why an email address is not acceptable - Where prices come from, when they were last checked, and that cost is computed on read rather than stored ===== ARCHITECTURE.md ===== # Architecture · Grow or Die ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and Fastify, server-rendered pages, one process | a dashboard for one person does not need a build pipeline and a client framework | | Storage | SQLite in one file, append-only ledgers, no destructive updates | every number on the page must be traceable to rows that were never rewritten | | Payments | Stripe webhooks with signature verification and event-id idempotency | delivery is at least once and order is not guaranteed, which the schema has to assume | | Model usage | A thin wrapper returning the provider response unchanged, recording usage beside it | if the wrapper changes the response people stop using it and the data stops arriving | | Prices | A versioned catalogue with effective dates, cost computed on the server | prices change and yesterday's calls must keep yesterday's cost | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tracking | Visitors, events and the identify binding. | Any tracker, provided the join key stays an identifier you issue. | | Revenue | Webhook intake and the append-only ledger. | Any processor, provided duplicates and disorder are assumed. | | Usage | The wrapper and token counts including unknowns. | Any provider, provided streaming usage is requested explicitly. | | Pricing | Dated catalogue and cost on read. | Any catalogue, provided no money value is stored on a usage row. | | Reporting | Windows, joins and the unknown counts. | Any presentation, provided unknowns travel with every total. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Events, revenue, usage and windows. Back it up; none of it can be refetched. - `STRIPE_WEBHOOK_SECRET` · required, secret · Verifies every incoming payment event before it is stored. - `STRIPE_API_KEY` · required, secret · Used to fetch objects an out-of-order event referenced but did not contain. - `MODEL_API_KEY` · required, secret · Passed straight through by the wrapper and never recorded anywhere. - `ADMIN_TOKEN` · required, secret · The only credential on the dashboard. Rotate it when you share a screenshot. - `SETTLEMENT_LAG_DAYS` · required · A window is not finalised until this much time has passed. - `PRICE_CATALOGUE_PATH` · required · Dated prices per model. The source and check date live in the file. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Grow or Die product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript and Fastify, server-rendered pages, one process, SQLite in one file, append-only ledgers, no destructive updates, Stripe webhooks with signature verification and event-id idempotency, A thin wrapper returning the provider response unchanged, recording usage beside it, A versioned catalogue with effective dates, cost computed on the server. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. - Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. ===== MILESTONES.md ===== # Delivery milestones · Grow or Die Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An identifier you issue, and honest gaps Product activity is recorded and bound to your own account identifier, with unbound activity visibly unbound. ### Steps 1. Issue the account identifier yourself It is the only column every other table will join on. Files: `src/identify.ts`, `tests/identity.test.ts` 2. Backfill on binding and record that you did Otherwise the same visitor appears as two customers. Files: `src/events.ts` 3. Count unmatched activity as unmatched It is a real number and it belongs on the page. Files: `src/windows.ts` ### Done when - [ ] Pre-identification events bind and the backfill is recorded - [ ] Unmatched events are counted and displayed - [ ] No address or fingerprint is stored per visitor - [ ] The tracker makes no third-party request ### Watch out - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. ## M2 · A revenue ledger that survives duplicates and disorder Payment events can arrive twice, out of order or late without changing any total. ### Steps 1. Unique on the event identifier, not on anything else It is the only duplicate detector the provider gives you. Files: `src/webhooks.ts`, `tests/idempotency.test.ts` 2. Store raw, respond, then process The endpoint has a deadline and your processing does not. Files: `src/webhooks.ts` 3. Make refunds rows, never edits An edited ledger cannot be reconciled against anything. Files: `src/revenue.ts`, `tests/out-of-order.test.ts` ### Done when - [ ] Twenty replays produce one row and no total change - [ ] Reversed delivery still reconciles correctly - [ ] The endpoint responds before processing - [ ] Raw payloads are retained per row ### Watch out - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. ## M3 · Usage you actually measured Every model call records real token counts or is marked as unknown, and never as zero. ### Steps 1. Ask for usage on every streaming call It is not returned otherwise, and the default looks like success. Files: `src/llm/wrapper.ts`, `tests/streaming-usage.test.ts` 2. Record unknown on interruption, never zero Those tokens were spent whether or not you were told. Files: `src/llm/wrapper.ts` 3. Keep cached input tokens in their own column They price differently, so folding them in breaks the cost later. Files: `src/usage.ts` ### Done when - [ ] Streamed and non-streamed calls record the same counts - [ ] An interrupted stream records unknown, not zero - [ ] Cached input tokens have their own column - [ ] No prompt or output text is stored ### Watch out - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. ## M4 · Prices that were true at the time Cost is computed on the server from a dated catalogue, and an unpriced model stays unpriced. ### Steps 1. Store tokens, compute cost on read A stored cost is a mistake you cannot correct. Files: `src/pricing.ts`, `tests/pricing.test.ts` 2. Match the call time against the effective range Yesterday's calls keep yesterday's price. Files: `src/pricing.ts` 3. Make an unpriced model visible, not free Free is the most expensive default in this build. Files: `src/windows.ts` ### Done when - [ ] A new effective date changes only later calls - [ ] An unknown model produces no cost and a visible count - [ ] No money value is stored on a usage row - [ ] Every price records its source and check date ### Watch out - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ## M5 · A dashboard that shows its own holes (production only) Every figure links to the rows behind it and states what is missing from it. ### Steps 1. Link every total to its rows A number you cannot open is a number you cannot defend. Files: `src/routes/dashboard.ts` 2. Finalise windows behind a settlement lag Late payments make recent windows liars. Files: `src/windows.ts`, `tests/windows.test.ts` 3. Make demo data mutually exclusive with real data Mixed demo data is the fastest way to lose trust in the whole thing. Files: `scripts/demo.ts` ### Done when - [ ] Totals carry their unknown counts and link to rows - [ ] Unfinalised windows are labelled everywhere - [ ] Demo data deletes in one command and cannot mix - [ ] A restore reproduces every total ### Watch out - Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. ===== OPERATIONS.md ===== # Operations · Grow or Die ## Backup One database file, on a schedule, off the host. Events and usage rows cannot be refetched from anywhere: the provider will not tell you what you called last month and the tracker data exists nowhere else. ## Restore Restore and confirm that revenue totals and token totals match the last report you looked at, then confirm the unknown counts match too. A restore where the totals agree but the unknown counts changed means rows were lost from the tail. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures and retries, the proportion of usage rows with unknown status, unpriced call count, unmatched event count, and how far behind the finalised window is. A rising unknown-usage share usually means a new call site bypassed the wrapper. ## Incident checklist The incident that matters is a number you acted on that was wrong. Because the ledgers are append-only and raw payloads are retained, you can reconstruct exactly what the dashboard showed on any past day and say why. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A replayed payment event is confirmed to change no total - [ ] An interrupted stream is confirmed to record unknown rather than zero - [ ] No money value is confirmed stored on a usage row - [ ] Unknown counts are confirmed present beside every headline figure - [ ] Demo data is confirmed unable to coexist with real data ## Launch constraint Do not market omitted Grow or Die capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, revenue, usage and windows. Back it up; none of it can be refetched. DATABASE_PATH=./data/profit.db # Required · secret. Verifies every incoming payment event before it is stored. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Used to fetch objects an out-of-order event referenced but did not contain. STRIPE_API_KEY=sk_live_...redacted # Required · secret. Passed straight through by the wrapper and never recorded anywhere. MODEL_API_KEY=sk-...redacted # Required · secret. The only credential on the dashboard. Rotate it when you share a screenshot. ADMIN_TOKEN=a long random string # Required. A window is not finalised until this much time has passed. SETTLEMENT_LAG_DAYS=3 # Required. Dated prices per model. The source and check date live in the file. PRICE_CATALOGUE_PATH=./config/prices.json
# Grow or Die · indie build A one-product profit dashboard whose hard problems are recording model usage that the provider only reports when asked, absorbing payment events that arrive duplicated and out of order, and carrying unknown data through every calculation instead of rounding it to zero. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and Fastify, server-rendered pages, one process | a dashboard for one person does not need a build pipeline and a client framework | | Storage | SQLite in one file, append-only ledgers, no destructive updates | every number on the page must be traceable to rows that were never rewritten | | Payments | Stripe webhooks with signature verification and event-id idempotency | delivery is at least once and order is not guaranteed, which the schema has to assume | | Model usage | A thin wrapper returning the provider response unchanged, recording usage beside it | if the wrapper changes the response people stop using it and the data stops arriving | | Prices | A versioned catalogue with effective dates, cost computed on the server | prices change and yesterday's calls must keep yesterday's cost | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the dashboard, the tracker endpoint and the webhook receiver. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A Stripe account with webhooks and a signing secret** · free to set up; transaction fees apply to real payments - Why: Revenue arrives as events, and the whole ledger design exists because those events repeat and arrive out of order. - Get it: Create a webhook endpoint for the events you care about and copy the signing secret. - Verify: The command line tool forwards a test event that your endpoint verifies - [ ] **A model provider key for the usage wrapper** · pay per token - Why: The wrapper is the only place token counts can be observed, and the streaming behaviour is the reason this phase exists. - Get it: A key with a spend limit set in the provider console. - Verify: A streamed call records the same token counts as the same call unstreamed - [ ] **Your own stable account identifier** · free - Why: Every join in this build is on this column, and choosing it after the tracker ships means reprocessing everything. - Get it: Decide what an account is in your product, how the identifier is generated, and where your application knows it. - Verify: The identify call is wired into your product and the column never contains an address - [ ] **A price catalogue you maintain by hand** · free - Why: Costs are computed from it and nobody is going to tell you when a figure changes. - Get it: Copy the current published prices for the models you use, with the date and the source URL, into the catalogue file. - Verify: Every model appearing in usage has a price entry covering that date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. - ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors - maintained OpenAI and Anthropic pricing rules, including cached tokens - seven language SDKs with streaming, failure, timeout, and cancellation tracking - careful unknown, unpriced, unmatched, and incomplete data states - managed retention, backups, source health, and production reliability If one of those is essential to you, that is the reason to keep paying for Grow or Die, and the README should say so rather than pretend.
# Build brief · Grow or Die The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-tenant profit dashboard that joins product activity, payments and model spend on one account identifier and refuses to guess to replace Grow or Die. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule decides this build: a missing number is never a zero. Not in the ledger, not in the usage table, not in the total at the top of the page. Unknown revenue, an unpriced model, an unmatched visitor and a partial import all propagate as unknown, and any figure computed from them is shown with the unknown count beside it. A profit number that quietly treats absent data as nothing is not conservative, it is wrong in the direction that makes you feel better. The sharpest example is in the usage wrapper, so get it right first. When you stream a completion, the token usage is not returned unless you ask for it: OpenAI's reference states that the usage field is present only when stream_options include_usage is set in the request, that it is null on every chunk except the last, and that the final chunk carries the totals for the whole request. A wrapper that reads response.usage from a stream without setting it records nothing, your code helpfully turns nothing into zero, and every margin on the dashboard is too good. Worse, an interrupted stream may never deliver that final chunk at all, so a cancelled or failed call has consumed tokens you will never see a figure for. Record that call with status failed and usage unknown, not with zero tokens. Second rule, smaller but load-bearing: the join key is an account identifier you control and issue. Never an email address. People change them, share them, type them differently at checkout, and a join on an email is a join that silently merges two customers or splits one. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and Fastify, server-rendered pages, one process · a dashboard for one person does not need a build pipeline and a client framework - Storage: SQLite in one file, append-only ledgers, no destructive updates · every number on the page must be traceable to rows that were never rewritten - Payments: Stripe webhooks with signature verification and event-id idempotency · delivery is at least once and order is not guaranteed, which the schema has to assume - Model usage: A thin wrapper returning the provider response unchanged, recording usage beside it · if the wrapper changes the response people stop using it and the data stops arriving - Prices: A versioned catalogue with effective dates, cost computed on the server · prices change and yesterday's calls must keep yesterday's cost ### Data model (create this before Phase 1) - visitors(id, first_seen, last_seen) · a random identifier, no address, no fingerprint - identities(visitor_id, account_id, bound_at) · set by your own identify call, never derived from an email - events(id, visitor_id, account_id, name, occurred_at, props_json) · account_id stays null until a binding exists and null means unmatched, not anonymous - revenue(id, stripe_event_id, account_id, kind, amount_minor, currency, occurred_at, received_at, raw_json) · append only, unique on the event id - usage(id, account_id, model_id, input_tokens, cached_input_tokens, output_tokens, status, usage_known, latency_ms, occurred_at) · usage_known is false for interrupted or failed calls - prices(model_id, effective_from, effective_to, input_minor_per_million, cached_input_minor_per_million, output_minor_per_million, source_note) - windows(window, finalised_through, unmatched_events, unknown_usage_calls, unpriced_calls) · the holes, computed and displayed with every total ### Phase 1 · An identifier you issue, and honest gaps Build: Ship a small first-party tracker that records page views and named events against a random visitor identifier. Add an endpoint your application calls to bind that visitor to your own stable account identifier. Backfill earlier events for that visitor when the binding arrives, and record the backfill. Leave everything still unbound counted as unmatched. Trap: Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. Done when: An event recorded before identification is bound to the account when identification happens, and the backfill is recorded, tested. Events never bound are counted as unmatched and that count appears with every total, not in a footnote. No address, agent string or network address is stored against a visitor, verified in the database. The tracker sends nothing to any third party, verified by watching the network. Do not build yet: No payments, model usage, pricing or dashboard yet. ### Phase 2 · A revenue ledger that survives duplicates and disorder Build: Verify the webhook signature, store the raw event keyed by its event identifier, and return success before doing anything else. Process asynchronously into an append-only revenue ledger. Read your account identifier from the payment metadata. Treat a refund as another ledger row rather than an edit, and reconstruct balances by summing. Trap: Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. Done when: Replaying the same event twenty times produces exactly one ledger row and no change to any total, tested. A refund processed before its charge arrives produces a correct balance once both are present, tested by delivering them in reverse. The endpoint responds before any processing, and a signature failure stores nothing. Every ledger row keeps the raw payload it came from. Do not build yet: No model usage, pricing or dashboard yet. ### Phase 3 · Usage you actually measured Build: Wrap the provider client so it returns the response unchanged and records a usage row beside it. For streaming calls, request usage explicitly and read it from the final chunk. If the stream ends without that chunk, or the call errors after tokens were consumed, write the row with status failed and usage unknown. Never record prompts or outputs, only counts. Trap: Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. Done when: A streamed call records the same token counts as the equivalent non-streamed call, tested against a recorded fixture. A stream cut off before its final chunk produces a row with usage unknown and never a row with zero tokens, tested. Cached input tokens are stored in their own column rather than folded into input tokens. No prompt or completion text appears anywhere in the database, verified by inspection. Do not build yet: No pricing, joins or dashboard yet. ### Phase 4 · Prices that were true at the time Build: Keep prices in a versioned file with effective dates per model and per token kind, including cached input. Compute cost at query time by matching the call's timestamp against the effective range. A model with no matching price produces no cost figure and increments an unpriced count that travels with every total that includes it. Trap: Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. Done when: Changing a price with a new effective date changes the cost of calls after that date and leaves earlier calls untouched, tested at the boundary. A call to a model absent from the catalogue produces no cost and increments the unpriced count shown with the total. No usage row stores a money value. Each price entry records where the figure came from and when it was checked. Do not build yet: No dashboard, demo data or retention yet. ### Phase 5 · A dashboard that shows its own holes Build: Join activity, revenue and usage only on the account identifier and only across the same finalised window. Show visitors, paying accounts, revenue, model cost, contribution and margin, each with unmatched, unknown-usage and unpriced counts beside it. Every total links to its rows. Protect the page with one token, ship deletable demo data, and back up on a schedule. Trap: Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. Done when: Every headline figure carries its unmatched, unknown-usage and unpriced counts and links to the underlying rows, checked by review. A window inside the settlement lag is labelled as still filling everywhere it appears, tested. Demo data is removable with one command and cannot coexist with real data, tested. A restore from backup reproduces every total exactly, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Connectors for hosted analytics products, which are OAuth maintenance rather than a feature and drift the moment their export schema changes. - Automatic discovery of model prices, because a wrong price discovered automatically is worse than a missing one you noticed. - Cross-device and cross-browser identity stitching, which is guesswork dressed as data and breaks the one rule this build has. - Multi-currency reporting, which needs a rate source with dated history and is a project of its own. ### README must contain - That a missing value is carried as unknown everywhere and never rendered as zero, with the counts that appear beside every total - That streamed model calls only report token usage when the request asks for it, and that an interrupted stream records unknown rather than zero - That payment events may arrive more than once and out of order, and that the ledger is keyed on the event identifier - That the join key is an account identifier you issue, and why an email address is not acceptable - Where prices come from, when they were last checked, and that cost is computed on read rather than stored
# Agent instructions · Grow or Die indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and Fastify, server-rendered pages, one process, SQLite in one file, append-only ledgers, no destructive updates, Stripe webhooks with signature verification and event-id idempotency, A thin wrapper returning the provider response unchanged, recording usage beside it, A versioned catalogue with effective dates, cost computed on the server. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically.
# Build plan · Grow or Die A one-product profit dashboard whose hard problems are recording model usage that the provider only reports when asked, absorbing payment events that arrive duplicated and out of order, and carrying unknown data through every calculation instead of rounding it to zero. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An identifier you issue, and honest gaps Product activity is recorded and bound to your own account identifier, with unbound activity visibly unbound. ### Steps 1. Issue the account identifier yourself It is the only column every other table will join on. Files: `src/identify.ts`, `tests/identity.test.ts` 2. Backfill on binding and record that you did Otherwise the same visitor appears as two customers. Files: `src/events.ts` 3. Count unmatched activity as unmatched It is a real number and it belongs on the page. Files: `src/windows.ts` ### Done when - [ ] Pre-identification events bind and the backfill is recorded - [ ] Unmatched events are counted and displayed - [ ] No address or fingerprint is stored per visitor - [ ] The tracker makes no third-party request ### Watch out - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. ## Phase 2 · A revenue ledger that survives duplicates and disorder Payment events can arrive twice, out of order or late without changing any total. ### Steps 1. Unique on the event identifier, not on anything else It is the only duplicate detector the provider gives you. Files: `src/webhooks.ts`, `tests/idempotency.test.ts` 2. Store raw, respond, then process The endpoint has a deadline and your processing does not. Files: `src/webhooks.ts` 3. Make refunds rows, never edits An edited ledger cannot be reconciled against anything. Files: `src/revenue.ts`, `tests/out-of-order.test.ts` ### Done when - [ ] Twenty replays produce one row and no total change - [ ] Reversed delivery still reconciles correctly - [ ] The endpoint responds before processing - [ ] Raw payloads are retained per row ### Watch out - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. ## Phase 3 · Usage you actually measured Every model call records real token counts or is marked as unknown, and never as zero. ### Steps 1. Ask for usage on every streaming call It is not returned otherwise, and the default looks like success. Files: `src/llm/wrapper.ts`, `tests/streaming-usage.test.ts` 2. Record unknown on interruption, never zero Those tokens were spent whether or not you were told. Files: `src/llm/wrapper.ts` 3. Keep cached input tokens in their own column They price differently, so folding them in breaks the cost later. Files: `src/usage.ts` ### Done when - [ ] Streamed and non-streamed calls record the same counts - [ ] An interrupted stream records unknown, not zero - [ ] Cached input tokens have their own column - [ ] No prompt or output text is stored ### Watch out - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. ## Phase 4 · Prices that were true at the time Cost is computed on the server from a dated catalogue, and an unpriced model stays unpriced. ### Steps 1. Store tokens, compute cost on read A stored cost is a mistake you cannot correct. Files: `src/pricing.ts`, `tests/pricing.test.ts` 2. Match the call time against the effective range Yesterday's calls keep yesterday's price. Files: `src/pricing.ts` 3. Make an unpriced model visible, not free Free is the most expensive default in this build. Files: `src/windows.ts` ### Done when - [ ] A new effective date changes only later calls - [ ] An unknown model produces no cost and a visible count - [ ] No money value is stored on a usage row - [ ] Every price records its source and check date ### Watch out - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ## Not in this build - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. ## After v1, if you want it - A daily snapshot of every headline figure, so you can see when a number changed and which rows arrived late - A second payment processor behind the same ledger interface, which is mostly a test of whether the ledger was general enough - Per-feature cost attribution, once you have enough call sites that one model bill stops being one answer
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, revenue, usage and windows. Back it up; none of it can be refetched. DATABASE_PATH=./data/profit.db # Required · secret. Verifies every incoming payment event before it is stored. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Used to fetch objects an out-of-order event referenced but did not contain. STRIPE_API_KEY=sk_live_...redacted # Required · secret. Passed straight through by the wrapper and never recorded anywhere. MODEL_API_KEY=sk-...redacted # Required · secret. The only credential on the dashboard. Rotate it when you share a screenshot. ADMIN_TOKEN=a long random string # Required. A window is not finalised until this much time has passed. SETTLEMENT_LAG_DAYS=3 # Required. Dated prices per model. The source and check date live in the file. PRICE_CATALOGUE_PATH=./config/prices.json
# Grow or Die · product brief ## Problem An agent can build the honest personal version: a first-party tracker, a Stripe webhook, an OpenAI usage wrapper, and a SQLite join keyed by your own account ID. That is enough to show which customers make money. The paid product starts earning its fee when the inputs stop being tidy. Analytics exports drift, refunds arrive late, model prices change, streaming calls fail after consuming tokens, and anonymous visitors do not naturally become the same people who paid. A weekend build can give one founder a useful ledger for one stack. Matching Grow or Die's connector coverage, SDKs, missing-data discipline, retention, and production reliability is ongoing analytics infrastructure, not a prompt. ## Product outcome A profit dashboard for one product where every total names what is missing from it, model usage is measured rather than assumed, and a payment event arriving three times changes nothing. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - VPS with a domain and TLS - Stripe webhook secret - OpenAI API key - first-party tracker and identify call - SQLite backups ## Explicit non-goals for v1 - Hosted analytics connectors. - Automatic price discovery. - Cross-device identity stitching. - Multi-currency reporting. - ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors - maintained OpenAI and Anthropic pricing rules, including cached tokens - seven language SDKs with streaming, failure, timeout, and cancellation tracking - careful unknown, unpriced, unmatched, and incomplete data states - managed retention, backups, source health, and production reliability ## Success criteria - A replayed payment event is confirmed to change no total - An interrupted stream is confirmed to record unknown rather than zero - No money value is confirmed stored on a usage row - Unknown counts are confirmed present beside every headline figure - Demo data is confirmed unable to coexist with real data
# Build brief · Grow or Die The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-tenant profit dashboard that joins product activity, payments and model spend on one account identifier and refuses to guess to replace Grow or Die. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule decides this build: a missing number is never a zero. Not in the ledger, not in the usage table, not in the total at the top of the page. Unknown revenue, an unpriced model, an unmatched visitor and a partial import all propagate as unknown, and any figure computed from them is shown with the unknown count beside it. A profit number that quietly treats absent data as nothing is not conservative, it is wrong in the direction that makes you feel better. The sharpest example is in the usage wrapper, so get it right first. When you stream a completion, the token usage is not returned unless you ask for it: OpenAI's reference states that the usage field is present only when stream_options include_usage is set in the request, that it is null on every chunk except the last, and that the final chunk carries the totals for the whole request. A wrapper that reads response.usage from a stream without setting it records nothing, your code helpfully turns nothing into zero, and every margin on the dashboard is too good. Worse, an interrupted stream may never deliver that final chunk at all, so a cancelled or failed call has consumed tokens you will never see a figure for. Record that call with status failed and usage unknown, not with zero tokens. Second rule, smaller but load-bearing: the join key is an account identifier you control and issue. Never an email address. People change them, share them, type them differently at checkout, and a join on an email is a join that silently merges two customers or splits one. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and Fastify, server-rendered pages, one process · a dashboard for one person does not need a build pipeline and a client framework - Storage: SQLite in one file, append-only ledgers, no destructive updates · every number on the page must be traceable to rows that were never rewritten - Payments: Stripe webhooks with signature verification and event-id idempotency · delivery is at least once and order is not guaranteed, which the schema has to assume - Model usage: A thin wrapper returning the provider response unchanged, recording usage beside it · if the wrapper changes the response people stop using it and the data stops arriving - Prices: A versioned catalogue with effective dates, cost computed on the server · prices change and yesterday's calls must keep yesterday's cost ### Data model (create this before Phase 1) - visitors(id, first_seen, last_seen) · a random identifier, no address, no fingerprint - identities(visitor_id, account_id, bound_at) · set by your own identify call, never derived from an email - events(id, visitor_id, account_id, name, occurred_at, props_json) · account_id stays null until a binding exists and null means unmatched, not anonymous - revenue(id, stripe_event_id, account_id, kind, amount_minor, currency, occurred_at, received_at, raw_json) · append only, unique on the event id - usage(id, account_id, model_id, input_tokens, cached_input_tokens, output_tokens, status, usage_known, latency_ms, occurred_at) · usage_known is false for interrupted or failed calls - prices(model_id, effective_from, effective_to, input_minor_per_million, cached_input_minor_per_million, output_minor_per_million, source_note) - windows(window, finalised_through, unmatched_events, unknown_usage_calls, unpriced_calls) · the holes, computed and displayed with every total ### Phase 1 · An identifier you issue, and honest gaps Build: Ship a small first-party tracker that records page views and named events against a random visitor identifier. Add an endpoint your application calls to bind that visitor to your own stable account identifier. Backfill earlier events for that visitor when the binding arrives, and record the backfill. Leave everything still unbound counted as unmatched. Trap: Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. Done when: An event recorded before identification is bound to the account when identification happens, and the backfill is recorded, tested. Events never bound are counted as unmatched and that count appears with every total, not in a footnote. No address, agent string or network address is stored against a visitor, verified in the database. The tracker sends nothing to any third party, verified by watching the network. Do not build yet: No payments, model usage, pricing or dashboard yet. ### Phase 2 · A revenue ledger that survives duplicates and disorder Build: Verify the webhook signature, store the raw event keyed by its event identifier, and return success before doing anything else. Process asynchronously into an append-only revenue ledger. Read your account identifier from the payment metadata. Treat a refund as another ledger row rather than an edit, and reconstruct balances by summing. Trap: Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. Done when: Replaying the same event twenty times produces exactly one ledger row and no change to any total, tested. A refund processed before its charge arrives produces a correct balance once both are present, tested by delivering them in reverse. The endpoint responds before any processing, and a signature failure stores nothing. Every ledger row keeps the raw payload it came from. Do not build yet: No model usage, pricing or dashboard yet. ### Phase 3 · Usage you actually measured Build: Wrap the provider client so it returns the response unchanged and records a usage row beside it. For streaming calls, request usage explicitly and read it from the final chunk. If the stream ends without that chunk, or the call errors after tokens were consumed, write the row with status failed and usage unknown. Never record prompts or outputs, only counts. Trap: Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. Done when: A streamed call records the same token counts as the equivalent non-streamed call, tested against a recorded fixture. A stream cut off before its final chunk produces a row with usage unknown and never a row with zero tokens, tested. Cached input tokens are stored in their own column rather than folded into input tokens. No prompt or completion text appears anywhere in the database, verified by inspection. Do not build yet: No pricing, joins or dashboard yet. ### Phase 4 · Prices that were true at the time Build: Keep prices in a versioned file with effective dates per model and per token kind, including cached input. Compute cost at query time by matching the call's timestamp against the effective range. A model with no matching price produces no cost figure and increments an unpriced count that travels with every total that includes it. Trap: Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. Done when: Changing a price with a new effective date changes the cost of calls after that date and leaves earlier calls untouched, tested at the boundary. A call to a model absent from the catalogue produces no cost and increments the unpriced count shown with the total. No usage row stores a money value. Each price entry records where the figure came from and when it was checked. Do not build yet: No dashboard, demo data or retention yet. ### Phase 5 · A dashboard that shows its own holes Build: Join activity, revenue and usage only on the account identifier and only across the same finalised window. Show visitors, paying accounts, revenue, model cost, contribution and margin, each with unmatched, unknown-usage and unpriced counts beside it. Every total links to its rows. Protect the page with one token, ship deletable demo data, and back up on a schedule. Trap: Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears. Done when: Every headline figure carries its unmatched, unknown-usage and unpriced counts and links to the underlying rows, checked by review. A window inside the settlement lag is labelled as still filling everywhere it appears, tested. Demo data is removable with one command and cannot coexist with real data, tested. A restore from backup reproduces every total exactly, exercised in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Connectors for hosted analytics products, which are OAuth maintenance rather than a feature and drift the moment their export schema changes. - Automatic discovery of model prices, because a wrong price discovered automatically is worse than a missing one you noticed. - Cross-device and cross-browser identity stitching, which is guesswork dressed as data and breaks the one rule this build has. - Multi-currency reporting, which needs a rate source with dated history and is a project of its own. ### README must contain - That a missing value is carried as unknown everywhere and never rendered as zero, with the counts that appear beside every total - That streamed model calls only report token usage when the request asks for it, and that an interrupted stream records unknown rather than zero - That payment events may arrive more than once and out of order, and that the ledger is keyed on the event identifier - That the join key is an account identifier you issue, and why an email address is not acceptable - Where prices come from, when they were last checked, and that cost is computed on read rather than stored
# Architecture · Grow or Die ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and Fastify, server-rendered pages, one process | a dashboard for one person does not need a build pipeline and a client framework | | Storage | SQLite in one file, append-only ledgers, no destructive updates | every number on the page must be traceable to rows that were never rewritten | | Payments | Stripe webhooks with signature verification and event-id idempotency | delivery is at least once and order is not guaranteed, which the schema has to assume | | Model usage | A thin wrapper returning the provider response unchanged, recording usage beside it | if the wrapper changes the response people stop using it and the data stops arriving | | Prices | A versioned catalogue with effective dates, cost computed on the server | prices change and yesterday's calls must keep yesterday's cost | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tracking | Visitors, events and the identify binding. | Any tracker, provided the join key stays an identifier you issue. | | Revenue | Webhook intake and the append-only ledger. | Any processor, provided duplicates and disorder are assumed. | | Usage | The wrapper and token counts including unknowns. | Any provider, provided streaming usage is requested explicitly. | | Pricing | Dated catalogue and cost on read. | Any catalogue, provided no money value is stored on a usage row. | | Reporting | Windows, joins and the unknown counts. | Any presentation, provided unknowns travel with every total. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Events, revenue, usage and windows. Back it up; none of it can be refetched. - `STRIPE_WEBHOOK_SECRET` · required, secret · Verifies every incoming payment event before it is stored. - `STRIPE_API_KEY` · required, secret · Used to fetch objects an out-of-order event referenced but did not contain. - `MODEL_API_KEY` · required, secret · Passed straight through by the wrapper and never recorded anywhere. - `ADMIN_TOKEN` · required, secret · The only credential on the dashboard. Rotate it when you share a screenshot. - `SETTLEMENT_LAG_DAYS` · required · A window is not finalised until this much time has passed. - `PRICE_CATALOGUE_PATH` · required · Dated prices per model. The source and check date live in the file. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Grow or Die product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript and Fastify, server-rendered pages, one process, SQLite in one file, append-only ledgers, no destructive updates, Stripe webhooks with signature verification and event-id idempotency, A thin wrapper returning the provider response unchanged, recording usage beside it, A versioned catalogue with effective dates, cost computed on the server. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. - Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears.
# Delivery milestones · Grow or Die Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An identifier you issue, and honest gaps Product activity is recorded and bound to your own account identifier, with unbound activity visibly unbound. ### Steps 1. Issue the account identifier yourself It is the only column every other table will join on. Files: `src/identify.ts`, `tests/identity.test.ts` 2. Backfill on binding and record that you did Otherwise the same visitor appears as two customers. Files: `src/events.ts` 3. Count unmatched activity as unmatched It is a real number and it belongs on the page. Files: `src/windows.ts` ### Done when - [ ] Pre-identification events bind and the backfill is recorded - [ ] Unmatched events are counted and displayed - [ ] No address or fingerprint is stored per visitor - [ ] The tracker makes no third-party request ### Watch out - Binding on an email address because it is the field you already have merges two people who share a family address and splits one person who changed employer, and because the join looks clean nobody ever checks it. Issue your own identifier, pass it to the identify endpoint, and make the schema refuse to store an address in that column. ## M2 · A revenue ledger that survives duplicates and disorder Payment events can arrive twice, out of order or late without changing any total. ### Steps 1. Unique on the event identifier, not on anything else It is the only duplicate detector the provider gives you. Files: `src/webhooks.ts`, `tests/idempotency.test.ts` 2. Store raw, respond, then process The endpoint has a deadline and your processing does not. Files: `src/webhooks.ts` 3. Make refunds rows, never edits An edited ledger cannot be reconciled against anything. Files: `src/revenue.ts`, `tests/out-of-order.test.ts` ### Done when - [ ] Twenty replays produce one row and no total change - [ ] Reversed delivery still reconciles correctly - [ ] The endpoint responds before processing - [ ] Raw payloads are retained per row ### Watch out - Ordering events by their created timestamp and assuming each arrives once produces a ledger that double-counts a retried delivery and applies a refund before its charge, and Stripe's own documentation says delivery is at least once, that order is not guaranteed, and that created must not be used to determine order or whether an event was already processed. Make the event identifier unique in the ledger and make every handler safe to run twice. ## M3 · Usage you actually measured Every model call records real token counts or is marked as unknown, and never as zero. ### Steps 1. Ask for usage on every streaming call It is not returned otherwise, and the default looks like success. Files: `src/llm/wrapper.ts`, `tests/streaming-usage.test.ts` 2. Record unknown on interruption, never zero Those tokens were spent whether or not you were told. Files: `src/llm/wrapper.ts` 3. Keep cached input tokens in their own column They price differently, so folding them in breaks the cost later. Files: `src/usage.ts` ### Done when - [ ] Streamed and non-streamed calls record the same counts - [ ] An interrupted stream records unknown, not zero - [ ] Cached input tokens have their own column - [ ] No prompt or output text is stored ### Watch out - Reading the usage object off a streamed response without asking for it returns nothing, and the obvious defensive code turns that nothing into zero tokens, which makes every margin on the dashboard flatteringly high and gives no hint that anything is missing. Set the option that returns usage, read the final chunk, and when it never arrives store the call as unknown so the number on the page carries a caveat. ## M4 · Prices that were true at the time Cost is computed on the server from a dated catalogue, and an unpriced model stays unpriced. ### Steps 1. Store tokens, compute cost on read A stored cost is a mistake you cannot correct. Files: `src/pricing.ts`, `tests/pricing.test.ts` 2. Match the call time against the effective range Yesterday's calls keep yesterday's price. Files: `src/pricing.ts` 3. Make an unpriced model visible, not free Free is the most expensive default in this build. Files: `src/windows.ts` ### Done when - [ ] A new effective date changes only later calls - [ ] An unknown model produces no cost and a visible count - [ ] No money value is stored on a usage row - [ ] Every price records its source and check date ### Watch out - Storing a cost figure on the usage row at write time freezes a mistake permanently, and the day you notice a price was wrong you have no way to recompute without knowing which rows used which figure. Store tokens only, compute cost from the dated catalogue on read, and let a corrected price fix history automatically. ## M5 · A dashboard that shows its own holes (production only) Every figure links to the rows behind it and states what is missing from it. ### Steps 1. Link every total to its rows A number you cannot open is a number you cannot defend. Files: `src/routes/dashboard.ts` 2. Finalise windows behind a settlement lag Late payments make recent windows liars. Files: `src/windows.ts`, `tests/windows.test.ts` 3. Make demo data mutually exclusive with real data Mixed demo data is the fastest way to lose trust in the whole thing. Files: `scripts/demo.ts` ### Done when - [ ] Totals carry their unknown counts and link to rows - [ ] Unfinalised windows are labelled everywhere - [ ] Demo data deletes in one command and cannot mix - [ ] A restore reproduces every total ### Watch out - Showing today alongside a completed month invites comparing a window that is still filling against one that is finished, and since payments settle late and events arrive out of order, today always looks worse and someone acts on it. Mark a window finalised only through a stated lag, and label anything more recent as still filling wherever it appears.
# Operations · Grow or Die ## Backup One database file, on a schedule, off the host. Events and usage rows cannot be refetched from anywhere: the provider will not tell you what you called last month and the tracker data exists nowhere else. ## Restore Restore and confirm that revenue totals and token totals match the last report you looked at, then confirm the unknown counts match too. A restore where the totals agree but the unknown counts changed means rows were lost from the tail. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures and retries, the proportion of usage rows with unknown status, unpriced call count, unmatched event count, and how far behind the finalised window is. A rising unknown-usage share usually means a new call site bypassed the wrapper. ## Incident checklist The incident that matters is a number you acted on that was wrong. Because the ledgers are append-only and raw payloads are retained, you can reconstruct exactly what the dashboard showed on any past day and say why. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A replayed payment event is confirmed to change no total - [ ] An interrupted stream is confirmed to record unknown rather than zero - [ ] No money value is confirmed stored on a usage row - [ ] Unknown counts are confirmed present beside every headline figure - [ ] Demo data is confirmed unable to coexist with real data ## Launch constraint Do not market omitted Grow or Die capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, revenue, usage and windows. Back it up; none of it can be refetched. DATABASE_PATH=./data/profit.db # Required · secret. Verifies every incoming payment event before it is stored. STRIPE_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Used to fetch objects an out-of-order event referenced but did not contain. STRIPE_API_KEY=sk_live_...redacted # Required · secret. Passed straight through by the wrapper and never recorded anywhere. MODEL_API_KEY=sk-...redacted # Required · secret. The only credential on the dashboard. Rotate it when you share a screenshot. ADMIN_TOKEN=a long random string # Required. A window is not finalised until this much time has passed. SETTLEMENT_LAG_DAYS=3 # Required. Dated prices per model. The source and check date live in the file. PRICE_CATALOGUE_PATH=./config/prices.json
$ choose a build depth, inspect the files, then open the complete pack in your agent
A profit number is useful only when every input means the same customer and reporting window. Paying avoids maintaining analytics OAuth, payment webhooks, provider usage edge cases, model pricing, identity joins, retention, and the boring rule that missing data must stay missing.
xready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors
xmaintained OpenAI and Anthropic pricing rules, including cached tokens
xseven language SDKs with streaming, failure, timeout, and cancellation tracking
xcareful unknown, unpriced, unmatched, and incomplete data states
xmanaged retention, backups, source health, and production reliability
Grow or Die pricing
founder$24.08/mo · $289 billed annually, equivalent to $24.08 per month · $288.96/yr
free tierThere is no permanent free tier; the Founder plan starts with a 7-day free trial.
verified 2026-08-10 · source ↗
Is Grow or Die free?
There is no permanent free tier; the Founder plan starts with a 7-day free trial. Paid is Founder at $24.08/mo (checked 2026-08-10).
Vibecode Grow or Die
Kinda. The core of Grow or Die is buildable in a weekend with the prompt on this page, but there are real gaps: ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors, maintained OpenAI and Anthropic pricing rules, including cached tokens. Read the honest list above before committing.
How much does Grow or Die cost?
Grow or Die costs about $24.08/month (Founder, checked 2026-08-10), which is $288.96 per year.
What do I lose by replacing Grow or Die?
Honestly: ready-made GA4, PostHog, Stripe, and Lemon Squeezy connectors; maintained OpenAI and Anthropic pricing rules, including cached tokens; seven language SDKs with streaming, failure, timeout, and cancellation tracking; careful unknown, unpriced, unmatched, and incomplete data states; managed retention, backups, source health, and production reliability. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Grow or Die?
Yes: PostHog (Open-source product analytics and LLM observability that covers two large pieces of the build), OpenMeter (Open-source usage metering infrastructure for turning model calls into auditable usage facts). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.