Send the ad click IDs to Stripe alongside the UTM parameters (#323)

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.
This commit is contained in:
Paulo Castellano 2026-08-31 23:01:37 -03:00 committed by GitHub
parent c1af03308a
commit b689e3902c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 74 additions and 12 deletions

View file

@ -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);

View file

@ -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'));
});