diff --git a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php index 2c4d5ae4..f7e33017 100644 --- a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php +++ b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php @@ -73,7 +73,10 @@ public function table(Table $table): Table } $ticket = $this->getOwnerRecord(); - $ticket->user->notify(new SupportTicketReplied($ticket, $record)); + + if ($ticket->user_id !== auth()->id()) { + $ticket->user->notify(new SupportTicketReplied($ticket, $record)); + } }), ]) ->actions([ diff --git a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php index 97836c7f..99910c0d 100644 --- a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php +++ b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php @@ -36,7 +36,7 @@ public function sendReply(): void 'note' => $this->isNote, ]); - if (! $this->isNote) { + if (! $this->isNote && $this->record->user_id !== auth()->id()) { $this->record->user->notify(new SupportTicketReplied($this->record, $reply)); } diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index 8298de3b..403c9a7a 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -167,8 +167,8 @@ public function requestClaudePluginsAccess(): RedirectResponse // Check if user has a Plugin Dev Kit license or is an Ultra team member $pluginDevKit = Product::where('slug', 'plugin-dev-kit')->first(); - if (! $user->isUltraTeamMember() && (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit))) { - return back()->with('error', 'You need a Plugin Dev Kit license or Ultra team membership to access the claude-code repository.'); + if (! $user->hasActiveUltraSubscription() && ! $user->isUltraTeamMember() && (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit))) { + return back()->with('error', 'You need a Plugin Dev Kit license, Ultra subscription, or Ultra team membership to access the claude-code repository.'); } $github = GitHubOAuth::make(); diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index d3f13e45..b618b1e1 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -4,7 +4,6 @@ use App\Enums\PayoutStatus; use App\Enums\Subscription; -use App\Exceptions\InvalidStateException; use App\Models\Cart; use App\Models\CartItem; use App\Models\License; @@ -25,7 +24,6 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Sleep; use Laravel\Cashier\Cashier; use Laravel\Cashier\SubscriptionItem; use Stripe\Invoice; @@ -83,8 +81,6 @@ private function handleSubscriptionCreated(): void return; } - // Normal flow - create a new license - $this->createLicense(); $this->updateSubscriptionCompedStatus(); } @@ -105,8 +101,6 @@ private function handleLegacyLicenseRenewal($subscription, string $licenseKey, s 'user_id' => $user->id, 'subscription_id' => $subscription->id, ]); - // Fallback to creating a new license - $this->createLicense(); return; } @@ -140,38 +134,6 @@ private function handleLegacyLicenseRenewal($subscription, string $licenseKey, s ]); } - private function createLicense(): void - { - // Add some delay to allow all the Stripe events to come in - Sleep::sleep(10); - - // Assert the invoice line item is for a price_id that relates to a license plan. - $plan = Subscription::fromStripePriceId($this->findPlanLineItem()->price->id); - - // Assert the invoice line item relates to a subscription and has a subscription item id. - if (blank($subscriptionItemId = $this->findPlanLineItem()->subscription_item)) { - throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); - } - - // Assert we have a subscription item record for this subscription item id. - $subscriptionItemModel = SubscriptionItem::query()->where('stripe_id', $subscriptionItemId)->firstOrFail(); - - // Assert we don't already have an existing license for this subscription item. - if ($license = License::query()->whereBelongsTo($subscriptionItemModel)->first()) { - throw new InvalidStateException("A license [{$license->id}] already exists for subscription item [{$subscriptionItemModel->id}]."); - } - - $user = $this->billable(); - - dispatch(new CreateAnystackLicenseJob( - $user, - $plan, - $subscriptionItemModel->id, - $user->first_name, - $user->last_name, - )); - } - private function handleSubscriptionRenewal(): void { // Get the subscription item ID from the invoice line @@ -186,9 +148,6 @@ private function handleSubscriptionRenewal(): void $license = License::query()->whereBelongsTo($subscriptionItemModel)->first(); if (! $license) { - // No existing license found - this might be a new subscription, handle as create - $this->createLicense(); - return; } diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index 7544b010..c293fb54 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -5,6 +5,7 @@ use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleInvoicePaidJob; use App\Jobs\RemoveDiscordMaxRoleJob; +use App\Jobs\RevokeTeamUserAccessJob; use App\Jobs\SuspendTeamJob; use App\Jobs\UnsuspendTeamJob; use App\Models\User; @@ -77,6 +78,7 @@ private function handleSubscriptionDeleted(WebhookReceived $event): void $this->removeDiscordRoleIfNoMaxLicense($user); dispatch(new SuspendTeamJob($user->id)); + dispatch(new RevokeTeamUserAccessJob($user->id)); } private function handleSubscriptionUpdated(WebhookReceived $event): void @@ -101,6 +103,7 @@ private function handleSubscriptionUpdated(WebhookReceived $event): void if (in_array($status, ['canceled', 'unpaid', 'past_due', 'incomplete_expired'])) { $this->removeDiscordRoleIfNoMaxLicense($user); dispatch(new SuspendTeamJob($user->id)); + dispatch(new RevokeTeamUserAccessJob($user->id)); } // Detect reactivation: status changed to active from a non-active state diff --git a/app/Notifications/SupportTicketReplied.php b/app/Notifications/SupportTicketReplied.php index 49236beb..de0d901e 100644 --- a/app/Notifications/SupportTicketReplied.php +++ b/app/Notifications/SupportTicketReplied.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -use Illuminate\Support\Str; class SupportTicketReplied extends Notification implements ShouldQueue { @@ -30,11 +29,11 @@ public function via(object $notifiable): array public function toMail(object $notifiable): MailMessage { return (new MailMessage) - ->subject('Update on your support request: '.$this->ticket->subject) + ->subject('Update on your support request: '.$this->ticket->mask) ->greeting("Hi {$notifiable->first_name},") - ->line('Your support ticket has received a new reply.') - ->line('**'.e($this->ticket->subject).'**') - ->line(Str::limit($this->reply->message, 500)) - ->action('View Ticket', route('customer.support.tickets.show', $this->ticket)); + ->line('Your support ticket **'.e($this->ticket->subject).'** has received a new reply.') + ->line('Please log in to your dashboard to view the message and respond.') + ->action('View Ticket', route('customer.support.tickets.show', $this->ticket)) + ->line('*Please do not reply to this email — responses must be submitted through the support portal.*'); } } diff --git a/app/Notifications/SupportTicketSubmitted.php b/app/Notifications/SupportTicketSubmitted.php index e1e72a36..a72d27e8 100644 --- a/app/Notifications/SupportTicketSubmitted.php +++ b/app/Notifications/SupportTicketSubmitted.php @@ -26,11 +26,13 @@ public function via(object $notifiable): array public function toMail(object $notifiable): MailMessage { $ticket = $this->ticket->loadMissing('user'); + $user = $ticket->user; return (new MailMessage) ->subject('New Support Ticket: '.$ticket->subject) - ->replyTo($ticket->user->email, $ticket->user->name) ->greeting('New support ticket received!') + ->line("**Customer:** {$user->name}") + ->line('**Email:** '.$this->obfuscateEmail($user->email)) ->line("**Product:** {$ticket->product}") ->line('**Issue Type:** '.($ticket->issue_type ?? 'N/A')) ->line("**Subject:** {$ticket->subject}") @@ -38,4 +40,25 @@ public function toMail(object $notifiable): MailMessage ->line(Str::limit($ticket->message, 500)) ->action('View Ticket', SupportTicketResource::getUrl('view', ['record' => $ticket])); } + + private function obfuscateEmail(string $email): string + { + $parts = explode('@', $email); + $local = $parts[0]; + $domain = $parts[1] ?? ''; + + $visibleLocal = Str::length($local) > 2 + ? Str::substr($local, 0, 2).str_repeat('*', Str::length($local) - 2) + : $local; + + $domainParts = explode('.', $domain); + $domainName = $domainParts[0] ?? ''; + $tld = implode('.', array_slice($domainParts, 1)); + + $visibleDomain = Str::length($domainName) > 2 + ? Str::substr($domainName, 0, 2).str_repeat('*', Str::length($domainName) - 2) + : $domainName; + + return "{$visibleLocal}@{$visibleDomain}.{$tld}"; + } } diff --git a/package-lock.json b/package-lock.json index 0d04291b..9b900603 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "cyan-salmon", + "name": "curious-dragon", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/public/brand-assets/logo/nativephp-for-light-background@2x.png b/public/brand-assets/logo/nativephp-for-light-background@2x.png new file mode 100644 index 00000000..22bfb08e Binary files /dev/null and b/public/brand-assets/logo/nativephp-for-light-background@2x.png differ diff --git a/resources/views/livewire/claude-plugins-access-banner.blade.php b/resources/views/livewire/claude-plugins-access-banner.blade.php index efbd6339..948239c2 100644 --- a/resources/views/livewire/claude-plugins-access-banner.blade.php +++ b/resources/views/livewire/claude-plugins-access-banner.blade.php @@ -2,7 +2,7 @@ @php $user = auth()->user(); $pluginDevKit = \App\Models\Product::where('slug', 'plugin-dev-kit')->first(); - $hasLicense = $pluginDevKit && $user->hasProductLicense($pluginDevKit); + $hasLicense = ($pluginDevKit && $user->hasProductLicense($pluginDevKit)) || $user->hasActiveUltraSubscription(); @endphp @if($hasLicense) diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index 313d3aef..f44a9d33 100644 --- a/resources/views/livewire/customer/integrations.blade.php +++ b/resources/views/livewire/customer/integrations.blade.php @@ -28,7 +28,7 @@ About Integrations

diff --git a/resources/views/vendor/mail/html/header.blade.php b/resources/views/vendor/mail/html/header.blade.php index df932926..78e0dd09 100644 --- a/resources/views/vendor/mail/html/header.blade.php +++ b/resources/views/vendor/mail/html/header.blade.php @@ -2,7 +2,7 @@ - + diff --git a/resources/views/vendor/mail/html/themes/default.css b/resources/views/vendor/mail/html/themes/default.css index d6ee6f0b..6523b40d 100644 --- a/resources/views/vendor/mail/html/themes/default.css +++ b/resources/views/vendor/mail/html/themes/default.css @@ -112,11 +112,11 @@ img { /* Logo */ .logo { - height: 75px; + height: auto; margin-top: 15px; margin-bottom: 10px; - max-height: 75px; - width: 75px; + max-width: 150px; + width: 150px; } /* Body */ diff --git a/tests/Feature/Jobs/HandleInvoicePaidJobTest.php b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php new file mode 100644 index 00000000..02241039 --- /dev/null +++ b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php @@ -0,0 +1,133 @@ +create([ + 'stripe_id' => 'cus_test123', + ]); + + $priceId = 'price_test_'.$planKey; + config(["subscriptions.plans.{$planKey}.stripe_price_id" => $priceId]); + + $subscription = \Laravel\Cashier\Subscription::factory() + ->for($user, 'user') + ->create([ + 'stripe_id' => 'sub_test123', + 'stripe_status' => 'active', + 'stripe_price' => $priceId, + 'quantity' => 1, + ]); + + SubscriptionItem::factory() + ->for($subscription, 'subscription') + ->create([ + 'stripe_id' => 'si_test123', + 'stripe_price' => $priceId, + 'quantity' => 1, + ]); + + $this->mockStripeSubscriptionRetrieve('sub_test123'); + + $invoice = $this->createStripeInvoice( + customerId: 'cus_test123', + subscriptionId: 'sub_test123', + billingReason: Invoice::BILLING_REASON_SUBSCRIPTION_CREATE, + priceId: $priceId, + subscriptionItemId: 'si_test123', + ); + + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + Bus::assertNotDispatched(CreateAnystackLicenseJob::class); + } + + public static function subscriptionPlanProvider(): array + { + return [ + 'mini' => ['mini'], + 'pro' => ['pro'], + 'max' => ['max'], + ]; + } + + private function createStripeInvoice( + string $customerId, + string $subscriptionId, + string $billingReason, + string $priceId, + string $subscriptionItemId, + ): Invoice { + return Invoice::constructFrom([ + 'id' => 'in_test_'.uniqid(), + 'object' => 'invoice', + 'customer' => $customerId, + 'subscription' => $subscriptionId, + 'billing_reason' => $billingReason, + 'total' => 25000, + 'currency' => 'usd', + 'payment_intent' => 'pi_test_'.uniqid(), + 'metadata' => [], + 'lines' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'il_test_'.uniqid(), + 'object' => 'line_item', + 'subscription_item' => $subscriptionItemId, + 'price' => [ + 'id' => $priceId, + 'object' => 'price', + 'active' => true, + 'currency' => 'usd', + 'unit_amount' => 25000, + ], + ], + ], + 'has_more' => false, + 'total_count' => 1, + ], + ]); + } + + private function mockStripeSubscriptionRetrieve(string $subscriptionId): void + { + $mockSubscription = Subscription::constructFrom([ + 'id' => $subscriptionId, + 'metadata' => [], + 'current_period_end' => now()->addYear()->timestamp, + ]); + + $mockSubscriptionsService = $this->createMock(SubscriptionService::class); + $mockSubscriptionsService->method('retrieve')->willReturn($mockSubscription); + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->subscriptions = $mockSubscriptionsService; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + } +} diff --git a/tests/Feature/StripePurchaseHandlingTest.php b/tests/Feature/StripePurchaseHandlingTest.php index 096343ee..174840cf 100644 --- a/tests/Feature/StripePurchaseHandlingTest.php +++ b/tests/Feature/StripePurchaseHandlingTest.php @@ -182,7 +182,7 @@ public function a_license_is_not_created_when_a_stripe_subscription_is_created() } #[Test] - public function a_license_is_created_when_a_stripe_invoice_is_paid() + public function a_license_is_not_created_when_a_stripe_invoice_is_paid() { Bus::fake([CreateAnystackLicenseJob::class]); @@ -245,13 +245,7 @@ public function a_license_is_created_when_a_stripe_invoice_is_paid() $this->postJson('/stripe/webhook', $payload); - Bus::assertDispatched(CreateAnystackLicenseJob::class, function (CreateAnystackLicenseJob $job) { - return $job->user->email === 'john@example.com' && - $job->subscription === Subscription::Max && - $job->subscriptionItemId === $job->user->subscriptions->first()->items()->first()->id && - $job->firstName === 'John' && - $job->lastName === 'Doe'; - }); + Bus::assertNotDispatched(CreateAnystackLicenseJob::class); } protected function mockStripeClient(?User $user = null): void diff --git a/tests/Feature/SupportTicketTest.php b/tests/Feature/SupportTicketTest.php index 12f03d72..1c3372a8 100644 --- a/tests/Feature/SupportTicketTest.php +++ b/tests/Feature/SupportTicketTest.php @@ -656,6 +656,31 @@ function (SupportTicketSubmitted $notification, array $channels, object $notifia ); } + #[Test] + public function support_ticket_email_includes_customer_details_with_obfuscated_email(): void + { + $user = User::factory()->create([ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + ]); + + $ticket = SupportTicket::factory()->create([ + 'user_id' => $user->id, + 'subject' => 'Test ticket', + 'product' => 'bifrost', + 'issue_type' => 'bug', + 'message' => 'Test message', + ]); + + $notification = new SupportTicketSubmitted($ticket); + $mailMessage = $notification->toMail($user); + $rendered = $mailMessage->render()->toHtml(); + + $this->assertStringContainsString('Jane Smith', $rendered); + $this->assertStringContainsString('ja**@ex*****.com', $rendered); + $this->assertStringNotContainsString('jane@example.com', $rendered); + } + #[Test] public function authenticated_ultra_user_can_reply_to_their_open_ticket(): void { @@ -877,6 +902,27 @@ public function internal_note_reply_does_not_send_notification_to_ticket_owner() Notification::assertNotSentTo($user, SupportTicketReplied::class); } + #[Test] + public function ticket_owner_does_not_receive_notification_for_own_reply(): void + { + Notification::fake(); + + $admin = User::factory()->create(['is_admin' => true]); + $ticket = SupportTicket::factory()->create(['user_id' => $admin->id]); + + Livewire::actingAs($admin) + ->test(TicketRepliesWidget::class, ['record' => $ticket]) + ->set('newMessage', 'Replying to my own ticket.') + ->call('sendReply'); + + $this->assertDatabaseHas('replies', [ + 'support_ticket_id' => $ticket->id, + 'message' => 'Replying to my own ticket.', + ]); + + Notification::assertNotSentTo($admin, SupportTicketReplied::class); + } + #[Test] public function support_ticket_replied_notification_contains_correct_mail_content(): void { @@ -892,9 +938,14 @@ public function support_ticket_replied_notification_contains_correct_mail_conten $notification = new SupportTicketReplied($ticket, $reply); $mail = $notification->toMail($user); + $rendered = $mail->render()->toHtml(); - $this->assertStringContainsString('Login issue', $mail->subject); + $this->assertStringContainsString($ticket->mask, $mail->subject); + $this->assertStringNotContainsString('Login issue', $mail->subject); $this->assertStringContainsString('Hi Jane', $mail->greeting); + $this->assertStringContainsString('log in to your dashboard', $rendered); + $this->assertStringContainsString('do not reply to this email', $rendered); + $this->assertStringNotContainsString('We have fixed the login issue', $rendered); } #[Test] diff --git a/tests/Feature/UltraClaudeCodeAccessTest.php b/tests/Feature/UltraClaudeCodeAccessTest.php new file mode 100644 index 00000000..7b75d72c --- /dev/null +++ b/tests/Feature/UltraClaudeCodeAccessTest.php @@ -0,0 +1,192 @@ + self::MAX_PRICE_ID]); + } + + private function createUltraUser(): User + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + ]); + + return $user; + } + + // ======================================== + // Banner Visibility Tests + // ======================================== + + public function test_ultra_subscriber_sees_claude_plugins_banner(): void + { + Http::fake(['github.com/*' => Http::response([], 200)]); + + $user = $this->createUltraUser(); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertStatus(200); + $response->assertSee('Repo Access'); + } + + public function test_plugin_dev_kit_license_holder_sees_claude_plugins_banner(): void + { + Http::fake(['github.com/*' => Http::response([], 200)]); + + $user = User::factory()->create(); + $product = Product::factory()->create(['slug' => 'plugin-dev-kit']); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertStatus(200); + $response->assertSee('Repo Access'); + } + + public function test_non_ultra_non_licensed_user_does_not_see_claude_plugins_banner(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertStatus(200); + $response->assertDontSee('Repo Access'); + } + + // ======================================== + // Request Access Tests + // ======================================== + + public function test_ultra_subscriber_can_request_claude_plugins_access(): void + { + Http::fake(['github.com/*' => Http::response([], 201)]); + + $user = $this->createUltraUser(); + $user->update(['github_username' => 'ultrauser']); + + $response = $this->actingAs($user) + ->post(route('github.request-claude-plugins-access')); + + $response->assertSessionHas('success'); + $this->assertNotNull($user->fresh()->claude_plugins_repo_access_granted_at); + } + + public function test_non_ultra_non_licensed_user_cannot_request_claude_plugins_access(): void + { + $user = User::factory()->create(['github_username' => 'someuser']); + + $response = $this->actingAs($user) + ->post(route('github.request-claude-plugins-access')); + + $response->assertSessionHas('error'); + $this->assertNull($user->fresh()->claude_plugins_repo_access_granted_at); + } + + // ======================================== + // Subscription Revocation Tests + // ======================================== + + public function test_revoke_job_dispatched_when_subscription_deleted(): void + { + Queue::fake(); + + $user = $this->createUltraUser(); + + $event = new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => [ + 'object' => [ + 'customer' => $user->stripe_id, + ], + ], + ]); + + $listener = new StripeWebhookReceivedListener; + $listener->handle($event); + + Queue::assertPushed(RevokeTeamUserAccessJob::class, function ($job) use ($user) { + return $job->userId === $user->id; + }); + } + + public function test_revoke_job_dispatched_when_subscription_canceled(): void + { + Queue::fake(); + + $user = $this->createUltraUser(); + + $event = new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => $user->stripe_id, + 'status' => 'canceled', + ], + 'previous_attributes' => [ + 'status' => 'active', + ], + ], + ]); + + $listener = new StripeWebhookReceivedListener; + $listener->handle($event); + + Queue::assertPushed(RevokeTeamUserAccessJob::class, function ($job) use ($user) { + return $job->userId === $user->id; + }); + } + + public function test_revoke_job_not_dispatched_when_subscription_reactivated(): void + { + Queue::fake(); + + $user = $this->createUltraUser(); + + $event = new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => $user->stripe_id, + 'status' => 'active', + ], + 'previous_attributes' => [ + 'status' => 'canceled', + ], + ], + ]); + + $listener = new StripeWebhookReceivedListener; + $listener->handle($event); + + Queue::assertNotPushed(RevokeTeamUserAccessJob::class); + } +}