Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/handoff/2026-07-20-stripe-smoketest-and-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Handoff — Stripe pack smoke test + analytics gap investigation

**Created:** 2026-07-20
**Context:** Follow-up to the "first paying customer got no credits" incident (resolved this session). Two independent tasks below; do either/both.

---

## Background (what just happened)

First paying customer (יהלי, `mamashlo99989@gmail.com`, `cus_UuXoQSTzKikZAH`) paid $10 but received no credits. Two root causes were found and fixed:

1. **`STRIPE_WEBHOOK_SECRET` had a trailing newline** → every live webhook rejected with a 400 signature error, so the credit-grant path never ran for anyone. Fixed: secret rolled + re-added cleanly (Secret Manager v4), `stripeWebhook` redeployed. Code now trims the secret + signature header — **PR #565 into `staging`** (`functions/src/stripeWebhook.ts`).
2. **Credit-pack Stripe price was `recurring`** → buying the "$10 credit pack" created a $10/mo subscription. Fixed: new one-time price `price_1TvHU6DTb0norRA0gqLmSkeO` created, `STRIPE_CREDIT_PACK_PRICE_ID` repointed (backend `functions/.env.clanker-prod` + client `.env`), product `default_price` reassigned, old price `price_1TF2okDTb0norRA0Ja9S6QZk` archived.

Customer recovery: subscription `sub_1TuiliDTb0norRA0uaLEUXx0` canceled (immediate), credits manually set to 10,050.

**Verified:** a request signed with the new secret returns HTTP 200 from the deployed function (direct probe). What is NOT yet verified end-to-end: a real Stripe → function delivery succeeding, and a real pack purchase producing a one-time charge + credit grant from an updated client.

**In flight at handoff:** front-end web/OTA deploy carrying the new pack price id. Until it lands, un-updated native clients send the old price id and the backend rejects it with `Unknown priceId` (fail-closed, intended).

Full detail is in agent memory: `project_stripe_first_customer_incident.md`.

---

## Task A — End-to-end smoke test (do once the front-end deploy has landed)

**Goal:** prove the whole pack-purchase path works from an updated client: Stripe delivers → function verifies → credits granted → charge is one-time (not a subscription).

### Preconditions
- Front-end web/OTA deploy with `EXPO_PUBLIC_STRIPE_CREDIT_PACK_PRICE_ID=price_1TvHU6DTb0norRA0gqLmSkeO` is live.
- Use a real test purchase account you control (this is **live mode** — a real $10 charge; refund it after, or coordinate with the owner).

### Steps
1. From an updated web or mobile client, buy the **credit pack**.
2. Watch the webhook logs live during checkout:
```bash
gcloud logging read 'resource.labels.service_name="stripewebhook" AND timestamp>="<now-ISO-UTC>"' \
--project=clanker-prod --limit=30 \
--format="value(timestamp,severity,jsonPayload.message,jsonPayload.type,httpRequest.status)"
```
Expect: `"Received Stripe event"` → `checkout.session.completed` → `"checkout.session.completed: credits added"` → 200. **No** `"Stripe signature verification failed"`.
3. Confirm the Stripe object is a **one-time payment**, not a subscription:
```bash
SK=$(gcloud secrets versions access latest --secret=STRIPE_SECRET_KEY --project=clanker-prod | tr -d '[:space:]')
# find the session/charge for your test customer, then:
curl -s "https://api.stripe.com/v1/checkout/sessions/<cs_id>" -u "$SK:" \
| python3 -c 'import sys,json;s=json.load(sys.stdin);print("mode:",s["mode"],"subscription:",s.get("subscription"))'
```
Expect `mode: payment`, `subscription: None`.
4. Confirm credits landed: admin dashboard → your test user → credits increased by `CREDIT_PACK_AMOUNT` (10000 Power) with a ~31-day expiry.
5. Refund the test charge if appropriate. Note `charge.refunded` is handled and will deduct the granted credits proportionally.

### Idempotency note
The handler dedupes on `event.id` (`stripeEventDedupeService`) and on `session.id` as the credit `referenceId`, so Stripe retries won't double-grant. Safe to resend events.

### Optional: confirm the live webhook without a purchase
Resend any recent event from the **`cloudfunctions.net/stripeWebhook`** destination's own *Event deliveries* tab (not an event's history rows — those can point at the deleted `run.app` endpoint) and confirm a 200 + `"Received Stripe event"` in the logs.

---

## Task B — Investigate the analytics gaps

