Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,56 @@ used** — `syncWorkspaceQuantity()` was removed with the per-workspace model.
`AccountPolicy::useAi` checks app access and nothing else. Do not
reintroduce per-call usage recording.

## Offers (`?offer=` links)

An offer is a Stripe coupon the Welcome checkout applies instead of the `$1`
first month (owner decision October 2026). It lives in `offers` (`slug`,
`stripe_coupon_id`, `starts_at` required, `ends_at` and `max_redemptions`
nullable: no end, no limit) and reaches an account through the pivot
`account_offer` (`claimed_at`, `redeemed_at`).

- **Capture.** `CaptureOffer` (web middleware) reads `?offer=<slug>` on any page.
A signed-in owner claims it at once (`ClaimOffer`). A guest's slug waits in the
session, only when it names an available offer, and the first authenticated
request claims it, so email signup, Google, GitHub and login need no code of
their own; moving between register and login keeps it. Slugs match exactly on
both engines through `Offer::findAvailable()` (MySQL compares without case). Ads and landing pages link to `/register?offer=<slug>`, win-back emails to
`/login?offer=<slug>`. There is no offer page or route.
- **Who gets it.** Only an account owner whose account has no active
subscription: new signups and former customers alike, with no
first-time-subscriber rule. An unknown, not yet started, ended or full offer is
ignored silently and nothing is kept for later. A redeemed offer is never offered
to that account again.
- **The `offers` row is the only source of truth for dates and places.** The
Stripe coupon has no `redeem_by` and no `max_redemptions`. Only redemptions use
up a place (`Offer::scopeAvailable`), never a claim; two checkouts racing for the
last place may both get it (accepted).
- **Checkout.** `ConfigureSubscriptionCheckout::apply()`: available offer
(`Account::currentOffer()`, the latest claim) → first-month coupon → trial. An
offer skips the trial and throws with `allow_promotion_codes`, like the coupon.
Validity is checked again when checkout starts. The controller resolves the
offer once (`offerFor()`) and hands the same one to `StartSubscriptionCheckout`
and `apply()`, so the coupon, the metadata and the tracked events agree. The
offer id goes in the subscription metadata `trypost_offer_id`; the
`customer.subscription.created` / `updated` webhook writes `redeemed_at`
(`RedeemOffer`) only once the subscription is `active` or `trialing`, so an
incomplete checkout never uses up a place. An offer checkout does not set
`trypost_first_month_coupon_id`, so it is not counted as the `$1` first month.
Offers apply only to the Welcome checkout (Socials monthly).
- **Copy.** Name, price and period live in `lang/<locale>/offers.php` under the
slug with dashes as underscores (`black-friday-2026` → `black_friday_2026`:
`name`, `price`, `period`), in all 16 locales; the price is display text per
locale, like `billing.subscribe.prices.first_month`. Nothing about the discount is
stored in the database. The page has no fallback language, so `OfferResource`
checks each key in the current locale only. The Welcome plan step shows the
badge (`welcome.plan_offer_badge`) and the offer price only when the price and
period exist; without the name the badge says `welcome.plan_offer_badge_generic`;
it never prints a raw key. `tests/Feature/Offer/OfferCopyTest.php` fails when an
offer row misses its copy in any locale.
- **Adding an offer.** Create the coupon in Stripe (no dates, no limit), add a
data migration that inserts the `offers` row (`DB::table`, `Str::uuid7()`), add
its copy to all 16 locales, deploy.

## Multiple social accounts per network

