01 · note
We built three pricing tiers for an AI brand identity product. Then we tested every purchase path and found 11 bugs across 7 flows. The worst was a single if-statement.
Freeform generates AI-powered brand identities from birth data. One product, three price points:
Each tier serves a different buying intent. Spark is for someone validating a business idea for less than a dinner. Origin is for someone ready to build. Alive is for someone who wants the brand to evolve.
The Founder Identity File is a synthesis of the founder's natal chart, Human Design profile, and personality archetypes. Claude writes it during brand synthesis.
Spark does not include it. On purpose. The Founder Identity File is the thing people share. When a Spark customer sees their basic synthesis and thinks "I want the full version," the Identity File pulls them forward. The product itself creates the upgrade desire.
Alive combines a one-time $97 payment with a recurring $19/month subscription. We tried three approaches before finding the right one:
One mode: 'subscription' session with two line items. The one-time price has recurring: null. Stripe treats it as a setup fee within the subscription context.
For differential upgrades (Spark → Origin), we use inline price_data because the amount varies:
const upgrade = ORIGIN_PRICE - existingOrder.amount // 9700 - 4700 = 5000
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: { currency: 'usd', unit_amount: upgrade, product: ORIGIN_PRODUCT_ID },
quantity: 1,
}],
})We wrote a systematic test script that walked through every purchase path. Seven flows. Eleven bugs.
The fulfillment function had standard deduplication at the top:
if (brand.status === 'paid') {
return { alreadyFulfilled: true }
}On an upgrade, the brand is already status: 'paid' because the customer bought Spark. The upgrade payment succeeds, the webhook arrives, and we early-exit before updating the tier. The brand stays on Spark. The customer paid $50 for nothing.
const isUpgrade = brand.status === 'paid' && brand.tier !== tier
if (brand.status === 'paid' && !isUpgrade) {
return { alreadyFulfilled: true }
}One line to the predicate.
await getGenerationQueue().add('GENERATE_BRAND_PDF', { brandId })No tier check. Every paid purchase queued the same PDF. Spark customers got the same 12-page brand book as Origin customers.
Fix: if (tier !== 'spark') { await getGenerationQueue().add('GENERATE_BRAND_PDF', ...) }
{isPaid && brand.brandBookUrl && (
<a href={/api/brand/${id}/pdf}>Download PDF</a>
)}isPaid is true for every tier. The correct gate: hasFullAccess = isPaid && (tier === 'origin' || tier === 'alive').
GET vs POST mismatch on the upgrade endpoint. Currency in dollars instead of cents. Missing idempotency on webhook retries. Hardcoded English in translated pages. Missing success URL parameters. Webhook signature validation disabled. Price ID environment mismatch between test and live.
Not one surfaced during happy-path testing.
| Component | Cost per brand |
|---|---|
| Claude synthesis | ~$0.26 |
| 5 AI images | ~$0.20 |
| Color panel | ~$0.04 |
| PDF | ~$0.02 |
| Total COGS | ~$0.52 |
At $97 for Origin: 99.5% gross margin on generation. Priced on value, not cost.
At 50% revenue allocation ($9.50/month), a subscriber could regenerate 237 individual elements. Most won't regenerate more than a few times. We set caps: 10 element regens + 3 full regens per month. Maximum cost per subscriber: $1.54/month, or 8% of revenue.
The Monthly Content Brief is the retention mechanism. On the 1st of every month, Claude Haiku reads the subscriber's Founder Identity File and generates 4 Instagram post ideas. Cost: ~$0.02/month. The email reminds subscribers their brand exists and that they have credits. It previews Signal Lite, our upcoming content automation product.
Three bugs on the brand detail page. Three one-to-three-line fixes. One root cause: we treated the tier as a label attached to a purchase, not as a product with different content, different downloads, different rules.
To make tiers real, enforce differentiation at three layers:
hasFullAccess, not isPaid.Missing any layer turns the tier into a label.
We wrote tier-products.spec.ts with 41 tests: content visibility per tier, download buttons, blur overlays, upgrade CTAs, checkout-API enforcement for every legal and illegal path, download-API gates.
await expect(page.locator('a[href*="/pdf"]')).toHaveCount(0)
await expect(page.locator('a[href*="download-identity"]')).toHaveCount(0)Counting absence is the only way to catch "we gave the $47 customer a $97 asset."
Payment flows are not one flow. They are N flows, where N = tiers × transitions × states. For 3 tiers: 7 distinct paths at minimum. Test all of them as complete user journeys.