At session start the owner observed: Google Analytics "Drive sales" showed **$0 revenue** despite the $10 sale; BigQuery `clanker-prod` **Connections** page was empty; Firebase Analytics dashboard was just open. These are **separate from the credits bug** — do not conflate them.

### What's already known (start here)
- Client analytics wrapper: `src/services/analyticsService.ts` (native) + `src/services/analyticsService.web.ts` (web). It's a thin `logEvent` wrapper.
- Approved events are **funnel-only** (design: `docs/superpowers/specs/2026-07-05-firebase-analytics-design.md`, "Custom Events" table): `sign_up`, `terms_accepted`, `character_created`, `message_sent`, `voice_session_started`, `subscribe_flow_started`.
- **There is no `purchase`/conversion event.** `subscribe_flow_started` fires at flow *entry* (`app/(drawer)/subscribe.tsx`). The checkout success page (`app/checkout/success.tsx`) calls `refreshBootstrap('purchase')` — `'purchase'` is only a data-refresh reason string, **not** a GA4 event.

### Hypotheses to confirm/refute
1. **No revenue event is logged → GA4 revenue is always $0 (primary).** GA4 ecommerce reports need a standard `purchase` event with `value` + `currency` (+ optional `items`). None is emitted. Confirm by searching for any `purchase`/`value`/`currency` logEvent call — expect none.
2. **Purchase completes off-app (Stripe hosted checkout), so client analytics is a poor source of truth for revenue.** The reliable signal is server-side (the Stripe webhook). Consider whether revenue should be reported via **GA4 Measurement Protocol from the `stripeWebhook` function** (`checkout.session.completed` / `invoice.payment_succeeded`) rather than the client. Weigh against the spec's client-only scope.
3. **BigQuery: owner looked at the wrong page.** Firebase→BigQuery export creates a dataset named `analytics_<GA4_property_id>` (with `events_*` / `events_intraday_*` tables), **not** a "Connection." Verify:
- Firebase console → Project settings → Integrations → **BigQuery** export enabled? (Spec "Console Operations" step 3 — manual, collects forward only.)
- `bq ls --project_id=clanker-prod` → is there an `analytics_*` dataset? `bq ls clanker-prod:analytics_XXXX` → any `events_*` tables?
4. **GA4 property / measurement id wiring.** Web reads `EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID` (`firebaseConfig.web.ts`). Confirm it's populated and a GA4 property exists (spec "Console Operations" steps 1–2). If missing, web events go nowhere.

### Suggested deliverable
A short findings doc: which of the above are true, and a recommendation — minimally, add a GA4 `purchase` event (client on `app/checkout/success.tsx`, and/or server-side via Measurement Protocol in `stripeWebhook`) with `value`/`currency`, and finish the BigQuery-export console setup. If server-side is chosen, note that `checkout.session.completed` already resolves the user and amount is on the session (`amount_total`, `currency`).

### Cautions
- Read `docs/superpowers/specs/2026-07-05-firebase-analytics-design.md` fully before proposing changes — scope, consent wiring, and privacy constraints (no PII in params, no ad-id collection) are deliberate.
- Some steps are **manual console operations** the agent cannot do (creating the GA4 property, enabling BigQuery export). Surface those as owner actions.

---

## Reference

| Thing | Value |
|---|---|
| Project | `clanker-prod` (gcloud), Stripe acct `acct_1MVIIADTb0norRA0` |
| Webhook fn | `stripeWebhook` (Cloud Run service `stripewebhook`, region `us-central1`) |
| Live webhook destination | `https://us-central1-equationalapplications-com.cloudfunctions.net/stripeWebhook` (keep) + RevenueCat (keep). The `run.app` duplicate was deleted. |
| New pack price (one-time) | `price_1TvHU6DTb0norRA0gqLmSkeO` |
| Old pack price (archived) | `price_1TF2okDTb0norRA0Ja9S6QZk` |
| Subscription tiers | `STRIPE_MONTHLY_20_PRICE_ID=price_1TF2nZ…`, `STRIPE_MONTHLY_50_PRICE_ID=price_1TF2oA…` |
| Credit amounts | `functions/src/constants/credits.ts` — pack 10000, sub renewal 30000, signup 5000 (Power = credits ×100) |
| Webhook code | `functions/src/stripeWebhook.ts`, tests `functions/src/stripeWebhook.test.ts` |
| Checkout callable | `functions/src/purchasePackageStripe.ts` (mode derived from `price.type`) |
| Client price map | `src/config/constants.ts` (reads `EXPO_PUBLIC_STRIPE_*`); values in gitignored root `.env` |
| Analytics | `src/services/analyticsService.ts` / `.web.ts`; spec `docs/superpowers/specs/2026-07-05-firebase-analytics-design.md` |
| Open PR | #565 (secret/signature trim) → `staging` |