A workspace may connect as many accounts of the same network as it wants (two
Expand Down Expand Up @@ -778,6 +828,7 @@ view turns a forgotten variable into a runtime-only failure.
- **Exception — the per-post table on the channel insights page** (owner decision 2026-10-05): it is page-based with numbered pages, 10 posts per page (`ListChannelPublicationPerformance::INSIGHTS_PAGE_SIZE`, through `insightsPage()`), and keeps `?page=` in the URL; a sort, period or filter change goes back to page 1. The API endpoint and the MCP tool for the same list keep the config page size through `handle()`.
- **Exception — the ideas board columns** (owner decision 2026-10-06): each stage column (and Unassigned) is its own scroll prop, `columns.<stage id|unassigned>`, with its own page name, and loads 10 ideas per page (`ListIdeas::BOARD_PAGE_SIZE`) as the column scrolls. A label filter change resets every column to page 1. Column counts come from the database (`stages.*.ideas_count`, `unassigned_count`), never from the loaded cards. The web board moves a card with `after_idea_id` (`MoveIdea::after()`), since a partly loaded column cannot send the full order; the API and MCP keep the full `idea_ids` contract. The gallery keeps the config page size.
- **Exception — the "What's New" modal** (owner decision 2026-10-14): it loads 5 entries per page, enough to fill the modal (capped at 640px) and the next one loads only when the user scrolls (`ListChangelogEntries::PAGE_SIZE`) under the page name `changelog_page`, as the shared optional scroll prop `changelogEntries` (built with the `changelog` card prop by `BuildChangelogProps`, which `HandleInertiaRequests` only spreads), requested by a partial reload when the modal opens.
- **Exception — the invoices tab on the billing page** (October 2026): invoices live in Stripe, which only pages by cursor, so `BillingController@index` sends `Inertia::scroll()` over Cashier's `cursorPaginateInvoices()` (paid invoices only, config page size). It is the one cursor paginator in the app; database lists keep `paginate()`.
- **This includes the public REST API** (`app/Http/Controllers/Api`). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing `app.pagination.default` therefore changes the API's page size too — deliberate, and the reason a list response always carries `meta.per_page` for clients to read rather than assume.

## Changelog ("What's New")
Expand Down
12 changes: 8 additions & 4 deletions app/Actions/Billing/StartSubscriptionCheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

use App\Enums\Billing\Interval;
use App\Models\Account;
use App\Models\Offer;
use App\Models\Plan;
use App\Support\Billing\ConfigureSubscriptionCheckout;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;

class StartSubscriptionCheckout
{
public function redirect(Account $account, string $priceId, string $cancelUrl, ?Plan $plan = null): Response
public function redirect(Account $account, string $priceId, string $cancelUrl, ?Plan $plan = null, ?Offer $offer = null): Response
{
// A leftover Workspaces row (unpaid/canceled without `deleted`) would
// let Processing treat auth.plan as checkout-complete and skip the wait.
Expand All @@ -26,9 +27,12 @@ public function redirect(Account $account, string $priceId, string $cancelUrl, ?
$monthlyPlan = $this->monthlyPlan($plan, $priceId);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId);

ConfigureSubscriptionCheckout::apply($subscription, $account, $monthlyPlan);
$offer = filled($monthlyPlan) ? $offer : null;

ConfigureSubscriptionCheckout::apply($subscription, $account, $monthlyPlan, $offer);
$subscription->withMetadata(array_filter([
ConfigureSubscriptionCheckout::FIRST_MONTH_COUPON_METADATA_KEY => $subscription->couponId,
ConfigureSubscriptionCheckout::FIRST_MONTH_COUPON_METADATA_KEY => blank($offer) ? $subscription->couponId : null,
ConfigureSubscriptionCheckout::OFFER_METADATA_KEY => $offer?->id,
], filled(...)));

$session = $subscription->checkout([
Expand All @@ -37,7 +41,7 @@ public function redirect(Account $account, string $priceId, string $cancelUrl, ?
'metadata' => array_filter([
ConfigureSubscriptionCheckout::PLAN_METADATA_KEY => $plan?->slug->value,
ConfigureSubscriptionCheckout::INTERVAL_METADATA_KEY => (filled($plan) ? Interval::forPrice($plan, $priceId) : Interval::Monthly)->value,
ConfigureSubscriptionCheckout::FIRST_MONTH_OFFER_METADATA_KEY => filled($subscription->couponId) ? 'true' : 'false',
ConfigureSubscriptionCheckout::FIRST_MONTH_OFFER_METADATA_KEY => blank($offer) && filled($subscription->couponId) ? 'true' : 'false',
], filled(...)),
]);

Expand Down
40 changes: 40 additions & 0 deletions app/Actions/Offer/ClaimOffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Actions\Offer;

use App\Models\Account;
use App\Models\Offer;

class ClaimOffer
{
/**
* Ties an available offer to an account without an active subscription.
* An unknown, unavailable or already redeemed offer is ignored.
*/
public static function execute(Account $account, string $slug): ?Offer
{
if ($account->hasActiveSubscription()) {
return null;
}

$offer = Offer::findAvailable($slug);

if (blank($offer)) {
return null;
}

if ($account->offers()->whereKey($offer->id)->wherePivotNotNull('redeemed_at')->exists()) {
return null;
}

$account->offers()->newPivotStatement()->upsert(
[['account_id' => $account->id, 'offer_id' => $offer->id, 'claimed_at' => now()]],
['account_id', 'offer_id'],
['claimed_at'],
);

return $offer;
}
}
27 changes: 27 additions & 0 deletions app/Actions/Offer/RedeemOffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Actions\Offer;

use App\Models\Account;
use Illuminate\Support\Str;

class RedeemOffer
{
/**
* Records that the account's subscription was created with the offer's
* coupon. Only the first redemption counts; an offer the account never
* claimed is ignored.
*/
public static function execute(Account $account, ?string $offerId): void
{
if (! Str::isUuid($offerId)) {
return;
}

$account->offers()
->wherePivotNull('redeemed_at')
->updateExistingPivot($offerId, ['redeemed_at' => now()]);
}
}
57 changes: 48 additions & 9 deletions app/Http/Controllers/App/BillingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@

use App\Enums\Billing\Interval;
use App\Http\Requests\App\Billing\ChangePlanRequest;
use App\Http\Resources\App\InvoiceResource;
use App\Http\Resources\App\PaymentMethodResource;
use App\Http\Resources\App\PlanResource;
use App\Http\Resources\App\SubscriptionDiscountResource;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Subscription;
use App\Support\Billing\SubscriptionPlanSync;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Cashier\Exceptions\IncompletePayment;
use Laravel\Cashier\PaymentMethod;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class BillingController extends Controller
Expand Down Expand Up @@ -60,18 +65,52 @@ public function index(Request $request): Response|RedirectResponse
]),
'plan' => $account->plan ? PlanResource::make($account->plan)->resolve() : null,
'workspaceCount' => $account->workspaces()->count(),
'workspaceLimit' => $account->workspaceLimit(),
'invoices' => $account->invoices()->map(fn ($invoice) => [
'id' => $invoice->id,
'date' => $invoice->date(),
'total' => $invoice->total(),
'status' => $invoice->status,
'invoice_pdf' => $invoice->invoice_pdf,
]),
'defaultPaymentMethod' => $account->displayablePaymentMethod(),
'invoices' => Inertia::scroll(
fn () => InvoiceResource::collection($account->cursorPaginateInvoices((int) config('app.pagination.default'), ['status' => 'paid'])),
)->matchOn('data.id'),
'paymentMethods' => $this->paymentMethods($account),
'discount' => $this->discount($account, $subscription),
]);
}

