From d63a2a2816cd7546a9d4294aa111be810e7993d0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 31 Aug 2026 22:21:46 -0300 Subject: [PATCH] Carry signup attribution into the Stripe subscription Every account arrives with the UTM parameters that brought it and the onboarding answers it gave, but none of that reached Stripe. Revenue lived in one system and the campaign that produced it in another, so answering "which campaign is paying for itself" meant joining the two by hand on email. The account owner's five UTM parameters, persona, goals and referral source now ride along as subscription metadata. Cashier's withMetadata() puts them in subscription_data.metadata during Checkout, so they land on the Stripe Subscription rather than the session: the session is gone in 24 hours, the subscription carries the attribution for as long as the customer does, and it shows up on the subscription in the dashboard. Stripe metadata holds strings, so the two enum-cast columns are unwrapped to their backing value and goals -- a JSON column, the one list answer -- is joined with commas. Passing either through as-is fails the request. array_filter drops the keys the user never filled in, since an absent key reads the same in reporting and Stripe treats null as a delete. The account may have no owner, so every read is null-safe and an account without attribution simply sends no metadata. --- .../Billing/StartSubscriptionCheckout.php | 18 +++++- .../Billing/StartSubscriptionCheckoutTest.php | 61 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 7991ee9b1..d49d9c0c7 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -15,7 +15,9 @@ class StartSubscriptionCheckout * Create a Stripe Checkout session for the given price and return an Inertia * redirect to it. Quantity tracks the account's workspace count. Trial days, * optional first-month coupon, and promotion codes come from cashier / - * trypost billing env config via ConfigureSubscriptionCheckout. + * trypost billing env config via ConfigureSubscriptionCheckout. The owner's + * signup attribution and onboarding answers ride along as subscription + * metadata, flattened to the strings Stripe accepts. */ public function redirect(Account $account, string $priceId, string $cancelUrl): Response { @@ -24,8 +26,20 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): 'name' => $account->stripeName(), ]); + $owner = $account->owner; + $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) - ->quantity(max(1, $account->workspaces()->count())); + ->quantity(max(1, $account->workspaces()->count())) + ->withMetadata(array_filter([ + 'utm_source' => $owner?->utm_source, + 'utm_medium' => $owner?->utm_medium, + 'utm_campaign' => $owner?->utm_campaign, + 'utm_term' => $owner?->utm_term, + 'utm_content' => $owner?->utm_content, + 'persona' => $owner?->persona?->value, + 'goals' => implode(',', $owner?->goals ?? []), + 'referral_source' => $owner?->referral_source?->value, + ])); ConfigureSubscriptionCheckout::apply($subscription, $account); diff --git a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php index 2deb46b78..01965625a 100644 --- a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php +++ b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php @@ -3,7 +3,10 @@ declare(strict_types=1); use App\Actions\Billing\StartSubscriptionCheckout; +use App\Enums\User\Persona; +use App\Enums\User\ReferralSource; use App\Models\Account; +use App\Models\User; use App\Models\Workspace; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Cashier\SubscriptionBuilder; @@ -12,7 +15,7 @@ uses(RefreshDatabase::class); -test('redirect applies checkout configuration before opening the stripe session', function () { +test('redirect applies checkout configuration and attribution metadata before opening the stripe session', function () { config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, @@ -22,11 +25,36 @@ $account = Account::factory()->create(); Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create([ + 'account_id' => $account->id, + 'utm_source' => 'google', + 'utm_medium' => 'cpc', + 'utm_campaign' => 'launch', + 'utm_term' => 'social scheduler', + 'utm_content' => 'headline-b', + 'persona' => Persona::Agency, + 'goals' => ['grow_audience', 'save_time'], + 'referral_source' => ReferralSource::ProductHunt, + ]); + $account->refresh(); $cancelUrl = route('app.welcome'); $builder = Mockery::mock(SubscriptionBuilder::class); $builder->shouldReceive('quantity')->once()->with(1)->andReturnSelf(); + $builder->shouldReceive('withMetadata') + ->once() + ->with([ + 'utm_source' => 'google', + 'utm_medium' => 'cpc', + 'utm_campaign' => 'launch', + 'utm_term' => 'social scheduler', + 'utm_content' => 'headline-b', + 'persona' => 'agency', + 'goals' => 'grow_audience,save_time', + 'referral_source' => 'product_hunt', + ]) + ->andReturnSelf(); $builder->shouldReceive('trialDays')->once()->with(8)->andReturnSelf(); $builder->shouldReceive('withCoupon')->never(); $builder->shouldReceive('allowPromotionCodes')->never(); @@ -55,3 +83,34 @@ expect($response->headers->get('Location'))->toBe('https://checkout.stripe.test/session') ->and($response->getStatusCode())->toBe(Response::HTTP_FOUND); }); + +test('redirect sends no metadata for an account whose owner left every field empty', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.trial_days' => 8, + 'cashier.first_month_coupon_id' => '', + 'cashier.allow_promotion_codes' => false, + ]); + + $account = Account::factory()->create(); + Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create(['account_id' => $account->id]); + $account->refresh(); + + $cancelUrl = route('app.welcome'); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('quantity')->once()->andReturnSelf(); + $builder->shouldReceive('withMetadata')->once()->with([])->andReturnSelf(); + $builder->shouldReceive('trialDays')->once()->andReturnSelf(); + $builder->shouldReceive('checkout') + ->once() + ->andReturn((object) ['url' => 'https://checkout.stripe.test/session']); + + /** @var Account&MockInterface $accountMock */ + $accountMock = Mockery::mock($account)->makePartial(); + $accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull(); + $accountMock->shouldReceive('newSubscription')->once()->andReturn($builder); + + app(StartSubscriptionCheckout::class)->redirect($accountMock, 'price_monthly_test', $cancelUrl); +});