feat(billing): direct offer (no trial) for paid-ads signups, Bento tag and CLI wording
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 withsupabase migration newafter merging main, so it sorts after every migration on main). It redefinesgenerate_org_user_stripe_info_on_org_create(); the only change from20260826103000_org_billing_column_guard.sqlis the trial line. If the creator (NEW.created_by) hasraw_app_meta_data->>'offer' = 'direct', it setstrial_at_date := NOW(); otherwise it keepsNOW() + 15 days.SECURITY DEFINER,search_path = '', ownerpostgres,REVOKE ALL FROM PUBLICand the grant toservice_roleare unchanged. - Two TS paths also wrote
+15 daysand would have brought the trial back. Both now use the same rule through the newutils/direct_offer.ts(resolveNewOrgTrialEnd, which reads the creator's auth app metadata):public/organization/post.ts: the pendingstripe_inforow written before the org insert. The console onboarding creates orgs through this path.utils/stripe_org.ts:createStripeCustomer, whichon_organization_createcalls when it finalizes the real Stripe customer. It inserts a newstripe_inforow with its owntrial_at.- These paths fail closed. If the creator lookup fails,
resolveNewOrgTrialEndlogs and rethrows rather than persisting a 15-day trial. Organization POST returnscannot_create_org_plan.createStripeCustomerresolves 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.tsandauth/apple.ts(registration_metadata) write the offer to app metadata. Whenofferis exactlydirect, they callauth.admin.updateUserById(id, { app_metadata: { offer: 'direct' } })before the profile insert that creates the first org.offeris never written to user metadata. If the write fails, email signup is rolled back (500registration_failed) and the Apple flow fails withapple_login_failed, so a direct-offer user never silently gets a trial. GoTrue mergesapp_metadata, so provider data is kept. The Apple flow only does this for new users.
Console
useBillingPaidAtderivestrialSkipped. It already fetches the org'sstripe_info; it now also fetchestrial_atand orgcreated_at.trialSkippedis true whentrial_atis within 24h ofcreated_at, on either side (normal trials are 15 days). The result is cached likepaid_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 alreadytrial_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.
trialSkippedalso requirespaid_atto 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.tsnow also selects the org'screated_atandstripe_info.paid_at, and returns them asbilling.orgCreatedAtandbilling.hasPaid. The CLI uses them to spot never-paid zero-trial (direct-offer) orgs, wheretrialEndsAt ≈ orgCreatedAtandhasPaid === 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 indocs/onboarding-progress-billing.md.
What a direct-offer user experiences
- They sign up on the website with
offer=directand land in the console. The pre-org onboarding at/onboarding/appcreates the org, which getstrial_at = now, so the org is immediately "unpaid". - 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.
- In the CLI,
initandapp addwork.bundle uploadhitsis_allowed_action_org_action, prints "You need to upgrade your plan…", opens/settings/organization/plans?oid=…and fails with "Plan upgrade required for upload".initoffers to retry the upload. - 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 getoffer=direct. Every direct-offer sync (on_user_create,on_organization_create,customerToSegmentOrg) also sendstrial,trial7,trial1,trial0,trial-card-on-fileandtrial-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 gettrial+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. Useoffer=directto 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 an86_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 valuedirectcounts. - User metadata
offer=direct(user-writable): ignored, still 15 days.
- App metadata
- Ran locally with
supabase test dbafter merging main:87,86_test_trial_expiry_keeps_retrying_subscriptions,73_test_org_billing_column_guard,06_org_functions,11_test_planand46_test_org_status_rpcsall 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.tsandtests/apple-auth.unit.test.ts:offer=directis 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:createStripeCustomerwith 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 getstrial-plan:Solo; direct creator getsoffer=directand no trial tag.tests/bento-first-org-lifecycle.unit.test.ts:on_user_createtagsoffer=directonly foroffer === 'direct'.tests/onboarding-progress-endpoint.unit.test.ts: billing summary includesorgCreatedAtand the select includescreated_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-cachesuites. - 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 onorigin/main, so they are unrelated to this PR.
oxlint,eslinton the changed files,typecheck:backendandtypecheck:frontendall 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/registerand the Apple flow (for example a directsupabase.auth.signUpcall) 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_atvs orgcreated_at.trial_atis 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-orgtrial_atis 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

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

Payment-required modal: direct offer vs expired trial

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

Active-trial user: dashboard and Plans 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.tsandcli/src/build/request.ts.
Strings changed (direct-offer orgs only)
| Where | Before | After |
|---|---|---|
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 error | Plan upgrade required / Plan upgrade required for upload | Plan required / Plan required for upload |
build request and build start, 429 need_plan_upgrade | Cannot 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.hasPaid | Your 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 oftest:plan-validation, 16 tests with mocked HTTP:isZeroLengthTrialedge cases.isDirectOfferApp: request shape,orgCreatedAtnull or missing,hasPaidtrue or missing, billing unavailable, 403, missing app id.checkPlanValidUploadandcheckPlanValidfor 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.throwIfBuildPlanLimitErrorfor 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 missinghasPaid,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:mcpandtest:bundleall 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)

After (this PR)

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.