Repository navigation
feat(analytics): credit paid purchases to the source product in Overwatch - #4434
Conversation
Self-reviewThe fixes are in Blocking - no recorded decision to credit purchasesThe description said "the product decision is in". The only decision on record says the opposite. @onoya on #3465, recording Keep for signups: "Cross-product Fixed
Verified, no change needed
Checks
🤖 Generated with Claude Code |
…atch Signups were already credited to the product a user came through (their first/last touch utm.source, marked self-reported). Paid purchases now are too, as a `purchase` event under each such product: - subscription invoices, from the invoice.payment_succeeded worker, for every invoice that collected money (first charge, renewal, seat increase); - credit packs, from payment_intent.succeeded after the credits are granted. The touches are read from user.acquisition (stored with consent at signup), since webhooks have no cookies and credit-pack intents carry no attribution metadata. Zero-amount invoices (trials), refunds and synthetic users send nothing. The eventId is keyed on the Stripe invoice or payment intent id, so redeliveries are de-duplicated by the receiver. The emit never throws and cannot fail or retry a webhook. Not named `subscribe`: Overwatch counts `subscribe` as a signup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Update the telemetry classification doc, which still said the subscription webhook emits nothing: it now describes the purchase events, what they carry, that Overwatch stores them as raw events only (its funnel has no purchase stage), and that a later consent withdrawal does not stop stored signup touches crediting purchases. - Correct the emitter docblocks to match: no funnel stage, the consent limitation, and a pointer to the doc instead of a decision claim. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2257dba to
78be284
Compare
onoya
left a comment
There was a problem hiding this comment.
LGTM. I pushed 78be284dd: organization invoices no longer inherit the checkout user's signup source, including malformed organization metadata, and failed purchase emits now leave a diagnostic without failing billing. The 103 affected tests and both package typechecks pass.
As owner, I approve directional raw purchase telemetry for individual subscriptions and credit packs, including use of stored signup touches after later cookie-consent withdrawal. These self-reported events remain excluded from payouts, contractual counts, external reports, and externally accessible funnels. Cross-product subscribe stays deferred until #4118.
Summary
Signups are already credited in Overwatch to the product a user came through (the first/last touch
utm.sourcethat names a product with an ingest key, markedattribution: 'self-reported'). This adds directional, self-reported rawpurchaseevents for individual subscription invoices and credit packs. It does not add a cross-productsubscribefunnel stage or a payout-grade ledger.What is sent
One
purchaseevent per source product, per payment:eventpurchaseeventIdstableEventId('purchase', productId, <Stripe invoice id or payment intent id>)userIdutmmetadata.touchfirst/last/bothmetadata.attributionself-reportedmetadata.kindsubscription/creditsmetadata.valuemetadata.currencymetadata.billingReasonsubscription_create,subscription_cycle,subscription_updateNot named
subscribe: Overwatch's signup stage matches bothsignupandsubscribe, so a renewal sent assubscribewould be counted as a new signup.Where money is collected
apps/workers/src/events/stripe/invoicePaymentSucceeded.ts, after the subscription/credit work and the stage guard. Every invoice that collected money counts (first charge, renewal, seat increase).payment_intent.succeededinpages/api/stripe/webhook.ts, aftergrantCreditPurchasecommits (the only caller;start-payment.tsis the only place pack intents are created).Excluded: organization invoices (the checkout user is not the organization's acquisition source), zero-amount invoices (trials, fully discounted or balance-paid), refunds and disputes, synthetic users, foreign-stage events, and intents without credits.
Where the credit is read from, and why
From
user.acquisition(the consented touches stored at signup), not from cookies or checkout metadata:A user with no stored acquisition (declined consent, or signed up before touches were stored) credits nothing.
Safety
Docs
docs-site/docs/security/telemetry-data-classification.mddescribed the subscription webhook as emitting nothing. It now describes the purchase stream: what is sent, where from, that Overwatch stores it as raw events only (its funnel has no purchase stage), and that a later consent withdrawal does not stop stored touches crediting purchases.Test plan
signupEvents.test.ts: first/last/both touches, host and keyless sources excluded (with the host keyed), no acquisition / no user, synthetic skipped, zero/negative/missing amount, missing payment id or currency, stable eventId per payment, value and currency, never throws when the emit rejects or the key lookup throwsinvoicePaymentSucceeded.test.ts: paid create/cycle/update individual invoices credited with the invoice-keyed eventId (stored touches used, checkout metadata ignored), organization invoice excluded, $0 invoice, synthetic and no-acquisition payers, foreign stage, handler still resolves when the payer lookup or emit fails. Replaces the test that held the deferral.webhook.test.ts: credit pack credited and keyed on the payment intent, same eventId on redelivery, nothing for no-touch or synthetic buyers, other environments or no credits, nothing when the grant rolls back, 200 when the emit rejects🤖 Generated with Claude Code