/**
* The customer's cards, the default one first. Stripe Checkout trials anchor
* the card to the subscription, not the customer, so without
* `invoice_settings.default_payment_method` the first card is the default.
*
* @return list<array{id: string, brand: string, last4: string, exp_month: int, exp_year: int, is_default: bool}>
*/
private function paymentMethods(Account $account): array
{
$paymentMethods = $account->paymentMethods('card');

if ($paymentMethods->isEmpty()) {
return [];
}

$defaultId = $account->defaultPaymentMethod()?->id ?? $paymentMethods->first()->id;

return $paymentMethods
->sortByDesc(fn (PaymentMethod $paymentMethod): bool => $paymentMethod->id === $defaultId)
->map(fn (PaymentMethod $paymentMethod): array => PaymentMethodResource::make($paymentMethod, $paymentMethod->id === $defaultId)->resolve())
->values()
->all();
}

/**
* @return array{value: string, duration: string, ends_at: string|null}|null
*/
private function discount(Account $account, ?Subscription $subscription): ?array
{
if (blank($subscription) || ! $account->hasStripeId()) {
return null;
}

$discount = $subscription->discount();

return filled($discount) ? SubscriptionDiscountResource::make($discount)->resolve() : null;
}

public function changePlan(ChangePlanRequest $request): RedirectResponse
{
if (config('trypost.self_hosted')) {
Expand Down
12 changes: 9 additions & 3 deletions app/Http/Controllers/App/WelcomeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use App\Http\Requests\App\Welcome\StoreWelcomeMcpConnectClickRequest;
use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest;
use App\Http\Requests\App\Welcome\StoreWelcomeSoftwareRequest;
use App\Http\Resources\App\OfferResource;
use App\Http\Resources\App\PlanResource;
use App\Models\Plan;
use App\Services\PostHogService;
Expand Down Expand Up @@ -154,10 +155,13 @@ public function plan(Request $request): InertiaResponse|RedirectResponse
}

$plan = Plan::active()->where('slug', Slug::Socials)->firstOrFail();
$account = $request->user()->account;
$offer = ConfigureSubscriptionCheckout::offerFor($account, $plan);

return Inertia::render('welcome/Plan', [
'plan' => PlanResource::make($plan)->resolve(),
'firstMonthOffer' => ConfigureSubscriptionCheckout::offersFirstMonthCoupon($request->user()->account, $plan),
'firstMonthOffer' => ConfigureSubscriptionCheckout::offersFirstMonthCoupon($account, $plan, $offer),
'offer' => filled($offer) ? OfferResource::make($offer)->resolve() : null,
]);
}

