fix: Stripe webhook setup completion, notifications routing, billing UI
- Fix StripeEventListener: mark setup as Completed when subscription.created webhook fires - Fix notifications routes: move outside subscribed middleware (non-critical feature) - Fix NotificationBell: guard response.ok before parsing JSON - Fix StripeEventListener: use data_get() for payload access - Rewrite billing page to use SettingsLayout with download buttons on invoices - Remove duplicate Unit/Listeners/StripeEventListenerTest (consolidated into Feature) - Add comprehensive Stripe webhook tests (14 scenarios: happy path, setup transitions, edge cases, error handling) - Update billing i18n: remove "no credit card" text, add status key - Move auth layout logo to top-left corner - Redesign onboarding Role page with compact list layout - Add persona i18n translations (EN, PT-BR, ES) - Update README with API, MCP, tech stack, quick start
This commit is contained in:
parent
d48b508ab3
commit
bb6d0a2678
22 changed files with 447 additions and 401 deletions
|
|
@ -7,6 +7,8 @@ APP_URL=http://localhost
|
|||
# Self-hosted mode (skips payment requirements)
|
||||
SELF_HOSTED=true
|
||||
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
|
|
|||
107
README.md
107
README.md
|
|
@ -19,36 +19,40 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trypost.it/docs">📚 Documentation</a> •
|
||||
<a href="https://github.com/orgs/trypost-it/projects/1">🗺️ Roadmap</a> •
|
||||
<a href="https://github.com/trypost-it/trypost/discussions">💬 Community</a>
|
||||
<a href="https://trypost.it/docs">Documentation</a> •
|
||||
<a href="https://github.com/orgs/trypost-it/projects/1">Roadmap</a> •
|
||||
<a href="https://github.com/trypost-it/trypost/discussions">Community</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Why TryPost?
|
||||
## Why TryPost?
|
||||
|
||||
Tired of paying expensive monthly fees for social media scheduling tools? Want full control over your data? TryPost is the solution.
|
||||
|
||||
| | |
|
||||
| ----------------------- | ---------------------------------------------------- |
|
||||
| 🔓 **100% Open Source** | Inspect the code, contribute, make it yours |
|
||||
| 🏠 **Self-Hosted** | Your data stays on your servers |
|
||||
| ♾️ **No Limits** | Schedule unlimited posts, connect unlimited accounts |
|
||||
| 🔒 **Privacy First** | No tracking, no analytics, no data selling |
|
||||
| **100% Open Source** | Inspect the code, contribute, make it yours |
|
||||
| **Self-Hosted** | Your data stays on your servers |
|
||||
| **No Limits** | Schedule unlimited posts, connect unlimited accounts |
|
||||
| **Privacy First** | No tracking, no analytics, no data selling |
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
| | |
|
||||
| ----------------------------- | ------------------------------------------------------- |
|
||||
| 📅 **Visual Calendar** | Drag and drop posts across your content calendar |
|
||||
| ✍️ **Post Composer** | Create and preview posts for multiple platforms at once |
|
||||
| 🖼️ **Media Library** | Upload images and videos with automatic optimization |
|
||||
| 👥 **Team Collaboration** | Invite team members with role-based permissions |
|
||||
| 🏢 **Workspaces** | Manage multiple brands or clients separately |
|
||||
| 👀 **Multi-Platform Preview** | See exactly how your post will look on each platform |
|
||||
| **Visual Calendar** | Drag and drop posts across your content calendar |
|
||||
| **Post Composer** | Create and preview posts for multiple platforms at once |
|
||||
| **Media Library** | Upload images and videos with automatic optimization |
|
||||
| **Team Collaboration** | Invite team members with role-based permissions (Owner, Admin, Member) |
|
||||
| **Workspaces** | Manage multiple brands or clients separately |
|
||||
| **REST API** | Full API with Bearer token authentication |
|
||||
| **MCP Server** | AI-ready with Model Context Protocol support |
|
||||
| **Google Login** | Sign up and log in with Google OAuth |
|
||||
| **Notifications** | In-app and email notifications for post status |
|
||||
| **i18n** | Available in English, Spanish, and Portuguese |
|
||||
|
||||
## 🌐 Supported Platforms
|
||||
## Supported Platforms
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
|
@ -67,34 +71,75 @@ ## 🌐 Supported Platforms
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ----- | ---------- |
|
||||
| **Backend** | Laravel 13, PHP 8.4 |
|
||||
| **Frontend** | Vue 3, Inertia.js v3, Tailwind CSS v4 |
|
||||
| **Database** | PostgreSQL |
|
||||
| **Queue** | Redis + Laravel Horizon |
|
||||
| **WebSockets** | Laravel Reverb |
|
||||
| **API** | REST with Bearer token auth |
|
||||
| **MCP** | Laravel MCP for AI integrations |
|
||||
| **Payments** | Laravel Cashier (Stripe) |
|
||||
|
||||
## Getting Started
|
||||
|
||||
Get TryPost running in minutes:
|
||||
|
||||
| | |
|
||||
| ----------------------------------------------------------------------------- | ---------------------------- |
|
||||
| 📖 [Installation Guide](https://trypost.it/docs/getting-started/installation) | Step-by-step setup |
|
||||
| 🐳 [Docker Setup](https://trypost.it/docs/self-hosting/docker) | Run with Laravel Sail |
|
||||
| ⚙️ [Configuration](https://trypost.it/docs/getting-started/configuration) | Environment setup |
|
||||
| 🔗 [Platform Setup](https://trypost.it/docs/platforms/) | Connect your social accounts |
|
||||
| | |
|
||||
| --- | --- |
|
||||
| [Installation Guide](https://trypost.it/docs/getting-started/installation) | Step-by-step setup |
|
||||
| [Docker Setup](https://trypost.it/docs/self-hosting/docker) | Run with Laravel Sail |
|
||||
| [Configuration](https://trypost.it/docs/getting-started/configuration) | Environment setup |
|
||||
| [Platform Setup](https://trypost.it/docs/platforms/) | Connect your social accounts |
|
||||
|
||||
## 🤝 Contributing
|
||||
### Quick Start
|
||||
|
||||
We love contributions! See our [Contributing Guide](https://docs.trypost.it/contributing) to get started.
|
||||
```bash
|
||||
git clone https://github.com/trypost-it/trypost.git
|
||||
cd trypost
|
||||
cp .env.example .env
|
||||
composer install
|
||||
npm install
|
||||
php artisan key:generate
|
||||
php artisan migrate
|
||||
npm run build
|
||||
```
|
||||
|
||||
- 💬 [Discussions](https://github.com/trypost-it/trypost/discussions) - Ask questions, share ideas
|
||||
- 🐛 [Issues](https://github.com/trypost-it/trypost/issues) - Report bugs, request features
|
||||
## API
|
||||
|
||||
## 📄 License
|
||||
TryPost includes a REST API for programmatic access. All endpoints are under `/api` and require a Bearer token.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer tp_your_token" \
|
||||
https://your-domain.com/api/posts
|
||||
```
|
||||
|
||||
See the [API documentation](https://trypost.it/docs/api) for all available endpoints.
|
||||
|
||||
## MCP Server
|
||||
|
||||
TryPost ships with a Model Context Protocol (MCP) server at `/mcp/trypost`, enabling AI assistants to manage your social media directly.
|
||||
|
||||
## Contributing
|
||||
|
||||
We love contributions! Check the [issues](https://github.com/trypost-it/trypost/issues) for open tasks.
|
||||
|
||||
- [Discussions](https://github.com/trypost-it/trypost/discussions) - Ask questions, share ideas
|
||||
- [Issues](https://github.com/trypost-it/trypost/issues) - Report bugs, request features
|
||||
|
||||
## License
|
||||
|
||||
TryPost is licensed under the [Functional Source License (FSL)](LICENSE.md).
|
||||
|
||||
✅ **You can:** Use for personal or business use, self-host, modify and contribute.
|
||||
**You can:** Use for personal or business use, self-host, modify and contribute.
|
||||
|
||||
❌ **You cannot:** Offer as a competing SaaS, white-label and resell.
|
||||
**You cannot:** Offer as a competing SaaS, white-label and resell.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>If TryPost helps you, please give us a ⭐</strong>
|
||||
<strong>If TryPost helps you, please give us a star</strong>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -15,26 +15,12 @@ enum Persona: string
|
|||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Founder => 'Founder',
|
||||
self::Creator => 'Creator',
|
||||
self::Agency => 'Agency',
|
||||
self::Enterprise => 'Enterprise',
|
||||
self::SmallBusiness => 'Small Business',
|
||||
self::Personal => 'Personal',
|
||||
};
|
||||
return __("onboarding.personas.{$this->value}.label");
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Founder => 'Building a startup or new venture',
|
||||
self::Creator => 'Content creator or influencer',
|
||||
self::Agency => 'Marketing or social media agency',
|
||||
self::Enterprise => 'Large company or corporation',
|
||||
self::SmallBusiness => 'Small to medium business',
|
||||
self::Personal => 'Personal brand or hobby',
|
||||
};
|
||||
return __("onboarding.personas.{$this->value}.description");
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -17,8 +18,8 @@ class StripeEventListener
|
|||
public function handle(WebhookReceived $event): void
|
||||
{
|
||||
try {
|
||||
$type = $event->payload['type'] ?? null;
|
||||
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
|
||||
$type = data_get($event->payload, 'type');
|
||||
$stripeCustomerId = data_get($event->payload, 'data.object.customer');
|
||||
|
||||
if (! $stripeCustomerId) {
|
||||
return;
|
||||
|
|
@ -46,6 +47,10 @@ public function handle(WebhookReceived $event): void
|
|||
|
||||
protected function handleSubscriptionCreated(User $user, array $payload): void
|
||||
{
|
||||
if ($user->setup === Setup::Subscription) {
|
||||
$user->update(['setup' => Setup::Completed]);
|
||||
}
|
||||
|
||||
SubscriptionCreated::dispatch($user);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
'subscribe' => [
|
||||
'page_title' => 'Start your free trial',
|
||||
'title' => 'Start your free trial',
|
||||
'description' => ':days days free. No credit card required to start.',
|
||||
'description' => ':days days free to explore all features.',
|
||||
'start_trial' => 'Start :days-day free trial',
|
||||
'cancel_anytime' => 'Cancel anytime. No questions asked.',
|
||||
'switch_workspace' => 'Switch workspace',
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
'subscription' => [
|
||||
'title' => 'Your Subscription',
|
||||
'status' => 'Status',
|
||||
'workspaces' => 'Workspaces',
|
||||
'quantity' => 'Subscription quantity',
|
||||
'expires' => 'Expires :date',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,33 @@
|
|||
'submit' => 'Continue',
|
||||
],
|
||||
|
||||
'personas' => [
|
||||
'founder' => [
|
||||
'label' => 'Founder',
|
||||
'description' => 'Building a startup or new venture',
|
||||
],
|
||||
'creator' => [
|
||||
'label' => 'Creator',
|
||||
'description' => 'Content creator or influencer',
|
||||
],
|
||||
'agency' => [
|
||||
'label' => 'Agency',
|
||||
'description' => 'Marketing or social media agency',
|
||||
],
|
||||
'enterprise' => [
|
||||
'label' => 'Enterprise',
|
||||
'description' => 'Large company or corporation',
|
||||
],
|
||||
'small_business' => [
|
||||
'label' => 'Small Business',
|
||||
'description' => 'Small to medium business',
|
||||
],
|
||||
'personal' => [
|
||||
'label' => 'Personal',
|
||||
'description' => 'Personal brand or hobby',
|
||||
],
|
||||
],
|
||||
|
||||
'connect' => [
|
||||
'page_title' => 'Connect your accounts',
|
||||
'title' => 'Connect your accounts',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
'subscribe' => [
|
||||
'page_title' => 'Comienza tu prueba gratuita',
|
||||
'title' => 'Comienza tu prueba gratuita',
|
||||
'description' => ':days días gratis. Sin tarjeta de crédito para empezar.',
|
||||
'description' => ':days días gratis para explorar todas las funciones.',
|
||||
'start_trial' => 'Comenzar prueba de :days días',
|
||||
'cancel_anytime' => 'Cancela en cualquier momento. Sin preguntas.',
|
||||
'switch_workspace' => 'Cambiar workspace',
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
'subscription' => [
|
||||
'title' => 'Tu suscripción',
|
||||
'status' => 'Estado',
|
||||
'workspaces' => 'Workspaces',
|
||||
'quantity' => 'Cantidad de suscripción',
|
||||
'expires' => 'Expira :date',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,33 @@
|
|||
'submit' => 'Continuar',
|
||||
],
|
||||
|
||||
'personas' => [
|
||||
'founder' => [
|
||||
'label' => 'Fundador',
|
||||
'description' => 'Construyendo una startup o nuevo negocio',
|
||||
],
|
||||
'creator' => [
|
||||
'label' => 'Creador de Contenido',
|
||||
'description' => 'Creador de contenido o influencer',
|
||||
],
|
||||
'agency' => [
|
||||
'label' => 'Agencia',
|
||||
'description' => 'Agencia de marketing o redes sociales',
|
||||
],
|
||||
'enterprise' => [
|
||||
'label' => 'Empresa',
|
||||
'description' => 'Gran empresa o corporación',
|
||||
],
|
||||
'small_business' => [
|
||||
'label' => 'Pequeño Negocio',
|
||||
'description' => 'Pequeña o mediana empresa',
|
||||
],
|
||||
'personal' => [
|
||||
'label' => 'Personal',
|
||||
'description' => 'Marca personal o hobby',
|
||||
],
|
||||
],
|
||||
|
||||
'connect' => [
|
||||
'page_title' => 'Conecta tus cuentas',
|
||||
'title' => 'Conecta tus cuentas',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,7 @@
|
|||
'subscribe' => [
|
||||
'page_title' => 'Comece seu teste gratuito',
|
||||
'title' => 'Comece seu teste gratuito',
|
||||
'description' => ':days dias grátis. Sem cartão de crédito para começar.',
|
||||
'description' => ':days dias grátis para explorar todos os recursos.',
|
||||
'start_trial' => 'Começar teste de :days dias',
|
||||
'cancel_anytime' => 'Cancele a qualquer momento. Sem perguntas.',
|
||||
'switch_workspace' => 'Trocar workspace',
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
'subscription' => [
|
||||
'title' => 'Sua Assinatura',
|
||||
'status' => 'Status',
|
||||
'workspaces' => 'Workspaces',
|
||||
'quantity' => 'Quantidade da assinatura',
|
||||
'expires' => 'Expira em :date',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,33 @@
|
|||
'submit' => 'Continuar',
|
||||
],
|
||||
|
||||
'personas' => [
|
||||
'founder' => [
|
||||
'label' => 'Fundador',
|
||||
'description' => 'Construindo uma startup ou novo negócio',
|
||||
],
|
||||
'creator' => [
|
||||
'label' => 'Criador de Conteúdo',
|
||||
'description' => 'Criador de conteúdo ou influenciador',
|
||||
],
|
||||
'agency' => [
|
||||
'label' => 'Agência',
|
||||
'description' => 'Agência de marketing ou mídias sociais',
|
||||
],
|
||||
'enterprise' => [
|
||||
'label' => 'Empresa',
|
||||
'description' => 'Grande empresa ou corporação',
|
||||
],
|
||||
'small_business' => [
|
||||
'label' => 'Pequeno Negócio',
|
||||
'description' => 'Pequena ou média empresa',
|
||||
],
|
||||
'personal' => [
|
||||
'label' => 'Pessoal',
|
||||
'description' => 'Marca pessoal ou hobby',
|
||||
],
|
||||
],
|
||||
|
||||
'connect' => [
|
||||
'page_title' => 'Conecte suas contas',
|
||||
'title' => 'Conecte suas contas',
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ :root {
|
|||
--sidebar-border: #e5e5e5;
|
||||
--sidebar-ring: #a3a3a3;
|
||||
--font-sans:
|
||||
'Space Grotesk', ui-sans-serif, system-ui, -apple-system,
|
||||
'Inter', ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
|
@ -114,7 +114,7 @@ .dark {
|
|||
--sidebar-border: #282828;
|
||||
--sidebar-ring: #525252;
|
||||
--font-sans:
|
||||
'Space Grotesk', ui-sans-serif, system-ui, -apple-system,
|
||||
'Inter', ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ const fetchNotifications = async () => {
|
|||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (! response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
notifications.value = data.notifications;
|
||||
unreadCount.value = data.unread_count;
|
||||
|
|
|
|||
|
|
@ -90,20 +90,23 @@ const platforms = [
|
|||
<template>
|
||||
<div class="grid min-h-svh lg:grid-cols-2">
|
||||
<div class="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div class="flex items-start">
|
||||
<img
|
||||
src="/images/trypost/logo-light.png"
|
||||
alt="TryPost"
|
||||
class="h-7 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/images/trypost/logo-dark.png"
|
||||
alt="TryPost"
|
||||
class="hidden h-7 dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<img
|
||||
src="/images/trypost/logo-light.png"
|
||||
alt="TryPost"
|
||||
class="mb-2 h-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/images/trypost/logo-dark.png"
|
||||
alt="TryPost"
|
||||
class="mb-2 hidden h-10 dark:block"
|
||||
/>
|
||||
<h1 v-if="title" class="text-2xl font-bold">{{ title }}</h1>
|
||||
<p v-if="description" class="text-sm text-balance text-muted-foreground">
|
||||
{{ description }}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { IconCreditCard, IconFileText, IconBuilding, IconExternalLink, IconSparkles } from '@tabler/icons-vue';
|
||||
import { IconCreditCard, IconDownload, IconExternalLink, IconFileText, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { portal } from '@/routes/app/billing';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { index as billingIndex, portal } from '@/routes/app/billing';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Subscription {
|
||||
stripe_status: string;
|
||||
|
|
@ -44,12 +47,10 @@ interface Props {
|
|||
|
||||
defineProps<Props>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItemType[] = [
|
||||
{
|
||||
title: trans('billing.title'),
|
||||
href: '/settings/billing',
|
||||
},
|
||||
];
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.title'), href: billingIndex.url() },
|
||||
{ title: trans('billing.title'), href: billingIndex.url() },
|
||||
]);
|
||||
|
||||
const openPortal = () => {
|
||||
window.location.href = portal.url();
|
||||
|
|
@ -67,108 +68,106 @@ const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructiv
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('billing.title')" />
|
||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||
<Head :title="$t('billing.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ $t('billing.title') }}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ $t('billing.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="sr-only">{{ $t('billing.title') }}</h1>
|
||||
|
||||
<Alert v-if="onTrial" class="border-primary/50 bg-primary/5">
|
||||
<IconSparkles class="h-4 w-4 text-primary" />
|
||||
<AlertTitle>{{ $t('billing.trial.title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ $t('billing.trial.description', { date: trialEndsAt ?? '' }) }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('billing.subscription.title')"
|
||||
:description="$t('billing.description')"
|
||||
/>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconBuilding class="h-5 w-5" />
|
||||
{{ $t('billing.subscription.title') }}
|
||||
</CardTitle>
|
||||
<Badge :variant="getStatusVariant(subscription?.stripe_status || '')">
|
||||
<Alert v-if="onTrial" class="border-primary/50 bg-primary/5">
|
||||
<IconSparkles class="h-4 w-4 text-primary" />
|
||||
<AlertTitle>{{ $t('billing.trial.title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ $t('billing.trial.description', { date: trialEndsAt ?? '' }) }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.status') }}</p>
|
||||
<Badge :variant="getStatusVariant(subscription?.stripe_status || '')" class="mt-1">
|
||||
{{ getStatusLabel(subscription?.stripe_status || '') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex justify-between items-center p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.workspaces') }}</p>
|
||||
<p class="text-2xl font-bold">{{ workspacesCount }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.quantity') }}</p>
|
||||
<p class="text-2xl font-bold">{{ subscription?.quantity || 0 }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">{{ $t('billing.subscription.workspaces') }}</p>
|
||||
<p class="text-lg font-semibold">{{ workspacesCount }} / {{ subscription?.quantity || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="defaultPaymentMethod" class="flex items-center gap-3 p-3 border rounded-lg">
|
||||
<IconCreditCard class="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p class="font-medium capitalize">{{ defaultPaymentMethod.brand }} **** {{ defaultPaymentMethod.last4 }}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('billing.subscription.expires', { date: `${defaultPaymentMethod.exp_month}/${defaultPaymentMethod.exp_year}` }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription?.ends_at" class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-950 dark:border-yellow-800">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{{ $t('billing.subscription.canceled_on', { date: subscription.ends_at }) }}
|
||||
<div v-if="defaultPaymentMethod" class="flex items-center gap-3 rounded-lg border p-4">
|
||||
<IconCreditCard class="h-6 w-6 text-muted-foreground" />
|
||||
<div>
|
||||
<p class="text-sm font-medium capitalize">{{ defaultPaymentMethod.brand }} **** {{ defaultPaymentMethod.last4 }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('billing.subscription.expires', { date: `${defaultPaymentMethod.exp_month}/${defaultPaymentMethod.exp_year}` }) }}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button @click="openPortal" variant="outline" class="w-full">
|
||||
<IconExternalLink class="mr-2 h-4 w-4" />
|
||||
{{ $t('billing.subscription.manage') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconFileText class="h-5 w-5" />
|
||||
{{ $t('billing.invoices.title') }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ $t('billing.invoices.description') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="invoices.length === 0" class="text-center py-6 text-muted-foreground">
|
||||
{{ $t('billing.invoices.empty') }}
|
||||
<div v-if="subscription?.ends_at" class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-950">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{{ $t('billing.subscription.canceled_on', { date: subscription.ends_at }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" @click="openPortal">
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
{{ $t('billing.subscription.manage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('billing.invoices.title')"
|
||||
:description="$t('billing.invoices.description')"
|
||||
/>
|
||||
|
||||
<div v-if="invoices.length === 0" class="text-sm text-muted-foreground">
|
||||
{{ $t('billing.invoices.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="invoice in invoices"
|
||||
:key="invoice.id"
|
||||
class="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<IconFileText class="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ invoice.date }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ invoice.total }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<a
|
||||
v-for="invoice in invoices"
|
||||
:key="invoice.id"
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ invoice.status === 'paid' ? $t('billing.invoices.paid') : invoice.status }}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
as="a"
|
||||
:href="invoice.invoice_pdf"
|
||||
target="_blank"
|
||||
class="flex items-center justify-between p-3 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ invoice.date }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ invoice.total }}</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{{ invoice.status === 'paid' ? $t('billing.invoices.paid') : invoice.status }}
|
||||
</Badge>
|
||||
</a>
|
||||
<IconDownload class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { IconBuilding, IconRocket, IconSparkles, IconBuildingStore, IconUser } from '@tabler/icons-vue';
|
||||
import { IconBuilding, IconBuildingStore, IconCheck, IconRocket, IconSparkles, IconUser } from '@tabler/icons-vue';
|
||||
|
||||
import { storeRole } from '@/actions/App/Http/Controllers/App/OnboardingController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import OnboardingLayout from '@/layouts/OnboardingLayout.vue';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
|
||||
interface Persona {
|
||||
value: string;
|
||||
|
|
@ -42,17 +42,16 @@ const isSelected = (value: string) => form.persona === value;
|
|||
<template>
|
||||
<Head :title="$t('onboarding.role.page_title')" />
|
||||
|
||||
<OnboardingLayout
|
||||
<AuthLayout
|
||||
:title="$t('onboarding.role.title')"
|
||||
:description="$t('onboarding.role.description')"
|
||||
:step="1"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="persona in personas"
|
||||
:key="persona.value"
|
||||
type="button"
|
||||
class="group flex flex-col items-center gap-3 rounded-xl border-2 p-6 text-center transition-all hover:border-primary hover:bg-accent"
|
||||
class="flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all hover:border-primary hover:bg-accent"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': isSelected(persona.value),
|
||||
'border-border': !isSelected(persona.value),
|
||||
|
|
@ -60,28 +59,28 @@ const isSelected = (value: string) => form.persona === value;
|
|||
@click="form.persona = persona.value"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full transition-colors"
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors"
|
||||
:class="{
|
||||
'bg-primary text-primary-foreground': isSelected(persona.value),
|
||||
'bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary': !isSelected(persona.value),
|
||||
'bg-muted text-muted-foreground': !isSelected(persona.value),
|
||||
}"
|
||||
>
|
||||
<component :is="icons[persona.icon]" class="h-6 w-6" />
|
||||
<component :is="icons[persona.icon]" class="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">{{ persona.label }}</h3>
|
||||
<p class="text-sm text-muted-foreground">{{ persona.description }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{{ persona.label }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ persona.description }}</p>
|
||||
</div>
|
||||
<IconCheck v-if="isSelected(persona.value)" class="h-4 w-4 shrink-0 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
class="mt-2 w-full"
|
||||
:disabled="!form.persona || form.processing"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('onboarding.role.submit') }}
|
||||
</Button>
|
||||
</OnboardingLayout>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -39,10 +39,12 @@
|
|||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&display=swap" rel="stylesheet">
|
||||
|
||||
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
|
||||
@vite(['resources/js/app.ts'])
|
||||
@inertiaHead
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
|
|
|
|||
|
|
@ -161,7 +161,10 @@
|
|||
Route::get('settings/billing', [BillingController::class, 'index'])->name('app.billing.index');
|
||||
Route::get('settings/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal');
|
||||
|
||||
// Notifications
|
||||
});
|
||||
|
||||
// Notifications (auth only, no subscription required)
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('notifications', [NotificationController::class, 'index'])->name('app.notifications.index');
|
||||
Route::put('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('app.notifications.read');
|
||||
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('app.notifications.read-all');
|
||||
|
|
|
|||
|
|
@ -2,153 +2,189 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create([
|
||||
'stripe_id' => 'cus_test123',
|
||||
]);
|
||||
|
||||
$this->listener = new StripeEventListener;
|
||||
});
|
||||
|
||||
test('stripe listener handles subscription created event', function () {
|
||||
// ========================================
|
||||
// customer.subscription.created
|
||||
// ========================================
|
||||
|
||||
test('subscription created dispatches event', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$payload = [
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_test123',
|
||||
'id' => 'sub_test123',
|
||||
],
|
||||
],
|
||||
];
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle($event);
|
||||
|
||||
Event::assertDispatched(SubscriptionCreated::class, function ($event) {
|
||||
return $event->user->id === $this->user->id;
|
||||
});
|
||||
Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->user->id === $this->user->id);
|
||||
});
|
||||
|
||||
test('stripe listener handles subscription updated event', function () {
|
||||
$payload = [
|
||||
test('subscription created marks setup as completed when on subscription step', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
$this->user->update(['setup' => Setup::Subscription]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
expect($this->user->fresh()->setup)->toBe(Setup::Completed);
|
||||
});
|
||||
|
||||
test('subscription created does not change setup if already completed', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
$this->user->update(['setup' => Setup::Completed]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
expect($this->user->fresh()->setup)->toBe(Setup::Completed);
|
||||
});
|
||||
|
||||
test('subscription created does not skip onboarding steps', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
$this->user->update(['setup' => Setup::Role]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
expect($this->user->fresh()->setup)->toBe(Setup::Role);
|
||||
});
|
||||
|
||||
test('subscription created does not skip connections step', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
$this->user->update(['setup' => Setup::Connections]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
expect($this->user->fresh()->setup)->toBe(Setup::Connections);
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// customer.subscription.updated
|
||||
// ========================================
|
||||
|
||||
test('subscription updated handles event without error', function () {
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_test123',
|
||||
'id' => 'sub_test123',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
|
||||
// Should not throw exception
|
||||
$listener->handle($event);
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('stripe listener handles subscription deleted event', function () {
|
||||
$payload = [
|
||||
// ========================================
|
||||
// customer.subscription.deleted
|
||||
// ========================================
|
||||
|
||||
test('subscription deleted handles event without error', function () {
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.deleted',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_test123',
|
||||
'id' => 'sub_test123',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
|
||||
// Should not throw exception
|
||||
$listener->handle($event);
|
||||
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('stripe listener ignores unknown event types', function () {
|
||||
// ========================================
|
||||
// Edge cases — missing/invalid data
|
||||
// ========================================
|
||||
|
||||
test('ignores event without customer id', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$payload = [
|
||||
'type' => 'unknown.event.type',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_test123',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle($event);
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => []],
|
||||
]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
test('stripe listener ignores events without customer id', function () {
|
||||
test('ignores event for unknown customer', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$payload = [
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle($event);
|
||||
'data' => ['object' => ['customer' => 'cus_nonexistent']],
|
||||
]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
test('stripe listener ignores events for unknown customers', function () {
|
||||
test('ignores unknown event types', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$payload = [
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_unknown',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle($event);
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'invoice.payment_succeeded',
|
||||
'data' => ['object' => ['customer' => 'cus_test123']],
|
||||
]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
test('stripe listener handles exceptions gracefully', function () {
|
||||
$payload = [
|
||||
test('ignores event with empty payload', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
test('ignores event with null type', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => null,
|
||||
'data' => ['object' => ['customer' => 'cus_test123']],
|
||||
]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Error handling
|
||||
// ========================================
|
||||
|
||||
test('logs error and does not throw on exception', function () {
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->withArgs(fn ($msg) => str_contains($msg, 'Stripe webhook error'));
|
||||
|
||||
// Force an exception by making Event::dispatch throw
|
||||
Event::listen(SubscriptionCreated::class, fn () => throw new Exception('Simulated error'));
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_test123',
|
||||
],
|
||||
],
|
||||
];
|
||||
'data' => ['object' => ['customer' => 'cus_test123']],
|
||||
]));
|
||||
});
|
||||
|
||||
// Delete user to cause an issue
|
||||
$this->user->delete();
|
||||
|
||||
$event = new WebhookReceived($payload);
|
||||
$listener = new StripeEventListener;
|
||||
|
||||
// Should not throw exception
|
||||
$listener->handle($event);
|
||||
test('handles malformed payload gracefully', function () {
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'data' => 'not_an_array',
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
test('listener dispatches subscription created event', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_123',
|
||||
]);
|
||||
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_123',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
Event::assertDispatched(SubscriptionCreated::class, function ($event) use ($user) {
|
||||
return $event->user->id === $user->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('listener handles subscription updated event', function () {
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_123',
|
||||
]);
|
||||
|
||||
$listener = new StripeEventListener;
|
||||
|
||||
// Should not throw any error
|
||||
$listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_123',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('listener handles subscription deleted event', function () {
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_123',
|
||||
]);
|
||||
|
||||
$listener = new StripeEventListener;
|
||||
|
||||
// Should not throw any error
|
||||
$listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.deleted',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_123',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('listener ignores events without customer id', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [],
|
||||
],
|
||||
]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
test('listener ignores events for unknown customers', function () {
|
||||
Event::fake([SubscriptionCreated::class]);
|
||||
|
||||
$listener = new StripeEventListener;
|
||||
$listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_unknown',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
Event::assertNotDispatched(SubscriptionCreated::class);
|
||||
});
|
||||
|
||||
test('listener handles unknown event types gracefully', function () {
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_123',
|
||||
]);
|
||||
|
||||
$listener = new StripeEventListener;
|
||||
|
||||
// Should not throw any error
|
||||
$listener->handle(new WebhookReceived([
|
||||
'type' => 'unknown.event.type',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_123',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue