diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index d49d9c0c7..c7a066b72 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -6,6 +6,7 @@ use App\Models\Account; use App\Support\Billing\ConfigureSubscriptionCheckout; +use Illuminate\Support\Str; use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; @@ -16,8 +17,11 @@ class StartSubscriptionCheckout * 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. The owner's - * signup attribution and onboarding answers ride along as subscription - * metadata, flattened to the strings Stripe accepts. + * signup attribution -- UTM parameters and ad click IDs -- and onboarding + * answers ride along as subscription metadata, flattened to the strings + * Stripe stores and cut to the 500 characters it allows per value. Stripe + * rejects a longer value outright rather than truncating it, which would + * fail the whole checkout: https://docs.stripe.com/api/metadata */ public function redirect(Account $account, string $priceId, string $cancelUrl): Response { @@ -28,18 +32,29 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): $owner = $account->owner; + $metadata = 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, + 'gclid' => $owner?->gclid, + 'fbclid' => $owner?->fbclid, + 'li_fat_id' => $owner?->li_fat_id, + 'ttclid' => $owner?->ttclid, + 'rdt_cid' => $owner?->rdt_cid, + 'epik' => $owner?->epik, + 'persona' => $owner?->persona?->value, + 'goals' => implode(',', $owner?->goals ?? []), + 'referral_source' => $owner?->referral_source?->value, + ]); + $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) ->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, - ])); + ->withMetadata(array_map( + fn (string $value): string => Str::limit($value, 500, ''), + $metadata, + )); ConfigureSubscriptionCheckout::apply($subscription, $account); diff --git a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php index 01965625a..f8d37e8bf 100644 --- a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php +++ b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php @@ -32,6 +32,12 @@ 'utm_campaign' => 'launch', 'utm_term' => 'social scheduler', 'utm_content' => 'headline-b', + 'gclid' => 'Cj0KCQgclid', + 'fbclid' => 'IwAR0fbclid', + 'li_fat_id' => '9f2li-fat-id', + 'ttclid' => 'E.C.Pttclid', + 'rdt_cid' => 'rdt-cid-value', + 'epik' => 'dj0yepik', 'persona' => Persona::Agency, 'goals' => ['grow_audience', 'save_time'], 'referral_source' => ReferralSource::ProductHunt, @@ -50,6 +56,12 @@ 'utm_campaign' => 'launch', 'utm_term' => 'social scheduler', 'utm_content' => 'headline-b', + 'gclid' => 'Cj0KCQgclid', + 'fbclid' => 'IwAR0fbclid', + 'li_fat_id' => '9f2li-fat-id', + 'ttclid' => 'E.C.Pttclid', + 'rdt_cid' => 'rdt-cid-value', + 'epik' => 'dj0yepik', 'persona' => 'agency', 'goals' => 'grow_audience,save_time', 'referral_source' => 'product_hunt', @@ -114,3 +126,38 @@ app(StartSubscriptionCheckout::class)->redirect($accountMock, 'price_monthly_test', $cancelUrl); }); + +test('redirect cuts an oversized click id to the stripe metadata limit', 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, + 'fbclid' => str_repeat('a', 900), + ]); + $account->refresh(); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('quantity')->once()->andReturnSelf(); + $builder->shouldReceive('withMetadata') + ->once() + ->with(['fbclid' => str_repeat('a', 500)]) + ->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', route('app.welcome')); +});