feat(billing): grant 30-day signup free trial via Stripe Checkout (BUNYIP-209) #248

Merged
Claude-Run merged 1 commit from feat/bunyip-209-signup-trial-period into main 2026-06-26 07:46:44 +02:00
Member

New users now get a one-time 30-day free trial on their first checkout instead of an immediate paid subscription. The trial length is config-driven: StripeConfig gains trial_period_days (u32), read from BUNYIP_BILLING_TRIAL_PERIOD_DAYS with a 30-day fallback, plumbed through from_env and from_db_model.

Eligibility is tracked per user. A new migration adds users.has_used_trial BOOLEAN NOT NULL DEFAULT FALSE. create_checkout_session takes an eligible_for_trial flag (the membership handler passes !db_user.has_used_trial): when eligible it sets subscription_data.trial_period_days = config.trial_period_days and payment_method_collection = IfRequired so the trial starts with no card up front, and tags the session with trial=true metadata. When not eligible the session is byte-for-byte the prior immediate-billing flow.

The trial is burned only when the checkout finalizes, not at session creation, so an abandoned checkout never consumes it. The checkout.session.completed webhook calls UserRepository::mark_trial_used when the completed session carries trial=true metadata; the UPDATE sets has_used_trial = TRUE and is idempotent on Stripe retries/replays. A returning user (has_used_trial = TRUE) therefore gets the existing no-trial flow on any later subscribe.

Operator action documented in dev-docs/stripe-test-mode.md: mirror the same 30-day trial on the membership product in the Stripe Dashboard as defense in depth for any subscription created outside our checkout helper.

#BUNYIP-209

New users now get a one-time 30-day free trial on their first checkout instead of an immediate paid subscription. The trial length is config-driven: StripeConfig gains trial_period_days (u32), read from BUNYIP_BILLING_TRIAL_PERIOD_DAYS with a 30-day fallback, plumbed through from_env and from_db_model. Eligibility is tracked per user. A new migration adds users.has_used_trial BOOLEAN NOT NULL DEFAULT FALSE. create_checkout_session takes an eligible_for_trial flag (the membership handler passes !db_user.has_used_trial): when eligible it sets subscription_data.trial_period_days = config.trial_period_days and payment_method_collection = IfRequired so the trial starts with no card up front, and tags the session with trial=true metadata. When not eligible the session is byte-for-byte the prior immediate-billing flow. The trial is burned only when the checkout finalizes, not at session creation, so an abandoned checkout never consumes it. The checkout.session.completed webhook calls UserRepository::mark_trial_used when the completed session carries trial=true metadata; the UPDATE sets has_used_trial = TRUE and is idempotent on Stripe retries/replays. A returning user (has_used_trial = TRUE) therefore gets the existing no-trial flow on any later subscribe. Operator action documented in dev-docs/stripe-test-mode.md: mirror the same 30-day trial on the membership product in the Stripe Dashboard as defense in depth for any subscription created outside our checkout helper. #BUNYIP-209
feat(billing): grant 30-day signup free trial via Stripe Checkout (BUNYIP-209)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 22s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m29s
Create release / Create release from merged PR (pull_request) Has been skipped
02243ce001
New users now get a one-time 30-day free trial on their first checkout instead of an immediate paid subscription. The trial length is config-driven: StripeConfig gains trial_period_days (u32), read from BUNYIP_BILLING_TRIAL_PERIOD_DAYS with a 30-day fallback, plumbed through from_env and from_db_model.

Eligibility is tracked per user. A new migration adds users.has_used_trial BOOLEAN NOT NULL DEFAULT FALSE. create_checkout_session takes an eligible_for_trial flag (the membership handler passes !db_user.has_used_trial): when eligible it sets subscription_data.trial_period_days = config.trial_period_days and payment_method_collection = IfRequired so the trial starts with no card up front, and tags the session with trial=true metadata. When not eligible the session is byte-for-byte the prior immediate-billing flow.

The trial is burned only when the checkout finalizes, not at session creation, so an abandoned checkout never consumes it. The checkout.session.completed webhook calls UserRepository::mark_trial_used when the completed session carries trial=true metadata; the UPDATE sets has_used_trial = TRUE and is idempotent on Stripe retries/replays. A returning user (has_used_trial = TRUE) therefore gets the existing no-trial flow on any later subscribe.

Operator action documented in dev-docs/stripe-test-mode.md: mirror the same 30-day trial on the membership product in the Stripe Dashboard as defense in depth for any subscription created outside our checkout helper.

#BUNYIP-209
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!248
No description provided.