2026-08-08 00:25:32 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Actions\Billing\StartSubscriptionCheckout;
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
use App\Enums\User\Persona;
|
|
|
|
|
use App\Enums\User\ReferralSource;
|
2026-08-08 00:25:32 +00:00
|
|
|
use App\Models\Account;
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
use App\Models\User;
|
2026-08-08 00:25:32 +00:00
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
|
use Laravel\Cashier\SubscriptionBuilder;
|
|
|
|
|
use Mockery\MockInterface;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
|
|
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
|
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
test('redirect applies checkout configuration and attribution metadata before opening the stripe session', function () {
|
2026-08-08 00:25:32 +00:00
|
|
|
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]);
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
User::factory()->create([
|
|
|
|
|
'account_id' => $account->id,
|
|
|
|
|
'utm_source' => 'google',
|
|
|
|
|
'utm_medium' => 'cpc',
|
|
|
|
|
'utm_campaign' => 'launch',
|
|
|
|
|
'utm_term' => 'social scheduler',
|
|
|
|
|
'utm_content' => 'headline-b',
|
2026-09-01 02:01:37 +00:00
|
|
|
'gclid' => 'Cj0KCQgclid',
|
|
|
|
|
'fbclid' => 'IwAR0fbclid',
|
|
|
|
|
'li_fat_id' => '9f2li-fat-id',
|
|
|
|
|
'ttclid' => 'E.C.Pttclid',
|
|
|
|
|
'rdt_cid' => 'rdt-cid-value',
|
|
|
|
|
'epik' => 'dj0yepik',
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
'persona' => Persona::Agency,
|
|
|
|
|
'goals' => ['grow_audience', 'save_time'],
|
|
|
|
|
'referral_source' => ReferralSource::ProductHunt,
|
|
|
|
|
]);
|
|
|
|
|
$account->refresh();
|
2026-08-08 00:25:32 +00:00
|
|
|
|
|
|
|
|
$cancelUrl = route('app.welcome');
|
|
|
|
|
|
|
|
|
|
$builder = Mockery::mock(SubscriptionBuilder::class);
|
|
|
|
|
$builder->shouldReceive('quantity')->once()->with(1)->andReturnSelf();
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
$builder->shouldReceive('withMetadata')
|
|
|
|
|
->once()
|
|
|
|
|
->with([
|
|
|
|
|
'utm_source' => 'google',
|
|
|
|
|
'utm_medium' => 'cpc',
|
|
|
|
|
'utm_campaign' => 'launch',
|
|
|
|
|
'utm_term' => 'social scheduler',
|
|
|
|
|
'utm_content' => 'headline-b',
|
2026-09-01 02:01:37 +00:00
|
|
|
'gclid' => 'Cj0KCQgclid',
|
|
|
|
|
'fbclid' => 'IwAR0fbclid',
|
|
|
|
|
'li_fat_id' => '9f2li-fat-id',
|
|
|
|
|
'ttclid' => 'E.C.Pttclid',
|
|
|
|
|
'rdt_cid' => 'rdt-cid-value',
|
|
|
|
|
'epik' => 'dj0yepik',
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
'persona' => 'agency',
|
|
|
|
|
'goals' => 'grow_audience,save_time',
|
|
|
|
|
'referral_source' => 'product_hunt',
|
|
|
|
|
])
|
|
|
|
|
->andReturnSelf();
|
2026-08-08 00:25:32 +00:00
|
|
|
$builder->shouldReceive('trialDays')->once()->with(8)->andReturnSelf();
|
|
|
|
|
$builder->shouldReceive('withCoupon')->never();
|
|
|
|
|
$builder->shouldReceive('allowPromotionCodes')->never();
|
|
|
|
|
$builder->shouldReceive('checkout')
|
|
|
|
|
->once()
|
|
|
|
|
->with([
|
|
|
|
|
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
|
|
|
|
'cancel_url' => $cancelUrl,
|
|
|
|
|
])
|
|
|
|
|
->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()
|
|
|
|
|
->with(Account::SUBSCRIPTION_NAME, 'price_monthly_test')
|
|
|
|
|
->andReturn($builder);
|
|
|
|
|
|
|
|
|
|
$response = app(StartSubscriptionCheckout::class)->redirect(
|
|
|
|
|
$accountMock,
|
|
|
|
|
'price_monthly_test',
|
|
|
|
|
$cancelUrl,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($response->headers->get('Location'))->toBe('https://checkout.stripe.test/session')
|
|
|
|
|
->and($response->getStatusCode())->toBe(Response::HTTP_FOUND);
|
|
|
|
|
});
|
Carry signup attribution into the Stripe subscription (#322)
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.
2026-09-01 01:31:09 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-09-01 02:01:37 +00:00
|
|
|
|
|
|
|
|
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'));
|
|
|
|
|
});
|