**General cautions:** live Stripe + prod DB + Secret Manager. gcloud auth may need `gcloud auth login` (interactive). Confirm hard-to-reverse / money / customer-facing actions with the owner before acting.
6 changes: 5 additions & 1 deletion functions/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
# CLOUD_SQL_DB_NAME=clanker

# Stripe
# - STRIPE_SECRET_KEY is sensitive and must be stored in Firebase Secret Manager.
# - STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are sensitive and must be stored
# in Firebase Secret Manager (never commit the real values here).
# STRIPE_WEBHOOK_SECRET must be the signing secret of the ACTIVE webhook
# endpoint and stored with no trailing whitespace/newline.
# - Price IDs and redirect URLs are non-sensitive and configured via .env.clanker-prod.
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_MONTHLY_20_PRICE_ID=price_...
STRIPE_MONTHLY_50_PRICE_ID=price_...
STRIPE_CREDIT_PACK_PRICE_ID=price_...
Expand Down
54 changes: 54 additions & 0 deletions functions/src/stripeWebhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,60 @@ test("stripeWebhookHandler returns 500 when STRIPE_SECRET_KEY is missing", async
}
});

test("stripeWebhookHandler trims whitespace from STRIPE_WEBHOOK_SECRET and signature before verifying", async (t) => {
const originalWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Reproduce the production incident: the Secret Manager value had a trailing
// newline, which made Stripe reject every event with a signature error.
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_123\n";

let capturedSecret: unknown;
let capturedSignature: unknown;
const stripe = new Stripe("sk_test_123");
t.mock.method(
stripe.webhooks,
"constructEvent",
(_payload: unknown, signature: unknown, secret: unknown) => {
capturedSignature = signature;
capturedSecret = secret;
return {id: "evt_trim_1", type: "unhandled.event", data: {object: {}}} as never;
}
);
setStripeClientFactoryForTests(() => stripe);
t.after(() => {
setStripeClientFactoryForTests(null);
process.env.STRIPE_WEBHOOK_SECRET = originalWebhookSecret;
});

const res = createResponseRecorder();
const deps = {
findUserByEmail: async () => null,
findUserByFirebaseUid: async () => null,
findUserByStripeCustomerId: async () => null,
upsertSubscription: async () => {},
renewSubscriptionCredits: async () => false,
addCredits: async () => {},
adjustCredits: async () => {},
markEventProcessed: async () => true,
completeEventProcessed: async () => {},
unmarkEventProcessed: async () => {},
getLastProcessedChargeRefundTotal: async () => 0,
};

await stripeWebhookHandler(
{
method: "POST",
headers: {"stripe-signature": " t=1,v1=sig "},
rawBody: Buffer.from("{}"),
} as never,
res as never,
deps as never
);

assert.equal(res.statusCode, 200);
assert.equal(capturedSecret, "whsec_test_123");
assert.equal(capturedSignature, "t=1,v1=sig");
});

test("mapStripeSubscriptionStatus maps active-like statuses to active", () => {
const statuses: Stripe.Subscription.Status[] = [
"active",
Expand Down
12 changes: 8 additions & 4 deletions functions/src/stripeWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,21 +248,25 @@ export const stripeWebhookHandler = async (
return;
}

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Trim to defend against stray whitespace/newlines in the Secret Manager
// value (an incident where the stored secret had a trailing newline made
// Stripe reject every event with a signature-verification error).
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
if (!webhookSecret) {
logger.error("STRIPE_WEBHOOK_SECRET is not configured");
res.status(500).send("Webhook secret not configured");
return;
}

const sig = req.headers["stripe-signature"];
if (typeof sig !== "string" || sig.trim().length === 0) {
const rawSig = req.headers["stripe-signature"];
if (typeof rawSig !== "string" || rawSig.trim().length === 0) {
logger.warn("Missing or invalid stripe-signature header", {
headerType: Array.isArray(sig) ? "array" : typeof sig,
headerType: Array.isArray(rawSig) ? "array" : typeof rawSig,
});
res.status(400).send("Missing or invalid Stripe signature header");
return;
}
const sig = rawSig.trim();

let stripe: Stripe;
try {
Expand Down