Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/GitHubIntegrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
41 changes: 0 additions & 41 deletions app/Jobs/HandleInvoicePaidJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -83,8 +81,6 @@ private function handleSubscriptionCreated(): void
return;
}

// Normal flow - create a new license
$this->createLicense();
$this->updateSubscriptionCompedStatus();
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions app/Listeners/StripeWebhookReceivedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 5 additions & 6 deletions app/Notifications/SupportTicketReplied.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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.*');
}
}
25 changes: 24 additions & 1 deletion app/Notifications/SupportTicketSubmitted.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,39 @@ 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}")
->line('**Message:**')
->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}";
}
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion resources/views/livewire/customer/integrations.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<flux:heading>About Integrations</flux:heading>
<div class="mt-4 prose dark:prose-invert prose-sm max-w-none">
<ul class="list-disc list-inside space-y-2">
<li><strong>GitHub:</strong> Max license holders can access the private <code>nativephp/mobile</code> repository. Plugin Dev Kit license holders can access <code>nativephp/claude-code</code>.</li>
<li><strong>GitHub:</strong> Max license holders can access the private <code>nativephp/mobile</code> repository. Plugin Dev Kit license holders and Ultra subscribers can access <code>nativephp/claude-code</code>.</li>
<li><strong>Discord:</strong> Max license holders receive a special "Max" role in the NativePHP Discord server.</li>
</ul>
<p class="mt-4">
Expand Down
2 changes: 1 addition & 1 deletion resources/views/vendor/mail/html/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
<img src="{{ asset('brand-assets/logo/nativephp-for-light-background.png') }}" class="logo" alt="{{ config('app.name') }}" style="max-width: 200px; height: auto;">
<img src="{{ asset('brand-assets/logo/nativephp-for-light-background@2x.png') }}" class="logo" alt="{{ config('app.name') }}" style="max-width: 150px; height: auto;">
</a>
</td>
</tr>
6 changes: 3 additions & 3 deletions resources/views/vendor/mail/html/themes/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
133 changes: 133 additions & 0 deletions tests/Feature/Jobs/HandleInvoicePaidJobTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

namespace Tests\Feature\Jobs;

use App\Jobs\CreateAnystackLicenseJob;
use App\Jobs\HandleInvoicePaidJob;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Laravel\Cashier\SubscriptionItem;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Stripe\Invoice;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
use Stripe\Subscription;
use Tests\TestCase;

class HandleInvoicePaidJobTest extends TestCase
{
use RefreshDatabase;

#[Test]
#[DataProvider('subscriptionPlanProvider')]
public function it_does_not_create_license_for_any_subscription(string $planKey): void
{
Bus::fake();

$user = User::factory()->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);
}
}
Loading
Loading