Skip to content

Apply offer coupons from ?offer= links at the Welcome checkout - #452

Open
paulocastellano wants to merge 4 commits into
mainfrom
offers
Open

paulocastellano wants to merge 4 commits into
mainfrom
offers

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What

An offer is a Stripe coupon that replaces the $1 first month at the Welcome checkout. A visitor gets one by opening any app URL with ?offer=<slug>:

  • app.trypost.it/register?offer=black-friday-2026 for ads and landing pages
  • app.trypost.it/login?offer=<slug> for win-back emails

When a visitor has an offer, the plan step shows a badge naming it ("You're seeing this price thanks to Black Friday 2026.") and the offer's price, and the checkout applies its coupon.

How

  • Tables:
    • offers: slug, stripe_coupon_id, starts_at (required), ends_at (null = no end), max_redemptions (null = no limit).
    • account_offer (pivot): claimed_at, redeemed_at.
  • Capture: the CaptureOffer web middleware keeps the slug of an available offer in the session and claims it on the first authenticated request (ClaimOffer). This covers email signup, Google, GitHub and login without touching their controllers.
  • Who gets an offer: only the account owner, and only if the account has no active subscription. New signups and former customers both qualify. An offer an account already redeemed is never offered to it again.
  • Checkout: ConfigureSubscriptionCheckout applies the offer first, then the first-month coupon, then the trial. The offer is checked again when checkout starts. Its id goes in the subscription metadata trypost_offer_id, and the customer.subscription.created webhook writes redeemed_at (RedeemOffer).
  • Places: only redemptions use up a place. The Stripe coupon itself has no dates and no limit.
  • Copy: the name, price and period live in lang/*/offers.php, under the slug with dashes turned into underscores, in all 16 locales. OfferCopyTest fails when an offer row has no copy. Without its name, the badge falls back to a generic sentence, and the page never shows a raw key.
  • Rules: written down in AGENTS.md ("Offers (?offer= links)"), including how to add an offer: create the coupon, add a data migration, add the copy, deploy.

No offer ships in this PR. The first one comes with its own migration and copy.

Decisions to check

  • An offer checkout does not set trypost_first_month_coupon_id, so PostHog does not count it as the $1 first month. checkout.started gets offer_slug instead.
  • If two checkouts race for the last place, both may get it (accepted).
  • The badge uses the Alert component, not Badge, because Badge can't hold a sentence on a phone.

Deferred (not in this PR)

  • ClaimOffer queries offers before the cheaper hasActiveSubscription() check.
  • offerFor() accepts any monthly plan. "Socials only" holds only because Welcome is the only caller.

Tests

  • PostgreSQL: Offer, Welcome, Billing, Listeners, Auth and MorphMap pass (428 before the review fixes; the affected areas, 289, after).
  • MySQL: Offer, Welcome and Billing pass (178).
  • Also passing: LocalizationParity, SingleLineCopy, tests/Browser/WelcomePlanTest.php (12) and eslint.

Manual test checklist

Three coupons exist in the Stripe test account (delete them when done):

Coupon id Discount
TRYPOST_TEST_BF_50_3M 50% off, 3 months
TRYPOST_TEST_WINBACK_30 30% off, 6 months
TRYPOST_TEST_FIRST_MONTH_FREE 100% off, first month

The local setup is already done on Paulo's machine:

  • black-friday: 50% off, ends in a week, 2 places.
  • winback: 30% off, no end.
  • first-month-free: starts tomorrow, on purpose.
  • Copy for all three is in lang/*/offers.php, uncommitted (English everywhere except pt-BR).
  • The Stripe webhook points at a local tunnel, and its secret is in .env.

Restart npm run dev so the new lang keys reach the browser.

New signup with an offer

  • Logged out, open /register?offer=black-friday and sign up with email.
  • Welcome plan step:
    • badge "You're seeing this price thanks to Black Friday."
    • price $9.50 with "for your first 3 months"
    • button "Activate for $9.50"
    • "Then $19/month."
  • Stripe Checkout shows 50% off. Pay with 4242 4242 4242 4242.
  • After the webhook, account_offer.redeemed_at is set for that account.
  • Repeat with Google signup.
  • In pt-BR: the badge and the price are translated (R$ 49,50).

Moving between register and login keeps the offer

  • /register?offer=black-friday → "Log in" → "Sign up" → sign up: the offer is kept.
  • /login?offer=winback → "Sign up" → "Log in" → log in: the offer is kept.

What must NOT give an offer

  • /register?offer=does-not-exist: no badge, normal $1 first month.
  • /register?offer=first-month-free (starts tomorrow): no badge.
    • Then set its starts_at to the past and sign in: still no badge, because nothing is kept for later.
  • /register?offer=BLACK-FRIDAY (wrong case): no badge.
  • A subscribed account opening /calendar?offer=winback: no row in account_offer.
  • A member (not the owner) opening the link: no row.

Win-back (former customer)

  • Cancel a test subscription in the Stripe dashboard and wait for the webhook, so the account loses access.
  • Log out, open /login?offer=winback and log in.
  • Plan step: badge "Welcome back" and $13.30 "for 6 months". Checkout applies 30% off.

Limits and timing

  • black-friday has max_redemptions = 2. After two paid checkouts, a third new signup with the link gets no badge.
  • Open the plan step with an offer. In tinker, set its ends_at to the past, then click "Activate": checkout shows the $1 first month, not the offer.
  • Badge layout at 390px wide: no horizontal scroll, and the text wraps inside the alert.

Cleanup

  • Delete the three coupons in Stripe: Products → Coupons.
  • php artisan tinker --execute 'App\Models\Offer::whereIn("slug", ["black-friday","winback","first-month-free"])->delete();'
  • git checkout lang/*/offers.php
  • Delete the tunnel webhook in Stripe, and restore STRIPE_WEBHOOK_SECRET / WEBHOOK_URL in .env.

Paulo Castellano added 4 commits October 26, 2026 20:49
An offer is a Stripe coupon tied to an account through ?offer=<slug> on any
page. The Welcome plan step shows its badge and price and checks out with its
coupon instead of the first-month one; the subscription.created webhook marks
it redeemed. Dates and places live on the offers row only.
- Match offer slugs exactly on both engines through Offer::findAvailable()
- Claim at once for a signed-in owner; keep only available slugs for guests
- Resolve the offer once per request and hand it to checkout
- Redeem only active or trialing subscriptions, on created and updated
- Check offer copy per locale without fallback (OfferResource), in every locale
- Show the offer badge only with its price
- Cover Google, GitHub and register/login round trips, end to end too
The plan price stays the list price; a line under it says what the coupon
takes off and until when. The payment method shows its brand logo and the
brand's proper name, keeping the generic card icon for brands without one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant