feat(billing): implement robust in-app subscription cancellation & fix CI/CD socket port typo

This commit is contained in:
Antigravity
2026-05-28 20:50:11 +00:00
parent f5608372dc
commit 457c6fa626
22 changed files with 656 additions and 460 deletions

View File

@@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Loader2 } from 'lucide-react';
import { BillingPlans } from '@/components/settings/billing-plans';
export const dynamic = 'force-dynamic';
export const metadata = {
title: 'Billing',
};

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { cancelSubscription } from '@/lib/billing/cancel-subscription';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
try {
const result = await cancelSubscription(userId);
if (!result.success) {
const isNotFound = result.error === 'No active subscription found';
return NextResponse.json(
{ error: result.error },
{ status: isNotFound ? 404 : 400 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('[billing/cancel] Route handler crash:', error);
return NextResponse.json({ error: 'Failed to cancel subscription' }, { status: 500 });
}
}

View File

@@ -31,21 +31,41 @@ export async function POST(req: NextRequest) {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
if (customerId && customerId.startsWith('cus_mock')) {
customerId = undefined;
}
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
});
customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
create: {
userId,
stripeCustomerId: customerId,
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
}
});
}
const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000';
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000';
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const sessionParams = {
customer: customerId,
mode: 'subscription' as const,
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded',
ui_mode: 'embedded_page',
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },

View File

@@ -3,6 +3,8 @@ import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {

View File

@@ -11,13 +11,21 @@ export async function POST(req: NextRequest) {
const userId = session.user.id;
let action = 'portal';
try {
const body = await req.json();
if (body?.action) action = body.action;
} catch (_) {}
try {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
if (!subscription?.stripeCustomerId) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 });
}
const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000';
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000';
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,

View File

