From 80ffdc18ebe2ef3311acb61ecf78b0d87af97fe5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 31 Aug 2026 22:53:08 -0300 Subject: [PATCH] Send the ad click IDs to Stripe alongside the UTM parameters Signup already captures a click ID per ad network -- gclid, fbclid, li_fat_id, ttclid, rdt_cid, epik -- but only the UTM parameters reached the subscription. UTMs say which campaign a customer came from; the click ID identifies the individual click, which is what Google and Meta need to match a subscription back to the ad that produced it. Every key is spelled out at the call site, so what reaches Stripe is readable in one place without following a constant. Click IDs are text columns on purpose, since truncating them at 255 would destroy the value. Stripe caps a metadata value at 500 characters and rejects the request rather than truncating, so an unbounded column reaching it would fail the whole checkout and the customer could not subscribe at all. Values are cut to 500 before they are sent. The UTM columns are varchar(255) and cannot reach it; this exists for the text ones. --- .../Billing/StartSubscriptionCheckout.php | 39 ++++++++++----- .../Billing/StartSubscriptionCheckoutTest.php | 47 +++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) 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')); +});