feat(billing): direct offer (no trial) for paid-ads signups, Bento tag and CLI wording

Merged
RI
1d ago
+1362-83

Summary

Signups that come from paid ads get no free trial ("pay first"). This PR pairs with the landing website PR, which sends offer: 'direct' to /auth/register at signup. The backend stores it in server-controlled auth app metadata (raw_app_meta_data->>'offer'), which users cannot edit. Only the exact value direct changes anything. Users without it keep the 15-day trial.

Instead of a trial, these users get a 14-day money-back guarantee. The console shows "14-day money-back guarantee. Cancel anytime." next to the "Choose a plan to start" wording, and only for zero-trial orgs.

Backend / DB

  • New migration 20261021211344_direct_offer_no_trial.sql (recreated with supabase migration new after merging main, so it sorts after every migration on main). It redefines generate_org_user_stripe_info_on_org_create(); the only change from 20260826103000_org_billing_column_guard.sql is the trial line. If the creator (NEW.created_by) has raw_app_meta_data->>'offer' = 'direct', it sets trial_at_date := NOW(); otherwise it keeps NOW() + 15 days. SECURITY DEFINER, search_path = '', owner postgres, REVOKE ALL FROM PUBLIC and the grant to service_role are unchanged.
  • Two TS paths also wrote +15 days and would have brought the trial back. Both now use the same rule through the new utils/direct_offer.ts (resolveNewOrgTrialEnd, which reads the creator's auth app metadata):
    • public/organization/post.ts: the pending stripe_info row written before the org insert. The console onboarding creates orgs through this path.
    • utils/stripe_org.ts: createStripeCustomer, which on_organization_create calls when it finalizes the real Stripe customer. It inserts a new stripe_info row with its own trial_at.
    • These paths fail closed. If the creator lookup fails, resolveNewOrgTrialEnd logs and rethrows rather than persisting a 15-day trial. Organization POST returns cannot_create_org_plan. createStripeCustomer resolves the trial before creating the Stripe customer, so the queue retry starts clean. Bento tagging lookups treat a failure as "unknown": no trial tag is added and nothing else changes until a later sync knows the offer.
  • auth/register.ts and auth/apple.ts (registration_metadata) write the offer to app metadata. When offer is exactly direct, they call auth.admin.updateUserById(id, { app_metadata: { offer: 'direct' } }) before the profile insert that creates the first org. offer is never written to user metadata. If the write fails, email signup is rolled back (500 registration_failed) and the Apple flow fails with apple_login_failed, so a direct-offer user never silently gets a trial. GoTrue merges app_metadata, so provider data is kept. The Apple flow only does this for new users.

Console

  • useBillingPaidAt derives trialSkipped. It already fetches the org's stripe_info; it now also fetches trial_at and org created_at. trialSkipped is true when trial_at is within 24h of created_at, on either side (normal trials are 15 days). The result is cached like paid_at, and concurrent callers for the same org (the two responsive navbar banners) share one in-flight request.
  • Zero-trial orgs see "Choose a plan to start" plus the money-back line instead of "Your free trial has ended" / "Trial over":
    • PaymentRequiredModal: title, description and the money-back line.
    • Plans.vue: header title, description and the money-back line.
    • Navbar Banner.vue: the badge reads "Choose a plan" and the detail is the money-back line. The extra lookup only runs when the status is already trial_over.
    • Getting started (/app/:id/getting-started): a callout with a "Choose a plan" link, scoped to the app's owner org (hidden until that org is the current org). Without it, the setup checklist waits for an upload that can never succeed and gives no reason.
  • trialSkipped also requires paid_at to be null, so a direct-offer customer who paid and later churned sees the normal "subscription required" copy.
  • Normal trial users see no change. Native app-store contexts keep their existing behavior; the lookup is disabled there.

CLI onboarding billing (used by the CLI section below)

  • private/onboarding_progress.ts now also selects the org's created_at and stripe_info.paid_at, and returns them as billing.orgCreatedAt and billing.hasPaid. The CLI uses them to spot never-paid zero-trial (direct-offer) orgs, where trialEndsAt ≈ orgCreatedAt and hasPaid === false, so it can show "choose a plan" instead of "Your trial has ended." A churned direct-offer org (hasPaid: true) keeps the normal copy. Both fields are additive; older CLIs ignore them. Documented in docs/onboarding-progress-billing.md.

What a direct-offer user experiences

  1. They sign up on the website with offer=direct and land in the console. The pre-org onboarding at /onboarding/app creates the org, which gets trial_at = now, so the org is immediately "unpaid".
  2. Creating the app in onboarding still works. App creation is RBAC-only and does not check the plan. The user is sent to Getting started, which now shows the "Choose a plan to start" callout and the money-back line. The navbar shows "Choose a plan" with the money-back line.
  3. In the CLI, init and app add work. bundle upload hits is_allowed_action_org_action, prints "You need to upgrade your plan…", opens /settings/organization/plans?oid=… and fails with "Plan upgrade required for upload". init offers to retry the upload.
  4. On the Plans page the header reads "Choose a plan to start" with the money-back line. After subscribing, everything unlocks.

Bento

  • Tag: offer=direct (exact name). It is added for direct-offer users at signup (on_user_create), at org creation (on_organization_create) and on every Stripe-driven segment sync (customerToSegmentOrg).
  • Direct-offer users never get trial tags, and legacy ones are removed. Instead of trial-plan:<plan> at org creation, they get offer=direct. Every direct-offer sync (on_user_create, on_organization_create, customerToSegmentOrg) also sends trial, trial7, trial1, trial0, trial-card-on-file and trial-plan:* (every plan name known to that path) as removals, so a reused Bento subscriber (for example an email that signed up before) leaves trial automations. Without this, an unpaid direct org would otherwise get trial + trial0 ("trial ended").
  • The detection rule is the same as the rest of the PR (creator auth app metadata offer === 'direct'). If the lookup fails, the offer is "unknown": trial tags are withheld and nothing else changes. Standard users get exactly the same tags as before.
  • Marketing action needed: trial email automations must be keyed on the trial tags (trial, trial7, trial1, trial0, trial-plan:*), not on generic signup or org-created events. Otherwise they will still reach direct-offer users. Use offer=direct to target the money-back-guarantee messaging.
  • The tag uses =. Bento tags are free-text strings, so this should be accepted, but it has not been verified against the live Bento account.

Test plan

  • supabase/tests/87_test_direct_offer_no_trial.sql (pgTAP; renumbered because main now has an 86_ test) covers four creators:
    • App metadata offer=direct: trial_at ≈ now(), and the org is outside the trial window.
    • No offer: ≈ now() + 15 days.
    • App metadata offer='Direct ': still 15 days, because only the literal value direct counts.
    • User metadata offer=direct (user-writable): ignored, still 15 days.
  • Ran locally with supabase test db after merging main: 87, 86_test_trial_expiry_keeps_retrying_subscriptions, 73_test_org_billing_column_guard, 06_org_functions, 11_test_plan and 46_test_org_status_rpcs all pass.
  • Unit tests:
    • tests/direct-offer.unit.test.ts (new): app metadata is read, user metadata is ignored, failed lookups are unknown for Bento and throw for entitlement.
    • tests/auth-register.unit.test.ts and tests/apple-auth.unit.test.ts: offer=direct is written to app metadata (before the profile insert for email signup), other values are ignored, and a failed write rolls the email signup back.
    • tests/payment-required-copy.unit.test.ts: isZeroLengthTrial.
    • tests/stripe-org-customer.unit.test.ts: createStripeCustomer with direct and non-direct creators.
    • tests/billing-bento-tags.unit.test.ts: applyDirectOfferBentoTags, direct vs standard.
    • tests/on-organization-create.unit.test.ts: standard creator gets trial-plan:Solo; direct creator gets offer=direct and no trial tag.
    • tests/bento-first-org-lifecycle.unit.test.ts: on_user_create tags offer=direct only for offer === 'direct'.
    • tests/onboarding-progress-endpoint.unit.test.ts: billing summary includes orgCreatedAt and the select includes created_at. tests/onboarding-progress-billing.test.ts (needs Supabase) was updated to expect the field but not run.
    • Also run and passing: the Apple, register and billing-paid-at-cache suites.
    • Full bun run test:unit: 24 failures in 4 files (dual-onboarding-checklists, dual-onboarding-identity, native-device-stats, onboarding-redirect). The same 24 fail on origin/main, so they are unrelated to this PR.
  • oxlint, eslint on the changed files, typecheck:backend and typecheck:frontend all pass.

Trigger cost (EXPLAIN (ANALYZE, BUFFERS))

The trigger runs once per org insert, which happens only on the service or creator path. The new lookup is a single primary-key read on auth.users:

Index Scan using users_pkey on users (actual time=0.009..0.009 rows=0 loops=1)
  Index Cond: (id = '…'::uuid)
  Filter: ((raw_app_meta_data ->> 'offer') = 'direct')
  Buffers: shared hit=2
Execution Time: 0.034 ms

No RLS policy, view or RPC changes.

Known gaps / risks

  • Signups that bypass /auth/register and the Apple flow (for example a direct supabase.auth.signUp call) never get the offer; they keep the standard trial. The website sends the offer through /auth/register.
  • Zero-length trial detection in the console and CLI is a 24h heuristic on trial_at vs org created_at. trial_at is written in the same transaction as the org insert (or by the org-create API), so it does not lag creation. The app metadata flag is per user, not per org, so it cannot tell an invited member which orgs had no trial; the per-org trial_at is the better signal.
  • The offer applies to every org that user creates, not only the first one.
  • The CLI wording needs a CLI release. Until a CLI built from this PR is published, direct-offer users on older CLIs still see "upgrade your plan" / "Your trial has ended."

Screenshots

Console run locally against local Supabase, 1440x900. Three seeded users: a direct-offer user (trial_at = org created_at), a normal user whose trial expired, and a normal user on an active trial.

Plans page header: direct offer vs expired trial
Plans page

Top-bar billing badge: direct offer / expired trial / active trial
Billing badge

Payment-required modal: direct offer vs expired trial
Payment modal

Getting started: new callout (direct offer) vs active trial (unchanged)
Getting started

Active-trial user: dashboard and Plans unchanged
Active trial unchanged

CLI (previously #3783)

The backend part of this PR creates direct-offer orgs with stripe_info.trial_at equal to the creation time and updates the console copy. The CLI still told these users "You need to upgrade your plan" when the plan check blocked them. This part switches them to the console's wording and leaves every other user's output unchanged.

Detection. This uses the same rule as the console's isZeroLengthTrial: an org counts as direct offer when its trial ends within 24h of its creation. The CLI now checks plans through private/cli/preflight, which does not return trial dates. So the lookup runs only after a command has been blocked. It makes one call to the existing private/onboarding_progress with includeBilling: true and reads billing.orgCreatedAt, billing.hasPaid (added above) and billing.trialEndsAt. The call uses N: 4 without initial, so none of the periodic onboarding checks run.

Any error, the 5s timeout, billing.status: 'unavailable', an older server without the fields, orgCreatedAt: null, or hasPaid !== false (a churned org) keeps today's copy. Paying orgs and orgs in an active trial never make the extra call.

New code:

  • cli/src/shared/direct-offer.ts: the pure rule and copy.
  • cli/src/api/direct-offer.ts: isDirectOfferApp, the network lookup.
  • Wired into cli/src/api/preflight.ts and cli/src/build/request.ts.

Strings changed (direct-offer orgs only)

WhereBeforeAfter
bundle upload and bundle unlink (preflight plan_upgrade_required; init shows the upload failure)You need to upgrade your plan to continue to use capgo. / Upgrade here: <plans url>Choose a plan to start using Capgo. 14-day money-back guarantee. Cancel anytime. / Choose a plan here: <plans url>
Thrown errorPlan upgrade required / Plan upgrade required for uploadPlan required / Plan required for upload
build request and build start, 429 need_plan_upgradeCannot request native build, upgrade plan to continue to build: <url>Choose a plan to start using Capgo. 14-day money-back guarantee. Cancel anytime. Choose a plan here: <url>
Setup-complete footer (setup-notices.ts), from billing.orgCreatedAt and billing.hasPaidYour trial has ended. / Choose a Capgo plan to continue using Capgo.Choose a plan to start using Capgo. 14-day money-back guarantee. Cancel anytime. (no trial line)

The Plans URL and the browser-open behavior are unchanged. Active-trial, expired-trial and paying orgs see exactly what they see today.

CLI test plan

  • New cli/test/test-direct-offer-plan.mjs, run as part of test:plan-validation, 16 tests with mocked HTTP:
    • isZeroLengthTrial edge cases.
    • isDirectOfferApp: request shape, orgCreatedAt null or missing, hasPaid true or missing, billing unavailable, 403, missing app id.
    • checkPlanValidUpload and checkPlanValid for direct-offer, expired-trial, active-trial and paying orgs. Direct-offer output never matches /trial|upgrade/i. Active-trial and paying orgs make only the preflight call.
    • throwIfBuildPlanLimitError for direct-offer, expired-trial and concurrency-limit errors; the concurrency error makes no lookup.
  • cli/test/test-setup-notices.mjs: new cases: direct offer, churned direct offer (hasPaid: true) and missing hasPaid, orgCreatedAt: null, and a 15-day trial.
  • bun run typecheck, bun run lint, bun run build, test:setup-notices (40/40), test:plan-validation (7 + 16), test:mcp and test:bundle all pass.

CLI screenshots

bundle upload blocked for a direct-offer org. The output comes from checkPlanValidUpload running against a stubbed preflight and onboarding-progress response.

Before (origin/main)

before

After (this PR)

after

Checklist

  • My code follows the code style of this project and passes
    bun run lint:backend && bun run lint.
  • My change requires a change to the documentation.
  • I have updated the documentation
    accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce
    my tests
  • No GHSA ids, unpublished advisory bodies or PoCs, or embargoed exploit detail in this public PR (title, body, commits, branch name).
  • Security-sensitive authz / OTA / SQL / webhook / path changes reviewed against docs/agent-security-regressions.md.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Direct-offer customers can start without a free trial and are guided to choose a plan.
    • Added dedicated plan-selection messaging, including a 14-day money-back guarantee, across the app, onboarding, organization plans, and CLI.
    • CLI plan and upload notices now distinguish direct-offer accounts from standard trial upgrades.
    • New organizations receive the appropriate trial experience based on their offer eligibility.
  • Documentation

    • Updated billing documentation to describe trial status and plan-selection behavior.