feat: keep past due subscriptions active (#416)
## Summary - keep Stripe past_due subscriptions active during retry window - share payment issue state with Inertia and show persistent toast - send toast action directly to Stripe Billing Portal - allow canceled users to fall back to free plan, while paid-only bank connection access remains blocked - add Spanish translations for new payment issue messages ## Tests - vendor/bin/pint --dirty --format agent - php artisan test --compact tests/Feature/SubscriptionTest.php tests/Feature/InertiaSharedDataTest.php - php artisan test --compact tests/Feature/LocalizationTest.php - npm test -- subscription-payment-issue-toast Note: npm run types still fails on pre-existing unrelated TypeScript errors.
This commit is contained in:
parent
a00c73ac1d
commit
88faa5beb6
|
|
@ -81,6 +81,10 @@ class HandleInertiaRequests extends Middleware
|
|||
'hasProPlan' => $user?->hasProPlan() ?? false,
|
||||
'isDemoAccount' => $isDemoAccount,
|
||||
],
|
||||
'subscriptionPaymentIssue' => $user?->hasPastDueSubscription() ? [
|
||||
'status' => 'past_due',
|
||||
'action_url' => route('settings.billing.portal'),
|
||||
] : null,
|
||||
'demoCredentials' => ($isDemoQuery || $isDemoAccount) ? [
|
||||
'email' => config('app.demo.email'),
|
||||
'password' => config('app.demo.password'),
|
||||
|
|
|
|||
|
|
@ -166,6 +166,19 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
return $this->subscribed('default');
|
||||
}
|
||||
|
||||
public function hasPastDueSubscription(): bool
|
||||
{
|
||||
if (! config('subscriptions.enabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscription = $this->subscription('default');
|
||||
|
||||
return $subscription !== null
|
||||
&& $subscription->stripe_status === 'past_due'
|
||||
&& ! $subscription->ended();
|
||||
}
|
||||
|
||||
/**
|
||||
* The tax rates that should apply to the customer's subscriptions.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit;
|
|||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
|
|
@ -24,6 +25,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function register(): void
|
||||
{
|
||||
Cashier::keepPastDueSubscriptionsActive();
|
||||
|
||||
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
|
||||
|
||||
$this->app->bind(BankingProviderInterface::class, function ($app) {
|
||||
|
|
|
|||
|
|
@ -1447,6 +1447,7 @@
|
|||
"Unused budget will carry over to the next period.": "El presupuesto no utilizado se acumulará al siguiente período.",
|
||||
"Update": "Actualizar",
|
||||
"Update Credentials": "Actualizar Credenciales",
|
||||
"Update payment": "Actualizar pago",
|
||||
"Update account balance": "Actualizar balance de cuenta",
|
||||
"Update balance": "Actualizar balance",
|
||||
"Update balances periodically to track growth": "Actualiza los balances periódicamente para rastrear el crecimiento",
|
||||
|
|
@ -1514,6 +1515,7 @@
|
|||
"We built Whisper Money because we were tired of finance apps that mine your data and sell your habits to third parties. We wanted something simple, private, and actually useful.": "Creamos Whisper Money porque estábamos cansados de apps de finanzas que explotan tus datos y venden tus hábitos a terceros. Queríamos algo simple, privado y realmente útil.",
|
||||
"We built Whisper Money because we were tired of giving our financial data to big companies who use it for who knows what. Whisper Money **never shares your data with third parties** - you are always the owner of your financial information.": "Creamos Whisper Money porque estábamos cansados de dar nuestros datos financieros a grandes empresas que los usan para quién sabe qué. Whisper Money **nunca comparte tus datos con terceros**: tú eres siempre el propietario de tu información financiera.",
|
||||
"We built Whisper Money to help you truly understand your finances — without giving up your privacy to do it. Here's what you can look forward to:": "Creamos Whisper Money para ayudarte a entender de verdad tus finanzas, sin sacrificar tu privacidad para ello. Esto es lo que puedes esperar:",
|
||||
"We could not collect your subscription payment.": "No hemos podido cobrar el pago de tu suscripción.",
|
||||
"We collect the following types of personal information:": "Recopilamos los siguientes tipos de información personal:",
|
||||
"We collect the following types of personal\\n information:": "Recopilamos los siguientes tipos de información personal:",
|
||||
"We disconnected your bank connection to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.|We disconnected your :count bank connections to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.": "Desconectamos tu conexión bancaria para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.|Desconectamos tus :count conexiones bancarias para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.",
|
||||
|
|
@ -1548,6 +1550,7 @@
|
|||
"We use essential cookies to maintain your\\n session and ensure the proper functioning of our\\n service. These cookies are necessary for the\\n service to work and cannot be disabled. We do\\n not use tracking cookies or analytics cookies\\n without your explicit consent.": "Usamos cookies esenciales para mantener tu sesión y garantizar el correcto funcionamiento de nuestro servicio. Estas cookies son necesarias para que el servicio funcione y no se pueden deshabilitar. No usamos cookies de seguimiento ni de análisis sin tu consentimiento explícito.",
|
||||
"We use the following third-party services to operate our platform:": "Usamos los siguientes servicios de terceros para operar nuestra plataforma:",
|
||||
"We use the following third-party services to\\n operate our platform:": "Utilizamos los siguientes servicios de terceros para operar nuestra plataforma:",
|
||||
"We will retry up to 4 times during the week. You keep access for now, but the subscription will be canceled if all retries fail.": "Lo reintentaremos hasta 4 veces durante la semana. Mantienes el acceso por ahora, pero la suscripción se cancelará si fallan todos los reintentos.",
|
||||
"We were unable to sync your :provider connection because your credentials appear to have expired or been revoked.": "No pudimos sincronizar tu conexión con :provider porque tus credenciales parecen haber expirado o sido revocadas.",
|
||||
"We're **Victor and Alvaro**, the founders of Whisper Money. Thanks so much for joining the list — it genuinely means a lot to us.": "Somos **Víctor y Álvaro**, los fundadores de Whisper Money. Muchísimas gracias por unirte a la lista — significa mucho para nosotros.",
|
||||
"We're Victor and Álvaro, the founders of Whisper Money. You signed up a while back to hear about our privacy-first personal finance app, and we're excited to tell you - we're live!": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Te registraste hace un tiempo para recibir noticias sobre nuestra app de finanzas personales centrada en la privacidad, y estamos emocionados de contarte: ¡ya estamos en vivo!",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
isFacebookInAppBrowserJavaBridgeNoise,
|
||||
isPostMessageDataCloneNoise,
|
||||
} from './lib/sentry';
|
||||
import { showSubscriptionPaymentIssueToast } from './lib/subscription-payment-issue-toast';
|
||||
import type { ExpiredBankingConnectionNotification, SharedData } from './types';
|
||||
import { __, setTranslations } from './utils/i18n';
|
||||
|
||||
|
|
@ -152,6 +153,8 @@ createInertiaApp({
|
|||
(initialPageProps?.expiredBankingConnections as
|
||||
| ExpiredBankingConnectionNotification[]
|
||||
| undefined) ?? [];
|
||||
const initialSubscriptionPaymentIssue =
|
||||
initialPageProps?.subscriptionPaymentIssue;
|
||||
|
||||
const syncUserTimezone = async (pageProps?: Partial<SharedData>) => {
|
||||
const user = pageProps?.auth?.user ?? null;
|
||||
|
|
@ -190,9 +193,15 @@ createInertiaApp({
|
|||
(pageProps?.translations as Record<string, string>) ?? {},
|
||||
);
|
||||
|
||||
showSubscriptionPaymentIssueToast(
|
||||
pageProps.subscriptionPaymentIssue,
|
||||
);
|
||||
|
||||
void syncUserTimezone(pageProps);
|
||||
});
|
||||
|
||||
showSubscriptionPaymentIssueToast(initialSubscriptionPaymentIssue);
|
||||
|
||||
void syncUserTimezone(initialPageProps);
|
||||
|
||||
root.render(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { showSubscriptionPaymentIssueToast } from './subscription-payment-issue-toast';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
warning: mocks.warning,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/i18n', () => ({
|
||||
__: (message: string) => message,
|
||||
}));
|
||||
|
||||
describe('showSubscriptionPaymentIssueToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a warning for past due subscription payments', () => {
|
||||
const shown = showSubscriptionPaymentIssueToast(
|
||||
{
|
||||
status: 'past_due',
|
||||
action_url: '/settings/billing/portal',
|
||||
},
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(mocks.warning).toHaveBeenCalledWith(
|
||||
'We could not collect your subscription payment.',
|
||||
expect.objectContaining({
|
||||
description:
|
||||
'We will retry up to 4 times during the week. You keep access for now, but the subscription will be canceled if all retries fail.',
|
||||
duration: Infinity,
|
||||
action: expect.objectContaining({
|
||||
label: 'Update payment',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show duplicate warnings for the same issue', () => {
|
||||
const notifiedKeys = new Set<string>();
|
||||
const issue = {
|
||||
status: 'past_due' as const,
|
||||
action_url: '/settings/billing/portal',
|
||||
};
|
||||
|
||||
expect(showSubscriptionPaymentIssueToast(issue, notifiedKeys)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(showSubscriptionPaymentIssueToast(issue, notifiedKeys)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mocks.warning).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { SubscriptionPaymentIssueNotification } from '@/types';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const notifiedSubscriptionPaymentIssueKeys = new Set<string>();
|
||||
|
||||
export function resetSubscriptionPaymentIssueToastNotifications(): void {
|
||||
notifiedSubscriptionPaymentIssueKeys.clear();
|
||||
}
|
||||
|
||||
export function showSubscriptionPaymentIssueToast(
|
||||
issue: SubscriptionPaymentIssueNotification | null | undefined,
|
||||
notifiedKeys: Set<string> = notifiedSubscriptionPaymentIssueKeys,
|
||||
): boolean {
|
||||
if (!issue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const toastKey = `${issue.status}:${issue.action_url}`;
|
||||
|
||||
if (notifiedKeys.has(toastKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
notifiedKeys.add(toastKey);
|
||||
|
||||
toast.warning(__('We could not collect your subscription payment.'), {
|
||||
description: __(
|
||||
'We will retry up to 4 times during the week. You keep access for now, but the subscription will be canceled if all retries fail.',
|
||||
),
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: __('Update payment'),
|
||||
onClick: () => {
|
||||
window.location.href = issue.action_url;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -52,6 +52,11 @@ export interface ExpiredBankingConnectionNotification {
|
|||
reconnect_url: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPaymentIssueNotification {
|
||||
status: 'past_due';
|
||||
action_url: string;
|
||||
}
|
||||
|
||||
export interface Flash {
|
||||
success: string | null;
|
||||
error: string | null;
|
||||
|
|
@ -71,6 +76,7 @@ export interface SharedData {
|
|||
includeLoansInNetWorthChart: boolean;
|
||||
includeRealEstateInNetWorthChart: boolean;
|
||||
subscriptionsEnabled: boolean;
|
||||
subscriptionPaymentIssue: SubscriptionPaymentIssueNotification | null;
|
||||
pricing: PricingConfig;
|
||||
sidebarOpen: boolean;
|
||||
features: Features;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,43 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
]);
|
||||
});
|
||||
|
||||
test('authenticated users receive subscription payment issue when subscription is past due', function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_past_due_test123',
|
||||
'stripe_status' => 'past_due',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->withoutVite()->get(route('dashboard'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('subscriptionPaymentIssue.status', 'past_due')
|
||||
->where('subscriptionPaymentIssue.action_url', route('settings.billing.portal'))
|
||||
);
|
||||
});
|
||||
|
||||
test('authenticated users do not receive subscription payment issue when subscription is active', function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_active_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->withoutVite()->get(route('dashboard'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('subscriptionPaymentIssue', null)
|
||||
);
|
||||
});
|
||||
|
||||
test('authenticated users receive expired banking connection reconnect links', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$expiredConnection = BankingConnection::factory()->create([
|
||||
|
|
|
|||
|
|
@ -102,6 +102,54 @@ test('subscribed users can access protected routes', function () {
|
|||
$this->get(route('dashboard'))->assertOk();
|
||||
});
|
||||
|
||||
test('past due subscribed users can access protected routes during stripe retries', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_past_due_test123',
|
||||
'stripe_status' => 'past_due',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
});
|
||||
|
||||
test('canceled subscribed users can use free plan without bank connections', function () {
|
||||
$user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]);
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_canceled_test123',
|
||||
'stripe_status' => 'canceled',
|
||||
'stripe_price' => 'price_test123',
|
||||
'ends_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
});
|
||||
|
||||
test('canceled subscribed users cannot use paid bank connection features', function () {
|
||||
$user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]);
|
||||
BankingConnection::factory()->for($user)->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_canceled_with_bank_test123',
|
||||
'stripe_status' => 'canceled',
|
||||
'stripe_price' => 'price_test123',
|
||||
'ends_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('dashboard'))->assertRedirect(route('subscribe'));
|
||||
});
|
||||
|
||||
test('users can view the success page after subscribing', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
|
|
@ -159,6 +207,22 @@ test('hasProPlan returns true for subscribed users', function () {
|
|||
expect($user->hasProPlan())->toBeTrue();
|
||||
});
|
||||
|
||||
test('hasProPlan returns true for past due users during stripe retries', function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_past_due_test123',
|
||||
'stripe_status' => 'past_due',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
expect($user->hasProPlan())->toBeTrue();
|
||||
expect($user->hasPastDueSubscription())->toBeTrue();
|
||||
});
|
||||
|
||||
test('landing page passes subscriptions enabled prop when enabled', function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue