Compare commits

..

1 Commits

Author SHA1 Message Date
Matti Nannt
046c56b9dd fix: sync legacy stripe payment methods 2026-03-16 17:02:01 +01:00
15 changed files with 127 additions and 188 deletions

View File

@@ -1,19 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
export const onRequestError: Instrumentation.onRequestError = (...args) => {
const [error] = args;
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
// These are handled gracefully in the UI and don't need server-side Sentry reporting
if (error instanceof Error && isExpectedError(error)) {
return;
}
Sentry.captureRequestError(...args);
};
export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {

View File

@@ -33,14 +33,14 @@ describe("Password Management", () => {
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
}, 15000);
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
}, 15000);
});
describe("Organization Access", () => {

View File

@@ -34,7 +34,7 @@ describe("Crypto Utils", () => {
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
}, 15000);
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
@@ -43,7 +43,7 @@ describe("Crypto Utils", () => {
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
}, 15000);
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
@@ -64,7 +64,7 @@ describe("Crypto Utils", () => {
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
}, 15000);
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";

View File

@@ -1021,6 +1021,6 @@ describe("updateSurveyDraftAction", () => {
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
}, 15000);
});
});

View File

@@ -159,6 +159,12 @@ describe("organization-billing", () => {
mocks.getCloudPlanFromProduct.mockReturnValue("pro");
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.customersList.mockResolvedValue({ data: [] });
mocks.customersRetrieve.mockResolvedValue({
id: "cus_1",
deleted: false,
invoice_settings: { default_payment_method: null },
default_source: null,
});
mocks.prismaMembershipFindFirst.mockResolvedValue(null);
mocks.productsList.mockResolvedValue({
data: [
@@ -639,6 +645,64 @@ describe("organization-billing", () => {
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe marks migrated customers with customer-level payment methods", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: { lastSyncedEventId: null },
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_1",
status: "active",
default_payment_method: null,
billing_cycle_anchor: 1739923200,
items: {
data: [
{
price: {
metadata: {},
product: { id: "prod_pro", metadata: { formbricks_plan: "pro" } },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
mocks.customersRetrieve.mockResolvedValue({
id: "cus_1",
deleted: false,
invoice_settings: { default_payment_method: "pm_legacy_default" },
default_source: null,
});
mocks.entitlementsList.mockResolvedValue({
data: [],
has_more: false,
});
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result?.stripe?.hasPaymentMethod).toBe(true);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stripe: expect.objectContaining({
hasPaymentMethod: true,
}),
}),
})
);
});
test("createPaidPlanCheckoutSession rejects mixed-interval yearly checkout", async () => {
await expect(
createPaidPlanCheckoutSession({

View File

@@ -1107,6 +1107,21 @@ const resolvePendingPlanChange = async (subscription: Stripe.Subscription | null
return null;
};
const resolveHasPaymentMethod = (
subscription: Stripe.Subscription | null,
customer: Stripe.Customer | Stripe.DeletedCustomer
) => {
if (subscription?.default_payment_method != null) {
return true;
}
if (customer.deleted) {
return false;
}
return customer.invoice_settings.default_payment_method != null || customer.default_source != null;
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
event?: { id: string; created: number }
@@ -1132,9 +1147,10 @@ export const syncOrganizationBillingFromStripe = async (
return billing;
}
const [subscription, featureLookupKeys] = await Promise.all([
const [subscription, featureLookupKeys, customer] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
stripeClient.customers.retrieve(customerId),
]);
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
@@ -1160,7 +1176,7 @@ export const syncOrganizationBillingFromStripe = async (
interval: billingInterval,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
hasPaymentMethod: subscription?.default_payment_method != null,
hasPaymentMethod: resolveHasPaymentMethod(subscription, customer),
features: featureLookupKeys,
pendingChange,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),

View File

@@ -11,7 +11,7 @@ export const TagsLoading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation activeId="tags" loading />
<ProjectConfigNavigation activeId="tags" />
</PageHeader>
<SettingsCard
title={t("environments.workspace.tags.manage_tags")}

View File

@@ -129,10 +129,8 @@ export const EditWelcomeCard = ({
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.length) {
if (url?.[0]) {
updateSurvey({ fileUrl: url[0] });
} else {
updateSurvey({ fileUrl: undefined });
}
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}

View File

@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
import { closeSurvey } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -316,9 +316,6 @@ export const setup = async (
addEventListeners();
addCleanupEventListeners();
// Preload surveys script so it's ready when a survey triggers
preloadSurveysScript(configInput.appUrl);
setIsSetup(true);
logger.debug("Set up complete");

View File

@@ -11,7 +11,6 @@ import {
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
@@ -61,24 +60,6 @@ export const renderWidget = async (
setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
}
@@ -106,15 +87,7 @@ export const renderWidget = async (
const overlay = projectOverwrites.overlay ?? project.overlay;
const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding;
let formbricksSurveys: TFormbricksSurveys;
try {
formbricksSurveys = await loadFormbricksSurveysExternally();
} catch (error) {
logger.error(`Failed to load surveys library: ${String(error)}`);
setIsSurveyRunning(false);
return;
}
const formbricksSurveys = await loadFormbricksSurveysExternally();
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
@@ -227,87 +200,30 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
const SURVEYS_POLL_INTERVAL_MS = 200;
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const check = (): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
// Apply stored nonce if it was set before surveys package loaded
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
return;
}
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
reject(new Error("Formbricks Surveys library did not become available within timeout"));
return;
}
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
};
check();
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
};
document.head.appendChild(script);
}
});
};
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
return Promise.resolve(globalThis.window.formbricksSurveys);
}
if (surveysLoadPromise) {
return surveysLoadPromise;
}
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
const config = Config.getInstance();
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
waitForSurveysGlobal()
.then(resolve)
.catch((error: unknown) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
});
};
script.onerror = (error) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
};
document.head.appendChild(script);
});
return surveysLoadPromise;
};
let isPreloaded = false;
export const preloadSurveysScript = (appUrl: string): void => {
// Don't preload if already loaded or already preloading
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) return;
if (isPreloaded) return;
isPreloaded = true;
const link = document.createElement("link");
link.rel = "preload";
link.as = "script";
link.href = `${appUrl}/js/surveys.umd.cjs`;
document.head.appendChild(link);
};