Expand All @@ -176,8 +180,9 @@ public function storePlan(

abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Price is not configured.');

$firstMonthOffer = ConfigureSubscriptionCheckout::offersFirstMonthCoupon($user->account, $plan);
$response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan);
$offer = ConfigureSubscriptionCheckout::offerFor($user->account, $plan);
$firstMonthOffer = ConfigureSubscriptionCheckout::offersFirstMonthCoupon($user->account, $plan, $offer);
$response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan, $offer);

try {
$postHog->capture(
Expand All @@ -187,6 +192,7 @@ public function storePlan(
'plan_name' => $plan->name,
'interval' => Interval::Monthly->value,
'is_first_month_offer' => $firstMonthOffer,
'offer_slug' => $offer?->slug,
],
$user->account,
);
Expand Down
47 changes: 47 additions & 0 deletions app/Http/Middleware/App/CaptureOffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware\App;

use App\Actions\Offer\ClaimOffer;
use App\Models\Offer;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CaptureOffer
{
public const string SESSION_KEY = 'pending_offer';

/**
* Claims an `?offer=` for a signed-in owner at once. A guest's slug waits
* in the session until they sign in: signup (email, Google, GitHub) and
* login all land on an authenticated request, so none of them handles it.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$slug = $request->query('offer');
$slug = is_string($slug) && filled($slug) ? $slug : null;
$user = $request->user();

if (blank($user)) {
if (filled($slug) && filled(Offer::findAvailable($slug))) {
$request->session()->put(self::SESSION_KEY, $slug);
}

return $next($request);
}

$pendingSlug = $request->session()->pull(self::SESSION_KEY);
$slug ??= $pendingSlug;

if (filled($slug) && $user->isAccountOwner() && filled($user->account)) {
ClaimOffer::execute($user->account, $slug);
}

return $next($request);
}
}
31 changes: 31 additions & 0 deletions app/Http/Resources/App/InvoiceResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Resources\App;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Laravel\Cashier\Invoice;

/**
* A paid invoice on the billing page's invoices tab.
*
* @mixin Invoice
*/
class InvoiceResource extends JsonResource
{
/**
* @return array{id: string, date: string, total: string, status: string, invoice_pdf: string|null}
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'date' => $this->date()->toIso8601String(),
'total' => $this->total(),
'status' => $this->status,
'invoice_pdf' => $this->invoice_pdf,
];
}
}
Loading
Loading