From 0bfeacdef8230dac39316e3d2638969210e43db6 Mon Sep 17 00:00:00 2001 From: "wizard-ci-bot[bot]" <254716194+wizard-ci-bot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:11:04 +0000 Subject: [PATCH] wizard-ci: stripe/stripe-next-js-saas-starter --- .../revenue-analytics-setup/.posthog-wizard | 0 .../skills/revenue-analytics-setup/SKILL.md | 115 +++++ .../references/connect-to-customers.md | 477 ++++++++++++++++++ .../references/identify-users.md | 272 ++++++++++ .../references/metadata.md | 67 +++ .../app/(login)/actions.ts | 12 +- .../lib/payments/actions.ts | 6 +- .../lib/payments/stripe.ts | 16 +- .../posthog-revenue-report.md | 26 + 9 files changed, 985 insertions(+), 6 deletions(-) create mode 100644 apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/.posthog-wizard create mode 100644 apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/SKILL.md create mode 100644 apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/connect-to-customers.md create mode 100644 apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/identify-users.md create mode 100644 apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/metadata.md create mode 100644 apps/revenue/stripe/stripe-next-js-saas-starter/posthog-revenue-report.md diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/.posthog-wizard b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/.posthog-wizard new file mode 100644 index 000000000..e69de29bb diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/SKILL.md b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/SKILL.md new file mode 100644 index 000000000..129408ec7 --- /dev/null +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/SKILL.md @@ -0,0 +1,115 @@ +--- +name: revenue-analytics-setup +description: Set up Stripe revenue analytics with PostHog +metadata: + author: PostHog + version: dev +--- + +# PostHog Revenue Analytics — Stripe Setup + +This skill connects Stripe revenue data to PostHog by adding `posthog_person_distinct_id` metadata to Stripe objects. This enables the Top Customers dashboard, `persons_revenue_analytics` table, and `groups_revenue_analytics` table in PostHog. + +## Reference files + +- `references/metadata.md` - Metadata +- `references/connect-to-customers.md` - Connect to customers - docs +- `references/identify-users.md` - Identify users - docs + +Consult the PostHog revenue analytics documentation for the full setup guide, and the Stripe API docs for language-specific code examples. + +## Guiding tenets + +Follow these tenets for every decision: + +1. **Never fabricate the value.** If the PostHog distinct_id is not available in the current scope, do NOT substitute another identifier (Stripe customer ID, internal user ID, org ID, etc.). A wrong value is worse than no value — it corrupts metadata and blocks correct identification downstream. Bring in the exact same identifier as used in the PostHog integrations. When this is not possible, use `"TODO_POSTHOG_DISTINCT_ID"` as a string placeholder and include this in the report. + +2. **Thread the value, don't invent it.** If a function needs the distinct_id but doesn't have it, add it as an optional parameter propagated from a caller that does. If no caller in the chain has it, skip that call site entirely and leave a TODO comment. + +3. **No extra API calls.** Never add new Stripe API calls (like `Customer.update`) just to set metadata. Instead, add `posthog_person_distinct_id` to the `metadata` parameter of Stripe objects that are already being created. + +4. **Follow existing Stripe abstraction patterns.** If the codebase wraps Stripe calls behind a utility/service layer, modify that layer. Don't call the Stripe API directly from business logic just to set metadata. + +5. **Never refactor unrelated existing code.** The only parts of the codebase that should be changed are the ones immediately related to getting PostHog distinct_id into Stripe calls. All remaining code should be left as is regardless. + +## How to find the PostHog distinct_id + +Before writing any code, determine what this project uses as the PostHog distinct_id: + +1. Search for `posthog.identify(` — the **first argument** is the distinct_id. This is the most reliable source. + - Example: `posthog.identify(email, { ... })` → the distinct_id is `email` + - Example: `posthog.identify(user.id, { ... })` → the distinct_id is `user.id` +2. Search for `posthog.capture(` or `client.capture(` — look for `distinctId` or `distinct_id` in the arguments. +3. Search for `posthog.get_distinct_id()` — the variable it's assigned to tells you what holds the distinct_id. + +Once you know WHAT value is the distinct_id, determine HOW to access that same value at each Stripe call site. The variable name may differ between files — trace the data flow. + +**Watch out!** Stripe's Checkout Sessions have a field called `client_reference_id`. This field **MAY NOT** be the same as PostHog distinct_id, so do not use it as a way to figure out what the distinc_id should be. + +If you could not find a valid PostHog distinct_id, emit `[ABORT] Could not find a PostHog distinct_id`. The wizard middleware catches `[ABORT]` and terminates the run — you do not need to halt yourself. + +## What to modify + +### Step 1: Add metadata to Stripe Customer creation + +For each `Customer.create` call, add `posthog_person_distinct_id` to the `metadata` parameter. + +- If the call already has a metadata object, ADD the `posthog_person_distinct_id` key. Do NOT overwrite existing metadata. +- If the distinct_id is not in scope, thread it as an optional parameter (Tenet 2). If no caller has it, skip this site with a TODO. +- Check if the codebase wraps Stripe calls behind a utility layer — if so, modify the wrapper (Tenet 4). + +If you could not find a valid Stripe integration, emit `[ABORT] Could not find a Stripe integration`. The wizard middleware catches `[ABORT]` and terminates the run — you do not need to halt yourself. + +### Step 2: Add metadata to Stripe payment/charge objects (REQUIRED) + +This step is **required**. The following Stripe objects support a `metadata` parameter: **Charge, PaymentIntent, Subscription, Invoice, Refund, Transfer**. Search the codebase for creation calls for any of these objects and add `posthog_person_distinct_id` to their `metadata`. + +This does NOT require any new API calls — just add the metadata field to the existing create calls that the app already makes. Same pattern as Step 1: add `posthog_person_distinct_id` to the `metadata` parameter. + +- If the distinct_id is not in scope at a call site, thread it as an optional parameter (Tenet 2). +- If the codebase wraps these calls behind a utility layer, modify the wrapper (Tenet 4). + +### Stripe Checkout special case + +If the project uses `checkout.Session.create`, add `posthog_person_distinct_id` to the session's `metadata` parameter. Also set `client_reference_id` to the user's distinct_id so it can be retrieved in webhooks. + +If the project has a `checkout.session.completed` webhook handler and Stripe auto-creates customers there, add the metadata to the customer in the webhook handler. + +### Step 3: Verify + +Read each modified file to verify: + +- No syntax errors +- Existing code logic is preserved +- The metadata uses the correct distinct_id value — not a fabricated property +- No new Stripe API calls were added (Tenet 3) — only existing calls were modified +- Changes respect existing abstraction patterns (Tenet 4) + +## Constraints + +- Do NOT add new Stripe API calls — only add metadata to existing create calls. +- Do NOT modify charge/payment logic — only add the metadata field. +- Do NOT remove any existing code. +- Do NOT add new packages or dependencies. +- Do NOT invent properties or values. Use only values that already exist in the codebase. +- Preserve all imports and error handling. + +## Status + +Report progress with `[STATUS]` prefixed messages: + +- Searching for PostHog distinct_id usage +- Identified distinct_id — updating Stripe Customer creation +- Adding metadata to payment/charge objects +- Verifying changes +- Revenue analytics setup complete + +## Abort statuses + +Report abort states with `[ABORT]` prefixed messages: +- Could not find PostHog distinct_id usage +- Could not find a Stripe integration + +## Framework guidelines + +_No specific framework guidelines._ diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/connect-to-customers.md b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/connect-to-customers.md new file mode 100644 index 000000000..262aa337a --- /dev/null +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/connect-to-customers.md @@ -0,0 +1,477 @@ +# Connect to customers - Docs + +**Revenue analytics is being deprecated** + +We'll remove the Revenue analytics dashboard on or after **June 30th, 2026**. + +We're not stepping away from revenue in PostHog — we're rethinking how it should work. Instead of maintaining a single, opinionated Revenue analytics dashboard, we're focusing on exposing revenue properties on persons and groups so you can use them everywhere: insights, SQL, and persons/groups profiles. Each use case (ecommerce, SaaS, recurring revenue, one-off, services, multi-tenant) can then build the dashboard it actually needs — or have PostHog AI and agents via our MCP build it for you. + +PostHog automatically connects revenue data to persons and groups when you use revenue events. For data warehouse sources, you need to map the connection manually. + +## Step 1: Add metadata when creating new customers + +Search your codebase for where you create Stripe customers (e.g. `stripe.Customer.create` or equivalent) and add the `posthog_person_distinct_id` metadata field. + +PostHog AI + +### Python + +```python +customer = stripe.Customer.create( + email=user.email, + metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, +) +``` + +### Node.js + +```javascript +const customer = await stripe.customers.create({ + email: user.email, + metadata: { posthog_person_distinct_id: user.posthogDistinctId }, +}); +``` + +### Ruby + +```ruby +customer = Stripe::Customer.create({ + email: user.email, + metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, +}) +``` + +### PHP + +```php +$customer = $stripe->customers->create([ + 'email' => $user->email, + 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], +]); +``` + +### Go + +```go +params := &stripe.CustomerParams{ + Email: stripe.String(user.Email), +} +params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) +cust, err := customer.New(params) +``` + +### Java + +```java +CustomerCreateParams params = CustomerCreateParams.builder() + .setEmail(user.getEmail()) + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build(); +Customer customer = Customer.create(params); +``` + +### dotnet + +```dotnet +var options = new CustomerCreateOptions +{ + Email = user.Email, + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, +}; +var customer = await customerService.CreateAsync(options); +``` + +## Step 2: Tag existing customers via charges, subscriptions, or invoices + +For customers created before you added the metadata in step 1, you don't need to update the customer object directly. Instead, pass `posthog_person_distinct_id` as metadata on any charge, subscription, or invoice tied to that customer. PostHog automatically resolves it from the most recently created child object. + +Add the metadata to whichever Stripe call you already make. Here are the most common patterns: + +### Subscriptions + +PostHog AI + +### Python + +```python +stripe.Subscription.create( + customer=user.stripe_customer_id, + items=[{"price": "price_xxx"}], + metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, +) +``` + +### Node.js + +```javascript +await stripe.subscriptions.create({ + customer: user.stripeCustomerId, + items: [{ price: 'price_xxx' }], + metadata: { posthog_person_distinct_id: user.posthogDistinctId }, +}); +``` + +### Ruby + +```ruby +Stripe::Subscription.create({ + customer: user.stripe_customer_id, + items: [{ price: 'price_xxx' }], + metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, +}) +``` + +### PHP + +```php +$stripe->subscriptions->create([ + 'customer' => $user->stripeCustomerId, + 'items' => [['price' => 'price_xxx']], + 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], +]); +``` + +### Go + +```go +params := &stripe.SubscriptionParams{ + Customer: stripe.String(user.StripeCustomerID), + Items: []*stripe.SubscriptionItemsParams{ + {Price: stripe.String("price_xxx")}, + }, +} +params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) +sub, err := subscription.New(params) +``` + +### Java + +```java +SubscriptionCreateParams params = SubscriptionCreateParams.builder() + .setCustomer(user.getStripeCustomerId()) + .addItem(SubscriptionCreateParams.Item.builder().setPrice("price_xxx").build()) + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build(); +Subscription subscription = Subscription.create(params); +``` + +### dotnet + +```dotnet +var options = new SubscriptionCreateOptions +{ + Customer = user.StripeCustomerId, + Items = new List + { + new() { Price = "price_xxx" }, + }, + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, +}; +var subscription = await subscriptionService.CreateAsync(options); +``` + +### One-off charges (payment intents) + +PostHog AI + +### Python + +```python +stripe.PaymentIntent.create( + amount=1000, + currency="usd", + customer=user.stripe_customer_id, + metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, +) +``` + +### Node.js + +```javascript +await stripe.paymentIntents.create({ + amount: 1000, + currency: 'usd', + customer: user.stripeCustomerId, + metadata: { posthog_person_distinct_id: user.posthogDistinctId }, +}); +``` + +### Ruby + +```ruby +Stripe::PaymentIntent.create({ + amount: 1000, + currency: 'usd', + customer: user.stripe_customer_id, + metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, +}) +``` + +### PHP + +```php +$stripe->paymentIntents->create([ + 'amount' => 1000, + 'currency' => 'usd', + 'customer' => $user->stripeCustomerId, + 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], +]); +``` + +### Go + +```go +params := &stripe.PaymentIntentParams{ + Amount: stripe.Int64(1000), + Currency: stripe.String(string(stripe.CurrencyUSD)), + Customer: stripe.String(user.StripeCustomerID), +} +params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) +pi, err := paymentintent.New(params) +``` + +### Java + +```java +PaymentIntentCreateParams params = PaymentIntentCreateParams.builder() + .setAmount(1000L) + .setCurrency("usd") + .setCustomer(user.getStripeCustomerId()) + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build(); +PaymentIntent intent = PaymentIntent.create(params); +``` + +### dotnet + +```dotnet +var options = new PaymentIntentCreateOptions +{ + Amount = 1000, + Currency = "usd", + Customer = user.StripeCustomerId, + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, +}; +var intent = await paymentIntentService.CreateAsync(options); +``` + +### Stripe Checkout + +Pass the metadata in the checkout session's `subscription_data` or `payment_intent_data` depending on your checkout mode. Also set `client_reference_id` to your internal user ID so you can look up the distinct ID. + +PostHog AI + +### Python + +```python +# For recurring (subscription) checkout +session = stripe.checkout.Session.create( + mode="subscription", + client_reference_id=user.id, + subscription_data={ + "metadata": {"posthog_person_distinct_id": user.posthog_distinct_id}, + }, + # ... other params +) +# For one-time payment checkout +session = stripe.checkout.Session.create( + mode="payment", + client_reference_id=user.id, + payment_intent_data={ + "metadata": {"posthog_person_distinct_id": user.posthog_distinct_id}, + }, + # ... other params +) +``` + +### Node.js + +```javascript +// For recurring (subscription) checkout +const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + client_reference_id: user.id, + subscription_data: { + metadata: { posthog_person_distinct_id: user.posthogDistinctId }, + }, + // ... other params +}); +// For one-time payment checkout +const session = await stripe.checkout.sessions.create({ + mode: 'payment', + client_reference_id: user.id, + payment_intent_data: { + metadata: { posthog_person_distinct_id: user.posthogDistinctId }, + }, + // ... other params +}); +``` + +### Ruby + +```ruby +# For recurring (subscription) checkout +session = Stripe::Checkout::Session.create({ + mode: 'subscription', + client_reference_id: user.id, + subscription_data: { + metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, + }, + # ... other params +}) +# For one-time payment checkout +session = Stripe::Checkout::Session.create({ + mode: 'payment', + client_reference_id: user.id, + payment_intent_data: { + metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, + }, + # ... other params +}) +``` + +### PHP + +```php +// For recurring (subscription) checkout +$session = $stripe->checkout->sessions->create([ + 'mode' => 'subscription', + 'client_reference_id' => $user->id, + 'subscription_data' => [ + 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], + ], + // ... other params +]); +// For one-time payment checkout +$session = $stripe->checkout->sessions->create([ + 'mode' => 'payment', + 'client_reference_id' => $user->id, + 'payment_intent_data' => [ + 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], + ], + // ... other params +]); +``` + +### Go + +```go +// For recurring (subscription) checkout +params := &stripe.CheckoutSessionParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + ClientReferenceID: stripe.String(user.ID), + SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ + Metadata: map[string]string{ + "posthog_person_distinct_id": user.PosthogDistinctID, + }, + }, +} +session, err := checkoutsession.New(params) +// For one-time payment checkout +params := &stripe.CheckoutSessionParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + ClientReferenceID: stripe.String(user.ID), + PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{ + Metadata: map[string]string{ + "posthog_person_distinct_id": user.PosthogDistinctID, + }, + }, +} +session, err := checkoutsession.New(params) +``` + +### Java + +```java +// For recurring (subscription) checkout +SessionCreateParams params = SessionCreateParams.builder() + .setMode(SessionCreateParams.Mode.SUBSCRIPTION) + .setClientReferenceId(user.getId()) + .setSubscriptionData( + SessionCreateParams.SubscriptionData.builder() + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build() + ) + .build(); +Session session = Session.create(params); +// For one-time payment checkout +SessionCreateParams params = SessionCreateParams.builder() + .setMode(SessionCreateParams.Mode.PAYMENT) + .setClientReferenceId(user.getId()) + .setPaymentIntentData( + SessionCreateParams.PaymentIntentData.builder() + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build() + ) + .build(); +Session session = Session.create(params); +``` + +### dotnet + +```dotnet +// For recurring (subscription) checkout +var options = new SessionCreateOptions +{ + Mode = "subscription", + ClientReferenceId = user.Id, + SubscriptionData = new SessionSubscriptionDataOptions + { + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, + }, +}; +var session = await sessionService.CreateAsync(options); +// For one-time payment checkout +var options = new SessionCreateOptions +{ + Mode = "payment", + ClientReferenceId = user.Id, + PaymentIntentData = new SessionPaymentIntentDataOptions + { + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, + }, +}; +var session = await sessionService.CreateAsync(options); +``` + +> **How does this work?** PostHog looks for `posthog_person_distinct_id` in the metadata of subscriptions, charges, and invoices tied to each Stripe customer. If the customer object doesn't have the metadata directly, PostHog uses the value from the most recently created child object. + +Once this is connected you'll be able to properly see who your top customers are in the [Top customers dashboard](https://app.posthog.com/revenue_analytics#top-customers). + +You'll also get access to the `persons_revenue_analytics` and `groups_revenue_analytics` tables in the [data warehouse](https://app.posthog.com/data-warehouse). This is a simple map of `person_id`/`group_key` to what their all-time revenue is. + +SQL + +[Run in PostHog](https://us.posthog.com/sql?open_query=--+Count+the+number+of+persons+with+revenue+greater+than+1%2C000%2C000%0ASELECT+COUNT%28*%29%0AFROM+persons_revenue_analytics%0AWHERE+amount+%3E+1000000) + +PostHog AI + +```sql +-- Count the number of persons with revenue greater than 1,000,000 +SELECT COUNT(*) +FROM persons_revenue_analytics +WHERE amount > 1000000 +``` + +### Community questions + +Ask a question + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/identify-users.md b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/identify-users.md new file mode 100644 index 000000000..1417e03a8 --- /dev/null +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/identify-users.md @@ -0,0 +1,272 @@ +# Identify users - Docs + +Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms. + +This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument. + +However, in the frontend of a [web](/docs/libraries/js/features.md#capturing-events) or [mobile app](/docs/libraries/ios.md#capturing-events), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features.md#capturing-anonymous-events). + +To link events to specific users, call `identify`: + +PostHog AI + +### Web + +```javascript +posthog.identify( + 'distinct_id', // Replace 'distinct_id' with your user's unique identifier + { email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties +); +``` + +### Android + +```kotlin +PostHog.identify( + distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier + // optional: set additional person properties + userProperties = mapOf( + "name" to "Max Hedgehog", + "email" to "max@hedgehogmail.com" + ) +) +``` + +### iOS + +```swift +PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier + userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties +``` + +### React Native + +```jsx +posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier + email: 'max@hedgehogmail.com', // optional: set additional person properties + name: 'Max Hedgehog' +}) +``` + +### Dart + +```dart +await Posthog().identify( + userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier + userProperties: { + 'email': 'max@hedgehogmail.com', // optional: set additional person properties + 'name': 'Max Hedgehog', + }, +); +``` + +Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already. + +Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed. + +## How identify works + +When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally. + +Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users – even across different sessions. + +By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together. + +Thus, all past and future events made with that anonymous ID are now associated with the distinct ID. + +This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms. + +Using identify in the backend + +Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles. + +## Best practices when using `identify` + +### 1\. Call `identify` as soon as you're able to + +In your frontend, you should call `identify` as soon as you're able to. + +Typically, this is every time your **app loads** for the first time, and directly after your **users log in**. + +This ensures that events sent during your users' sessions are correctly associated with them. + +You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily. + +If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls. + +### 2\. Use unique strings for distinct IDs + +If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are: + +- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID. +- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`. + +PostHog also has built-in protections to stop the most common distinct ID mistakes. + +### 3\. Reset after logout + +If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user. + +This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions. + +**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.** + +You can do that like so: + +PostHog AI + +### Web + +```javascript +posthog.reset() +``` + +### iOS + +```swift +PostHogSDK.shared.reset() +``` + +### Android + +```kotlin +PostHog.reset() +``` + +### React Native + +```jsx +posthog.reset() +``` + +### Dart + +```dart +await Posthog().reset(); +``` + +If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument: + +Web + +PostHog AI + +```javascript +posthog.reset(true) +``` + +### 4\. Person profiles and properties + +You'll notice that one of the parameters in the `identify` method is a `properties` object. + +This enables you to set [person properties](/docs/product-analytics/person-properties.md). + +Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date. + +Person properties can also be set being adding a `$set` property to a event `capture` call. + +See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices. + +### 5\. Use deep links between platforms + +We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in. + +This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are: + +- Onboarding and signup flows before authentication. +- Unauthenticated web pages redirecting to authenticated mobile apps. +- Authenticated web apps prompting an app download. + +In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users. + +1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog. +2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters. +3. When the user is redirected to the app, parse the deep link and handle the following cases: + +- The mobile app is already authenticated. In this case, call [`posthog.alias()`](/docs/libraries/js/features.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person. +- The mobile app is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features.md#identifying-users) with the distinct ID from the web so pre-login mobile events stay connected to the web session. When the user later logs in on mobile, call `identify()` again with your canonical user ID. + +As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms. + +Here's an example implementation for handling deep links from web to mobile: + +PostHog AI + +### iOS + +```swift +import PostHog +class DeepLinkIdentityManager { + static let shared = DeepLinkIdentityManager() + // MARK: - Deep Link Received + func handleDeepLink(_ url: URL, isAuthenticatedOnMobile: Bool) { + guard let webDistinctId = URLComponents(url: url, resolvingAgainstBaseURL: true)? + .queryItems?.first(where: { $0.name == "ph_distinct_id" })?.value else { + return + } + if isAuthenticatedOnMobile { + // The mobile app already knows the current user. + // Alias the incoming web distinct ID to that user. + PostHogSDK.shared.alias(webDistinctId) + } else { + // Reuse the web distinct ID until login on mobile. + PostHogSDK.shared.identify(webDistinctId) + } + } + // MARK: - Login/Signup + func handleLogin(canonicalUserId: String) { + // Switch from the web distinct ID (or a mobile anon ID) + // to your canonical user ID. + PostHogSDK.shared.identify(canonicalUserId) + // Set user properties, track signup event, etc. + } + func handleLogout() { + PostHogSDK.shared.reset() + } +} +``` + +### Android + +```kotlin +import android.net.Uri +import com.posthog.PostHog +object DeepLinkIdentityManager { + // Deep Link Received + fun handleDeepLink(uri: Uri, isAuthenticatedOnMobile: Boolean) { + val webDistinctId = uri.getQueryParameter("ph_distinct_id") ?: return + if (isAuthenticatedOnMobile) { + // The mobile app already knows the current user. + // Alias the incoming web distinct ID to that user. + PostHog.alias(webDistinctId) + } else { + // Reuse the web distinct ID until login on mobile. + PostHog.identify(webDistinctId) + } + } + // Login/Signup + fun handleLogin(canonicalUserId: String) { + // Switch from the web distinct ID (or a mobile anon ID) + // to your canonical user ID. + PostHog.identify(canonicalUserId) + // Set user properties, track signup event, etc. + } + fun handleLogout() { + PostHog.reset() + } +} +``` + +## Further reading + +- [Identifying users docs](/docs/product-analytics/identify.md) +- [How person processing works](/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing) +- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md) + +### Community questions + +Ask a question + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/metadata.md b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/metadata.md new file mode 100644 index 000000000..9a31d8193 --- /dev/null +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/.claude/skills/revenue-analytics-setup/references/metadata.md @@ -0,0 +1,67 @@ +# Metadata + +Updateable Stripe objects—including [Account](https://docs.stripe.com/api/accounts.md), [Charge](https://docs.stripe.com/api/charges.md), [Customer](https://docs.stripe.com/api/customers.md), [PaymentIntent](https://docs.stripe.com/api/payment_intents.md), [Refund](https://docs.stripe.com/api/refunds.md), [Subscription](https://docs.stripe.com/api/subscriptions.md), and [Transfer](https://docs.stripe.com/api/transfers.md) have a `metadata` parameter. You can use this parameter to attach key-value data to these Stripe objects. + +You can specify up to 50 keys, with key names up to 40 characters long and values up to 500 characters long. Keys and values are stored as strings and can contain any characters with one exception: you can’t use square brackets ([ and ]) in keys. + +You can use metadata to store additional, structured information on an object. For example, you could store your user’s full name and corresponding unique identifier from your system on a Stripe [Customer](https://docs.stripe.com/api/customers.md) object. Stripe doesn’t use metadata—for example, we don’t use it to authorize or decline a charge and it won’t be seen by your users unless you choose to show it to them. + +Some of the objects listed above also support a `description` parameter. You can use the `description` parameter to annotate a charge-for example, a human-readable description such as `2 shirts for test@example.com`. Unlike `metadata`, `description` is a single string, which your users might see (for example, in email receipts Stripe sends on your behalf). + +Don’t store any sensitive information (bank account numbers, card details, and so on) as metadata or in the `description` parameter. + +- Related guide: [Metadata](https://docs.stripe.com/metadata.md) + +## Request + +```curl +curl https://api.stripe.com/v1/customers \ + -u "<>" \ + -d "metadata[order_id]=6735" +``` + +```json +{ + "id": "cus_123456789", + "object": "customer", + "address": { + "city": "city", + "country": "US", + "line1": "line 1", + "line2": "line 2", + "postal_code": "90210", + "state": "CA" + }, + "balance": 0, + "created": 1483565364, + "currency": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": null, + "invoice_prefix": "C11F7E1", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "order_id": "6735" + }, + "name": null, + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none" +} +``` + +## Sample metadata use cases + +- **Link IDs**: Attach your system’s unique IDs to a Stripe object to simplify lookups. For example, add your order number to a charge, your user ID to a customer or recipient, or a unique receipt number to a transfer. +- **Refund papertrails**: Store information about the reason for a refund and the individual responsible for its creation. +- **Customer details**: Annotate a customer by storing an internal ID for your future use. diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/app/(login)/actions.ts b/apps/revenue/stripe/stripe-next-js-saas-starter/app/(login)/actions.ts index 6b132053b..4688dead3 100644 --- a/apps/revenue/stripe/stripe-next-js-saas-starter/app/(login)/actions.ts +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/app/(login)/actions.ts @@ -110,7 +110,11 @@ export const signIn = validatedAction(signInSchema, async (data, formData) => { const redirectTo = formData.get('redirect') as string | null; if (redirectTo === 'checkout') { const priceId = formData.get('priceId') as string; - return createCheckoutSession({ team: foundTeam, priceId }); + return createCheckoutSession({ + team: foundTeam, + priceId, + posthogDistinctId: String(foundUser.id) + }); } redirect('/dashboard'); @@ -259,7 +263,11 @@ export const signUp = validatedAction(signUpSchema, async (data, formData) => { const redirectTo = formData.get('redirect') as string | null; if (redirectTo === 'checkout') { const priceId = formData.get('priceId') as string; - return createCheckoutSession({ team: createdTeam, priceId }); + return createCheckoutSession({ + team: createdTeam, + priceId, + posthogDistinctId: String(createdUser.id) + }); } redirect('/dashboard'); diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/actions.ts b/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/actions.ts index 3404e8474..ec11b816b 100644 --- a/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/actions.ts +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/actions.ts @@ -20,7 +20,11 @@ export const checkoutAction = withTeam(async (formData, team) => { await posthog.shutdown(); } - await createCheckoutSession({ team: team, priceId }); + await createCheckoutSession({ + team: team, + priceId, + posthogDistinctId: String(user?.id) + }); }); export const customerPortalAction = withTeam(async (_, team) => { diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/stripe.ts b/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/stripe.ts index 6f24b794d..316cc3bda 100644 --- a/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/stripe.ts +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/lib/payments/stripe.ts @@ -13,10 +13,12 @@ export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { export async function createCheckoutSession({ team, - priceId + priceId, + posthogDistinctId }: { team: Team | null; priceId: string; + posthogDistinctId?: string; }) { const user = await getUser(); @@ -24,6 +26,8 @@ export async function createCheckoutSession({ redirect(`/sign-up?redirect=checkout&priceId=${priceId}`); } + const distinctId = posthogDistinctId ?? user.id.toString(); + const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [ @@ -36,10 +40,16 @@ export async function createCheckoutSession({ success_url: `${process.env.BASE_URL}/api/stripe/checkout?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL}/pricing`, customer: team.stripeCustomerId || undefined, - client_reference_id: user.id.toString(), + client_reference_id: distinctId, allow_promotion_codes: true, + metadata: { + posthog_person_distinct_id: distinctId + }, subscription_data: { - trial_period_days: 14 + trial_period_days: 14, + metadata: { + posthog_person_distinct_id: distinctId + } } }); diff --git a/apps/revenue/stripe/stripe-next-js-saas-starter/posthog-revenue-report.md b/apps/revenue/stripe/stripe-next-js-saas-starter/posthog-revenue-report.md new file mode 100644 index 000000000..b5b24032a --- /dev/null +++ b/apps/revenue/stripe/stripe-next-js-saas-starter/posthog-revenue-report.md @@ -0,0 +1,26 @@ +# PostHog Revenue Analytics Report + +## What changes were made + +- Updated the Stripe checkout session creation flow to include `posthog_person_distinct_id` in Stripe metadata. +- Set Stripe Checkout `client_reference_id` to the same PostHog distinct ID used elsewhere in the app (`String(user.id)`). +- Threaded the PostHog distinct ID through existing checkout call sites so Stripe metadata uses the real analytics identifier instead of a fabricated value. +- Added local environment variables for the PostHog project token, host, and project ID in `.env.local`. + +## Files modified or created + +### Modified +- `lib/payments/stripe.ts` +- `lib/payments/actions.ts` +- `app/(login)/actions.ts` +- `.env.local` + +### Created +- `posthog-revenue-report.md` + +## Manual steps to take next + +- Install project dependencies, since verification was limited by missing `node_modules` in this environment. +- Run `pnpm build` locally after installing dependencies to confirm the project still builds cleanly. +- In Stripe, complete a test checkout and confirm the resulting Checkout Session and Subscription contain `posthog_person_distinct_id` in metadata. +- In PostHog, connect Stripe revenue analytics for project `483112` and verify revenue data starts appearing against identified users.