@@ -1,20 +1,79 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier } from '@/lib/billing/stripe-prices';
export async function GET() {
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
const { prisma } = await import('@/lib/prisma');
const sessionId = req.nextUrl.searchParams.get('session_id');
if (sessionId && sessionId.startsWith('cs_')) {
try {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
const sub = await stripe.subscriptions.retrieve(subId);
const priceId = sub.items.data[0].price.id;
const tier = priceIdToTier(priceId) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
sub.items?.data?.[0]?.current_period_start ??
sub.start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
sub.current_period_end ??
sub.items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
await prisma.subscription.upsert({
where: { userId },
update: {
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
create: {
userId,
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
},
});
}
} catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err);
}
}
try {
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId);
const { prisma } = await import('@/lib/prisma');
const subscription = await prisma.subscription.findUnique({ where: { userId } });
return NextResponse.json({

View File

@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
export const dynamic = 'force-dynamic';
export async function GET() {
const session = await auth();

View File

@@ -1210,295 +1210,6 @@ html.font-system * {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* --- Inline Database Block (US-NEXTGEN-EDITOR) --- */
.database-block-wrapper {
margin: 0.75rem 0;
}
.database-block__inner {
border: 1px solid #e8e6e3;
border-radius: 12px;
background: rgba(255, 255, 255, 0.6);
padding: 1rem 1.1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.dark .database-block__inner {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(24, 24, 27, 0.5);
}
.database-block__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.database-block__title {
display: block;
font-size: 0.9rem;
font-weight: 700;
color: var(--color-ink, #1a1a1a);
}
.database-block__id {
display: block;
font-size: 0.65rem;
font-family: ui-monospace, monospace;
opacity: 0.45;
margin-top: 0.1rem;
}
.database-block__hint {
font-size: 0.72rem;
opacity: 0.55;
margin: 0 0 0.85rem;
}
.database-block__view-toggle {
display: inline-flex;
padding: 2px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .database-block__view-toggle {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
.database-block__view-toggle button {
border: none;
background: transparent;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.35rem 0.65rem;
border-radius: 6px;
cursor: pointer;
opacity: 0.65;
color: inherit;
}
.database-block__view-toggle button.is-active {
background: white;
opacity: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.dark .database-block__view-toggle button.is-active {
background: rgba(255, 255, 255, 0.12);
}
.database-block__table-wrap {
overflow-x: auto;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .database-block__table-wrap {
border-color: rgba(255, 255, 255, 0.08);
}
.database-block__table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.database-block__table th {
text-align: left;
padding: 0.5rem 0.75rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.database-block__table td {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
vertical-align: top;
}
.database-block__works-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
}
.database-block__rollup {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.database-block__delete-btn {
border: none;
background: transparent;
font-size: 0.65rem;
opacity: 0.45;
cursor: pointer;
color: #ef4444;
}
.database-block__delete-btn:hover {
opacity: 1;
}
.database-block__inline-form,
.database-block__book-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.database-block__book-form {
flex-direction: column;
align-items: stretch;
}
.database-block__form-label,
.database-block__form-heading {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
width: 100%;
}
.database-block__input {
font-size: 0.8rem;
padding: 0.45rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: white;
color: inherit;
}
.dark .database-block__input {
background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.1);
}
.database-block__primary-btn,
.database-block__submit {
border: none;
border-radius: 8px;
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.45rem 0.85rem;
cursor: pointer;
}
.database-block__primary-btn:hover,
.database-block__submit:hover {
background: #2563eb;
}
.database-block__card {
position: relative;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: white;
}
.dark .database-block__card {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.08);
}
.database-block__card-delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
border: none;
border-radius: 6px;
padding: 4px;
background: rgba(0, 0, 0, 0.45);
color: white;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.database-block__card:hover .database-block__card-delete {
opacity: 1;
}
.database-block__card-cover {
aspect-ratio: 4 / 3;
overflow: hidden;
background: #f4f4f5;
}
.database-block__card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.database-block__card-body {
padding: 0.55rem 0.65rem 0.65rem;
}
.database-block__card-title {
font-size: 0.78rem;
font-weight: 700;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.database-block__tag {
font-size: 0.62rem;
font-weight: 600;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.database-block__tag--author {
background: rgba(59, 130, 246, 0.12);
color: #2563eb;
}
.database-block__tag--genre {
background: rgba(0, 0, 0, 0.06);
opacity: 0.75;
}
.database-block__card-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 140px;
border-radius: 10px;
border: 2px dashed rgba(0, 0, 0, 0.08);
opacity: 0.5;
text-align: center;
padding: 1rem;
}
/* --- Drop Indicator Line --- */
.notion-drop-indicator {
@@ -1622,23 +1333,24 @@ html.font-system * {
}
/* --- Lists --- */
.notion-editor-wrapper .ProseMirror ul {
/* TipTap wraps list content in <p> (block). inside markers force bullet on its own line. */
.notion-editor-wrapper .ProseMirror ul:not([data-type="taskList"]) {
list-style-type: disc;
list-style-position: inside;
padding-inline-start: 0;
list-style-position: outside;
padding-inline-start: 1.5rem;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror ol {
list-style-type: decimal;
list-style-position: inside;
padding-inline-start: 0;
list-style-position: outside;
padding-inline-start: 1.5rem;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror li>p {
margin: 0.1em 0;
padding-inline-start: 0.25rem;
margin: 0;
padding-inline-start: 0;
}
.notion-editor-wrapper .ProseMirror li>ul,

View File

@@ -24,7 +24,7 @@ interface BillingStatus {
hasStripeSubscription: boolean;
}
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
let stripePromise: ReturnType<typeof loadStripe> | null = null;
function getStripePromise() {
@@ -43,12 +43,14 @@ export function BillingPlans() {
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null);
const { data: status, isLoading } = useQuery<BillingStatus>({
queryKey: ['billing', 'status'],
queryFn: async () => {
const res = await fetch('/api/billing/status');
const search = typeof window !== 'undefined' ? window.location.search : '';
const res = await fetch(`/api/billing/status${search}`);
if (!res.ok) throw new Error('Failed to fetch billing status');
return res.json();
},
@@ -69,6 +71,7 @@ export function BillingPlans() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (sessionId) {
const tier = status?.effectiveTier ?? 'Pro';
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
@@ -112,10 +115,15 @@ export function BillingPlans() {
}
};
const handlePortal = async () => {
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
const actualAction = typeof action === 'string' ? action : 'portal';
setPortalLoading(true);
try {
const res = await fetch('/api/billing/portal', { method: 'POST' });
const res = await fetch('/api/billing/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: actualAction }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to open billing portal.');
@@ -130,6 +138,34 @@ export function BillingPlans() {
}
};
const handleCancelSubscription = async () => {
const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.";
if (!window.confirm(confirmMsg)) {
return;
}
setCancelLoading(true);
try {
const res = await fetch('/api/billing/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to cancel subscription.');
return;
}
toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.");
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
} catch (err) {
console.error('[BillingPlans] cancel error:', err);
toast.error('Failed to cancel subscription.');
} finally {
setCancelLoading(false);
}
};
const handleCheckoutComplete = useCallback(() => {
setIsCheckoutOpen(false);
setCheckoutClientSecret(null);
@@ -310,38 +346,42 @@ export function BillingPlans() {
{effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')}
</h4>
</div>
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span>
</div>
{isPaid && (
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
</p>
{isPaid && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
</p>
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
</p>
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
</p>
</div>
</div>
)}
{isPaid && (
<div className="flex flex-wrap gap-3">
@@ -355,13 +395,14 @@ export function BillingPlans() {
{t('billing.manageBilling') || 'Gérer la facturation'}
</button>
{status?.hasStripeSubscription && (
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handlePortal}
disabled={portalLoading}
onClick={handleCancelSubscription}
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
</button>
)}

View File

@@ -114,10 +114,10 @@ export function StructuredViewBlockEmbed({
if (json.success && Array.isArray(json.data)) {
setNotes(json.data)
} else {
setNotesError(t('structuredViewBlock.loadError') || 'Failed to load notes')
setNotesError(t('structuredViewBlock.loadError'))
}
} catch (e) {
setNotesError(e instanceof Error ? e.message : 'Error loading notes')
setNotesError(e instanceof Error ? e.message : t('structuredViewBlock.notesLoadError'))
} finally {
setNotesLoading(false)
}
@@ -157,7 +157,7 @@ export function StructuredViewBlockEmbed({
const newId = `col-${Date.now()}`
const newCol: LocalColumn = {
id: newId,
name: `Propriété ${localColumns.length + 1}`,
name: t('structuredViewBlock.propertyName', { index: localColumns.length + 1 }),
type: 'text',
}
const updatedCols = [...localColumns, newCol]
@@ -166,7 +166,7 @@ export function StructuredViewBlockEmbed({
values: { ...row.values, [newId]: '' }
}))
updateLocalData(updatedCols, updatedRows)
toast.success('Colonne ajoutée !')
toast.success(t('structuredViewBlock.columnAdded'))
}
const deleteLocalColumn = (colId: string) => {
@@ -177,7 +177,7 @@ export function StructuredViewBlockEmbed({
return { ...row, values: nextValues }
})
updateLocalData(updatedCols, updatedRows)
toast.success('Colonne supprimée')
toast.success(t('structuredViewBlock.columnRemoved'))
}
const updateLocalColumnName = (colId: string, name: string) => {
@@ -190,7 +190,7 @@ export function StructuredViewBlockEmbed({
if (c.id === colId) {
const next: LocalColumn = { ...c, type }
if (type === 'select') {
next.options = ['Option 1', 'Option 2']
next.options = [t('structuredViewBlock.defaultOption1'), t('structuredViewBlock.defaultOption2')]
} else {
delete next.options
}
@@ -255,7 +255,7 @@ export function StructuredViewBlockEmbed({
const handleConvertToNotebook = async () => {
const name = newNotebookName.trim()
if (!name) {
toast.error('Veuillez entrer un nom pour le carnet.')
toast.error(t('structuredViewBlock.convertNotebookNameRequired'))
return
}
setConverting(true)
@@ -268,7 +268,7 @@ export function StructuredViewBlockEmbed({
})
const createdNbJson = await createNbRes.json()
if (!createNbRes.ok || !createdNbJson.success) {
throw new Error(createdNbJson.error || 'Erreur lors de la création du carnet.')
throw new Error(createdNbJson.error || t('structuredViewBlock.convertNotebookError'))
}
const newNotebook = createdNbJson.data
@@ -278,7 +278,7 @@ export function StructuredViewBlockEmbed({
})
const schemaJson = await enableSchemaRes.json()
if (!enableSchemaRes.ok || !schemaJson.success) {
throw new Error(schemaJson.error || 'Erreur lors de l\'activation de la structure.')
throw new Error(schemaJson.error || t('structuredViewBlock.convertSchemaError'))
}
const schema = schemaJson.data.schema
@@ -300,7 +300,7 @@ export function StructuredViewBlockEmbed({
})
const propJson = await propRes.json()
if (!propRes.ok || !propJson.success) {
throw new Error(propJson.error || 'Erreur d\'ajout de propriété.')
throw new Error(propJson.error || t('structuredViewBlock.convertPropertyError'))
}
// Find added property ID in the updated schema
const addedProp = propJson.data.schema.properties.find((p: any) => p.name === col.name)
@@ -311,7 +311,7 @@ export function StructuredViewBlockEmbed({
// 4. Create Notes for each row
for (const row of localRows) {
const titleVal = row.values['col-title'] || 'Sans titre'
const titleVal = row.values['col-title'] || t('structuredViewBlock.untitled')
const noteRes = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -323,7 +323,7 @@ export function StructuredViewBlockEmbed({
})
const noteJson = await noteRes.json()
if (!noteRes.ok || !noteJson.success) {
throw new Error('Erreur de création de note.')
throw new Error(t('structuredViewBlock.convertNoteError'))
}
const createdNote = noteJson.data
@@ -351,11 +351,11 @@ export function StructuredViewBlockEmbed({
})
await refreshNotebooks()
toast.success('Conversion réussie ! Base liée créée.')
toast.success(t('structuredViewBlock.convertSuccess'))
setShowConvertModal(false)
} catch (e) {
console.error(e)
toast.error(e instanceof Error ? e.message : 'Une erreur est survenue.')
toast.error(e instanceof Error ? e.message : t('structuredViewBlock.convertGenericError'))
} finally {
setConverting(false)
}
@@ -416,7 +416,7 @@ export function StructuredViewBlockEmbed({
const q = (titleVal || '').trim()
if (!q) {
setEchoConnections([])
setEchoError("Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.")
setEchoError(t('structuredViewBlock.echoNameRequired'))
setEchoLoading(false)
return
}
@@ -438,11 +438,11 @@ export function StructuredViewBlockEmbed({
}
if (data.length === 0) {
setEchoError(`Aucune note correspondante contenant "${q}" n'a été trouvée dans votre espace de travail.`)
setEchoError(t('structuredViewBlock.echoNoMatch', { query: q }))
} else {
setEchoConnections(data.map((n: any, idx: number) => ({
noteId: n.id,
title: n.title || 'Sans titre',
title: n.title || t('structuredViewBlock.untitled'),
similarity: Math.round((0.88 - idx * 0.04) * 100),
updatedAt: n.updatedAt,
isTextMatch: true
@@ -450,7 +450,7 @@ export function StructuredViewBlockEmbed({
}
} catch (e) {
console.error(e)
setEchoError("Une erreur est survenue lors de la recherche.")
setEchoError(t('structuredViewBlock.echoSearchError'))
} finally {
setEchoLoading(false)
}
@@ -545,7 +545,7 @@ export function StructuredViewBlockEmbed({
return (
<div className="p-4 border-b border-border/40 bg-muted/10 text-center text-xs text-muted-foreground italic flex items-center justify-center gap-1.5">
<BarChart3 className="w-4 h-4 text-muted-foreground/60 animate-pulse" />
<span>{t('structuredViewBlock.analyticsNoData') || 'Aucune donnée d\'analyse disponible.'}</span>
<span>{t('structuredViewBlock.analyticsNoData')}</span>
</div>
)
}
@@ -555,10 +555,10 @@ export function StructuredViewBlockEmbed({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-foreground/85">
<BarChart3 className="w-4 h-4 text-primary" />
<span>{t('structuredViewBlock.analyticsTitle') || 'Analyses & Insights'}</span>
<span>{t('structuredViewBlock.analyticsTitle')}</span>
</div>
<div className="text-[10px] text-muted-foreground bg-muted border border-border/60 rounded-md px-2 py-0.5 font-medium">
{t('structuredViewBlock.analyticsTotalRows') || 'Total des lignes'} : <span className="font-bold text-foreground">{total}</span>
{t('structuredViewBlock.analyticsTotalRows')}: <span className="font-bold text-foreground">{total}</span>
</div>
</div>
@@ -567,7 +567,7 @@ export function StructuredViewBlockEmbed({
{hasCheckboxStats && (
<div className="bg-card border border-border/40 rounded-xl p-3 space-y-3 shadow-sm">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground block">
{t('structuredViewBlock.analyticsCompletion') || 'Taux de complétion'}
{t('structuredViewBlock.analyticsCompletion')}
</span>
<div className="space-y-2.5">
{checkboxStats.map(stat => (
@@ -592,7 +592,7 @@ export function StructuredViewBlockEmbed({
{hasSelectStats && (
<div className="bg-card border border-border/40 rounded-xl p-3 space-y-3 shadow-sm">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground block">
{t('structuredViewBlock.analyticsDistribution') || 'Répartition'}
{t('structuredViewBlock.analyticsDistribution')}
</span>
<div className="space-y-3 max-h-[140px] overflow-y-auto custom-scrollbar pr-1">
{selectStats.map(stat => {
@@ -650,7 +650,7 @@ export function StructuredViewBlockEmbed({
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary animate-pulse" />
<span className="font-bold text-foreground/80 tracking-wide">
{t('structuredViewBlock.localDbTitle') || 'Base de Données Autonome'}
{t('structuredViewBlock.localDbTitle')}
</span>
</div>
@@ -663,7 +663,7 @@ export function StructuredViewBlockEmbed({
className="text-[11px] h-7 px-2.5 rounded-lg transition-all flex items-center gap-1.5"
>
<BarChart3 className="w-3.5 h-3.5 text-muted-foreground" />
<span>Analyses</span>
<span>{t('structuredViewBlock.analyticsShort')}</span>
</Button>
<span className="h-4 w-px bg-border/50" />
@@ -676,7 +676,7 @@ export function StructuredViewBlockEmbed({
className="text-[11px] h-7 px-2.5 rounded-lg border-primary/30 hover:border-primary hover:bg-primary/5 text-primary transition-all flex items-center gap-1.5"
>
<FolderPlus className="w-3.5 h-3.5" />
<span>Convertir en carnet</span>
<span>{t('structuredViewBlock.convertToNotebook')}</span>
</Button>
<span className="h-4 w-px bg-border/50" />
@@ -688,7 +688,7 @@ export function StructuredViewBlockEmbed({
onClick={() => updateAttributes({ isLocal: false })}
className="text-[11px] h-7 px-2 text-muted-foreground hover:text-foreground"
>
Lier à un carnet
{t('structuredViewBlock.linkToNotebook')}
</Button>
</div>
</div>
@@ -702,16 +702,16 @@ export function StructuredViewBlockEmbed({
<div className="bg-card border border-border rounded-2xl shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<FolderPlus className="w-5 h-5 text-primary" />
Convertir en carnet structuré
{t('structuredViewBlock.convertModalTitle')}
</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note de votre carnet, et vos colonnes seront configurées comme propriétés réutilisables.
{t('structuredViewBlock.convertModalDesc')}
</p>
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Nom du nouveau carnet</label>
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('structuredViewBlock.convertNotebookNameLabel')}</label>
<input
type="text"
placeholder="ex. Mes lectures, Suivi de projets"
placeholder={t('structuredViewBlock.convertNotebookNamePlaceholder')}
value={newNotebookName}
onChange={(e) => setNewNotebookName(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
@@ -719,15 +719,15 @@ export function StructuredViewBlockEmbed({
</div>
<div className="flex items-center justify-end gap-2 text-xs pt-2">
<Button size="sm" variant="ghost" onClick={() => setShowConvertModal(false)} disabled={converting}>
Annuler
{t('structuredViewBlock.cancel')}
</Button>
<Button size="sm" onClick={handleConvertToNotebook} disabled={converting || !newNotebookName}>
{converting ? (
<>
<Loader2 className="w-3 h-3 animate-spin mr-1.5" />
Conversion...
{t('structuredViewBlock.converting')}
</>
) : 'Créer le carnet'}
) : t('structuredViewBlock.createNotebook')}
</Button>
</div>
</div>
@@ -758,16 +758,16 @@ export function StructuredViewBlockEmbed({
onChange={(e) => updateLocalColumnType(col.id, e.target.value as any)}
className="text-[9px] bg-transparent border border-border/30 rounded px-1 py-0.5 text-muted-foreground hover:border-border transition-colors outline-none cursor-pointer"
>
<option value="text">Texte</option>
<option value="checkbox">Case</option>
<option value="select">Liste</option>
<option value="text">{t('structuredViewBlock.colTypeText')}</option>
<option value="checkbox">{t('structuredViewBlock.colTypeCheckbox')}</option>
<option value="select">{t('structuredViewBlock.colTypeSelect')}</option>
</select>
)}
{/* Display select options configurations inline */}
{col.type === 'select' && (
<input
placeholder="opts separées par virgule"
placeholder={t('structuredViewBlock.selectOptionsPlaceholder')}
value={col.options?.join(', ') || ''}
onChange={(e) => updateLocalColumnOptions(col.id, e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
@@ -784,7 +784,7 @@ export function StructuredViewBlockEmbed({
deleteLocalColumn(col.id)
}}
className="opacity-40 hover:opacity-100 hover:text-red-500 p-0.5 rounded transition-all ml-auto shrink-0"
title="Supprimer la colonne"
title={t('structuredViewBlock.deleteColumn')}
>
<Trash2 className="w-3 h-3" />
</button>
@@ -797,7 +797,7 @@ export function StructuredViewBlockEmbed({
type="button"
onClick={addLocalColumn}
className="p-1 rounded bg-primary/10 hover:bg-primary/20 text-primary transition-all"
title="Ajouter une colonne"
title={t('structuredViewBlock.addColumn')}
>
<Plus className="w-3.5 h-3.5" />
</button>
@@ -815,7 +815,7 @@ export function StructuredViewBlockEmbed({
type="button"
onClick={() => deleteLocalRow(row.id)}
className="opacity-0 group-hover/row:opacity-100 hover:text-red-500 p-1 rounded hover:bg-red-500/10 transition-all"
title="Supprimer la ligne"
title={t('structuredViewBlock.deleteRow')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -828,7 +828,7 @@ export function StructuredViewBlockEmbed({
? "text-purple-400 bg-purple-500/10 opacity-100"
: "hover:text-purple-400 hover:bg-purple-500/10"
)}
title="Résonances sémantiques"
title={t('structuredViewBlock.semanticEcho')}
>
<Sparkles className="w-3.5 h-3.5" />
</button>
@@ -875,7 +875,7 @@ export function StructuredViewBlockEmbed({
<input
type="text"
value={cellVal || ''}
placeholder={col.id === 'col-title' ? 'Saisir un nom…' : ''}
placeholder={col.id === 'col-title' ? t('structuredViewBlock.namePlaceholder') : ''}
onChange={(e) => updateLocalCellValue(row.id, col.id, e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
@@ -902,20 +902,20 @@ export function StructuredViewBlockEmbed({
<div className="flex items-center justify-between text-[11px] font-bold text-foreground/80">
<span className="flex items-center gap-1.5 text-purple-400">
<Brain className="w-3.5 h-3.5 animate-pulse" />
{t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'}
{t('structuredViewBlock.echoPopoverTitle')}
</span>
<button
onClick={() => { setActiveEchoRowId(null); setEchoConnections([]); }}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
Fermer
{t('structuredViewBlock.close')}
</button>
</div>
{echoLoading ? (
<div className="flex items-center gap-2 text-[11px] text-muted-foreground py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-purple-400" />
<span>{t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'}</span>
<span>{t('structuredViewBlock.echoLoading')}</span>
</div>
) : echoError ? (
<div className="space-y-2 py-1">
@@ -923,16 +923,16 @@ export function StructuredViewBlockEmbed({
{echoError}
</p>
<div className="text-[10px] text-muted-foreground/75 bg-muted/30 p-2.5 rounded-lg border border-border/40 max-w-lg">
{t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
{t('structuredViewBlock.echoUpgradeText')}
</div>
</div>
) : echoConnections.length === 0 ? (
<div className="space-y-2 py-1">
<p className="text-[11px] text-muted-foreground/80 italic">
{t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
{t('structuredViewBlock.noEchoFound')}
</p>
<div className="text-[10px] text-muted-foreground/75 bg-muted/30 p-2.5 rounded-lg border border-border/40 max-w-lg">
{t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
{t('structuredViewBlock.echoUpgradeText')}
</div>
</div>
) : (
@@ -959,20 +959,20 @@ export function StructuredViewBlockEmbed({
window.dispatchEvent(new CustomEvent('memento-insert-citation', {
detail: {
noteId: conn.noteId,
noteTitle: conn.title || 'Sans titre',
noteTitle: conn.title || t('structuredViewBlock.untitled'),
excerpt: '',
atEnd: false
}
}))
toast.success("Citation insérée dans l'éditeur !")
toast.success(t('structuredViewBlock.citationInserted'))
}}
className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors"
title="Insérer le lien dans l'éditeur"
title={t('structuredViewBlock.insertCitation')}
>
<Link2 className="w-3.5 h-3.5" />
</button>
<span className="text-[9px] text-purple-400 font-bold bg-purple-500/10 px-2 py-0.5 rounded-full flex items-center gap-0.5 border border-purple-500/20">
{conn.isTextMatch ? 'Mot-clé' : `${conn.similarity}%`}
{conn.isTextMatch ? t('structuredViewBlock.keywordMatch') : `${conn.similarity}%`}
</span>
</div>
</div>
@@ -990,7 +990,7 @@ export function StructuredViewBlockEmbed({
</table>
{localRows.length === 0 && (
<p className="text-center py-6 text-muted-foreground text-xs italic">Aucune ligne dans le tableau.</p>
<p className="text-center py-6 text-muted-foreground text-xs italic">{t('structuredViewBlock.emptyTable')}</p>
)}
{/* Add row button */}
@@ -1002,7 +1002,7 @@ export function StructuredViewBlockEmbed({
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 h-8 px-2.5 rounded-lg"
>
<Plus className="w-3.5 h-3.5" />
<span>Ajouter une ligne</span>
<span>{t('structuredViewBlock.addRow')}</span>
</Button>
</div>
</div>
@@ -1023,10 +1023,10 @@ export function StructuredViewBlockEmbed({
>
<div className="flex items-center gap-2 text-muted-foreground font-medium">
<Table className="w-4 h-4 text-primary" />
<span>{t('structuredViewBlock.selectNotebook') || 'Lier à un carnet'}</span>
<span>{t('structuredViewBlock.selectNotebook')}</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{t('structuredViewBlock.noNotebookDesc') || 'Ce bloc affiche la vue structurée d\'un carnet. Choisissez le carnet à lier :'}
{t('structuredViewBlock.noNotebookDesc')}
</p>
<div className="flex items-center gap-3 w-full max-w-md">
@@ -1039,7 +1039,7 @@ export function StructuredViewBlockEmbed({
className="rounded-lg border border-border bg-background px-3 py-1.5 text-xs text-foreground outline-none focus:border-primary max-w-xs w-full shadow-sm"
defaultValue=""
>
<option value="" disabled>-- {t('structuredViewBlock.chooseNotebook') || 'Choisir un carnet'} --</option>
<option value="" disabled>-- {t('structuredViewBlock.chooseNotebook')} --</option>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>
{nb.icon ? `${nb.icon} ` : ''}{nb.name}
@@ -1047,7 +1047,7 @@ export function StructuredViewBlockEmbed({
))}
</select>
<span className="text-xs text-muted-foreground">ou</span>
<span className="text-xs text-muted-foreground">{t('structuredViewBlock.or')}</span>
<Button
size="sm"
@@ -1055,7 +1055,7 @@ export function StructuredViewBlockEmbed({
onClick={() => updateAttributes({ isLocal: true })}
className="text-xs text-primary hover:bg-primary/5"
>
Créer une base locale autonome
{t('structuredViewBlock.createLocalDb')}
</Button>
</div>
</div>
@@ -1085,7 +1085,7 @@ export function StructuredViewBlockEmbed({
<span>{schemaHook.error || notesError}</span>
</div>
<Button size="sm" variant="outline" onClick={handleRetry}>
{t('structuredViewBlock.retry') || 'Réessayer'}
{t('structuredViewBlock.retry')}
</Button>
</div>
)
@@ -1100,16 +1100,16 @@ export function StructuredViewBlockEmbed({
>
<div className="flex items-center gap-2 text-muted-foreground font-medium">
<Table className="w-4 h-4" />
<span>{currentNotebook?.name || t('structuredViewBlock.insertLabel') || 'Vue structurée'}</span>
<span>{currentNotebook?.name || t('structuredViewBlock.insertLabel')}</span>
<button
onClick={() => updateAttributes({ notebookId: null })}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
>
({t('structuredViewBlock.change') || 'Changer'})
({t('structuredViewBlock.change')})
</button>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{t('structuredViewBlock.noSchema') || 'Ce carnet n\'a pas encore de vue structurée. Configurez-en une depuis l\'en-tête du carnet.'}
{t('structuredViewBlock.noSchema')}
</p>
<Button
size="sm"
@@ -1117,7 +1117,7 @@ export function StructuredViewBlockEmbed({
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
className="text-xs flex items-center gap-1.5"
>
<span>{t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}</span>
<span>{t('structuredViewBlock.openInNotebook')}</span>
<ExternalLink className="w-3.5 h-3.5" />
</Button>
</div>
@@ -1149,9 +1149,9 @@ export function StructuredViewBlockEmbed({
<button
onClick={() => updateAttributes({ notebookId: null })}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
title={t('structuredViewBlock.changeNotebook') || 'Changer de carnet'}
title={t('structuredViewBlock.changeNotebook')}
>
({t('structuredViewBlock.change') || 'Changer'})
({t('structuredViewBlock.change')})
</button>
</div>
@@ -1166,7 +1166,7 @@ export function StructuredViewBlockEmbed({
? "bg-muted text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
title={t('structuredViewBlock.displayModeTable') || 'Tableau'}
title={t('structuredViewBlock.displayModeTable')}
>
<Table className="w-3.5 h-3.5" />
</button>
@@ -1178,7 +1178,7 @@ export function StructuredViewBlockEmbed({
? "bg-muted text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
title={t('structuredViewBlock.displayModeGallery') || 'Galerie'}
title={t('structuredViewBlock.displayModeGallery')}
>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
@@ -1195,7 +1195,7 @@ export function StructuredViewBlockEmbed({
)}
>
<BarChart3 className="w-3 h-3" />
<span>Analyses</span>
<span>{t('structuredViewBlock.analyticsShort')}</span>
</button>
<span className="h-4 w-px bg-border/80" />
@@ -1205,7 +1205,7 @@ export function StructuredViewBlockEmbed({
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}</span>
<span>{t('structuredViewBlock.openInNotebook')}</span>
<ExternalLink className="w-3 h-3" />
</button>
@@ -1214,7 +1214,7 @@ export function StructuredViewBlockEmbed({
onClick={() => updateAttributes({ isLocal: true, notebookId: null })}
className="text-[11px] text-primary hover:underline ml-1"
>
Passer en base locale
{t('structuredViewBlock.switchToLocalDb')}
</button>
</div>
</div>

View File

@@ -0,0 +1,66 @@
import { prisma } from '@/lib/prisma';
import { stripe } from '@/lib/stripe';
export interface CancelSubscriptionResult {
success: boolean;
error?: string;
}
/**
* Cancels an active user subscription by setting its auto-renew (cancel_at_period_end) status to true
* in Stripe and synchronizing the status back into the local Prisma database.
*
* @param userId - Unique identifier of the user
* @returns An object indicating the success or failure of the operation
*/
export async function cancelSubscription(userId: string): Promise<CancelSubscriptionResult> {
if (!userId) {
return { success: false, error: 'User ID is required' };
}
try {
const subscription = await prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
return { success: false, error: 'No active subscription found' };
}
if (subscription.stripeSubscriptionId) {
// Direct call to Stripe API with period end cancellation set to true
const updatedSub = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
});
// Local database synchronization
await prisma.subscription.update({
where: { userId },
data: {
cancelAtPeriodEnd: true,
canceledAt: updatedSub.canceled_at ? new Date(updatedSub.canceled_at * 1000) : new Date(),
currentPeriodEnd: updatedSub.current_period_end ? new Date(updatedSub.current_period_end * 1000) : subscription.currentPeriodEnd,
updatedAt: new Date(),
},
});
} else {
// Mock mode cancel (e.g. for development / local bypass testing)
await prisma.subscription.update({
where: { userId },
data: {
cancelAtPeriodEnd: true,
canceledAt: new Date(),
updatedAt: new Date(),
},
});
}
return { success: true };
} catch (error: any) {
console.error('[cancelSubscription] Error processing cancellation:', error);
return {
success: false,
error: error.message || 'Failed to cancel subscription',
};
}
}

View File

@@ -16,12 +16,22 @@ export function resolvePriceId(tier: BillingTier, interval: BillingInterval): st
};
const priceId = map[tier][interval];
if (!priceId) {
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
if (isMock && process.env.NODE_ENV !== 'test') {
return `price_mock_${tier.toLowerCase()}_${interval}`;
}
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
}
return priceId;
}
export function priceIdToTier(priceId: string): SubscriptionTier | null {
if (priceId && priceId.startsWith('price_mock_')) {
if (priceId.includes('pro')) return 'PRO';
if (priceId.includes('business')) return 'BUSINESS';
return 'BASIC';
}
const entries: Array<[string | undefined, SubscriptionTier]> = [
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],

View File

@@ -44,8 +44,19 @@ export async function syncSubscriptionFromStripe(
const status = mapStripeStatus(subscription.status);
const currentPeriodStart = new Date(((subscription as any).current_period_start as number) * 1000);
const currentPeriodEnd = new Date(((subscription as any).current_period_end as number) * 1000);
const currentPeriodStartTimestamp =
subscription.current_period_start ??
(subscription as any).items?.data?.[0]?.current_period_start ??
(subscription as any).start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
subscription.current_period_end ??
(subscription as any).items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
const currentPeriodStart = new Date(currentPeriodStartTimestamp * 1000);
const currentPeriodEnd = new Date(currentPeriodEndTimestamp * 1000);
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },

View File

@@ -3355,7 +3355,7 @@
"turnInto_taskList": "Task List",
"turnInto_blockquote": "Quote",
"turnInto_codeBlock": "Code Block",
"turnInto_database": "Database",
"turnInto_database": "Inline database",
"copyRef": "Copy block reference",
"copied": "Reference copied!",
"copyRefFailed": "Could not copy block reference",
@@ -3414,6 +3414,52 @@
"analyticsNoData": "No analysis data available.",
"analyticsCompletion": "Completion Rate",
"analyticsDistribution": "Distribution",
"analyticsTotalRows": "Total Rows"
"analyticsTotalRows": "Total Rows",
"analyticsShort": "Analytics",
"turnIntoLabel": "Inline database",
"columnAdded": "Column added!",
"columnRemoved": "Column removed",
"propertyName": "Property {{index}}",
"convertNotebookNameRequired": "Please enter a name for the notebook.",
"convertNotebookError": "Could not create the notebook.",
"convertSchemaError": "Could not enable structured view.",
"convertPropertyError": "Could not add property.",
"convertNoteError": "Could not create note.",
"convertSuccess": "Conversion complete! Linked notebook created.",
"convertGenericError": "Something went wrong.",
"echoNameRequired": "Enter a name for this row first to search for semantic connections.",
"echoSearchError": "An error occurred while searching.",
"echoNoMatch": "No matching notes containing \"{{query}}\" were found in your workspace.",
"convertToNotebook": "Convert to notebook",
"linkToNotebook": "Link to a notebook",
"convertModalTitle": "Convert to structured notebook",
"convertModalDesc": "This local table will become a real notebook. Each row becomes a note and your columns become reusable properties.",
"convertNotebookNameLabel": "New notebook name",
"convertNotebookNamePlaceholder": "e.g. My reading list, Project tracker",
"cancel": "Cancel",
"converting": "Converting…",
"createNotebook": "Create notebook",
"deleteColumn": "Delete column",
"addColumn": "Add column",
"deleteRow": "Delete row",
"semanticEcho": "Semantic resonances",
"close": "Close",
"insertCitation": "Insert link in editor",
"keywordMatch": "Keyword",
"emptyTable": "No rows in the table.",
"addRow": "Add row",
"colTypeText": "Text",
"colTypeCheckbox": "Checkbox",
"colTypeSelect": "Select",
"selectOptionsPlaceholder": "Options separated by commas",
"namePlaceholder": "Enter a name…",
"or": "or",
"createLocalDb": "Create a standalone local database",
"switchToLocalDb": "Switch to local database",
"untitled": "Untitled",
"citationInserted": "Link inserted in the editor!",
"notesLoadError": "Error loading notes",
"defaultOption1": "Option 1",
"defaultOption2": "Option 2"
}
}

View File

@@ -3359,7 +3359,7 @@
"turnInto_taskList": "Liste de tâches",
"turnInto_blockquote": "Citation",
"turnInto_codeBlock": "Bloc de code",
"turnInto_database": "Base de données",
"turnInto_database": "Base de données inline",
"copyRef": "Copier la référence du bloc",
"copied": "Référence copiée !",
"copyRefFailed": "Impossible de copier la référence du bloc",
@@ -3418,6 +3418,52 @@
"analyticsNoData": "Aucune donnée d'analyse disponible.",
"analyticsCompletion": "Taux de complétion",
"analyticsDistribution": "Répartition",
"analyticsTotalRows": "Total des lignes"
"analyticsTotalRows": "Total des lignes",
"analyticsShort": "Analyses",
"turnIntoLabel": "Base de données inline",
"columnAdded": "Colonne ajoutée !",
"columnRemoved": "Colonne supprimée",
"propertyName": "Propriété {{index}}",
"convertNotebookNameRequired": "Veuillez entrer un nom pour le carnet.",
"convertNotebookError": "Erreur lors de la création du carnet.",
"convertSchemaError": "Erreur lors de l'activation de la structure.",
"convertPropertyError": "Erreur d'ajout de propriété.",
"convertNoteError": "Erreur de création de note.",
"convertSuccess": "Conversion réussie ! Base liée créée.",
"convertGenericError": "Une erreur est survenue.",
"echoNameRequired": "Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.",
"echoSearchError": "Une erreur est survenue lors de la recherche.",
"echoNoMatch": "Aucune note correspondante contenant « {{query}} » n'a été trouvée dans votre espace de travail.",
"convertToNotebook": "Convertir en carnet",
"linkToNotebook": "Lier à un carnet",
"convertModalTitle": "Convertir en carnet structuré",
"convertModalDesc": "Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note et vos colonnes seront configurées comme propriétés réutilisables.",
"convertNotebookNameLabel": "Nom du nouveau carnet",
"convertNotebookNamePlaceholder": "ex. Mes lectures, Suivi de projets",
"cancel": "Annuler",
"converting": "Conversion…",
"createNotebook": "Créer le carnet",
"deleteColumn": "Supprimer la colonne",
"addColumn": "Ajouter une colonne",
"deleteRow": "Supprimer la ligne",
"semanticEcho": "Résonances sémantiques",
"close": "Fermer",
"insertCitation": "Insérer le lien dans l'éditeur",
"keywordMatch": "Mot-clé",
"emptyTable": "Aucune ligne dans le tableau.",
"addRow": "Ajouter une ligne",
"colTypeText": "Texte",
"colTypeCheckbox": "Case",
"colTypeSelect": "Liste",
"selectOptionsPlaceholder": "Options séparées par des virgules",
"namePlaceholder": "Saisir un nom…",
"or": "ou",
"createLocalDb": "Créer une base locale autonome",
"switchToLocalDb": "Passer en base locale",
"untitled": "Sans titre",
"citationInserted": "Citation insérée dans l'éditeur !",
"notesLoadError": "Erreur de chargement des notes",
"defaultOption1": "Option 1",
"defaultOption2": "Option 2"
}
}

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the dependencies before importing the function
vi.mock('@/lib/prisma', () => ({
prisma: {
subscription: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock('@/lib/stripe', () => ({
stripe: {
subscriptions: {
update: vi.fn(),
},
},
}));
import { cancelSubscription } from '@/lib/billing/cancel-subscription';
import { prisma } from '@/lib/prisma';
import { stripe } from '@/lib/stripe';
describe('cancelSubscription', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return error when userId is missing', async () => {
const result = await cancelSubscription('');
expect(result).toEqual({ success: false, error: 'User ID is required' });
expect(prisma.subscription.findUnique).not.toHaveBeenCalled();
});
it('should return error when no subscription is found in db', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
const result = await cancelSubscription('user_123');
expect(result).toEqual({ success: false, error: 'No active subscription found' });
expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
where: { userId: 'user_123' },
});
expect(stripe.subscriptions.update).not.toHaveBeenCalled();
expect(prisma.subscription.update).not.toHaveBeenCalled();
});
it('should cancel subscription in Stripe and update DB successfully', async () => {
const mockDbSub = {
userId: 'user_123',
stripeSubscriptionId: 'sub_stripe_123',
currentPeriodEnd: new Date(1702678400 * 1000),
};
const mockStripeSub = {
id: 'sub_stripe_123',
canceled_at: 1700100000,
current_period_end: 1702678400,
};
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
vi.mocked(stripe.subscriptions.update).mockResolvedValue(mockStripeSub as any);
vi.mocked(prisma.subscription.update).mockResolvedValue({} as any);
const result = await cancelSubscription('user_123');
expect(result).toEqual({ success: true });
expect(stripe.subscriptions.update).toHaveBeenCalledWith('sub_stripe_123', {
cancel_at_period_end: true,
});
expect(prisma.subscription.update).toHaveBeenCalledWith({
where: { userId: 'user_123' },
data: expect.objectContaining({
cancelAtPeriodEnd: true,
canceledAt: expect.any(Date),
currentPeriodEnd: expect.any(Date),
}),
});
});
it('should support local / mock mode cancel when stripeSubscriptionId is missing', async () => {
const mockDbSub = {
userId: 'user_123',
stripeSubscriptionId: null,
};
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
vi.mocked(prisma.subscription.update).mockResolvedValue({} as any);
const result = await cancelSubscription('user_123');
expect(result).toEqual({ success: true });
expect(stripe.subscriptions.update).not.toHaveBeenCalled();
expect(prisma.subscription.update).toHaveBeenCalledWith({
where: { userId: 'user_123' },
data: expect.objectContaining({
cancelAtPeriodEnd: true,
canceledAt: expect.any(Date),
}),
});
});
it('should return error when Stripe API call fails', async () => {
const mockDbSub = {
userId: 'user_123',
stripeSubscriptionId: 'sub_stripe_123',
};
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
vi.mocked(stripe.subscriptions.update).mockRejectedValue(new Error('Stripe connection error'));
const result = await cancelSubscription('user_123');
expect(result).toEqual({ success: false, error: 'Stripe connection error' });
expect(prisma.subscription.update).not.toHaveBeenCalled();
});
it('should return error when database write fails', async () => {
const mockDbSub = {
userId: 'user_123',
stripeSubscriptionId: 'sub_stripe_123',
};
const mockStripeSub = {
id: 'sub_stripe_123',
canceled_at: 1700100000,
current_period_end: 1702678400,
};
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
vi.mocked(stripe.subscriptions.update).mockResolvedValue(mockStripeSub as any);
vi.mocked(prisma.subscription.update).mockRejectedValue(new Error('Prisma database error'));
const result = await cancelSubscription('user_123');
expect(result).toEqual({ success: false, error: 'Prisma database error' });
});
});