View File

@@ -8,9 +8,7 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {}
@@ -65,45 +63,17 @@ export class UpdateQueue {
return !this.updates;
}
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> {
const logger = Logger.getInstance();
if (!this.updates) {
return;
}
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
const flushPromise = new Promise<void>((resolve, reject) => {
return new Promise((resolve, reject) => {
const handler = async (): Promise<void> => {
try {
let currentUpdates = { ...this.updates };
@@ -177,10 +147,8 @@ export class UpdateQueue {
}
this.clearUpdates();
this.pendingFlush = null;
resolve();
} catch (error: unknown) {
this.pendingFlush = null;
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
@@ -190,8 +158,5 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
});
this.pendingFlush = flushPromise;
return flushPromise;
}
}

View File

@@ -1,4 +1,4 @@
import { sanitize } from "isomorphic-dompurify";
import DOMPurify from "isomorphic-dompurify";
import * as React from "react";
import { cn, stripInlineStyles } from "@/lib/utils";
@@ -39,7 +39,7 @@ function Label({
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml =
isHtml && strippedContent
? sanitize(strippedContent, {
? DOMPurify.sanitize(strippedContent, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"],
})

View File

@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { sanitize } from "isomorphic-dompurify";
import DOMPurify from "isomorphic-dompurify";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
@@ -27,16 +27,14 @@ export function cn(...inputs: ClassValue[]): string {
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return sanitize(preStripped, {
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};

View File

@@ -4,7 +4,6 @@
"baseUrl": ".",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
"noEmit": true,
"paths": {
"@/*": ["./src/*"]

View File

@@ -10,16 +10,14 @@ import DOMPurify from "isomorphic-dompurify";
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return DOMPurify.sanitize(preStripped, {
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};