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); +});