diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 9f97dd0d4c..a2962c4ce5 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use App\Jobs\SyncMailcheepContactJob; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; @@ -58,6 +59,14 @@ class CreateNewUser implements CreatesNewUsers $team = $user->teams()->first(); if (isCloud()) { $user->sendVerificationEmail(); + if (config('subscription.mailcheep_api_key')) { + SyncMailcheepContactJob::dispatch( + action: 'create_or_update', + email: $user->email, + name: $user->name, + customFields: ['team_id' => (string) $team->id], + ); + } } else { $user->markEmailAsVerified(); } diff --git a/app/Console/Commands/SyncMailcheep.php b/app/Console/Commands/SyncMailcheep.php new file mode 100644 index 0000000000..97509fac0d --- /dev/null +++ b/app/Console/Commands/SyncMailcheep.php @@ -0,0 +1,76 @@ +error('This command can only be run on the cloud instance.'); + + return self::FAILURE; + } + + if (! config('subscription.mailcheep_api_key')) { + $this->error('MAILCHEEP_API_KEY is not configured.'); + + return self::FAILURE; + } + + $dryRun = $this->option('dry-run'); + $stats = ['subscribed' => 0, 'churned' => 0, 'no_subscription' => 0]; + + Team::with(['subscription', 'members'])->chunk(100, function ($teams) use ($dryRun, &$stats) { + foreach ($teams as $team) { + $owner = $team->members->firstWhere('pivot.role', 'owner'); + + if (! $owner) { + continue; + } + + $subscription = $team->subscription; + + if ($subscription && $subscription->stripe_invoice_paid && $subscription->stripe_subscription_id) { + $action = 'create_or_update'; + $extraFields = [ + 'team_id' => (string) $team->id, + 'plan' => $subscription->billingInterval(), + ]; + $stats['subscribed']++; + } elseif ($subscription && (! $subscription->stripe_invoice_paid || ! $subscription->stripe_subscription_id)) { + $action = 'add_to_churned'; + $extraFields = []; + $stats['churned']++; + } else { + $action = 'create_or_update'; + $extraFields = ['team_id' => (string) $team->id]; + $stats['no_subscription']++; + } + + if ($dryRun) { + $this->line("[DRY RUN] {$action}: {$owner->email} (Team #{$team->id})"); + } else { + SyncMailcheepContactJob::dispatch( + action: $action, + email: $owner->email, + name: $owner->name, + customFields: $extraFields, + ); + } + } + }); + + $this->info("Subscribed: {$stats['subscribed']}, Churned: {$stats['churned']}, No subscription: {$stats['no_subscription']}"); + + return self::SUCCESS; + } +} diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 3485ffe32c..cd34da0ab7 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -299,6 +299,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue $team = data_get($subscription, 'team'); if ($team) { $team->subscriptionEnded(); + SyncMailcheepContactJob::dispatchForTeam($team, 'add_to_churned'); } else { // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); @@ -311,6 +312,13 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_invoice_paid' => true, ]); } + $team = data_get($subscription, 'team'); + if ($team) { + SyncMailcheepContactJob::dispatchForTeam($team, 'create_or_update', [ + 'team_id' => (string) $team->id, + 'plan' => $subscription->billingInterval(), + ]); + } } if ($feedback) { $reason = "Cancellation feedback for {$customerId}: '".$feedback."'"; @@ -328,6 +336,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue $team = data_get($subscription, 'team'); if ($team) { $team->subscriptionEnded(); + SyncMailcheepContactJob::dispatchForTeam($team, 'add_to_churned'); } else { // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); diff --git a/app/Jobs/SyncMailcheepContactJob.php b/app/Jobs/SyncMailcheepContactJob.php new file mode 100644 index 0000000000..ac418e4d27 --- /dev/null +++ b/app/Jobs/SyncMailcheepContactJob.php @@ -0,0 +1,67 @@ + $customFields + */ + public function __construct( + public string $action, + public string $email, + public string $name, + public array $customFields = [], + ) { + $this->onQueue('high'); + } + + public function handle(MailcheepService $mailcheep): void + { + match ($this->action) { + 'create_or_update' => $mailcheep->createOrUpdateContact( + $this->email, + $this->name, + config('subscription.mailcheep_list_subscribed'), + $this->customFields, + ), + 'add_to_churned' => $mailcheep->addToChurnedList($this->email), + default => null, + }; + } + + /** + * @param array $extraFields + */ + public static function dispatchForTeam(Team $team, string $action, array $extraFields = []): void + { + if (! config('subscription.mailcheep_api_key')) { + return; + } + + $owner = $team->members->firstWhere('pivot.role', 'owner'); + + if (! $owner) { + return; + } + + static::dispatch( + action: $action, + email: $owner->email, + name: $owner->name, + customFields: $extraFields, + ); + } +} diff --git a/app/Services/MailcheepService.php b/app/Services/MailcheepService.php new file mode 100644 index 0000000000..c3ac60c502 --- /dev/null +++ b/app/Services/MailcheepService.php @@ -0,0 +1,138 @@ +apiKey = config('subscription.mailcheep_api_key'); + } + + /** + * @return array|null + */ + public function findContactByEmail(string $email): ?array + { + try { + $response = $this->client()->get("{$this->baseUrl}/contacts", [ + 'search' => $email, + ]); + + if ($response->successful()) { + $contacts = $response->json('data', []); + foreach ($contacts as $contact) { + if (data_get($contact, 'email') === $email) { + return $contact; + } + } + } + + return null; + } catch (\Exception $e) { + Log::error('Mailcheep: failed to find contact', ['email' => $email, 'error' => $e->getMessage()]); + + return null; + } + } + + /** + * @param array $customFields + * @return array|null + */ + public function createContact(string $email, string $name, string $listId, array $customFields = []): ?array + { + try { + $response = $this->client()->post("{$this->baseUrl}/contacts", [ + 'email' => $email, + 'name' => $name, + 'list_id' => $listId, + 'custom_fields' => $customFields, + ]); + + if ($response->successful()) { + return $response->json('data'); + } + + Log::warning('Mailcheep: failed to create contact', ['email' => $email, 'status' => $response->status()]); + + return null; + } catch (\Exception $e) { + Log::error('Mailcheep: failed to create contact', ['email' => $email, 'error' => $e->getMessage()]); + + return null; + } + } + + /** + * @param array $data + * @return array|null + */ + public function updateContact(string $id, array $data): ?array + { + try { + $response = $this->client()->put("{$this->baseUrl}/contacts/{$id}", $data); + + if ($response->successful()) { + return $response->json('data'); + } + + Log::warning('Mailcheep: failed to update contact', ['id' => $id, 'status' => $response->status()]); + + return null; + } catch (\Exception $e) { + Log::error('Mailcheep: failed to update contact', ['id' => $id, 'error' => $e->getMessage()]); + + return null; + } + } + + /** + * @param array $customFields + * @return array|null + */ + public function createOrUpdateContact(string $email, string $name, string $listId, array $customFields = []): ?array + { + $existing = $this->findContactByEmail($email); + + if ($existing) { + return $this->updateContact(data_get($existing, 'id'), [ + 'name' => $name, + 'list_id' => $listId, + 'custom_fields' => $customFields, + ]); + } + + return $this->createContact($email, $name, $listId, $customFields); + } + + /** + * @return array|null + */ + public function addToChurnedList(string $email): ?array + { + $existing = $this->findContactByEmail($email); + + if ($existing) { + return $this->updateContact(data_get($existing, 'id'), [ + 'list_id' => config('subscription.mailcheep_list_churned'), + ]); + } + + return null; + } + + private function client(): \Illuminate\Http\Client\PendingRequest + { + return Http::withHeaders([ + 'X-API-Key' => $this->apiKey, + ])->timeout(15)->retry(2, 100); + } +} diff --git a/config/subscription.php b/config/subscription.php index a033862d26..5f55afe74e 100644 --- a/config/subscription.php +++ b/config/subscription.php @@ -9,4 +9,9 @@ return [ 'stripe_excluded_plans' => env('STRIPE_EXCLUDED_PLANS', null), 'stripe_price_id_dynamic_monthly' => env('STRIPE_PRICE_ID_DYNAMIC_MONTHLY', null), 'stripe_price_id_dynamic_yearly' => env('STRIPE_PRICE_ID_DYNAMIC_YEARLY', null), + + // Mailcheep + 'mailcheep_api_key' => env('MAILCHEEP_API_KEY'), + 'mailcheep_list_subscribed' => env('MAILCHEEP_LIST_SUBSCRIBED'), + 'mailcheep_list_churned' => env('MAILCHEEP_LIST_CHURNED'), ]; diff --git a/tests/Feature/MailcheepIntegrationTest.php b/tests/Feature/MailcheepIntegrationTest.php new file mode 100644 index 0000000000..7e4b69bf2b --- /dev/null +++ b/tests/Feature/MailcheepIntegrationTest.php @@ -0,0 +1,207 @@ +set('subscription.mailcheep_api_key', 'test-api-key'); + config()->set('subscription.mailcheep_list_subscribed', 'list-subscribed-id'); + config()->set('subscription.mailcheep_list_churned', 'list-churned-id'); +}); + +describe('MailcheepService', function () { + it('finds a contact by email', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts*' => Http::response([ + 'data' => [ + ['id' => 'contact-1', 'email' => 'test@example.com', 'name' => 'Test User'], + ], + ]), + ]); + + $service = new MailcheepService; + $result = $service->findContactByEmail('test@example.com'); + + expect($result)->not->toBeNull() + ->and($result['email'])->toBe('test@example.com'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/contacts') && $request->data()['search'] === 'test@example.com'; + }); + }); + + it('returns null when contact is not found', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts*' => Http::response(['data' => []]), + ]); + + $service = new MailcheepService; + $result = $service->findContactByEmail('missing@example.com'); + + expect($result)->toBeNull(); + }); + + it('creates a contact', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts' => Http::response([ + 'data' => ['id' => 'contact-new', 'email' => 'new@example.com'], + ]), + ]); + + $service = new MailcheepService; + $result = $service->createContact('new@example.com', 'New User', 'list-id', ['team_id' => '1']); + + expect($result)->not->toBeNull() + ->and($result['email'])->toBe('new@example.com'); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request['email'] === 'new@example.com' + && $request['list_id'] === 'list-id'; + }); + }); + + it('updates a contact', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts/contact-1' => Http::response([ + 'data' => ['id' => 'contact-1', 'list_id' => 'new-list'], + ]), + ]); + + $service = new MailcheepService; + $result = $service->updateContact('contact-1', ['list_id' => 'new-list']); + + expect($result)->not->toBeNull(); + + Http::assertSent(function ($request) { + return $request->method() === 'PUT' && $request['list_id'] === 'new-list'; + }); + }); + + it('creates or updates a contact when existing', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts?search=*' => Http::response([ + 'data' => [['id' => 'contact-1', 'email' => 'existing@example.com']], + ]), + 'api.mailcheep.cloud/v1/contacts/contact-1' => Http::response([ + 'data' => ['id' => 'contact-1', 'name' => 'Updated'], + ]), + ]); + + $service = new MailcheepService; + $result = $service->createOrUpdateContact('existing@example.com', 'Updated', 'list-id'); + + expect($result)->not->toBeNull(); + }); + + it('returns null on API failure without throwing', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts*' => Http::response('Server Error', 500), + ]); + + $service = new MailcheepService; + $result = $service->findContactByEmail('fail@example.com'); + + expect($result)->toBeNull(); + }); + + it('sends X-API-Key header', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts*' => Http::response(['data' => []]), + ]); + + $service = new MailcheepService; + $service->findContactByEmail('test@example.com'); + + Http::assertSent(function ($request) { + return $request->hasHeader('X-API-Key', 'test-api-key'); + }); + }); +}); + +describe('SyncMailcheepContactJob', function () { + it('calls create_or_update on the service', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts?search=*' => Http::response(['data' => []]), + 'api.mailcheep.cloud/v1/contacts' => Http::response([ + 'data' => ['id' => 'new', 'email' => 'job@example.com'], + ]), + ]); + + $job = new SyncMailcheepContactJob( + action: 'create_or_update', + email: 'job@example.com', + name: 'Job User', + customFields: ['team_id' => '1'], + ); + + $job->handle(app(MailcheepService::class)); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' && $request['email'] === 'job@example.com'; + }); + }); + + it('calls add_to_churned on the service', function () { + Http::fake([ + 'api.mailcheep.cloud/v1/contacts?search=*' => Http::response([ + 'data' => [['id' => 'contact-1', 'email' => 'churn@example.com']], + ]), + 'api.mailcheep.cloud/v1/contacts/contact-1' => Http::response([ + 'data' => ['id' => 'contact-1'], + ]), + ]); + + $job = new SyncMailcheepContactJob( + action: 'add_to_churned', + email: 'churn@example.com', + name: 'Churn User', + ); + + $job->handle(app(MailcheepService::class)); + + Http::assertSent(function ($request) { + return $request->method() === 'PUT' && $request['list_id'] === 'list-churned-id'; + }); + }); + + it('dispatchForTeam skips when no API key configured', function () { + config()->set('subscription.mailcheep_api_key', null); + Bus::fake(); + + $team = Team::factory()->create(); + + SyncMailcheepContactJob::dispatchForTeam($team, 'create_or_update'); + + Bus::assertNothingDispatched(); + }); + + it('dispatchForTeam dispatches job for team owner', function () { + Bus::fake(); + + $user = User::factory()->create(); + $team = Team::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $team->load('members'); + + SyncMailcheepContactJob::dispatchForTeam($team, 'create_or_update', ['team_id' => (string) $team->id]); + + Bus::assertDispatched(SyncMailcheepContactJob::class, function ($job) use ($user) { + return $job->action === 'create_or_update' + && $job->email === $user->email + && $job->name === $user->name; + }); + }); +}); + +describe('SyncMailcheep command', function () { + it('fails when not on cloud', function () { + // isCloud() returns false by default in tests + $this->artisan('sync:mailcheep') + ->assertFailed(); + }); +});