mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-17 09:51:14 -05:00
Compare commits
2 Commits
fix/7494-m
...
fix-manage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21da0e1b39 | ||
|
|
b112559d5f |
@@ -150,6 +150,7 @@ NOTION_OAUTH_CLIENT_ID=
|
||||
NOTION_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# Stripe Billing Variables
|
||||
STRIPE_PRICING_TABLE_ID=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
@@ -50,7 +50,6 @@ vi.mock("@/lib/env", () => ({
|
||||
RECAPTCHA_SITE_KEY: "site-key",
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -139,7 +138,6 @@ describe("sendTelemetryEvents", () => {
|
||||
expect(payload.userCount).toBe(5);
|
||||
expect(payload.integrations.notion).toBe(true);
|
||||
expect(payload.sso.github).toBe(true);
|
||||
expect(payload.sso.saml).toBe(true);
|
||||
|
||||
// Check cache update (no TTL parameter)
|
||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
||||
|
||||
@@ -212,7 +212,6 @@ const sendTelemetry = async (lastSent: number) => {
|
||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
||||
saml: !!env.SAML_DATABASE_URL || ssoProviders.some((p) => p.provider === "saml"),
|
||||
};
|
||||
|
||||
// Construct telemetry payload with usage statistics and configuration.
|
||||
|
||||
@@ -372,7 +372,7 @@ checksums:
|
||||
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
|
||||
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
|
||||
common/sort_by: 8adf3dbc5668379558957662f0c43563
|
||||
common/start_free_trial: e346e4ed7d138dcc873db187922369da
|
||||
common/upgrade_plan: 4fab76a3fc5d5c94e3248cd279cfdd14
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
|
||||
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
|
||||
@@ -417,7 +417,6 @@ checksums:
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
common/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
common/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
|
||||
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
|
||||
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
|
||||
@@ -918,57 +917,30 @@ checksums:
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
|
||||
environments/settings/billing/add_payment_method_to_upgrade_tooltip: 977005ad38bfe0800a78c21edcd16e4d
|
||||
environments/settings/billing/billing_interval_toggle: 62c76eb73507108fc6aefdf1ab86cc38
|
||||
environments/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
|
||||
environments/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
|
||||
environments/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
|
||||
environments/settings/billing/custom_plan_title: f3b71be0d1cd4f81a177ada040119f30
|
||||
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
|
||||
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
|
||||
environments/settings/billing/keep_current_plan: 57ac15ffa2c29ac364dd405669eeb7f6
|
||||
environments/settings/billing/manage_billing_details: 40448f0b5ed4b3bb1d864ba6e1bb6a3b
|
||||
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
|
||||
environments/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
|
||||
environments/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
|
||||
environments/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
|
||||
environments/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
|
||||
environments/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
|
||||
environments/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
|
||||
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
|
||||
environments/settings/billing/plan_change_applied: d1e04599487247dd0e21a7d99785dc7a
|
||||
environments/settings/billing/plan_change_scheduled: 16455d4aa9a152b156ee434d8c7e34d4
|
||||
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
|
||||
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2
|
||||
environments/settings/billing/plan_feature_everything_in_hobby: 5417a498136fa29988c8215291e3fd8b
|
||||
environments/settings/billing/plan_feature_everything_in_pro: 3f5129ff1f01eed4f051a8790ed62997
|
||||
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
|
||||
environments/settings/billing/plan_hobby_description: 1fa1cf69b42ec82727aebc5ef1ec24a2
|
||||
environments/settings/billing/plan_hobby_feature_responses: d1e6c1d83f5e57cbae2a09e6a818a25d
|
||||
environments/settings/billing/plan_hobby_feature_workspaces: 02a34669419ed7f30f728980f54d42ef
|
||||
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
|
||||
environments/settings/billing/plan_pro_description: 748c848ea0d8cf81a66704762edcd6f4
|
||||
environments/settings/billing/plan_pro_feature_responses: e16ffe385051a16dba76538c13d97a5f
|
||||
environments/settings/billing/plan_pro_feature_workspaces: 819874022b491209ca7f0f1ab1e3daea
|
||||
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
environments/settings/billing/plan_scale_description: ef5c66e0b52686f56319e31388bd8409
|
||||
environments/settings/billing/plan_scale_feature_responses: 0b74bf8d089c738ebb7f0867bdd7d7f1
|
||||
environments/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
|
||||
environments/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
|
||||
environments/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
|
||||
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
|
||||
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
|
||||
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0
|
||||
environments/settings/billing/scale_banner_description: 79a9734c77ab0336d5d2fadb5f2151be
|
||||
environments/settings/billing/scale_banner_title: a2a78f57ebcbf444ad881ece234b8f45
|
||||
environments/settings/billing/scale_feature_api: 67231215e5452944b86edc2bc47d2a16
|
||||
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
|
||||
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
|
||||
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
|
||||
environments/settings/billing/select_plan_header_subtitle: 8de6b4e3ce5726829829bd46582f343a
|
||||
environments/settings/billing/select_plan_header_title: b15a9d86b819a7fae8e956a50572184c
|
||||
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
|
||||
environments/settings/billing/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
|
||||
environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad
|
||||
environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b
|
||||
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
|
||||
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
|
||||
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9
|
||||
environments/settings/billing/switch_at_period_end: 9c91b2287886e077a0571efab8908623
|
||||
environments/settings/billing/switch_plan_now: dad56622a1916fe5d1a2bda5b0393194
|
||||
environments/settings/billing/this_includes: 127e0fe104f47886b54106a057a6b26f
|
||||
environments/settings/billing/trial_alert_description: aba3076cc6814cc6128d425d3d1957e8
|
||||
environments/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
|
||||
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d
|
||||
@@ -986,11 +958,8 @@ checksums:
|
||||
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
|
||||
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
|
||||
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
|
||||
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
|
||||
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
|
||||
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
|
||||
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
|
||||
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
|
||||
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
|
||||
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
|
||||
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
|
||||
|
||||
@@ -85,6 +85,7 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_PRICING_TABLE_ID: z.string().optional(),
|
||||
PUBLIC_URL: z
|
||||
.url()
|
||||
.refine(
|
||||
@@ -202,6 +203,7 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
|
||||
"sort_by": "Sortieren nach",
|
||||
"start_free_trial": "Kostenlose Testversion starten",
|
||||
"start_free_trial": "Kostenlos starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Aktualisierung",
|
||||
"updated": "Aktualisiert",
|
||||
"updated_at": "Aktualisiert am",
|
||||
"upgrade_plan": "Plan upgraden",
|
||||
"upload": "Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Zahlungsmethode hinzufügen",
|
||||
"add_payment_method_to_upgrade_tooltip": "Bitte füge oben eine Zahlungsmethode hinzu, um auf einen kostenpflichtigen Plan zu upgraden",
|
||||
"billing_interval_toggle": "Abrechnungsintervall",
|
||||
"current_plan_badge": "Aktuell",
|
||||
"current_plan_cta": "Aktueller Tarif",
|
||||
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardtarife unten wechseln.",
|
||||
"custom_plan_title": "Individueller Tarif",
|
||||
"cancelling": "Wird storniert",
|
||||
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"keep_current_plan": "Aktuellen Tarif beibehalten",
|
||||
"manage_billing_details": "Kartendaten & Rechnungen verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"most_popular": "Am beliebtesten",
|
||||
"pending_change_removed": "Geplante Tarifänderung entfernt.",
|
||||
"pending_plan_badge": "Geplant",
|
||||
"pending_plan_change_description": "Dein Tarif wechselt am {{date}} zu {{plan}}.",
|
||||
"pending_plan_change_title": "Geplante Tarifänderung",
|
||||
"pending_plan_cta": "Geplant",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_change_applied": "Tarif erfolgreich aktualisiert.",
|
||||
"plan_change_scheduled": "Tarifänderung erfolgreich geplant.",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Alles aus Hobby",
|
||||
"plan_feature_everything_in_pro": "Alles aus Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Für Einzelpersonen und kleine Teams, die mit Formbricks Cloud starten.",
|
||||
"plan_hobby_feature_responses": "250 Antworten / Monat",
|
||||
"plan_hobby_feature_workspaces": "1 Arbeitsbereich",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Für wachsende Teams, die höhere Limits, Automatisierungen und dynamische Überschreitungen benötigen.",
|
||||
"plan_pro_feature_responses": "2.000 Antworten / Monat (dynamische Überschreitung)",
|
||||
"plan_pro_feature_workspaces": "3 Arbeitsbereiche",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Für größere Teams, die mehr Kapazität, stärkere Governance und höheres Antwortvolumen benötigen.",
|
||||
"plan_scale_feature_responses": "5.000 Antworten / Monat (dynamische Mehrnutzung)",
|
||||
"plan_scale_feature_workspaces": "5 Arbeitsbereiche",
|
||||
"plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle dann direkt in Formbricks den Plan.",
|
||||
"plan_selection_title": "Wähle deinen Plan",
|
||||
"plan_unknown": "Unbekannt",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"retry_setup": "Erneut einrichten",
|
||||
"scale_banner_description": "Schalte höhere Limits, Teamzusammenarbeit und erweiterte Sicherheitsfunktionen mit dem Scale-Tarif frei.",
|
||||
"scale_banner_title": "Bereit für den nächsten Schritt?",
|
||||
"scale_feature_api": "Vollständiger API-Zugang",
|
||||
"scale_feature_quota": "Quotenverwaltung",
|
||||
"scale_feature_spam": "Spamschutz",
|
||||
"scale_feature_teams": "Teams & Zugriffsrollen",
|
||||
"select_plan_header_subtitle": "Keine Kreditkarte erforderlich, keine versteckten Bedingungen.",
|
||||
"select_plan_header_title": "Nahtlos integrierte Umfragen, 100% deine Marke.",
|
||||
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben",
|
||||
"stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig",
|
||||
"stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Verwalte Dein Abonnement und behalte Deine Nutzung im Blick",
|
||||
"switch_at_period_end": "Am Ende der Periode wechseln",
|
||||
"switch_plan_now": "Plan jetzt wechseln",
|
||||
"this_includes": "Das beinhaltet",
|
||||
"trial_alert_description": "Füge eine Zahlungsmethode hinzu, um weiterhin Zugriff auf alle Funktionen zu behalten.",
|
||||
"trial_already_used": "Für diese E-Mail-Adresse wurde bereits eine kostenlose Testversion genutzt. Bitte upgraden Sie stattdessen auf einen kostenpflichtigen Plan.",
|
||||
"trial_feature_api_access": "API-Zugriff",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
"unlimited_workspaces": "Unbegrenzte Projekte",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Jetzt upgraden",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "verwendet",
|
||||
"yearly": "Jährlich",
|
||||
"yearly_checkout_unavailable": "Die jährliche Abrechnung ist noch nicht verfügbar. Füge zuerst eine Zahlungsmethode bei einem monatlichen Plan hinzu oder kontaktiere den Support.",
|
||||
"your_plan": "Dein Tarif"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
|
||||
"sort_by": "Sort by",
|
||||
"start_free_trial": "Start free trial",
|
||||
"start_free_trial": "Upgrade plan",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
"updated_at": "Updated at",
|
||||
"upgrade_plan": "Upgrade plan",
|
||||
"upload": "Upload",
|
||||
"upload_failed": "Upload failed. Please try again.",
|
||||
"upload_input_description": "Click or drag to upload files.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Add payment method",
|
||||
"add_payment_method_to_upgrade_tooltip": "Please add a payment method above to upgrade to a paid plan",
|
||||
"billing_interval_toggle": "Billing interval",
|
||||
"current_plan_badge": "Current",
|
||||
"current_plan_cta": "Current plan",
|
||||
"custom_plan_description": "Your organization is on a custom billing setup. You can still switch to one of the standard plans below.",
|
||||
"custom_plan_title": "Custom plan",
|
||||
"cancelling": "Cancelling",
|
||||
"failed_to_start_trial": "Failed to start trial. Please try again.",
|
||||
"keep_current_plan": "Keep current plan",
|
||||
"manage_billing_details": "Manage card details & invoices",
|
||||
"monthly": "Monthly",
|
||||
"most_popular": "Most popular",
|
||||
"pending_change_removed": "Scheduled plan change removed.",
|
||||
"pending_plan_badge": "Scheduled",
|
||||
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
|
||||
"pending_plan_change_title": "Scheduled plan change",
|
||||
"pending_plan_cta": "Scheduled",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_change_applied": "Plan updated successfully.",
|
||||
"plan_change_scheduled": "Plan change scheduled successfully.",
|
||||
"manage_subscription": "Manage subscription",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Everything in Hobby",
|
||||
"plan_feature_everything_in_pro": "Everything in Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "For individuals and small teams getting started with Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 responses / month",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "For growing teams that need higher limits, automations, and dynamic overages.",
|
||||
"plan_pro_feature_responses": "2,000 responses / month (dynamic overage)",
|
||||
"plan_pro_feature_workspaces": "3 workspaces",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "For larger teams that need more capacity, stronger governance, and higher response volume.",
|
||||
"plan_scale_feature_responses": "5,000 responses / month (dynamic overage)",
|
||||
"plan_scale_feature_workspaces": "5 workspaces",
|
||||
"plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.",
|
||||
"plan_selection_title": "Choose your plan",
|
||||
"plan_unknown": "Unknown",
|
||||
"remove_branding": "Remove Branding",
|
||||
"retry_setup": "Retry setup",
|
||||
"scale_banner_description": "Unlock higher limits, team collaboration, and advanced security features with the Scale plan.",
|
||||
"scale_banner_title": "Ready to scale up?",
|
||||
"scale_feature_api": "Full API Access",
|
||||
"scale_feature_quota": "Quota Management",
|
||||
"scale_feature_spam": "Spam Protection",
|
||||
"scale_feature_teams": "Teams & Access Roles",
|
||||
"select_plan_header_subtitle": "No credit card required, no strings attached.",
|
||||
"select_plan_header_title": "Seamlessly integrated surveys, 100% your brand.",
|
||||
"select_plan_header_title": "Ship professional, unbranded surveys today!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "I want to stay on the Hobby plan",
|
||||
"stripe_setup_incomplete": "Billing setup incomplete",
|
||||
"stripe_setup_incomplete_description": "Billing setup did not complete successfully. Please retry to activate your subscription.",
|
||||
"subscription": "Subscription",
|
||||
"subscription_description": "Manage your subscription plan and monitor your usage",
|
||||
"switch_at_period_end": "Switch at period end",
|
||||
"switch_plan_now": "Switch plan now",
|
||||
"this_includes": "This includes",
|
||||
"trial_alert_description": "Add a payment method to keep access to all features.",
|
||||
"trial_already_used": "A free trial has already been used for this email address. Please upgrade to a paid plan instead.",
|
||||
"trial_feature_api_access": "API Access",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Unlimited Responses",
|
||||
"unlimited_workspaces": "Unlimited Workspaces",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Upgrade now",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "used",
|
||||
"yearly": "Yearly",
|
||||
"yearly_checkout_unavailable": "Yearly checkout is not available yet. Add a payment method on a monthly plan first or contact support.",
|
||||
"your_plan": "Your plan"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Actualizar",
|
||||
"updated": "Actualizado",
|
||||
"updated_at": "Actualizado el",
|
||||
"upgrade_plan": "Mejorar plan",
|
||||
"upload": "Subir",
|
||||
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
|
||||
"upload_input_description": "Haz clic o arrastra para subir archivos.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Añadir método de pago",
|
||||
"add_payment_method_to_upgrade_tooltip": "Por favor, añade un método de pago arriba para mejorar a un plan de pago",
|
||||
"billing_interval_toggle": "Intervalo de facturación",
|
||||
"current_plan_badge": "Actual",
|
||||
"current_plan_cta": "Plan actual",
|
||||
"custom_plan_description": "Tu organización tiene una configuración de facturación personalizada. Aún puedes cambiar a uno de los planes estándar a continuación.",
|
||||
"custom_plan_title": "Plan personalizado",
|
||||
"cancelling": "Cancelando",
|
||||
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
|
||||
"keep_current_plan": "Mantener plan actual",
|
||||
"manage_billing_details": "Gestionar datos de tarjeta y facturas",
|
||||
"monthly": "Mensual",
|
||||
"most_popular": "Más popular",
|
||||
"pending_change_removed": "Cambio de plan programado eliminado.",
|
||||
"pending_plan_badge": "Programado",
|
||||
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
|
||||
"pending_plan_change_title": "Cambio de plan programado",
|
||||
"pending_plan_cta": "Programado",
|
||||
"per_month": "por mes",
|
||||
"per_year": "por año",
|
||||
"plan_change_applied": "Plan actualizado correctamente.",
|
||||
"plan_change_scheduled": "Cambio de plan programado correctamente.",
|
||||
"manage_subscription": "Gestionar suscripción",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Todo lo de Hobby",
|
||||
"plan_feature_everything_in_pro": "Todo lo de Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Para individuos y equipos pequeños que comienzan con Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 respuestas / mes",
|
||||
"plan_hobby_feature_workspaces": "1 espacio de trabajo",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Para equipos en crecimiento que necesitan límites más altos, automatizaciones y excesos dinámicos.",
|
||||
"plan_pro_feature_responses": "2.000 respuestas / mes (uso excedente dinámico)",
|
||||
"plan_pro_feature_workspaces": "3 espacios de trabajo",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Para equipos más grandes que necesitan mayor capacidad, gobernanza más sólida y mayor volumen de respuestas.",
|
||||
"plan_scale_feature_responses": "5.000 respuestas/mes (excedente dinámico)",
|
||||
"plan_scale_feature_workspaces": "5 espacios de trabajo",
|
||||
"plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.",
|
||||
"plan_selection_title": "Elige tu plan",
|
||||
"plan_unknown": "Desconocido",
|
||||
"remove_branding": "Eliminar marca",
|
||||
"retry_setup": "Reintentar configuración",
|
||||
"scale_banner_description": "Desbloquea límites superiores, colaboración en equipo y funciones de seguridad avanzadas con el plan Scale.",
|
||||
"scale_banner_title": "¿Listo para crecer?",
|
||||
"scale_feature_api": "Acceso completo a la API",
|
||||
"scale_feature_quota": "Gestión de cuota",
|
||||
"scale_feature_spam": "Protección contra spam",
|
||||
"scale_feature_teams": "Equipos y roles de acceso",
|
||||
"select_plan_header_subtitle": "Sin tarjeta de crédito, sin compromisos.",
|
||||
"select_plan_header_title": "Encuestas perfectamente integradas, 100% tu marca.",
|
||||
"select_plan_header_title": "¡Lanza encuestas profesionales sin marca hoy mismo!",
|
||||
"status_trialing": "Prueba",
|
||||
"stay_on_hobby_plan": "Quiero quedarme en el plan Hobby",
|
||||
"stripe_setup_incomplete": "Configuración de facturación incompleta",
|
||||
"stripe_setup_incomplete_description": "La configuración de facturación no se completó correctamente. Por favor, vuelve a intentarlo para activar tu suscripción.",
|
||||
"subscription": "Suscripción",
|
||||
"subscription_description": "Gestiona tu plan de suscripción y monitorea tu uso",
|
||||
"switch_at_period_end": "Cambiar al final del período",
|
||||
"switch_plan_now": "Cambiar de plan ahora",
|
||||
"this_includes": "Esto incluye",
|
||||
"trial_alert_description": "Añade un método de pago para mantener el acceso a todas las funciones.",
|
||||
"trial_already_used": "Ya se ha utilizado una prueba gratuita para esta dirección de correo electrónico. Por favor, actualiza a un plan de pago.",
|
||||
"trial_feature_api_access": "Acceso a la API",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Respuestas ilimitadas",
|
||||
"unlimited_workspaces": "Proyectos ilimitados",
|
||||
"upgrade": "Actualizar",
|
||||
"upgrade_now": "Actualizar ahora",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "usados",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "El pago anual aún no está disponible. Primero añade un método de pago en un plan mensual o contacta con soporte.",
|
||||
"your_plan": "Tu plan"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Quelque chose s'est mal passé.",
|
||||
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"sort_by": "Trier par",
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"start_free_trial": "Essayer gratuitement",
|
||||
"status": "Statut",
|
||||
"step_by_step_manual": "Manuel étape par étape",
|
||||
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Mise à jour",
|
||||
"updated": "Mise à jour",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upgrade_plan": "Améliorer le forfait",
|
||||
"upload": "Télécharger",
|
||||
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
|
||||
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Ajouter un moyen de paiement",
|
||||
"add_payment_method_to_upgrade_tooltip": "Veuillez ajouter un moyen de paiement ci-dessus pour passer à un forfait payant",
|
||||
"billing_interval_toggle": "Intervalle de facturation",
|
||||
"current_plan_badge": "Actuel",
|
||||
"current_plan_cta": "Formule actuelle",
|
||||
"custom_plan_description": "Votre organisation dispose d'une configuration de facturation personnalisée. Tu peux toujours basculer vers l'une des formules standard ci-dessous.",
|
||||
"custom_plan_title": "Formule personnalisée",
|
||||
"cancelling": "Annulation en cours",
|
||||
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
|
||||
"keep_current_plan": "Conserver la formule actuelle",
|
||||
"manage_billing_details": "Gérer les détails de la carte et les factures",
|
||||
"monthly": "Mensuel",
|
||||
"most_popular": "Le plus populaire",
|
||||
"pending_change_removed": "Changement de formule programmé supprimé.",
|
||||
"pending_plan_badge": "Programmé",
|
||||
"pending_plan_change_description": "Ta formule passera à {{plan}} le {{date}}.",
|
||||
"pending_plan_change_title": "Changement de formule programmé",
|
||||
"pending_plan_cta": "Programmé",
|
||||
"per_month": "par mois",
|
||||
"per_year": "par an",
|
||||
"plan_change_applied": "Formule mise à jour avec succès.",
|
||||
"plan_change_scheduled": "Changement de formule programmé avec succès.",
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tout ce qui est inclus dans Hobby",
|
||||
"plan_feature_everything_in_pro": "Tout ce qui est inclus dans Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Pour les particuliers et les petites équipes qui débutent avec Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 réponses / mois",
|
||||
"plan_hobby_feature_workspaces": "1 espace de travail",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Pour les équipes en croissance qui ont besoin de limites plus élevées, d'automatisations et de dépassements dynamiques.",
|
||||
"plan_pro_feature_responses": "2 000 réponses / mois (dépassement dynamique)",
|
||||
"plan_pro_feature_workspaces": "3 espaces de travail",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Pour les grandes équipes qui ont besoin de plus de capacité, d'une meilleure gouvernance et d'un volume de réponses plus élevé.",
|
||||
"plan_scale_feature_responses": "5 000 réponses / mois (dépassement dynamique)",
|
||||
"plan_scale_feature_workspaces": "5 espaces de travail",
|
||||
"plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.",
|
||||
"plan_selection_title": "Choisis ta formule",
|
||||
"plan_unknown": "Inconnu",
|
||||
"remove_branding": "Suppression du logo",
|
||||
"retry_setup": "Réessayer la configuration",
|
||||
"scale_banner_description": "Débloque des limites plus élevées, la collaboration en équipe et des fonctionnalités de sécurité avancées avec l’offre Scale.",
|
||||
"scale_banner_title": "Prêt à passer à la vitesse supérieure ?",
|
||||
"scale_feature_api": "Accès API complet",
|
||||
"scale_feature_quota": "Gestion des quotas",
|
||||
"scale_feature_spam": "Protection contre le spam",
|
||||
"scale_feature_teams": "Équipes & rôles d’accès",
|
||||
"select_plan_header_subtitle": "Aucune carte bancaire requise, aucun engagement.",
|
||||
"select_plan_header_title": "Sondages parfaitement intégrés, 100 % à ton image.",
|
||||
"select_plan_header_title": "Envoyez des sondages professionnels et personnalisés dès aujourd'hui !",
|
||||
"status_trialing": "Essai",
|
||||
"stay_on_hobby_plan": "Je veux rester sur le plan Hobby",
|
||||
"stripe_setup_incomplete": "Configuration de la facturation incomplète",
|
||||
"stripe_setup_incomplete_description": "La configuration de la facturation n’a pas abouti. Merci de réessayer pour activer ton abonnement.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Gère ton abonnement et surveille ta consommation",
|
||||
"switch_at_period_end": "Changer à la fin de la période",
|
||||
"switch_plan_now": "Changer de formule maintenant",
|
||||
"this_includes": "Cela inclut",
|
||||
"trial_alert_description": "Ajoute un moyen de paiement pour conserver l'accès à toutes les fonctionnalités.",
|
||||
"trial_already_used": "Un essai gratuit a déjà été utilisé pour cette adresse e-mail. Passe plutôt à un plan payant.",
|
||||
"trial_feature_api_access": "Accès API",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Réponses illimitées",
|
||||
"unlimited_workspaces": "Projets illimités",
|
||||
"upgrade": "Mise à niveau",
|
||||
"upgrade_now": "Passer à la formule supérieure maintenant",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilisé(s)",
|
||||
"yearly": "Annuel",
|
||||
"yearly_checkout_unavailable": "Le paiement annuel n'est pas encore disponible. Ajoute d'abord un moyen de paiement sur un forfait mensuel ou contacte le support.",
|
||||
"your_plan": "Ton offre"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Valami probléma történt",
|
||||
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
|
||||
"sort_by": "Rendezési sorrend",
|
||||
"start_free_trial": "Ingyenes próbaverzió indítása",
|
||||
"start_free_trial": "Ingyenes próba indítása",
|
||||
"status": "Állapot",
|
||||
"step_by_step_manual": "Lépésenkénti kézikönyv",
|
||||
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Frissítés",
|
||||
"updated": "Frissítve",
|
||||
"updated_at": "Frissítve",
|
||||
"upgrade_plan": "Csomag frissítése",
|
||||
"upload": "Feltöltés",
|
||||
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
|
||||
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Fizetési mód hozzáadása",
|
||||
"add_payment_method_to_upgrade_tooltip": "Kérjük, adjon hozzá egy fizetési módot fent a fizetős csomagra való frissítéshez",
|
||||
"billing_interval_toggle": "Számlázási időszak",
|
||||
"current_plan_badge": "Jelenlegi",
|
||||
"current_plan_cta": "Jelenlegi csomag",
|
||||
"custom_plan_description": "A szervezete egyedi számlázási beállítással rendelkezik. Továbbra is válthat az alábbi standard csomagok egyikére.",
|
||||
"custom_plan_title": "Egyedi csomag",
|
||||
"cancelling": "Lemondás folyamatban",
|
||||
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
|
||||
"keep_current_plan": "Jelenlegi csomag megtartása",
|
||||
"manage_billing_details": "Kártyaadatok és számlák kezelése",
|
||||
"monthly": "Havi",
|
||||
"most_popular": "Legnépszerűbb",
|
||||
"pending_change_removed": "Az ütemezett csomagváltás eltávolítva.",
|
||||
"pending_plan_badge": "Ütemezett",
|
||||
"pending_plan_change_description": "A csomagja {{date}}-án átvált erre: {{plan}}.",
|
||||
"pending_plan_change_title": "Ütemezett csomagváltás",
|
||||
"pending_plan_cta": "Ütemezett",
|
||||
"per_month": "havonta",
|
||||
"per_year": "évente",
|
||||
"plan_change_applied": "A csomag sikeresen frissítve.",
|
||||
"plan_change_scheduled": "A csomagváltás sikeresen ütemezve.",
|
||||
"manage_subscription": "Előfizetés kezelése",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Minden, ami a Hobby csomagban",
|
||||
"plan_feature_everything_in_pro": "Minden, ami a Pro csomagban",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Magánszemélyek és kisebb csapatok számára, akik most kezdik a Formbricks Cloud használatát.",
|
||||
"plan_hobby_feature_responses": "250 válasz / hó",
|
||||
"plan_hobby_feature_workspaces": "1 munkaterület",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Növekvő csapatok számára, amelyeknek magasabb korlátokra, automatizálásokra és dinamikus túlhasználatra van szükségük.",
|
||||
"plan_pro_feature_responses": "2 000 válasz / hó (dinamikus túlhasználat)",
|
||||
"plan_pro_feature_workspaces": "3 munkaterület",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Nagyobb csapatok számára, amelyeknek nagyobb kapacitásra, erősebb irányításra és magasabb válaszmennyiségre van szükségük.",
|
||||
"plan_scale_feature_responses": "5000 válasz / hónap (dinamikus túllépés)",
|
||||
"plan_scale_feature_workspaces": "5 munkaterület",
|
||||
"plan_selection_description": "Hasonlítsa össze a Hobby, Pro és Scale csomagokat, majd váltson csomagot közvetlenül a Formbricks alkalmazásból.",
|
||||
"plan_selection_title": "Válassza ki az Ön csomagját",
|
||||
"plan_unknown": "Ismeretlen",
|
||||
"remove_branding": "Márkajel eltávolítása",
|
||||
"retry_setup": "Újrapróbálkozás a beállítással",
|
||||
"scale_banner_description": "Nagyobb limitek, csapatmunka és fejlett biztonsági funkciók a Scale csomaggal.",
|
||||
"scale_banner_title": "Készen áll a növekedésre?",
|
||||
"scale_feature_api": "Teljes API hozzáférés",
|
||||
"scale_feature_quota": "Keretkezelés",
|
||||
"scale_feature_spam": "Spamvédelem",
|
||||
"scale_feature_teams": "Csapatok és hozzáférési szerepkörök",
|
||||
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
|
||||
"select_plan_header_title": "Zökkenőmentesen integrált felmérések, 100%-ban az Ön márkája.",
|
||||
"select_plan_header_title": "Küldjön professzionális, márkajelzés nélküli felméréseket még ma!",
|
||||
"status_trialing": "Próbaverzió",
|
||||
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
|
||||
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
|
||||
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
|
||||
"subscription": "Előfizetés",
|
||||
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
|
||||
"switch_at_period_end": "Váltás az időszak végén",
|
||||
"switch_plan_now": "Csomag váltása most",
|
||||
"this_includes": "Ez tartalmazza",
|
||||
"trial_alert_description": "Adjon hozzá fizetési módot, hogy megtarthassa a hozzáférést az összes funkcióhoz.",
|
||||
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.",
|
||||
"trial_feature_api_access": "API-hozzáférés",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Korlátlan válaszok",
|
||||
"unlimited_workspaces": "Korlátlan munkaterület",
|
||||
"upgrade": "Frissítés",
|
||||
"upgrade_now": "Frissítés most",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "felhasználva",
|
||||
"yearly": "Éves",
|
||||
"yearly_checkout_unavailable": "Az éves fizetés még nem érhető el. Kérjük, adjon hozzá fizetési módot egy havi előfizetéshez, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
|
||||
"your_plan": "Az Ön csomagja"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "更新",
|
||||
"updated": "更新済み",
|
||||
"updated_at": "更新日時",
|
||||
"upgrade_plan": "プランをアップグレード",
|
||||
"upload": "アップロード",
|
||||
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
|
||||
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "支払い方法を追加",
|
||||
"add_payment_method_to_upgrade_tooltip": "有料プランにアップグレードするには、上記で支払い方法を追加してください",
|
||||
"billing_interval_toggle": "請求間隔",
|
||||
"current_plan_badge": "現在のプラン",
|
||||
"current_plan_cta": "現在のプラン",
|
||||
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
|
||||
"custom_plan_title": "カスタムプラン",
|
||||
"cancelling": "キャンセル中",
|
||||
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
|
||||
"keep_current_plan": "現在のプランを継続",
|
||||
"manage_billing_details": "カード情報と請求書を管理",
|
||||
"monthly": "月払い",
|
||||
"most_popular": "人気",
|
||||
"pending_change_removed": "予定されていたプラン変更を取り消しました。",
|
||||
"pending_plan_badge": "変更予定",
|
||||
"pending_plan_change_description": "{{date}}に{{plan}}へ切り替わります。",
|
||||
"pending_plan_change_title": "プラン変更の予定",
|
||||
"pending_plan_cta": "変更予定",
|
||||
"per_month": "/月",
|
||||
"per_year": "/年",
|
||||
"plan_change_applied": "プランを更新しました。",
|
||||
"plan_change_scheduled": "プラン変更を予約しました。",
|
||||
"manage_subscription": "サブスクリプションを管理",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Hobbyプランの全機能",
|
||||
"plan_feature_everything_in_pro": "Proプランの全機能",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Formbricks Cloudを始める個人や小規模チーム向けのプランです。",
|
||||
"plan_hobby_feature_responses": "月250回の回答",
|
||||
"plan_hobby_feature_workspaces": "1ワークスペース",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "より高い制限、自動化、動的なオーバーエージが必要な成長中のチーム向け。",
|
||||
"plan_pro_feature_responses": "月2,000回の回答(超過分は従量制)",
|
||||
"plan_pro_feature_workspaces": "3つのワークスペース",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "より多くの容量、強力なガバナンス、高いレスポンス量が必要な大規模チーム向け。",
|
||||
"plan_scale_feature_responses": "月間5,000レスポンス(動的な超過課金)",
|
||||
"plan_scale_feature_workspaces": "5つのワークスペース",
|
||||
"plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。",
|
||||
"plan_selection_title": "プランを選択",
|
||||
"plan_unknown": "不明",
|
||||
"remove_branding": "ブランディングを削除",
|
||||
"retry_setup": "セットアップを再試行",
|
||||
"scale_banner_description": "Scaleプランで、上限の引き上げ、チームでのコラボレーション、高度なセキュリティ機能を利用しましょう。",
|
||||
"scale_banner_title": "スケールアップの準備はできていますか?",
|
||||
"scale_feature_api": "APIフルアクセス",
|
||||
"scale_feature_quota": "クォータ管理",
|
||||
"scale_feature_spam": "スパム防止機能",
|
||||
"scale_feature_teams": "チーム&アクセス権限管理",
|
||||
"select_plan_header_subtitle": "クレジットカード不要、縛りなし。",
|
||||
"select_plan_header_title": "シームレスに統合されたアンケート、100%あなたのブランド。",
|
||||
"select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Hobbyプランを継続する",
|
||||
"stripe_setup_incomplete": "請求情報の設定が未完了",
|
||||
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
|
||||
"subscription": "サブスクリプション",
|
||||
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
|
||||
"switch_at_period_end": "期間終了時に切り替え",
|
||||
"switch_plan_now": "今すぐプランを切り替え",
|
||||
"this_includes": "これには以下が含まれます",
|
||||
"trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
|
||||
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
|
||||
"trial_feature_api_access": "APIアクセス",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "無制限の回答",
|
||||
"unlimited_workspaces": "無制限ワークスペース",
|
||||
"upgrade": "アップグレード",
|
||||
"upgrade_now": "今すぐアップグレード",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "使用済み",
|
||||
"yearly": "年間",
|
||||
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
|
||||
"your_plan": "ご利用プラン"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Er is iets misgegaan",
|
||||
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"sort_by": "Sorteer op",
|
||||
"start_free_trial": "Start gratis proefperiode",
|
||||
"start_free_trial": "Gratis proefperiode starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Stap voor stap handleiding",
|
||||
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Update",
|
||||
"updated": "Bijgewerkt",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upgrade_plan": "Abonnement upgraden",
|
||||
"upload": "Uploaden",
|
||||
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
|
||||
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Betaalmethode toevoegen",
|
||||
"add_payment_method_to_upgrade_tooltip": "Voeg hierboven een betaalmethode toe om te upgraden naar een betaald abonnement",
|
||||
"billing_interval_toggle": "Factureringsinterval",
|
||||
"current_plan_badge": "Huidig",
|
||||
"current_plan_cta": "Huidig abonnement",
|
||||
"custom_plan_description": "Je organisatie heeft een aangepaste factureringsopzet. Je kunt nog steeds overstappen naar een van de standaard abonnementen hieronder.",
|
||||
"custom_plan_title": "Aangepast abonnement",
|
||||
"cancelling": "Bezig met annuleren",
|
||||
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
|
||||
"keep_current_plan": "Huidig abonnement behouden",
|
||||
"manage_billing_details": "Kaartgegevens en facturen beheren",
|
||||
"monthly": "Maandelijks",
|
||||
"most_popular": "Meest populair",
|
||||
"pending_change_removed": "Geplande abonnementswijziging verwijderd.",
|
||||
"pending_plan_badge": "Gepland",
|
||||
"pending_plan_change_description": "Je abonnement wordt op {{date}} omgezet naar {{plan}}.",
|
||||
"pending_plan_change_title": "Geplande abonnementswijziging",
|
||||
"pending_plan_cta": "Gepland",
|
||||
"per_month": "per maand",
|
||||
"per_year": "per jaar",
|
||||
"plan_change_applied": "Abonnement succesvol bijgewerkt.",
|
||||
"plan_change_scheduled": "Abonnementswijziging succesvol ingepland.",
|
||||
"manage_subscription": "Abonnement beheren",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Alles in Hobby",
|
||||
"plan_feature_everything_in_pro": "Alles in Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Voor individuen en kleine teams die aan de slag gaan met Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 reacties / maand",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Voor groeiende teams die hogere limieten, automatiseringen en dynamische overschrijdingen nodig hebben.",
|
||||
"plan_pro_feature_responses": "2.000 reacties / maand (dynamische overschrijding)",
|
||||
"plan_pro_feature_workspaces": "3 werkruimtes",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Voor grotere teams die meer capaciteit, beter bestuur en een hoger responsvolume nodig hebben.",
|
||||
"plan_scale_feature_responses": "5.000 reacties / maand (dynamische overbrugging)",
|
||||
"plan_scale_feature_workspaces": "5 werkruimtes",
|
||||
"plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.",
|
||||
"plan_selection_title": "Kies je abonnement",
|
||||
"plan_unknown": "Onbekend",
|
||||
"remove_branding": "Branding verwijderen",
|
||||
"retry_setup": "Opnieuw proberen",
|
||||
"scale_banner_description": "Ontgrendel hogere limieten, team samenwerking, en geavanceerde beveiligingsfuncties met het Scale-abonnement.",
|
||||
"scale_banner_title": "Klaar om op te schalen?",
|
||||
"scale_feature_api": "Volledige API-toegang",
|
||||
"scale_feature_quota": "Quotabeheer",
|
||||
"scale_feature_spam": "Spam-beveiliging",
|
||||
"scale_feature_teams": "Teams & toegangsrollen",
|
||||
"select_plan_header_subtitle": "Geen creditcard vereist, geen verplichtingen.",
|
||||
"select_plan_header_title": "Naadloos geïntegreerde enquêtes, 100% jouw merk.",
|
||||
"select_plan_header_title": "Verstuur vandaag nog professionele, ongemerkte enquêtes!",
|
||||
"status_trialing": "Proefperiode",
|
||||
"stay_on_hobby_plan": "Ik wil op het Hobby-abonnement blijven",
|
||||
"stripe_setup_incomplete": "Facturatie-instelling niet voltooid",
|
||||
"stripe_setup_incomplete_description": "Het instellen van de facturatie is niet gelukt. Probeer het opnieuw om je abonnement te activeren.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Beheer je abonnement en houd je gebruik bij",
|
||||
"switch_at_period_end": "Schakel aan het einde van de periode",
|
||||
"switch_plan_now": "Schakel nu van abonnement",
|
||||
"this_includes": "Dit omvat",
|
||||
"trial_alert_description": "Voeg een betaalmethode toe om toegang te houden tot alle functies.",
|
||||
"trial_already_used": "Er is al een gratis proefperiode gebruikt voor dit e-mailadres. Upgrade in plaats daarvan naar een betaald abonnement.",
|
||||
"trial_feature_api_access": "API-toegang",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Onbeperkte reacties",
|
||||
"unlimited_workspaces": "Onbeperkt werkruimtes",
|
||||
"upgrade": "Upgraden",
|
||||
"upgrade_now": "Nu upgraden",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "gebruikt",
|
||||
"yearly": "Jaarlijks",
|
||||
"yearly_checkout_unavailable": "Jaarlijkse checkout is nog niet beschikbaar. Voeg eerst een betaalmethode toe bij een maandelijks abonnement of neem contact op met support.",
|
||||
"your_plan": "Jouw abonnement"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Algo deu errado",
|
||||
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
|
||||
"sort_by": "Ordenar por",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "atualizar",
|
||||
"updated": "atualizado",
|
||||
"updated_at": "Atualizado em",
|
||||
"upgrade_plan": "Fazer upgrade do plano",
|
||||
"upload": "Enviar",
|
||||
"upload_failed": "Falha no upload. Tente novamente.",
|
||||
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adicionar forma de pagamento",
|
||||
"add_payment_method_to_upgrade_tooltip": "Por favor, adicione uma forma de pagamento acima para fazer upgrade para um plano pago",
|
||||
"billing_interval_toggle": "Intervalo de cobrança",
|
||||
"current_plan_badge": "Atual",
|
||||
"current_plan_cta": "Plano atual",
|
||||
"custom_plan_description": "Sua organização está em uma configuração de cobrança personalizada. Você ainda pode mudar para um dos planos padrão abaixo.",
|
||||
"custom_plan_title": "Plano personalizado",
|
||||
"cancelling": "Cancelando",
|
||||
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
|
||||
"keep_current_plan": "Manter plano atual",
|
||||
"manage_billing_details": "Gerenciar detalhes do cartão e faturas",
|
||||
"monthly": "Mensal",
|
||||
"most_popular": "Mais popular",
|
||||
"pending_change_removed": "Mudança de plano agendada removida.",
|
||||
"pending_plan_badge": "Agendado",
|
||||
"pending_plan_change_description": "Seu plano mudará para {{plan}} em {{date}}.",
|
||||
"pending_plan_change_title": "Mudança de plano agendada",
|
||||
"pending_plan_cta": "Agendado",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_change_applied": "Plano atualizado com sucesso.",
|
||||
"plan_change_scheduled": "Mudança de plano agendada com sucesso.",
|
||||
"manage_subscription": "Gerenciar assinatura",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tudo do Hobby",
|
||||
"plan_feature_everything_in_pro": "Tudo do Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Para indivíduos e pequenas equipes começando com o Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 respostas / mês",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Para equipes em crescimento que precisam de limites maiores, automações e excedentes dinâmicos.",
|
||||
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_pro_feature_workspaces": "3 espaços de trabalho",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Para equipes maiores que precisam de mais capacidade, governança mais forte e maior volume de respostas.",
|
||||
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_scale_feature_workspaces": "5 espaços de trabalho",
|
||||
"plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.",
|
||||
"plan_selection_title": "Escolha seu plano",
|
||||
"plan_unknown": "desconhecido",
|
||||
"remove_branding": "Remover Marca",
|
||||
"retry_setup": "Tentar novamente",
|
||||
"scale_banner_description": "Desbloqueie limites maiores, colaboração em equipe e recursos avançados de segurança com o plano Scale.",
|
||||
"scale_banner_title": "Pronto para expandir?",
|
||||
"scale_feature_api": "Acesso completo à API",
|
||||
"scale_feature_quota": "Gestão de cota",
|
||||
"scale_feature_spam": "Proteção contra spam",
|
||||
"scale_feature_teams": "Equipes e papéis de acesso",
|
||||
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
|
||||
"select_plan_header_title": "Pesquisas perfeitamente integradas, 100% sua marca.",
|
||||
"select_plan_header_title": "Envie pesquisas profissionais e sem marca hoje mesmo!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Quero continuar no plano Hobby",
|
||||
"stripe_setup_incomplete": "Configuração de cobrança incompleta",
|
||||
"stripe_setup_incomplete_description": "A configuração de cobrança não foi concluída com sucesso. Tente novamente para ativar sua assinatura.",
|
||||
"subscription": "Assinatura",
|
||||
"subscription_description": "Gerencie seu plano de assinatura e acompanhe seu uso",
|
||||
"switch_at_period_end": "Mudar no final do período",
|
||||
"switch_plan_now": "Mudar de plano agora",
|
||||
"this_includes": "Isso inclui",
|
||||
"trial_alert_description": "Adicione uma forma de pagamento para manter o acesso a todos os recursos.",
|
||||
"trial_already_used": "Um período de teste gratuito já foi usado para este endereço de e-mail. Por favor, faça upgrade para um plano pago.",
|
||||
"trial_feature_api_access": "Acesso à API",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"upgrade_now": "Fazer upgrade agora",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "usado",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "O checkout anual ainda não está disponível. Adicione um método de pagamento em um plano mensal primeiro ou entre em contato com o suporte.",
|
||||
"your_plan": "Seu plano"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Algo correu mal",
|
||||
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
|
||||
"sort_by": "Ordem",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Atualizar",
|
||||
"updated": "Atualizado",
|
||||
"updated_at": "Atualizado em",
|
||||
"upgrade_plan": "Fazer upgrade do plano",
|
||||
"upload": "Carregar",
|
||||
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
|
||||
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adicionar método de pagamento",
|
||||
"add_payment_method_to_upgrade_tooltip": "Por favor, adiciona um método de pagamento acima para fazeres upgrade para um plano pago",
|
||||
"billing_interval_toggle": "Intervalo de faturação",
|
||||
"current_plan_badge": "Atual",
|
||||
"current_plan_cta": "Plano atual",
|
||||
"custom_plan_description": "A tua organização tem uma configuração de faturação personalizada. Podes mudar para um dos planos padrão abaixo.",
|
||||
"custom_plan_title": "Plano personalizado",
|
||||
"cancelling": "A cancelar",
|
||||
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
|
||||
"keep_current_plan": "Manter plano atual",
|
||||
"manage_billing_details": "Gerir detalhes do cartão e faturas",
|
||||
"monthly": "Mensal",
|
||||
"most_popular": "Mais popular",
|
||||
"pending_change_removed": "Alteração de plano agendada removida.",
|
||||
"pending_plan_badge": "Agendado",
|
||||
"pending_plan_change_description": "O teu plano mudará para {{plan}} em {{date}}.",
|
||||
"pending_plan_change_title": "Alteração de plano agendada",
|
||||
"pending_plan_cta": "Agendado",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_change_applied": "Plano atualizado com sucesso.",
|
||||
"plan_change_scheduled": "Alteração de plano agendada com sucesso.",
|
||||
"manage_subscription": "Gerir subscrição",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tudo no Hobby",
|
||||
"plan_feature_everything_in_pro": "Tudo no Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Para indivíduos e pequenas equipas que estão a começar com o Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 respostas / mês",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Para equipas em crescimento que precisam de limites mais elevados, automatizações e excedentes dinâmicos.",
|
||||
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_pro_feature_workspaces": "3 áreas de trabalho",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Para equipas maiores que precisam de mais capacidade, maior controlo e um volume de respostas mais elevado.",
|
||||
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_scale_feature_workspaces": "5 áreas de trabalho",
|
||||
"plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.",
|
||||
"plan_selection_title": "Escolhe o teu plano",
|
||||
"plan_unknown": "Desconhecido",
|
||||
"remove_branding": "Possibilidade de remover o logo",
|
||||
"retry_setup": "Tentar novamente configurar",
|
||||
"scale_banner_description": "Desbloqueia limites mais elevados, colaboração em equipa e funcionalidades avançadas de segurança com o plano Scale.",
|
||||
"scale_banner_title": "Preparado para aumentar a escala?",
|
||||
"scale_feature_api": "Acesso total à API",
|
||||
"scale_feature_quota": "Gestão de quotas",
|
||||
"scale_feature_spam": "Proteção contra spam",
|
||||
"scale_feature_teams": "Equipas e papéis de acesso",
|
||||
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
|
||||
"select_plan_header_title": "Inquéritos perfeitamente integrados, 100% da tua marca.",
|
||||
"select_plan_header_title": "Envia inquéritos profissionais sem marca hoje!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Quero manter o plano Hobby",
|
||||
"stripe_setup_incomplete": "Configuração de faturação incompleta",
|
||||
"stripe_setup_incomplete_description": "A configuração de faturação não foi concluída com sucesso. Por favor, tenta novamente para ativar a tua subscrição.",
|
||||
"subscription": "Subscrição",
|
||||
"subscription_description": "Gere o teu plano de subscrição e acompanha a tua utilização",
|
||||
"switch_at_period_end": "Mudar no fim do período",
|
||||
"switch_plan_now": "Mudar de plano agora",
|
||||
"this_includes": "Isto inclui",
|
||||
"trial_alert_description": "Adiciona um método de pagamento para manteres acesso a todas as funcionalidades.",
|
||||
"trial_already_used": "Já foi utilizado um período de teste gratuito para este endereço de email. Por favor, atualiza para um plano pago.",
|
||||
"trial_feature_api_access": "Acesso à API",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"upgrade_now": "Fazer upgrade agora",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilizado",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "O pagamento anual ainda não está disponível. Adiciona primeiro um método de pagamento num plano mensal ou contacta o suporte.",
|
||||
"your_plan": "O teu plano"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "Ceva nu a mers bine",
|
||||
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
|
||||
"sort_by": "Sortare după",
|
||||
"start_free_trial": "Începe perioada de probă gratuită",
|
||||
"start_free_trial": "Începe perioada de testare gratuită",
|
||||
"status": "Stare",
|
||||
"step_by_step_manual": "Manual pas cu pas",
|
||||
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Actualizare",
|
||||
"updated": "Actualizat",
|
||||
"updated_at": "Actualizat la",
|
||||
"upgrade_plan": "Actualizează planul",
|
||||
"upload": "Încărcați",
|
||||
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
|
||||
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adaugă o metodă de plată",
|
||||
"add_payment_method_to_upgrade_tooltip": "Te rugăm să adaugi o metodă de plată mai sus pentru a trece la un plan plătit",
|
||||
"billing_interval_toggle": "Interval de facturare",
|
||||
"current_plan_badge": "Curent",
|
||||
"current_plan_cta": "Plan curent",
|
||||
"custom_plan_description": "Organizația ta folosește o configurație de facturare personalizată. Poți totuși să treci la unul dintre planurile standard de mai jos.",
|
||||
"custom_plan_title": "Plan personalizat",
|
||||
"cancelling": "Anulare în curs",
|
||||
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
|
||||
"keep_current_plan": "Păstrează planul curent",
|
||||
"manage_billing_details": "Gestionează detaliile cardului și facturile",
|
||||
"monthly": "Lunar",
|
||||
"most_popular": "Cel mai popular",
|
||||
"pending_change_removed": "Schimbarea de plan programată a fost anulată.",
|
||||
"pending_plan_badge": "Programat",
|
||||
"pending_plan_change_description": "Planul tău va trece la {{plan}} pe {{date}}.",
|
||||
"pending_plan_change_title": "Schimbare de plan programată",
|
||||
"pending_plan_cta": "Programat",
|
||||
"per_month": "pe lună",
|
||||
"per_year": "pe an",
|
||||
"plan_change_applied": "Planul a fost actualizat cu succes.",
|
||||
"plan_change_scheduled": "Schimbarea de plan a fost programată cu succes.",
|
||||
"manage_subscription": "Gestionează abonamentul",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tot ce include Hobby",
|
||||
"plan_feature_everything_in_pro": "Tot ce include Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Pentru persoane individuale și echipe mici care încep să folosească Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 de răspunsuri / lună",
|
||||
"plan_hobby_feature_workspaces": "1 spațiu de lucru",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Pentru echipele în creștere care au nevoie de limite mai mari, automatizări și depășiri dinamice.",
|
||||
"plan_pro_feature_responses": "2.000 de răspunsuri / lună (depășire dinamică)",
|
||||
"plan_pro_feature_workspaces": "3 spații de lucru",
|
||||
"plan_scale": "Scală",
|
||||
"plan_scale_description": "Pentru echipe mai mari care au nevoie de mai multă capacitate, guvernanță mai puternică și volum mai mare de răspunsuri.",
|
||||
"plan_scale_feature_responses": "5.000 răspunsuri / lună (suprataxă dinamică)",
|
||||
"plan_scale_feature_workspaces": "5 spații de lucru",
|
||||
"plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.",
|
||||
"plan_selection_title": "Alege-ți planul",
|
||||
"plan_unknown": "Necunoscut",
|
||||
"remove_branding": "Eliminare branding",
|
||||
"retry_setup": "Încearcă din nou configurarea",
|
||||
"scale_banner_description": "Deblochează limite mai mari, colaborare în echipă și funcții avansate de securitate cu pachetul Scale.",
|
||||
"scale_banner_title": "Gata să treci la nivelul următor?",
|
||||
"scale_feature_api": "Acces complet API",
|
||||
"scale_feature_quota": "Gestionare cote",
|
||||
"scale_feature_spam": "Protecție anti-spam",
|
||||
"scale_feature_teams": "Echipe și roluri de acces",
|
||||
"select_plan_header_subtitle": "Nu este necesar card de credit, fără obligații.",
|
||||
"select_plan_header_title": "Sondaje integrate perfect, 100% brandul tău.",
|
||||
"select_plan_header_title": "Lansează chestionare profesionale, fără branding, astăzi!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Vreau să rămân pe planul Hobby",
|
||||
"stripe_setup_incomplete": "Configurare facturare incompletă",
|
||||
"stripe_setup_incomplete_description": "Configurarea facturării nu a fost finalizată cu succes. Încearcă din nou pentru a activa abonamentul.",
|
||||
"subscription": "Abonament",
|
||||
"subscription_description": "Gestionează-ți abonamentul și monitorizează-ți consumul",
|
||||
"switch_at_period_end": "Schimbă la sfârșitul perioadei",
|
||||
"switch_plan_now": "Schimbă planul acum",
|
||||
"this_includes": "Aceasta include",
|
||||
"trial_alert_description": "Adaugă o metodă de plată pentru a păstra accesul la toate funcționalitățile.",
|
||||
"trial_already_used": "O perioadă de probă gratuită a fost deja utilizată pentru această adresă de email. Te rugăm să treci la un plan plătit în schimb.",
|
||||
"trial_feature_api_access": "Acces API",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Răspunsuri nelimitate",
|
||||
"unlimited_workspaces": "Workspaces nelimitate",
|
||||
"upgrade": "Actualizare",
|
||||
"upgrade_now": "Actualizează acum",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilizat",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "Plata anuală nu este disponibilă încă. Adaugă mai întâi o metodă de plată pe un abonament lunar sau contactează asistența.",
|
||||
"your_plan": "Planul tău"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Обновить",
|
||||
"updated": "Обновлено",
|
||||
"updated_at": "Обновлено",
|
||||
"upgrade_plan": "Перейти на другой тариф",
|
||||
"upload": "Загрузить",
|
||||
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
|
||||
"upload_input_description": "Кликните или перетащите файлы для загрузки.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Добавить способ оплаты",
|
||||
"add_payment_method_to_upgrade_tooltip": "Пожалуйста, добавьте способ оплаты выше, чтобы перейти на платный тариф",
|
||||
"billing_interval_toggle": "Интервал выставления счетов",
|
||||
"current_plan_badge": "Текущий",
|
||||
"current_plan_cta": "Текущий тариф",
|
||||
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
|
||||
"custom_plan_title": "Индивидуальный тариф",
|
||||
"cancelling": "Отмена",
|
||||
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
|
||||
"keep_current_plan": "Оставить текущий тариф",
|
||||
"manage_billing_details": "Управление данными карты и счетами",
|
||||
"monthly": "Ежемесячно",
|
||||
"most_popular": "Самый популярный",
|
||||
"pending_change_removed": "Запланированное изменение тарифа отменено.",
|
||||
"pending_plan_badge": "Запланирован",
|
||||
"pending_plan_change_description": "Ваш тариф изменится на {{plan}} {{date}}.",
|
||||
"pending_plan_change_title": "Запланированное изменение тарифа",
|
||||
"pending_plan_cta": "Запланирован",
|
||||
"per_month": "в месяц",
|
||||
"per_year": "в год",
|
||||
"plan_change_applied": "Тариф успешно обновлен.",
|
||||
"plan_change_scheduled": "Изменение тарифа успешно запланировано.",
|
||||
"manage_subscription": "Управление подпиской",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Все возможности Hobby",
|
||||
"plan_feature_everything_in_pro": "Все возможности Pro",
|
||||
"plan_hobby": "Хобби",
|
||||
"plan_hobby_description": "Для частных лиц и небольших команд, начинающих работу с Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 ответов в месяц",
|
||||
"plan_hobby_feature_workspaces": "1 рабочее пространство",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Для растущих команд, которым нужны более высокие лимиты, автоматизация и динамические дополнительные ресурсы.",
|
||||
"plan_pro_feature_responses": "2 000 ответов в месяц (динамическое превышение)",
|
||||
"plan_pro_feature_workspaces": "3 рабочих пространства",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Для крупных команд, которым нужно больше возможностей, строгое управление и больший объем ответов.",
|
||||
"plan_scale_feature_responses": "5 000 ответов / месяц (динамический перерасход)",
|
||||
"plan_scale_feature_workspaces": "5 рабочих пространств",
|
||||
"plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.",
|
||||
"plan_selection_title": "Выбери свой план",
|
||||
"plan_unknown": "Неизвестно",
|
||||
"remove_branding": "Удалить брендинг",
|
||||
"retry_setup": "Повторить настройку",
|
||||
"scale_banner_description": "Откройте новые лимиты, командную работу и расширенные функции безопасности с тарифом Scale.",
|
||||
"scale_banner_title": "Готовы развиваться?",
|
||||
"scale_feature_api": "Полный доступ к API",
|
||||
"scale_feature_quota": "Управление квотами",
|
||||
"scale_feature_spam": "Защита от спама",
|
||||
"scale_feature_teams": "Команды и роли доступа",
|
||||
"select_plan_header_subtitle": "Кредитная карта не требуется, никаких обязательств.",
|
||||
"select_plan_header_title": "Бесшовно интегрированные опросы, 100% ваш бренд.",
|
||||
"select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
|
||||
"status_trialing": "Пробный",
|
||||
"stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby",
|
||||
"stripe_setup_incomplete": "Настройка оплаты не завершена",
|
||||
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
|
||||
"subscription": "Подписка",
|
||||
"subscription_description": "Управляйте своим тарифом и следите за использованием",
|
||||
"switch_at_period_end": "Переключить в конце периода",
|
||||
"switch_plan_now": "Переключить план сейчас",
|
||||
"this_includes": "Это включает",
|
||||
"trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
|
||||
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
|
||||
"trial_feature_api_access": "Доступ к API",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Неограниченное количество ответов",
|
||||
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
|
||||
"upgrade": "Обновить",
|
||||
"upgrade_now": "Обновить сейчас",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "использовано",
|
||||
"yearly": "Годовой",
|
||||
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
|
||||
"your_plan": "Ваш тариф"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "Uppdatera",
|
||||
"updated": "Uppdaterad",
|
||||
"updated_at": "Uppdaterad",
|
||||
"upgrade_plan": "Uppgradera plan",
|
||||
"upload": "Ladda upp",
|
||||
"upload_failed": "Uppladdning misslyckades. Vänligen försök igen.",
|
||||
"upload_input_description": "Klicka eller dra för att ladda upp filer.",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Lägg till betalningsmetod",
|
||||
"add_payment_method_to_upgrade_tooltip": "Lägg till en betalningsmetod ovan för att uppgradera till en betald plan",
|
||||
"billing_interval_toggle": "Faktureringsintervall",
|
||||
"current_plan_badge": "Nuvarande",
|
||||
"current_plan_cta": "Nuvarande abonnemang",
|
||||
"custom_plan_description": "Din organisation har en anpassad faktureringslösning. Du kan fortfarande byta till något av standardabonnemangen nedan.",
|
||||
"custom_plan_title": "Anpassat abonnemang",
|
||||
"cancelling": "Avbryter",
|
||||
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
|
||||
"keep_current_plan": "Behåll nuvarande abonnemang",
|
||||
"manage_billing_details": "Hantera kortuppgifter & fakturor",
|
||||
"monthly": "Månatlig",
|
||||
"most_popular": "Mest populär",
|
||||
"pending_change_removed": "Schemalagd abonnemangsändring har tagits bort.",
|
||||
"pending_plan_badge": "Schemalagd",
|
||||
"pending_plan_change_description": "Ditt abonnemang kommer att ändras till {{plan}} den {{date}}.",
|
||||
"pending_plan_change_title": "Schemalagd abonnemangsändring",
|
||||
"pending_plan_cta": "Schemalagd",
|
||||
"per_month": "per månad",
|
||||
"per_year": "per år",
|
||||
"plan_change_applied": "Abonnemanget har uppdaterats.",
|
||||
"plan_change_scheduled": "Abonnemangsändring har schemalagts.",
|
||||
"manage_subscription": "Hantera prenumeration",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Allt i Hobby",
|
||||
"plan_feature_everything_in_pro": "Allt i Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "För privatpersoner och små team som kommer igång med Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 svar / månad",
|
||||
"plan_hobby_feature_workspaces": "1 arbetsyta",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "För växande team som behöver högre gränser, automationer och dynamiska överskott.",
|
||||
"plan_pro_feature_responses": "2 000 svar / månad (dynamisk överförbrukning)",
|
||||
"plan_pro_feature_workspaces": "3 arbetsytor",
|
||||
"plan_scale": "Skala",
|
||||
"plan_scale_description": "För större team som behöver mer kapacitet, starkare styrning och högre svarsvolym.",
|
||||
"plan_scale_feature_responses": "5 000 svar / månad (dynamisk överförbrukning)",
|
||||
"plan_scale_feature_workspaces": "5 arbetsytor",
|
||||
"plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.",
|
||||
"plan_selection_title": "Välj din plan",
|
||||
"plan_unknown": "Okänd",
|
||||
"remove_branding": "Ta bort varumärke",
|
||||
"retry_setup": "Försök igen med inställningen",
|
||||
"scale_banner_description": "Lås upp högre gränser, samarbete i team och avancerade säkerhetsfunktioner med Scale-planen.",
|
||||
"scale_banner_title": "Redo att växla upp?",
|
||||
"scale_feature_api": "Full API-åtkomst",
|
||||
"scale_feature_quota": "Kvothantering",
|
||||
"scale_feature_spam": "Spamskydd",
|
||||
"scale_feature_teams": "Team & åtkomstroller",
|
||||
"select_plan_header_subtitle": "Inget kreditkort krävs, inga villkor.",
|
||||
"select_plan_header_title": "Sömlöst integrerade undersökningar, 100% ditt varumärke.",
|
||||
"select_plan_header_title": "Skicka professionella undersökningar utan varumärke idag!",
|
||||
"status_trialing": "Testperiod",
|
||||
"stay_on_hobby_plan": "Jag vill behålla Hobby-planen",
|
||||
"stripe_setup_incomplete": "Faktureringsinställningar ofullständiga",
|
||||
"stripe_setup_incomplete_description": "Faktureringsinställningen slutfördes inte riktigt. Försök igen för att aktivera ditt abonnemang.",
|
||||
"subscription": "Abonnemang",
|
||||
"subscription_description": "Hantera din abonnemangsplan och följ din användning",
|
||||
"switch_at_period_end": "Byt vid periodens slut",
|
||||
"switch_plan_now": "Byt plan nu",
|
||||
"this_includes": "Detta inkluderar",
|
||||
"trial_alert_description": "Lägg till en betalningsmetod för att behålla tillgång till alla funktioner.",
|
||||
"trial_already_used": "En gratis provperiod har redan använts för denna e-postadress. Uppgradera till en betald plan istället.",
|
||||
"trial_feature_api_access": "API-åtkomst",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "Obegränsade svar",
|
||||
"unlimited_workspaces": "Obegränsat antal arbetsytor",
|
||||
"upgrade": "Uppgradera",
|
||||
"upgrade_now": "Uppgradera nu",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "använt",
|
||||
"yearly": "Årligen",
|
||||
"yearly_checkout_unavailable": "Årlig betalning är inte tillgänglig ännu. Lägg till en betalningsmetod på en månatlig plan först eller kontakta support.",
|
||||
"your_plan": "Din plan"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"something_went_wrong": "出错了",
|
||||
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
|
||||
"sort_by": "排序 依据",
|
||||
"start_free_trial": "开始免费试用",
|
||||
"start_free_trial": "开始 免费试用",
|
||||
"status": "状态",
|
||||
"step_by_step_manual": "分步 手册",
|
||||
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新 于",
|
||||
"upgrade_plan": "升级套餐",
|
||||
"upload": "上传",
|
||||
"upload_failed": "上传失败,请重试。",
|
||||
"upload_input_description": "点击 或 拖动 上传 文件",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "添加支付方式",
|
||||
"add_payment_method_to_upgrade_tooltip": "请先在上方添加付款方式以升级到付费套餐",
|
||||
"billing_interval_toggle": "账单周期",
|
||||
"current_plan_badge": "当前",
|
||||
"current_plan_cta": "当前方案",
|
||||
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
|
||||
"custom_plan_title": "自定义方案",
|
||||
"cancelling": "正在取消",
|
||||
"failed_to_start_trial": "试用启动失败,请重试。",
|
||||
"keep_current_plan": "保持当前方案",
|
||||
"manage_billing_details": "管理卡片详情与发票",
|
||||
"monthly": "按月",
|
||||
"most_popular": "最受欢迎",
|
||||
"pending_change_removed": "已取消预定的方案变更。",
|
||||
"pending_plan_badge": "已预定",
|
||||
"pending_plan_change_description": "您的方案将在 {{date}} 切换至 {{plan}}。",
|
||||
"pending_plan_change_title": "预定的方案变更",
|
||||
"pending_plan_cta": "已预定",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_change_applied": "方案更新成功。",
|
||||
"plan_change_scheduled": "方案变更预定成功。",
|
||||
"manage_subscription": "管理订阅",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
|
||||
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
|
||||
"plan_hobby": "兴趣版",
|
||||
"plan_hobby_description": "适合开始使用 Formbricks Cloud 的个人和小团队。",
|
||||
"plan_hobby_feature_responses": "250 条回复 / 月",
|
||||
"plan_hobby_feature_workspaces": "1 个工作区",
|
||||
"plan_pro": "专业版",
|
||||
"plan_pro_description": "适合需要更高限额、自动化功能和动态超额使用的成长型团队。",
|
||||
"plan_pro_feature_responses": "2,000 条回复 / 月(动态超额)",
|
||||
"plan_pro_feature_workspaces": "3 个工作区",
|
||||
"plan_scale": "规模版",
|
||||
"plan_scale_description": "适合需要更大容量、更强治理能力和更高响应量的大型团队。",
|
||||
"plan_scale_feature_responses": "每月 5,000 次响应(动态超额)",
|
||||
"plan_scale_feature_workspaces": "5 个工作区",
|
||||
"plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。",
|
||||
"plan_selection_title": "选择您的套餐",
|
||||
"plan_unknown": "未知",
|
||||
"remove_branding": "移除 品牌",
|
||||
"retry_setup": "重试设置",
|
||||
"scale_banner_description": "升级到 Scale 套餐,解锁更高额度、团队协作和高级安全功能。",
|
||||
"scale_banner_title": "准备好扩容了吗?",
|
||||
"scale_feature_api": "完整 API 访问权限",
|
||||
"scale_feature_quota": "额度管理",
|
||||
"scale_feature_spam": "垃圾防护",
|
||||
"scale_feature_teams": "团队与访问角色",
|
||||
"select_plan_header_subtitle": "无需信用卡,没有任何附加条件。",
|
||||
"select_plan_header_title": "无缝集成的调查问卷,100% 展现您的品牌。",
|
||||
"select_plan_header_title": "立即发布专业的无品牌调查!",
|
||||
"status_trialing": "试用版",
|
||||
"stay_on_hobby_plan": "我想继续使用免费版计划",
|
||||
"stripe_setup_incomplete": "账单设置未完成",
|
||||
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
|
||||
"subscription": "订阅",
|
||||
"subscription_description": "管理你的订阅套餐并监控用量",
|
||||
"switch_at_period_end": "在周期结束时切换",
|
||||
"switch_plan_now": "立即切换套餐",
|
||||
"this_includes": "包含以下内容",
|
||||
"trial_alert_description": "添加支付方式以继续使用所有功能。",
|
||||
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
|
||||
"trial_feature_api_access": "API 访问",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "无限反馈",
|
||||
"unlimited_workspaces": "无限工作区",
|
||||
"upgrade": "升级",
|
||||
"upgrade_now": "立即升级",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已用",
|
||||
"yearly": "按年付费",
|
||||
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
|
||||
"your_plan": "你的套餐"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -444,7 +444,6 @@
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新時間",
|
||||
"upgrade_plan": "升級方案",
|
||||
"upload": "上傳",
|
||||
"upload_failed": "上傳失敗。請再試一次。",
|
||||
"upload_input_description": "點擊或拖曳以上傳檔案。",
|
||||
@@ -973,57 +972,30 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "新增付款方式",
|
||||
"add_payment_method_to_upgrade_tooltip": "請先在上方新增付款方式以升級至付費方案",
|
||||
"billing_interval_toggle": "帳單週期",
|
||||
"current_plan_badge": "目前",
|
||||
"current_plan_cta": "目前方案",
|
||||
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
|
||||
"custom_plan_title": "自訂方案",
|
||||
"cancelling": "正在取消",
|
||||
"failed_to_start_trial": "無法開始試用。請再試一次。",
|
||||
"keep_current_plan": "保留目前方案",
|
||||
"manage_billing_details": "管理卡片資訊與發票",
|
||||
"monthly": "每月",
|
||||
"most_popular": "最受歡迎",
|
||||
"pending_change_removed": "已取消預定的方案變更。",
|
||||
"pending_plan_badge": "已排程",
|
||||
"pending_plan_change_description": "您的方案將於 {{date}} 切換至 {{plan}}。",
|
||||
"pending_plan_change_title": "已排程的方案變更",
|
||||
"pending_plan_cta": "已排程",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_change_applied": "方案更新成功。",
|
||||
"plan_change_scheduled": "方案變更已成功排程。",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
|
||||
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
|
||||
"plan_hobby": "興趣版",
|
||||
"plan_hobby_description": "適合個人與小型團隊開始使用 Formbricks Cloud。",
|
||||
"plan_hobby_feature_responses": "每月 250 次回應",
|
||||
"plan_hobby_feature_workspaces": "1 個工作區",
|
||||
"plan_pro": "專業版",
|
||||
"plan_pro_description": "適合需要更高限制、自動化功能和彈性超量使用的成長中團隊。",
|
||||
"plan_pro_feature_responses": "每月 2,000 次回應(動態超量計費)",
|
||||
"plan_pro_feature_workspaces": "3 個工作區",
|
||||
"plan_scale": "規模版",
|
||||
"plan_scale_description": "適合需要更大容量、更強管理機制和更高回應量的大型團隊。",
|
||||
"plan_scale_feature_responses": "每月 5,000 則回應(動態超額計費)",
|
||||
"plan_scale_feature_workspaces": "5 個工作區",
|
||||
"plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。",
|
||||
"plan_selection_title": "選擇您的方案",
|
||||
"plan_unknown": "未知",
|
||||
"remove_branding": "移除品牌",
|
||||
"retry_setup": "重新設定",
|
||||
"scale_banner_description": "加入 Scale 方案,解鎖更高限制、團隊協作和進階安全功能。",
|
||||
"scale_banner_title": "準備好升級規模了嗎?",
|
||||
"scale_feature_api": "完整 API 存取",
|
||||
"scale_feature_quota": "額度管理",
|
||||
"scale_feature_spam": "垃圾訊息防護",
|
||||
"scale_feature_teams": "團隊與存取權限",
|
||||
"select_plan_header_subtitle": "無需信用卡,完全沒有附加條件。",
|
||||
"select_plan_header_title": "完美整合的問卷調查,100% 展現你的品牌。",
|
||||
"select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
|
||||
"status_trialing": "試用版",
|
||||
"stay_on_hobby_plan": "我想繼續使用 Hobby 方案",
|
||||
"stripe_setup_incomplete": "帳單設定尚未完成",
|
||||
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
|
||||
"subscription": "訂閱",
|
||||
"subscription_description": "管理您的訂閱方案並監控用量",
|
||||
"switch_at_period_end": "週期結束時切換",
|
||||
"switch_plan_now": "立即切換方案",
|
||||
"this_includes": "包含內容",
|
||||
"trial_alert_description": "新增付款方式以繼續使用所有功能。",
|
||||
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
|
||||
"trial_feature_api_access": "API 存取",
|
||||
@@ -1041,11 +1013,8 @@
|
||||
"unlimited_responses": "無限回應",
|
||||
"unlimited_workspaces": "無限工作區",
|
||||
"upgrade": "升級",
|
||||
"upgrade_now": "立即升級",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已使用",
|
||||
"yearly": "年繳",
|
||||
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
|
||||
"your_plan": "您的方案"
|
||||
},
|
||||
"domain": {
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("Auth Utils", () => {
|
||||
expect(hashedComplex.length).toBe(60);
|
||||
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
|
||||
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
test("should handle bcrypt errors gracefully and log warning", async () => {
|
||||
// Save the original bcryptjs implementation
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { startHobbyAction, startProTrialAction } from "./actions";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
getOrganization: vi.fn(),
|
||||
createProTrialSubscription: vi.fn(),
|
||||
ensureCloudStripeSetupForOrganization: vi.fn(),
|
||||
ensureStripeCustomerForOrganization: vi.fn(),
|
||||
reconcileCloudStripeSubscriptionsForOrganization: vi.fn(),
|
||||
syncOrganizationBillingFromStripe: vi.fn(),
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
createCustomerPortalSession: vi.fn(),
|
||||
createSetupCheckoutSession: vi.fn(),
|
||||
isSubscriptionCancelled: vi.fn(),
|
||||
stripeCustomerSessionsCreate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
authenticatedActionClient: {
|
||||
inputSchema: vi.fn(() => ({
|
||||
action: vi.fn((fn) => fn),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "https://app.formbricks.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: mocks.getOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/api/lib/create-customer-portal-session", () => ({
|
||||
createCustomerPortalSession: mocks.createCustomerPortalSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/api/lib/create-setup-checkout-session", () => ({
|
||||
createSetupCheckoutSession: mocks.createSetupCheckoutSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/api/lib/is-subscription-cancelled", () => ({
|
||||
isSubscriptionCancelled: mocks.isSubscriptionCancelled,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
createProTrialSubscription: mocks.createProTrialSubscription,
|
||||
ensureCloudStripeSetupForOrganization: mocks.ensureCloudStripeSetupForOrganization,
|
||||
ensureStripeCustomerForOrganization: mocks.ensureStripeCustomerForOrganization,
|
||||
reconcileCloudStripeSubscriptionsForOrganization: mocks.reconcileCloudStripeSubscriptionsForOrganization,
|
||||
syncOrganizationBillingFromStripe: mocks.syncOrganizationBillingFromStripe,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/lib/stripe-client", () => ({
|
||||
stripeClient: {
|
||||
customerSessions: {
|
||||
create: mocks.stripeCustomerSessionsCreate,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("billing actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: "org_1",
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
},
|
||||
});
|
||||
mocks.ensureStripeCustomerForOrganization.mockResolvedValue({ customerId: "cus_1" });
|
||||
mocks.createProTrialSubscription.mockResolvedValue(undefined);
|
||||
mocks.reconcileCloudStripeSubscriptionsForOrganization.mockResolvedValue(undefined);
|
||||
mocks.syncOrganizationBillingFromStripe.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("startHobbyAction ensures a customer, reconciles hobby, and syncs billing", async () => {
|
||||
const result = await startHobbyAction({
|
||||
ctx: { user: { id: "user_1" } },
|
||||
parsedInput: { organizationId: "org_1" },
|
||||
} as any);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
"start-hobby"
|
||||
);
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("startHobbyAction reuses an existing stripe customer id", async () => {
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: "org_1",
|
||||
billing: {
|
||||
stripeCustomerId: "cus_existing",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await startHobbyAction({
|
||||
ctx: { user: { id: "user_1" } },
|
||||
parsedInput: { organizationId: "org_1" },
|
||||
} as any);
|
||||
|
||||
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
"start-hobby"
|
||||
);
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("startProTrialAction uses ensured customer when org snapshot has no stripe customer id", async () => {
|
||||
const result = await startProTrialAction({
|
||||
ctx: { user: { id: "user_1" } },
|
||||
parsedInput: { organizationId: "org_1" },
|
||||
} as any);
|
||||
|
||||
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("startProTrialAction reuses an existing stripe customer id", async () => {
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: "org_1",
|
||||
billing: {
|
||||
stripeCustomerId: "cus_existing",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await startProTrialAction({
|
||||
ctx: { user: { id: "user_1" } },
|
||||
parsedInput: { organizationId: "org_1" },
|
||||
} as any);
|
||||
|
||||
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
||||
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZCloudBillingInterval } from "@formbricks/types/organizations";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -12,16 +11,14 @@ import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
|
||||
import { createSetupCheckoutSession } from "@/modules/ee/billing/api/lib/create-setup-checkout-session";
|
||||
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
|
||||
import {
|
||||
createPaidPlanCheckoutSession,
|
||||
createProTrialSubscription,
|
||||
ensureCloudStripeSetupForOrganization,
|
||||
ensureStripeCustomerForOrganization,
|
||||
reconcileCloudStripeSubscriptionsForOrganization,
|
||||
switchOrganizationToCloudPlan,
|
||||
syncOrganizationBillingFromStripe,
|
||||
undoPendingOrganizationPlanChange,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
|
||||
|
||||
const ZManageSubscriptionAction = z.object({
|
||||
environmentId: ZId,
|
||||
@@ -49,7 +46,7 @@ export const manageSubscriptionAction = authenticatedActionClient
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
@@ -57,64 +54,75 @@ export const manageSubscriptionAction = authenticatedActionClient
|
||||
organization.billing.stripeCustomerId,
|
||||
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
|
||||
);
|
||||
ctx.auditLoggingCtx.newObject = { portalSessionCreated: true };
|
||||
ctx.auditLoggingCtx.newObject = { portalSession: result };
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZCreatePlanCheckoutAction = z.object({
|
||||
environmentId: ZId,
|
||||
targetPlan: z.enum(["pro", "scale"]),
|
||||
targetInterval: ZCloudBillingInterval,
|
||||
const ZIsSubscriptionCancelledAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const createPlanCheckoutAction = authenticatedActionClient
|
||||
.inputSchema(ZCreatePlanCheckoutAction)
|
||||
.action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
export const isSubscriptionCancelledAction = authenticatedActionClient
|
||||
.inputSchema(ZIsSubscriptionCancelledAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
return await isSubscriptionCancelled(parsedInput.organizationId);
|
||||
});
|
||||
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
const ZCreatePricingTableCustomerSessionAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
if (organization.billing.stripe?.subscriptionId) {
|
||||
throw new OperationNotAllowedError("paid_checkout_requires_no_existing_subscription");
|
||||
}
|
||||
export const createPricingTableCustomerSessionAction = authenticatedActionClient
|
||||
.inputSchema(ZCreatePricingTableCustomerSessionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const checkoutUrl = await createPaidPlanCheckoutSession({
|
||||
organizationId,
|
||||
customerId: organization.billing.stripeCustomerId,
|
||||
environmentId: parsedInput.environmentId,
|
||||
plan: parsedInput.targetPlan,
|
||||
interval: parsedInput.targetInterval,
|
||||
});
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
checkoutCreated: true,
|
||||
targetPlan: parsedInput.targetPlan,
|
||||
targetInterval: parsedInput.targetInterval,
|
||||
};
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
return checkoutUrl;
|
||||
})
|
||||
);
|
||||
if (!stripeClient) {
|
||||
return { clientSecret: null };
|
||||
}
|
||||
|
||||
const customerSession = await stripeClient.customerSessions.create({
|
||||
customer: organization.billing.stripeCustomerId,
|
||||
components: {
|
||||
pricing_table: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { clientSecret: customerSession.client_secret ?? null };
|
||||
});
|
||||
|
||||
const ZRetryStripeSetupAction = z.object({
|
||||
organizationId: ZId,
|
||||
@@ -164,7 +172,7 @@ export const createTrialPaymentCheckoutAction = authenticatedActionClient
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
|
||||
}
|
||||
|
||||
const subscriptionId = organization.billing.stripe?.subscriptionId;
|
||||
@@ -181,7 +189,7 @@ export const createTrialPaymentCheckoutAction = authenticatedActionClient
|
||||
organizationId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = { setupCheckoutCreated: true };
|
||||
ctx.auditLoggingCtx.newObject = { checkoutUrl };
|
||||
return checkoutUrl;
|
||||
})
|
||||
);
|
||||
@@ -190,37 +198,6 @@ const ZStartScaleTrialAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const startHobbyAction = authenticatedActionClient
|
||||
.inputSchema(ZStartScaleTrialAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(parsedInput.organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
organization.billing?.stripeCustomerId ??
|
||||
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
|
||||
if (!customerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const startProTrialAction = authenticatedActionClient
|
||||
.inputSchema(ZStartScaleTrialAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
@@ -240,112 +217,12 @@ export const startProTrialAction = authenticatedActionClient
|
||||
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
organization.billing?.stripeCustomerId ??
|
||||
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
|
||||
if (!customerId) {
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await createProTrialSubscription(parsedInput.organizationId, customerId);
|
||||
await createProTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const ZChangeBillingPlanAction = z.discriminatedUnion("targetPlan", [
|
||||
z.object({
|
||||
environmentId: ZId,
|
||||
targetPlan: z.literal("hobby"),
|
||||
targetInterval: z.literal("monthly"),
|
||||
}),
|
||||
z.object({
|
||||
environmentId: ZId,
|
||||
targetPlan: z.enum(["pro", "scale"]),
|
||||
targetInterval: ZCloudBillingInterval,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const changeBillingPlanAction = authenticatedActionClient.inputSchema(ZChangeBillingPlanAction).action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
const result = await switchOrganizationToCloudPlan({
|
||||
organizationId,
|
||||
customerId: organization.billing.stripeCustomerId,
|
||||
targetPlan: parsedInput.targetPlan,
|
||||
targetInterval: parsedInput.targetInterval,
|
||||
});
|
||||
|
||||
if (result.mode === "immediate") {
|
||||
await syncOrganizationBillingFromStripe(organizationId);
|
||||
}
|
||||
// Scheduled downgrades already persist the pending snapshot locally and
|
||||
// the ensuing subscription_schedule webhook performs the full Stripe resync.
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
targetPlan: parsedInput.targetPlan,
|
||||
targetInterval: parsedInput.targetInterval,
|
||||
mode: result.mode,
|
||||
};
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZUndoPendingPlanChangeAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const undoPendingPlanChangeAction = authenticatedActionClient
|
||||
.inputSchema(ZUndoPendingPlanChangeAction)
|
||||
.action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
await undoPendingOrganizationPlanChange(organizationId, organization.billing.stripeCustomerId);
|
||||
await syncOrganizationBillingFromStripe(organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getStripeClient } from "./stripe-client";
|
||||
|
||||
export const isSubscriptionCancelled = async (
|
||||
organizationId: string
|
||||
): Promise<{
|
||||
cancelled: boolean;
|
||||
date: Date | null;
|
||||
}> => {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Team not found.");
|
||||
let isNewTeam =
|
||||
!organization.billing.stripeCustomerId ||
|
||||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
|
||||
|
||||
if (!organization.billing.stripeCustomerId || isNewTeam) {
|
||||
return {
|
||||
cancelled: false,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: organization.billing.stripeCustomerId,
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions.data) {
|
||||
if (subscription.cancel_at_period_end) {
|
||||
const periodEndTimestamp = subscription.cancel_at ?? subscription.items.data[0]?.current_period_end;
|
||||
return {
|
||||
cancelled: true,
|
||||
date: periodEndTimestamp ? new Date(periodEndTimestamp * 1000) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
cancelled: false,
|
||||
date: null,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(err, "Error checking if subscription is cancelled");
|
||||
return {
|
||||
cancelled: false,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -12,12 +12,8 @@ const relevantEvents = new Set([
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"invoice.finalized",
|
||||
"entitlements.active_entitlement_summary.updated",
|
||||
"subscription_schedule.created",
|
||||
"subscription_schedule.updated",
|
||||
"subscription_schedule.released",
|
||||
"subscription_schedule.canceled",
|
||||
"subscription_schedule.completed",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,38 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Script from "next/script";
|
||||
import { createElement, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type TCloudBillingInterval,
|
||||
type TOrganization,
|
||||
type TOrganizationStripePendingChange,
|
||||
type TOrganizationStripeSubscriptionStatus,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { TOrganization, TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import {
|
||||
changeBillingPlanAction,
|
||||
createPlanCheckoutAction,
|
||||
createPricingTableCustomerSessionAction,
|
||||
createTrialPaymentCheckoutAction,
|
||||
isSubscriptionCancelledAction,
|
||||
manageSubscriptionAction,
|
||||
retryStripeSetupAction,
|
||||
undoPendingPlanChangeAction,
|
||||
} from "../actions";
|
||||
import type { TStripeBillingCatalogDisplay } from "../lib/stripe-billing-catalog";
|
||||
import { TrialAlert } from "./trial-alert";
|
||||
import { UsageCard } from "./usage-card";
|
||||
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
const STRIPE_SUPPORTED_LOCALES = new Set([
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"en-GB",
|
||||
"es",
|
||||
"es-419",
|
||||
"et",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"fr-CA",
|
||||
"hr",
|
||||
"hu",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"ms",
|
||||
"mt",
|
||||
"nb",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sl",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"vi",
|
||||
"zh",
|
||||
"zh-HK",
|
||||
"zh-TW",
|
||||
]);
|
||||
|
||||
type TDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "unknown";
|
||||
type TStandardPlan = "hobby" | "pro" | "scale";
|
||||
const getStripeLocaleOverride = (locale?: string): string | undefined => {
|
||||
if (!locale) return undefined;
|
||||
|
||||
const normalizedLocale = locale.trim();
|
||||
if (STRIPE_SUPPORTED_LOCALES.has(normalizedLocale)) {
|
||||
return normalizedLocale;
|
||||
}
|
||||
|
||||
const baseLocale = normalizedLocale.split("-")[0];
|
||||
if (STRIPE_SUPPORTED_LOCALES.has(baseLocale)) {
|
||||
return baseLocale;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
|
||||
interface PricingTableProps {
|
||||
organization: TOrganization;
|
||||
@@ -42,22 +89,18 @@ interface PricingTableProps {
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
hasBillingRights: boolean;
|
||||
currentCloudPlan: TDisplayPlan;
|
||||
currentBillingInterval: TCloudBillingInterval | null;
|
||||
currentCloudPlan: "hobby" | "pro" | "scale" | "custom" | "unknown";
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
pendingChange: TOrganizationStripePendingChange | null;
|
||||
stripePublishableKey: string | null;
|
||||
stripePricingTableId: string | null;
|
||||
isStripeSetupIncomplete: boolean;
|
||||
trialDaysRemaining: number | null;
|
||||
billingCatalog: TStripeBillingCatalogDisplay;
|
||||
}
|
||||
|
||||
const STANDARD_PLAN_LEVEL: Record<TStandardPlan, number> = {
|
||||
hobby: 0,
|
||||
pro: 1,
|
||||
scale: 2,
|
||||
};
|
||||
|
||||
const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string) => {
|
||||
const getCurrentCloudPlanLabel = (
|
||||
plan: "hobby" | "pro" | "scale" | "custom" | "unknown",
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (plan === "hobby") return t("environments.settings.billing.plan_hobby");
|
||||
if (plan === "pro") return t("environments.settings.billing.plan_pro");
|
||||
if (plan === "scale") return t("environments.settings.billing.plan_scale");
|
||||
@@ -65,78 +108,6 @@ const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string
|
||||
return t("environments.settings.billing.plan_unknown");
|
||||
};
|
||||
|
||||
const formatMoney = (currency: string, unitAmount: number | null, locale: string) => {
|
||||
if (unitAmount == null) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: unitAmount % 100 === 0 ? 0 : 2,
|
||||
}).format(unitAmount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date, locale: string) =>
|
||||
date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
type TPlanCardData = {
|
||||
plan: TStandardPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
amount: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const getPlanPeriodLabel = (
|
||||
plan: TStandardPlan,
|
||||
interval: TCloudBillingInterval,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (plan === "hobby" || interval === "monthly") {
|
||||
return t("environments.settings.billing.per_month");
|
||||
}
|
||||
|
||||
return t("environments.settings.billing.per_year");
|
||||
};
|
||||
|
||||
const getPlanChangePayload = (environmentId: string, plan: TStandardPlan, interval: TCloudBillingInterval) =>
|
||||
plan === "hobby"
|
||||
? {
|
||||
environmentId,
|
||||
targetPlan: "hobby" as const,
|
||||
targetInterval: "monthly" as const,
|
||||
}
|
||||
: {
|
||||
environmentId,
|
||||
targetPlan: plan,
|
||||
targetInterval: interval,
|
||||
};
|
||||
|
||||
const getPlanChangeSuccessMessage = (
|
||||
mode: "immediate" | "scheduled" | undefined,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (mode === "scheduled") {
|
||||
return t("environments.settings.billing.plan_change_scheduled");
|
||||
}
|
||||
|
||||
return t("environments.settings.billing.plan_change_applied");
|
||||
};
|
||||
|
||||
const getActionErrorMessage = (serverError: string, t: (key: string) => string) => {
|
||||
if (serverError === "mixed_interval_checkout_unsupported") {
|
||||
return t("environments.settings.billing.yearly_checkout_unavailable");
|
||||
}
|
||||
|
||||
return t("common.something_went_wrong_please_try_again");
|
||||
};
|
||||
|
||||
export const PricingTable = ({
|
||||
environmentId,
|
||||
organization,
|
||||
@@ -146,35 +117,55 @@ export const PricingTable = ({
|
||||
usageCycleEnd,
|
||||
hasBillingRights,
|
||||
currentCloudPlan,
|
||||
currentBillingInterval,
|
||||
currentSubscriptionStatus,
|
||||
pendingChange,
|
||||
stripePublishableKey,
|
||||
stripePricingTableId,
|
||||
isStripeSetupIncomplete,
|
||||
trialDaysRemaining,
|
||||
billingCatalog,
|
||||
}: PricingTableProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
|
||||
const [isPlanActionPending, setIsPlanActionPending] = useState<string | null>(null);
|
||||
const [selectedInterval, setSelectedInterval] = useState<TCloudBillingInterval>(
|
||||
currentBillingInterval ?? "monthly"
|
||||
);
|
||||
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
|
||||
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
|
||||
const isTrialing = currentSubscriptionStatus === "trialing";
|
||||
const hasPaymentMethod = organization.billing.stripe?.hasPaymentMethod === true;
|
||||
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
|
||||
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
|
||||
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
|
||||
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
|
||||
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const currentPlanLevel =
|
||||
currentCloudPlan === "hobby" || currentCloudPlan === "pro" || currentCloudPlan === "scale"
|
||||
? STANDARD_PLAN_LEVEL[currentCloudPlan]
|
||||
: null;
|
||||
const showPricingTable =
|
||||
hasBillingRights && isUpgradeablePlan && !isTrialing && !!stripePublishableKey && !!stripePricingTableId;
|
||||
const canManageSubscription =
|
||||
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
|
||||
const stripeLocaleOverride = useMemo(
|
||||
() => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
|
||||
[i18n.language, i18n.resolvedLanguage]
|
||||
);
|
||||
const stripePricingTableProps = useMemo(() => {
|
||||
const props: Record<string, string> = {
|
||||
"pricing-table-id": stripePricingTableId ?? "",
|
||||
"publishable-key": stripePublishableKey ?? "",
|
||||
};
|
||||
|
||||
if (stripeLocaleOverride) {
|
||||
props["__locale-override"] = stripeLocaleOverride;
|
||||
}
|
||||
|
||||
if (pricingTableCustomerSessionClientSecret) {
|
||||
props["customer-session-client-secret"] = pricingTableCustomerSessionClientSecret;
|
||||
} else {
|
||||
props["client-reference-id"] = organization.id;
|
||||
}
|
||||
|
||||
return props;
|
||||
}, [
|
||||
organization.id,
|
||||
pricingTableCustomerSessionClientSecret,
|
||||
stripeLocaleOverride,
|
||||
stripePricingTableId,
|
||||
stripePublishableKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("checkout_success")) {
|
||||
@@ -183,96 +174,68 @@ export const PricingTable = ({
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const planCards = useMemo<TPlanCardData[]>(() => {
|
||||
return [
|
||||
{
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
amount: formatMoney(
|
||||
billingCatalog.hobby.monthly.currency,
|
||||
billingCatalog.hobby.monthly.unitAmount,
|
||||
locale
|
||||
),
|
||||
description: t("environments.settings.billing.plan_hobby_description"),
|
||||
features: [
|
||||
t("environments.settings.billing.plan_hobby_feature_workspaces"),
|
||||
t("environments.settings.billing.plan_hobby_feature_responses"),
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "pro",
|
||||
interval: selectedInterval,
|
||||
amount: formatMoney(
|
||||
billingCatalog.pro[selectedInterval].currency,
|
||||
billingCatalog.pro[selectedInterval].unitAmount,
|
||||
locale
|
||||
),
|
||||
description: t("environments.settings.billing.plan_pro_description"),
|
||||
features: [
|
||||
t("environments.settings.billing.plan_feature_everything_in_hobby"),
|
||||
t("environments.settings.billing.plan_pro_feature_workspaces"),
|
||||
t("environments.settings.billing.plan_pro_feature_responses"),
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "scale",
|
||||
interval: selectedInterval,
|
||||
amount: formatMoney(
|
||||
billingCatalog.scale[selectedInterval].currency,
|
||||
billingCatalog.scale[selectedInterval].unitAmount,
|
||||
locale
|
||||
),
|
||||
description: t("environments.settings.billing.plan_scale_description"),
|
||||
features: [
|
||||
t("environments.settings.billing.plan_feature_everything_in_pro"),
|
||||
t("environments.settings.billing.plan_scale_feature_workspaces"),
|
||||
t("environments.settings.billing.plan_scale_feature_responses"),
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [billingCatalog, locale, selectedInterval, t]);
|
||||
useEffect(() => {
|
||||
const checkSubscriptionStatus = async () => {
|
||||
if (!hasBillingRights || !canManageSubscription) {
|
||||
setCancellingOn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
if (isSubscriptionCancelledResponse?.data) {
|
||||
setCancellingOn(isSubscriptionCancelledResponse.data.date);
|
||||
}
|
||||
} catch {
|
||||
// Ignore permission/network failures here and keep rendering billing UI.
|
||||
}
|
||||
};
|
||||
checkSubscriptionStatus();
|
||||
}, [canManageSubscription, hasBillingRights, organization.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPricingTable) {
|
||||
setPricingTableCustomerSessionClientSecret(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const persistEnvironmentId = () => {
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToExternalUrl = (url: string) => {
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.window.location.href = url;
|
||||
}
|
||||
};
|
||||
const loadPricingTableCustomerSession = async () => {
|
||||
try {
|
||||
const response = await createPricingTableCustomerSessionAction({ environmentId });
|
||||
setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
|
||||
} catch {
|
||||
setPricingTableCustomerSessionClientSecret(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openBillingPortal = async () => {
|
||||
const response = await manageSubscriptionAction({ environmentId });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
router.push(response.data);
|
||||
return;
|
||||
}
|
||||
void loadPricingTableCustomerSession();
|
||||
}, [environmentId, showPricingTable]);
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
const openCustomerPortal = async () => {
|
||||
const manageSubscriptionResponse = await manageSubscriptionAction({
|
||||
environmentId,
|
||||
});
|
||||
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
|
||||
router.push(manageSubscriptionResponse.data);
|
||||
}
|
||||
};
|
||||
|
||||
const openTrialPaymentCheckout = async () => {
|
||||
try {
|
||||
persistEnvironmentId();
|
||||
const response = await createTrialPaymentCheckoutAction({ environmentId });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
navigateToExternalUrl(response.data);
|
||||
return;
|
||||
globalThis.location.href = response.data;
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} catch (error) {
|
||||
console.error("Failed to create setup checkout session:", error);
|
||||
console.error("Failed to create checkout session:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
@@ -281,15 +244,11 @@ export const PricingTable = ({
|
||||
setIsRetryingStripeSetup(true);
|
||||
try {
|
||||
const response = await retryStripeSetupAction({ organizationId: organization.id });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data) {
|
||||
router.refresh();
|
||||
return;
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
@@ -297,118 +256,25 @@ export const PricingTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToPlanCheckout = async (
|
||||
plan: Exclude<TStandardPlan, "hobby">,
|
||||
interval: TCloudBillingInterval
|
||||
): Promise<void> => {
|
||||
if (existingSubscriptionId) {
|
||||
await openTrialPaymentCheckout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (interval === "yearly") {
|
||||
toast.error(t("environments.settings.billing.yearly_checkout_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
persistEnvironmentId();
|
||||
const response = await createPlanCheckoutAction({
|
||||
environmentId,
|
||||
targetPlan: plan,
|
||||
targetInterval: interval,
|
||||
});
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
navigateToExternalUrl(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
};
|
||||
|
||||
const handlePlanAction = async (plan: TStandardPlan, interval: TCloudBillingInterval) => {
|
||||
const actionKey = `${plan}-${interval}`;
|
||||
setIsPlanActionPending(actionKey);
|
||||
|
||||
try {
|
||||
if (!hasPaymentMethod && plan !== "hobby") {
|
||||
await redirectToPlanCheckout(plan, interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await changeBillingPlanAction(getPlanChangePayload(environmentId, plan, interval));
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
toast.success(getPlanChangeSuccessMessage(response?.data?.mode, t));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to change billing plan:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsPlanActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const undoPendingChange = async () => {
|
||||
setIsPlanActionPending("undo");
|
||||
try {
|
||||
const response = await undoPendingPlanChangeAction({ environmentId });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.billing.pending_change_removed"));
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} catch (error) {
|
||||
console.error("Failed to undo pending plan change:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsPlanActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getCtaLabel = (plan: TStandardPlan, interval: TCloudBillingInterval) => {
|
||||
const isCurrentSelection =
|
||||
currentCloudPlan === plan && (plan === "hobby" || currentBillingInterval === interval);
|
||||
if (isCurrentSelection) {
|
||||
return t("environments.settings.billing.current_plan_cta");
|
||||
}
|
||||
|
||||
const isPendingSelection =
|
||||
pendingChange?.targetPlan === plan && (plan === "hobby" || pendingChange.targetInterval === interval);
|
||||
if (isPendingSelection) {
|
||||
return t("environments.settings.billing.pending_plan_cta");
|
||||
}
|
||||
|
||||
if (!hasPaymentMethod && plan !== "hobby") {
|
||||
return t("environments.settings.billing.upgrade_now");
|
||||
}
|
||||
|
||||
if (currentPlanLevel === null) {
|
||||
return t("environments.settings.billing.switch_plan_now");
|
||||
}
|
||||
|
||||
return STANDARD_PLAN_LEVEL[plan] > currentPlanLevel
|
||||
? t("environments.settings.billing.upgrade_now")
|
||||
: t("environments.settings.billing.switch_at_period_end");
|
||||
};
|
||||
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})} - ${usageCycleEnd.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex max-w-6xl flex-col gap-4">
|
||||
<div className="flex max-w-4xl flex-col gap-4">
|
||||
{trialDaysRemaining !== null &&
|
||||
(hasPaymentMethod ? (
|
||||
(organization.billing.stripe?.hasPaymentMethod ? (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_payment_method_added_description")}
|
||||
@@ -426,23 +292,6 @@ export const PricingTable = ({
|
||||
)}
|
||||
</TrialAlert>
|
||||
))}
|
||||
|
||||
{pendingChange && (
|
||||
<Alert variant="info" className="max-w-4xl">
|
||||
<AlertTitle>{t("environments.settings.billing.pending_plan_change_title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.pending_plan_change_description")
|
||||
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
|
||||
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
|
||||
</AlertDescription>
|
||||
{hasBillingRights && (
|
||||
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
|
||||
{t("environments.settings.billing.keep_current_plan")}
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isStripeSetupIncomplete && hasBillingRights && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
|
||||
@@ -454,24 +303,15 @@ export const PricingTable = ({
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentCloudPlan === "custom" && (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.settings.billing.custom_plan_title")}</AlertTitle>
|
||||
<AlertDescription>{t("environments.settings.billing.custom_plan_description")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SettingsCard
|
||||
title={t("environments.settings.billing.subscription")}
|
||||
description={t("environments.settings.billing.subscription_description")}
|
||||
buttonInfo={
|
||||
canShowSubscriptionButton
|
||||
(canManageSubscription && currentSubscriptionStatus !== "trialing") ||
|
||||
(hasBillingRights && !!organization.billing.stripe?.hasPaymentMethod)
|
||||
? {
|
||||
text: hasPaymentMethod
|
||||
? t("environments.settings.billing.manage_billing_details")
|
||||
: t("environments.settings.billing.add_payment_method"),
|
||||
onClick: () => void (hasPaymentMethod ? openBillingPortal() : openTrialPaymentCheckout()),
|
||||
text: t("environments.settings.billing.manage_subscription"),
|
||||
onClick: () => void openCustomerPortal(),
|
||||
variant: "default",
|
||||
}
|
||||
: undefined
|
||||
@@ -481,19 +321,8 @@ export const PricingTable = ({
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.settings.billing.your_plan")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="success" size="normal" text={getCurrentCloudPlanLabel(currentCloudPlan, t)} />
|
||||
{currentCloudPlan !== "hobby" && currentBillingInterval && (
|
||||
<Badge
|
||||
type="gray"
|
||||
size="normal"
|
||||
text={
|
||||
currentBillingInterval === "monthly"
|
||||
? t("environments.settings.billing.monthly")
|
||||
: t("environments.settings.billing.yearly")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentSubscriptionStatus === "trialing" && (
|
||||
<Badge
|
||||
type="warning"
|
||||
@@ -501,9 +330,24 @@ export const PricingTable = ({
|
||||
text={t("environments.settings.billing.status_trialing")}
|
||||
/>
|
||||
)}
|
||||
{cancellingOn && (
|
||||
<Badge
|
||||
type="warning"
|
||||
size="normal"
|
||||
text={`${t("environments.settings.billing.cancelling")}: ${cancellingOn.toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<UsageCard
|
||||
metric={t("common.responses")}
|
||||
@@ -512,11 +356,11 @@ export const PricingTable = ({
|
||||
isUnlimited={responsesUnlimitedCheck}
|
||||
unlimitedLabel={t("environments.settings.billing.unlimited_responses")}
|
||||
/>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageCard
|
||||
metric={t("common.workspaces")}
|
||||
currentCount={projectCount}
|
||||
@@ -527,136 +371,35 @@ export const PricingTable = ({
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{showPlanSelector && (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.billing.plan_selection_title")}
|
||||
description={t("environments.settings.billing.plan_selection_description")}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div
|
||||
className="flex w-fit rounded-xl border border-slate-200 bg-slate-100 p-1"
|
||||
role="tablist"
|
||||
aria-label={t("environments.settings.billing.billing_interval_toggle")}>
|
||||
{(["monthly", "yearly"] as const).map((interval) => (
|
||||
<button
|
||||
key={interval}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedInterval === interval}
|
||||
tabIndex={selectedInterval === interval ? 0 : -1}
|
||||
onClick={() => setSelectedInterval(interval)}
|
||||
className={cn(
|
||||
"rounded-lg px-5 py-2 text-sm font-medium transition-colors",
|
||||
selectedInterval === interval
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-slate-600 hover:text-slate-900"
|
||||
)}>
|
||||
{interval === "monthly"
|
||||
? t("environments.settings.billing.monthly")
|
||||
: t("environments.settings.billing.yearly")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{planCards.map((planCard) => {
|
||||
const isCurrentSelection =
|
||||
currentCloudPlan === planCard.plan &&
|
||||
(planCard.plan === "hobby" || currentBillingInterval === planCard.interval);
|
||||
const isPendingSelection =
|
||||
pendingChange?.targetPlan === planCard.plan &&
|
||||
(planCard.plan === "hobby" || pendingChange.targetInterval === planCard.interval);
|
||||
const isMissingPaymentMethodUpgrade =
|
||||
hasBillingRights &&
|
||||
!isStripeSetupIncomplete &&
|
||||
!isTrialing &&
|
||||
!isCurrentSelection &&
|
||||
!isPendingSelection &&
|
||||
!hasPaymentMethod &&
|
||||
planCard.plan !== "hobby";
|
||||
const isDisabled =
|
||||
!hasBillingRights ||
|
||||
isCurrentSelection ||
|
||||
isPendingSelection ||
|
||||
isStripeSetupIncomplete ||
|
||||
isMissingPaymentMethodUpgrade ||
|
||||
(isTrialing && !hasPaymentMethod);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${planCard.plan}-${planCard.interval}`}
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[minmax(1.75rem,auto)_minmax(8rem,auto)_minmax(4.5rem,auto)_auto_1fr] rounded-2xl border bg-white p-6 shadow-sm",
|
||||
planCard.plan === "pro" ? "border-slate-900/20" : "border-slate-200"
|
||||
)}>
|
||||
<div className="mb-4 flex min-h-7 items-start gap-2">
|
||||
{planCard.plan === "pro" && (
|
||||
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">
|
||||
{t("environments.settings.billing.most_popular")}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentSelection && (
|
||||
<span className="rounded-md bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">
|
||||
{t("environments.settings.billing.current_plan_badge")}
|
||||
</span>
|
||||
)}
|
||||
{isPendingSelection && (
|
||||
<span className="rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
{t("environments.settings.billing.pending_plan_badge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-32">
|
||||
<h3 className="text-3xl font-semibold text-slate-900">
|
||||
{getCurrentCloudPlanLabel(planCard.plan, t)}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">{planCard.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex min-h-[3rem] items-end gap-2">
|
||||
<span className="text-3xl font-normal tracking-tight text-slate-900">
|
||||
{planCard.amount}
|
||||
</span>
|
||||
<span className="pb-1 text-sm text-slate-500">
|
||||
{getPlanPeriodLabel(planCard.plan, planCard.interval, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipRenderer
|
||||
shouldRender={isMissingPaymentMethodUpgrade}
|
||||
triggerClass="block w-full"
|
||||
tooltipContent={t(
|
||||
"environments.settings.billing.add_payment_method_to_upgrade_tooltip"
|
||||
)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-4 w-full"
|
||||
disabled={isDisabled}
|
||||
loading={isPlanActionPending === `${planCard.plan}-${planCard.interval}`}
|
||||
onClick={() => void handlePlanAction(planCard.plan, planCard.interval)}>
|
||||
{getCtaLabel(planCard.plan, planCard.interval)}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<div className="mt-8 border-t border-slate-100 pt-6">
|
||||
<p className="mb-4 text-sm font-semibold text-slate-900">
|
||||
{t("environments.settings.billing.this_includes")}
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{planCard.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-slate-700">
|
||||
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{currentCloudPlan === "pro" && !isTrialing && (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t("environments.settings.billing.scale_banner_title")}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-300">
|
||||
{t("environments.settings.billing.scale_banner_description")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-400">
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_teams")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_api")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_quota")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_spam")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={openCustomerPortal} className="shrink-0">
|
||||
{t("environments.settings.billing.upgrade")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPricingTable && (
|
||||
<div className="mb-12 w-full max-w-4xl">
|
||||
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
|
||||
{createElement("stripe-pricing-table", stripePricingTableProps)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -12,7 +12,6 @@ import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
|
||||
import githubLogo from "@/images/customer-logos/github-logo.png";
|
||||
import siemensLogo from "@/images/customer-logos/siemens.png";
|
||||
import { startProTrialAction } from "@/modules/ee/billing/actions";
|
||||
import { startHobbyAction } from "@/modules/ee/billing/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SelectPlanCardProps {
|
||||
@@ -32,7 +31,6 @@ const CUSTOMER_LOGOS = [
|
||||
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
|
||||
const router = useRouter();
|
||||
const [isStartingTrial, setIsStartingTrial] = useState(false);
|
||||
const [isStartingHobby, setIsStartingHobby] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const TRIAL_FEATURE_KEYS = [
|
||||
@@ -66,20 +64,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueHobby = async () => {
|
||||
setIsStartingHobby(true);
|
||||
try {
|
||||
const result = await startHobbyAction({ organizationId });
|
||||
if (result?.data) {
|
||||
router.push(nextUrl);
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
setIsStartingHobby(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
setIsStartingHobby(false);
|
||||
}
|
||||
const handleContinueFree = () => {
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,8 +98,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
|
||||
onClick={handleStartTrial}
|
||||
className="mt-4 w-full"
|
||||
loading={isStartingTrial}
|
||||
disabled={isStartingTrial || isStartingHobby}>
|
||||
{t("common.start_free_trial")}
|
||||
disabled={isStartingTrial}>
|
||||
{t("common.upgrade_plan")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -138,10 +124,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleContinueHobby}
|
||||
disabled={isStartingTrial || isStartingHobby}
|
||||
onClick={handleContinueFree}
|
||||
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
|
||||
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
|
||||
{t("environments.settings.billing.stay_on_hobby_plan")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ export const TrialAlert = ({
|
||||
const variant = hasPaymentMethod ? "success" : getTrialVariant(trialDaysRemaining);
|
||||
|
||||
return (
|
||||
<Alert variant={variant} size={size} className="max-w-4xl">
|
||||
<Alert variant={variant} size={size}>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{children}
|
||||
</Alert>
|
||||
|
||||
@@ -29,9 +29,7 @@ describe("cloud-billing-display", () => {
|
||||
expect(result).toEqual({
|
||||
organizationId: "org_1",
|
||||
currentCloudPlan: "pro",
|
||||
currentBillingInterval: null,
|
||||
currentSubscriptionStatus: null,
|
||||
pendingChange: null,
|
||||
trialDaysRemaining: null,
|
||||
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
|
||||
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import "server-only";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
type TCloudBillingInterval,
|
||||
type TOrganizationStripePendingChange,
|
||||
type TOrganizationStripeSubscriptionStatus,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
|
||||
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
|
||||
|
||||
@@ -13,9 +9,7 @@ export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "u
|
||||
export type TCloudBillingDisplayContext = {
|
||||
organizationId: string;
|
||||
currentCloudPlan: TCloudBillingDisplayPlan;
|
||||
currentBillingInterval: TCloudBillingInterval | null;
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
pendingChange: TOrganizationStripePendingChange | null;
|
||||
trialDaysRemaining: number | null;
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
@@ -34,18 +28,6 @@ const resolveCurrentSubscriptionStatus = (
|
||||
return billing.stripe?.subscriptionStatus ?? null;
|
||||
};
|
||||
|
||||
const resolveCurrentBillingInterval = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): TCloudBillingInterval | null => {
|
||||
return billing.stripe?.interval ?? null;
|
||||
};
|
||||
|
||||
const resolvePendingChange = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): TOrganizationStripePendingChange | null => {
|
||||
return billing.stripe?.pendingChange ?? null;
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
const resolveTrialDaysRemaining = (
|
||||
@@ -76,9 +58,7 @@ export const getCloudBillingDisplayContext = async (
|
||||
return {
|
||||
organizationId,
|
||||
currentCloudPlan: resolveCurrentCloudPlan(billing),
|
||||
currentBillingInterval: resolveCurrentBillingInterval(billing),
|
||||
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
|
||||
pendingChange: resolvePendingChange(billing),
|
||||
trialDaysRemaining: resolveTrialDaysRemaining(billing),
|
||||
usageCycleStart: usageCycleWindow.start,
|
||||
usageCycleEnd: usageCycleWindow.end,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,24 +4,13 @@ import Stripe from "stripe";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
type TCloudBillingInterval,
|
||||
type TCloudBillingPlan,
|
||||
type TOrganizationBilling,
|
||||
type TOrganizationStripePendingChange,
|
||||
type TOrganizationStripeSubscriptionStatus,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import {
|
||||
type TStandardCloudPlan,
|
||||
getCatalogItemForPlan,
|
||||
getCatalogItemsForPlan,
|
||||
getIntervalFromPrice,
|
||||
getPlanFromPrice,
|
||||
getPriceKindFromPrice,
|
||||
} from "./stripe-billing-catalog";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { stripeClient } from "./stripe-client";
|
||||
import { CLOUD_PLAN_LEVEL, type TCloudStripePlan, getCloudPlanFromProduct } from "./stripe-plan";
|
||||
|
||||
@@ -78,31 +67,6 @@ const getDateFromBilling = (value: string | null | undefined): Date | null => {
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const updatePendingPlanChangeSnapshot = async (
|
||||
organizationId: string,
|
||||
pendingChange: TOrganizationStripePendingChange | null
|
||||
): Promise<void> => {
|
||||
const existingBilling = await ensureOrganizationBillingRecord(organizationId);
|
||||
if (!existingBilling) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
const nextStripeSnapshot = existingBilling.stripe ? { ...existingBilling.stripe } : {};
|
||||
|
||||
await prisma.organizationBilling.update({
|
||||
where: { organizationId },
|
||||
data: {
|
||||
stripe: {
|
||||
...nextStripeSnapshot,
|
||||
pendingChange,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await invalidateOrganizationBillingCache(organizationId);
|
||||
};
|
||||
|
||||
const listAllActiveEntitlements = async (customerId: string): Promise<string[]> => {
|
||||
if (!stripeClient) return [];
|
||||
|
||||
@@ -207,149 +171,6 @@ const hydrateSubscriptionProducts = async <
|
||||
}));
|
||||
};
|
||||
|
||||
const hydratePrices = async <
|
||||
TPriceContainer extends {
|
||||
price: string | Stripe.Price | Stripe.DeletedPrice;
|
||||
},
|
||||
>(
|
||||
items: TPriceContainer[]
|
||||
): Promise<Array<TPriceContainer & { price: Stripe.Price }>> => {
|
||||
if (!stripeClient || items.length === 0) {
|
||||
return items.filter(
|
||||
(item): item is TPriceContainer & { price: Stripe.Price } =>
|
||||
typeof item.price !== "string" && !item.price.deleted
|
||||
);
|
||||
}
|
||||
const client = stripeClient;
|
||||
|
||||
const missingPriceIds = [
|
||||
...new Set(items.flatMap((item) => (typeof item.price === "string" ? [item.price] : []))),
|
||||
];
|
||||
|
||||
const retrievedPrices = await Promise.all(
|
||||
missingPriceIds.map(
|
||||
async (priceId) =>
|
||||
[
|
||||
priceId,
|
||||
await client.prices.retrieve(priceId, {
|
||||
expand: ["product"],
|
||||
}),
|
||||
] as const
|
||||
)
|
||||
);
|
||||
|
||||
const pricesById = new Map(retrievedPrices);
|
||||
|
||||
return items.flatMap((item) => {
|
||||
if (typeof item.price !== "string") {
|
||||
if (item.price.deleted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...item, price: item.price }];
|
||||
}
|
||||
|
||||
const price = pricesById.get(item.price);
|
||||
if (!price) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...item, price }];
|
||||
});
|
||||
};
|
||||
|
||||
const getBasePriceFromSubscription = (
|
||||
subscription: {
|
||||
items: {
|
||||
data: Array<{
|
||||
id?: string;
|
||||
price: Stripe.Price;
|
||||
}>;
|
||||
};
|
||||
} | null
|
||||
): Stripe.Price | null => {
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
subscription.items.data.find((item) => {
|
||||
const plan = getPlanFromPrice(item.price);
|
||||
const kind = getPriceKindFromPrice(item.price);
|
||||
|
||||
return plan !== null && kind === "base";
|
||||
})?.price ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSubscriptionInterval = (
|
||||
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
|
||||
): TCloudBillingInterval | null => {
|
||||
return getIntervalFromPrice(getBasePriceFromSubscription(subscription));
|
||||
};
|
||||
|
||||
const mapSubscriptionItemsToScheduleItems = (
|
||||
items: Array<{
|
||||
price: Stripe.Price;
|
||||
quantity?: number | null;
|
||||
}>
|
||||
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.Item> => {
|
||||
return items.map((item) => {
|
||||
const scheduleItem: Stripe.SubscriptionScheduleUpdateParams.Phase.Item = {
|
||||
price: item.price.id,
|
||||
};
|
||||
|
||||
if (item.price.recurring?.usage_type !== "metered") {
|
||||
scheduleItem.quantity = item.quantity ?? 1;
|
||||
}
|
||||
|
||||
return scheduleItem;
|
||||
});
|
||||
};
|
||||
|
||||
const getPendingPlanChangeFromSchedule = async (
|
||||
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
|
||||
): Promise<TOrganizationStripePendingChange | null> => {
|
||||
if (!stripeClient || !subscription?.schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleId =
|
||||
typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule.id;
|
||||
const schedule = await stripeClient.subscriptionSchedules.retrieve(scheduleId);
|
||||
const currentPhaseEnd = schedule.current_phase?.end_date ?? null;
|
||||
|
||||
if (!currentPhaseEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextPhase = schedule.phases.find((phase) => phase.start_date >= currentPhaseEnd);
|
||||
if (!nextPhase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseItems = await hydratePrices(
|
||||
nextPhase.items.map((item) => ({
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
);
|
||||
|
||||
const basePrice = phaseItems.find((item) => getPriceKindFromPrice(item.price) === "base")?.price ?? null;
|
||||
const targetPlan = getPlanFromPrice(basePrice);
|
||||
|
||||
if (!targetPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "plan_change",
|
||||
targetPlan,
|
||||
targetInterval: getIntervalFromPrice(basePrice),
|
||||
effectiveAt: new Date(nextPhase.start_date * 1000).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const getSubscriptionTopPlanLevel = (
|
||||
subscription: {
|
||||
items: {
|
||||
@@ -380,7 +201,6 @@ const resolveCurrentSubscription = async (customerId: string) => {
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
limit: 20,
|
||||
expand: ["data.schedule"],
|
||||
});
|
||||
const subscriptionsWithProducts = await hydrateSubscriptionProducts(subscriptions.data);
|
||||
|
||||
@@ -431,42 +251,48 @@ const resolveUsageCycleAnchor = (
|
||||
return new Date(subscription.billing_cycle_anchor * 1000);
|
||||
};
|
||||
|
||||
const resolvePendingChangeEffectiveAt = (
|
||||
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
|
||||
): string | null => {
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription.cancel_at) {
|
||||
return new Date(subscription.cancel_at * 1000).toISOString();
|
||||
}
|
||||
|
||||
const currentPeriodEnd = subscription.items.data.reduce<number | null>((latestEnd, item) => {
|
||||
const itemPeriodEnd = item.current_period_end ?? null;
|
||||
|
||||
if (itemPeriodEnd == null) {
|
||||
return latestEnd;
|
||||
}
|
||||
|
||||
return latestEnd == null ? itemPeriodEnd : Math.max(latestEnd, itemPeriodEnd);
|
||||
}, null);
|
||||
|
||||
return currentPeriodEnd ? new Date(currentPeriodEnd * 1000).toISOString() : null;
|
||||
};
|
||||
|
||||
const ensureHobbySubscription = async (
|
||||
organizationId: string,
|
||||
customerId: string,
|
||||
idempotencySuffix: string
|
||||
): Promise<void> => {
|
||||
if (!stripeClient) return;
|
||||
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
|
||||
|
||||
const products = await stripeClient.products.list({
|
||||
active: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const hobbyProduct = products.data.find((product) => product.metadata.formbricks_plan === "hobby");
|
||||
if (!hobbyProduct) {
|
||||
throw new Error("Stripe product metadata formbricks_plan=hobby not found");
|
||||
}
|
||||
|
||||
const defaultPrice =
|
||||
typeof hobbyProduct.default_price === "string" ? null : (hobbyProduct.default_price ?? null);
|
||||
|
||||
const fallbackPrices = await stripeClient.prices.list({
|
||||
product: hobbyProduct.id,
|
||||
active: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const hobbyPrice =
|
||||
defaultPrice ??
|
||||
fallbackPrices.data.find(
|
||||
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
|
||||
) ??
|
||||
fallbackPrices.data[0] ??
|
||||
null;
|
||||
|
||||
if (!hobbyPrice) {
|
||||
throw new Error(`No active price found for Stripe hobby product ${hobbyProduct.id}`);
|
||||
}
|
||||
|
||||
await stripeClient.subscriptions.create(
|
||||
{
|
||||
customer: customerId,
|
||||
items: hobbyItems,
|
||||
items: [{ price: hobbyPrice.id, quantity: 1 }],
|
||||
metadata: { organizationId },
|
||||
},
|
||||
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
|
||||
@@ -513,24 +339,50 @@ export const createProTrialSubscription = async (
|
||||
customerId: string
|
||||
): Promise<void> => {
|
||||
if (!stripeClient) return;
|
||||
const proCatalogItem = await getCatalogItemForPlan("pro", "monthly");
|
||||
const proProductId =
|
||||
typeof proCatalogItem.basePrice.product === "string"
|
||||
? proCatalogItem.basePrice.product
|
||||
: proCatalogItem.basePrice.product.id;
|
||||
|
||||
const products = await stripeClient.products.list({
|
||||
active: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const proProduct = products.data.find((product) => product.metadata.formbricks_plan === "pro");
|
||||
if (!proProduct) {
|
||||
throw new Error("Stripe product metadata formbricks_plan=pro not found");
|
||||
}
|
||||
|
||||
const customer = await stripeClient.customers.retrieve(customerId);
|
||||
if (!customer.deleted && customer.email) {
|
||||
const alreadyUsed = await hasEmailUsedProTrial(customer.email, proProductId);
|
||||
const alreadyUsed = await hasEmailUsedProTrial(customer.email, proProduct.id);
|
||||
if (alreadyUsed) {
|
||||
throw new OperationNotAllowedError("trial_already_used");
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPrice =
|
||||
typeof proProduct.default_price === "string" ? null : (proProduct.default_price ?? null);
|
||||
|
||||
const fallbackPrices = await stripeClient.prices.list({
|
||||
product: proProduct.id,
|
||||
active: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const proPrice =
|
||||
defaultPrice ??
|
||||
fallbackPrices.data.find(
|
||||
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
|
||||
) ??
|
||||
fallbackPrices.data[0] ??
|
||||
null;
|
||||
|
||||
if (!proPrice) {
|
||||
throw new Error(`No active price found for Stripe pro product ${proProduct.id}`);
|
||||
}
|
||||
|
||||
await stripeClient.subscriptions.create(
|
||||
{
|
||||
customer: customerId,
|
||||
items: await getCatalogItemsForPlan("pro", "monthly"),
|
||||
items: [{ price: proPrice.id, quantity: 1 }],
|
||||
trial_period_days: 14,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
@@ -546,380 +398,6 @@ export const createProTrialSubscription = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const createPaidPlanCheckoutSession = async (input: {
|
||||
organizationId: string;
|
||||
customerId: string;
|
||||
environmentId: string;
|
||||
plan: Exclude<TStandardCloudPlan, "hobby">;
|
||||
interval: TCloudBillingInterval;
|
||||
}): Promise<string> => {
|
||||
if (!stripeClient) {
|
||||
throw new Error("Stripe is not configured");
|
||||
}
|
||||
|
||||
const catalogItem = await getCatalogItemForPlan(input.plan, input.interval);
|
||||
const checkoutIntervals = new Set<Stripe.Price.Recurring.Interval>(
|
||||
[catalogItem.basePrice.recurring?.interval, catalogItem.responsePrice?.recurring?.interval].filter(
|
||||
(interval): interval is Stripe.Price.Recurring.Interval => interval != null
|
||||
)
|
||||
);
|
||||
|
||||
if (checkoutIntervals.size > 1) {
|
||||
throw new OperationNotAllowedError("mixed_interval_checkout_unsupported");
|
||||
}
|
||||
|
||||
const items = await getCatalogItemsForPlan(input.plan, input.interval);
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
customer: input.customerId,
|
||||
line_items: items,
|
||||
client_reference_id: input.organizationId,
|
||||
billing_address_collection: "required",
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
required: "if_supported",
|
||||
},
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
name: "auto",
|
||||
},
|
||||
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${input.environmentId}&checkout_success=1`,
|
||||
cancel_url: `${WEBAPP_URL}/environments/${input.environmentId}/settings/billing`,
|
||||
metadata: {
|
||||
organizationId: input.organizationId,
|
||||
targetPlan: input.plan,
|
||||
targetInterval: input.interval,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error("Stripe did not return a Checkout Session URL");
|
||||
}
|
||||
|
||||
return session.url;
|
||||
};
|
||||
|
||||
const getRequiredActiveSubscription = async (
|
||||
organizationId: string,
|
||||
customerId: string
|
||||
): Promise<NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>> => {
|
||||
const subscription = await resolveCurrentSubscription(customerId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new ResourceNotFoundError("subscription", organizationId);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
};
|
||||
|
||||
const clearPendingPlanState = async (
|
||||
organizationId: string,
|
||||
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>
|
||||
): Promise<void> => {
|
||||
if (!stripeClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.cancel_at_period_end) {
|
||||
await stripeClient.subscriptions.update(subscription.id, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.schedule) {
|
||||
const scheduleId =
|
||||
typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule.id;
|
||||
|
||||
await stripeClient.subscriptionSchedules.release(scheduleId, {
|
||||
preserve_cancel_date: false,
|
||||
});
|
||||
}
|
||||
|
||||
await updatePendingPlanChangeSnapshot(organizationId, null);
|
||||
};
|
||||
|
||||
const updateSubscriptionItemsImmediately = async (
|
||||
organizationId: string,
|
||||
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>,
|
||||
targetPlan: TStandardCloudPlan,
|
||||
targetInterval: TCloudBillingInterval
|
||||
): Promise<void> => {
|
||||
if (!stripeClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItems = await getCatalogItemsForPlan(targetPlan, targetInterval);
|
||||
const existingDeletions = subscription.items.data.map((item) => ({
|
||||
id: item.id,
|
||||
deleted: true as const,
|
||||
}));
|
||||
|
||||
await stripeClient.subscriptions.update(subscription.id, {
|
||||
cancel_at_period_end: false,
|
||||
items: [...existingDeletions, ...targetItems],
|
||||
proration_behavior: "always_invoice",
|
||||
// We don't grant the upgraded plan until Stripe can actually collect the prorated invoice.
|
||||
payment_behavior: "error_if_incomplete",
|
||||
...(subscription.trial_end ? { trial_end: subscription.trial_end } : {}),
|
||||
metadata: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getScheduleItemsForPlanChange = async (
|
||||
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>,
|
||||
targetPlan: TStandardCloudPlan,
|
||||
targetInterval: TCloudBillingInterval
|
||||
) => {
|
||||
const currentItems = mapSubscriptionItemsToScheduleItems(subscription.items.data);
|
||||
const targetCatalogItem = await getCatalogItemForPlan(targetPlan, targetInterval);
|
||||
const targetItems = mapSubscriptionItemsToScheduleItems([
|
||||
{ price: targetCatalogItem.basePrice, quantity: 1 },
|
||||
...(targetCatalogItem.responsePrice ? [{ price: targetCatalogItem.responsePrice }] : []),
|
||||
]);
|
||||
|
||||
return { currentItems, targetItems };
|
||||
};
|
||||
|
||||
const getOrCreatePlanChangeSchedule = async (
|
||||
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>
|
||||
) => {
|
||||
if (!stripeClient) {
|
||||
throw new Error("Stripe is not configured");
|
||||
}
|
||||
|
||||
const existingScheduleId =
|
||||
typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
|
||||
|
||||
if (existingScheduleId) {
|
||||
return {
|
||||
schedule: await stripeClient.subscriptionSchedules.retrieve(existingScheduleId),
|
||||
createdSchedule: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schedule: await stripeClient.subscriptionSchedules.create({
|
||||
// Stripe rejects metadata when cloning from an existing subscription.
|
||||
from_subscription: subscription.id,
|
||||
}),
|
||||
createdSchedule: true,
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentSchedulePhase = (schedule: Stripe.SubscriptionSchedule) => {
|
||||
const currentPhase = schedule.current_phase;
|
||||
|
||||
if (!currentPhase) {
|
||||
throw new Error(`Stripe subscription schedule ${schedule.id} has no current phase`);
|
||||
}
|
||||
|
||||
if (!currentPhase.end_date) {
|
||||
throw new Error(
|
||||
`Stripe subscription schedule ${schedule.id} current phase has no end date; cannot schedule a plan change`
|
||||
);
|
||||
}
|
||||
|
||||
return currentPhase;
|
||||
};
|
||||
|
||||
const buildPlanChangePhases = (input: {
|
||||
currentPhase: { start_date: number; end_date: number };
|
||||
currentItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[];
|
||||
targetItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[];
|
||||
organizationId: string;
|
||||
targetPlan: TStandardCloudPlan;
|
||||
targetInterval: TCloudBillingInterval;
|
||||
}) => {
|
||||
const { currentPhase, currentItems, targetItems, organizationId, targetPlan, targetInterval } = input;
|
||||
|
||||
return [
|
||||
{
|
||||
start_date: currentPhase.start_date,
|
||||
end_date: currentPhase.end_date,
|
||||
items: currentItems,
|
||||
},
|
||||
{
|
||||
start_date: currentPhase.end_date,
|
||||
items: targetItems,
|
||||
metadata: {
|
||||
organizationId,
|
||||
targetPlan,
|
||||
targetInterval,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const rollbackFailedPlanChangeScheduleUpdate = async (input: {
|
||||
organizationId: string;
|
||||
subscriptionId: string;
|
||||
scheduleId: string;
|
||||
createdSchedule: boolean;
|
||||
hadCancelAtPeriodEnd: boolean;
|
||||
}) => {
|
||||
const { organizationId, subscriptionId, scheduleId, createdSchedule, hadCancelAtPeriodEnd } = input;
|
||||
|
||||
if (!stripeClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (createdSchedule) {
|
||||
try {
|
||||
await stripeClient.subscriptionSchedules.release(scheduleId, {
|
||||
preserve_cancel_date: false,
|
||||
});
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
{ error: releaseError, organizationId, scheduleId },
|
||||
"Failed to release newly created Stripe schedule after plan change update error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadCancelAtPeriodEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stripeClient.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
logger.error(
|
||||
{ error: restoreError, organizationId, subscriptionId },
|
||||
"Failed to restore Stripe cancel_at_period_end after plan change scheduling error"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSubscriptionPlanChange = async (
|
||||
organizationId: string,
|
||||
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>,
|
||||
targetPlan: TStandardCloudPlan,
|
||||
targetInterval: TCloudBillingInterval
|
||||
): Promise<TOrganizationStripePendingChange> => {
|
||||
if (!stripeClient) {
|
||||
throw new Error("Stripe is not configured");
|
||||
}
|
||||
|
||||
const hadCancelAtPeriodEnd = subscription.cancel_at_period_end;
|
||||
if (hadCancelAtPeriodEnd) {
|
||||
await stripeClient.subscriptions.update(subscription.id, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
}
|
||||
|
||||
const { currentItems, targetItems } = await getScheduleItemsForPlanChange(
|
||||
subscription,
|
||||
targetPlan,
|
||||
targetInterval
|
||||
);
|
||||
const { schedule, createdSchedule } = await getOrCreatePlanChangeSchedule(subscription);
|
||||
const currentPhase = getCurrentSchedulePhase(schedule);
|
||||
|
||||
let updatedSchedule: Stripe.SubscriptionSchedule;
|
||||
|
||||
try {
|
||||
updatedSchedule = await stripeClient.subscriptionSchedules.update(schedule.id, {
|
||||
end_behavior: "release",
|
||||
metadata: {
|
||||
organizationId,
|
||||
},
|
||||
proration_behavior: "none",
|
||||
phases: buildPlanChangePhases({
|
||||
currentPhase,
|
||||
currentItems,
|
||||
targetItems,
|
||||
organizationId,
|
||||
targetPlan,
|
||||
targetInterval,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
await rollbackFailedPlanChangeScheduleUpdate({
|
||||
organizationId,
|
||||
subscriptionId: subscription.id,
|
||||
scheduleId: schedule.id,
|
||||
createdSchedule,
|
||||
hadCancelAtPeriodEnd,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextPhase = updatedSchedule.phases.find((phase) => phase.start_date >= currentPhase.end_date);
|
||||
if (!nextPhase) {
|
||||
throw new Error(`Stripe subscription schedule ${updatedSchedule.id} has no next phase`);
|
||||
}
|
||||
|
||||
const pendingChange: TOrganizationStripePendingChange = {
|
||||
type: "plan_change",
|
||||
targetPlan,
|
||||
targetInterval: targetPlan === "hobby" ? "monthly" : targetInterval,
|
||||
effectiveAt: new Date(nextPhase.start_date * 1000).toISOString(),
|
||||
};
|
||||
|
||||
await updatePendingPlanChangeSnapshot(organizationId, pendingChange);
|
||||
|
||||
return pendingChange;
|
||||
};
|
||||
|
||||
export const switchOrganizationToCloudPlan = async (input: {
|
||||
organizationId: string;
|
||||
customerId: string;
|
||||
targetPlan: TStandardCloudPlan;
|
||||
targetInterval: TCloudBillingInterval;
|
||||
}): Promise<{ mode: "immediate" | "scheduled"; pendingChange: TOrganizationStripePendingChange | null }> => {
|
||||
const subscription = await getRequiredActiveSubscription(input.organizationId, input.customerId);
|
||||
const currentPlan = resolveCloudPlanFromSubscription(subscription);
|
||||
const currentInterval = resolveSubscriptionInterval(subscription);
|
||||
|
||||
const isImmediateUpgrade = CLOUD_PLAN_LEVEL[input.targetPlan] > CLOUD_PLAN_LEVEL[currentPlan];
|
||||
const isSameSelection = currentPlan === input.targetPlan && currentInterval === input.targetInterval;
|
||||
|
||||
if (isSameSelection) {
|
||||
return { mode: "immediate", pendingChange: null };
|
||||
}
|
||||
|
||||
if (isImmediateUpgrade) {
|
||||
await updateSubscriptionItemsImmediately(
|
||||
input.organizationId,
|
||||
subscription,
|
||||
input.targetPlan,
|
||||
input.targetInterval
|
||||
);
|
||||
|
||||
if (subscription.schedule) {
|
||||
await clearPendingPlanState(input.organizationId, subscription);
|
||||
}
|
||||
|
||||
return { mode: "immediate", pendingChange: null };
|
||||
}
|
||||
|
||||
const pendingChange = await scheduleSubscriptionPlanChange(
|
||||
input.organizationId,
|
||||
subscription,
|
||||
input.targetPlan,
|
||||
input.targetInterval
|
||||
);
|
||||
return { mode: "scheduled", pendingChange };
|
||||
};
|
||||
|
||||
export const undoPendingOrganizationPlanChange = async (
|
||||
organizationId: string,
|
||||
customerId: string
|
||||
): Promise<void> => {
|
||||
const subscription = await getRequiredActiveSubscription(organizationId, customerId);
|
||||
await clearPendingPlanState(organizationId, subscription);
|
||||
};
|
||||
|
||||
const ensureOrganizationBillingRecord = async (
|
||||
organizationId: string
|
||||
): Promise<TOrganizationBilling | null> => {
|
||||
@@ -972,6 +450,25 @@ const getOrganizationOwner = async (
|
||||
return { email: membership.user.email, name: membership.user.name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches Stripe for an existing non-deleted customer with the given email.
|
||||
* Returns the first match, or null if none found.
|
||||
*/
|
||||
const findStripeCustomerByEmail = async (email: string): Promise<Stripe.Customer | null> => {
|
||||
if (!stripeClient) return null;
|
||||
|
||||
const customers = await stripeClient.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const customer = customers.data[0];
|
||||
if (customer && !customer.deleted) {
|
||||
return customer;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ensureStripeCustomerForOrganization = async (
|
||||
organizationId: string
|
||||
): Promise<{ customerId: string | null }> => {
|
||||
@@ -988,6 +485,9 @@ export const ensureStripeCustomerForOrganization = async (
|
||||
return { customerId: null };
|
||||
}
|
||||
|
||||
// Look up the org owner's email/name and check if a Stripe customer already exists for it.
|
||||
// This reuses the old customer (and its trial history) when a user deletes their account
|
||||
// and signs up again with the same email.
|
||||
const owner = await getOrganizationOwner(organization.id);
|
||||
if (!owner) {
|
||||
logger.error({ organizationId }, "Cannot set up Stripe customer: organization has no owner");
|
||||
@@ -995,14 +495,37 @@ export const ensureStripeCustomerForOrganization = async (
|
||||
}
|
||||
|
||||
const { email: ownerEmail, name: ownerName } = owner;
|
||||
const customer = await stripeClient.customers.create(
|
||||
{
|
||||
name: ownerName ?? undefined,
|
||||
email: ownerEmail,
|
||||
metadata: { organizationId: organization.id, organizationName: organization.name },
|
||||
},
|
||||
{ idempotencyKey: `ensure-customer-${organization.id}` }
|
||||
);
|
||||
let existingCustomer: Stripe.Customer | null = null;
|
||||
|
||||
if (ownerEmail) {
|
||||
const foundCustomer = await findStripeCustomerByEmail(ownerEmail);
|
||||
if (foundCustomer) {
|
||||
// Only reuse if this customer is not already linked to another org's billing record
|
||||
const existingBillingOwner = await findOrganizationIdByStripeCustomerId(foundCustomer.id);
|
||||
if (!existingBillingOwner || existingBillingOwner === organizationId) {
|
||||
existingCustomer = foundCustomer;
|
||||
await stripeClient.customers.update(existingCustomer.id, {
|
||||
name: ownerName ?? undefined,
|
||||
metadata: { organizationId: organization.id, organizationName: organization.name },
|
||||
});
|
||||
logger.info(
|
||||
{ organizationId, customerId: existingCustomer.id, email: ownerEmail },
|
||||
"Reusing existing Stripe customer for new organization"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customer =
|
||||
existingCustomer ??
|
||||
(await stripeClient.customers.create(
|
||||
{
|
||||
name: ownerName ?? undefined,
|
||||
email: ownerEmail,
|
||||
metadata: { organizationId: organization.id, organizationName: organization.name },
|
||||
},
|
||||
{ idempotencyKey: `ensure-customer-${organization.id}` }
|
||||
));
|
||||
|
||||
const defaultBilling = getDefaultOrganizationBilling();
|
||||
|
||||
@@ -1015,11 +538,11 @@ export const ensureStripeCustomerForOrganization = async (
|
||||
stripeCustomerId: customer.id,
|
||||
limits: defaultBilling.limits,
|
||||
usageCycleAnchor: defaultBilling.usageCycleAnchor,
|
||||
stripe: { plan: "hobby", lastSyncedAt: new Date().toISOString() },
|
||||
stripe: { lastSyncedAt: new Date().toISOString() },
|
||||
},
|
||||
update: {
|
||||
stripeCustomerId: customer.id,
|
||||
stripe: { plan: "hobby", lastSyncedAt: new Date().toISOString() },
|
||||
stripe: { lastSyncedAt: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1027,31 +550,43 @@ export const ensureStripeCustomerForOrganization = async (
|
||||
return { customerId: customer.id };
|
||||
};
|
||||
|
||||
const shouldSkipStripeSyncForEvent = (
|
||||
existingStripeSnapshot: TOrganizationBilling["stripe"],
|
||||
export const syncOrganizationBillingFromStripe = async (
|
||||
organizationId: string,
|
||||
event?: { id: string; created: number }
|
||||
) => {
|
||||
): Promise<TOrganizationBilling | null> => {
|
||||
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const billing = await ensureOrganizationBillingRecord(organizationId);
|
||||
if (!billing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customerId = billing.stripeCustomerId;
|
||||
if (!customerId) return billing;
|
||||
|
||||
const existingStripeSnapshot = billing.stripe;
|
||||
const previousEventDate = getDateFromBilling(existingStripeSnapshot?.lastStripeEventCreatedAt ?? null);
|
||||
const incomingEventDate = event ? new Date(event.created * 1000) : null;
|
||||
|
||||
if (event?.id && existingStripeSnapshot?.lastSyncedEventId === event.id) {
|
||||
return { shouldSkip: true as const, previousEventDate, incomingEventDate };
|
||||
return billing;
|
||||
}
|
||||
|
||||
if (incomingEventDate && previousEventDate && incomingEventDate < previousEventDate) {
|
||||
return { shouldSkip: true as const, previousEventDate, incomingEventDate };
|
||||
return billing;
|
||||
}
|
||||
|
||||
return { shouldSkip: false as const, previousEventDate, incomingEventDate };
|
||||
};
|
||||
const [subscription, featureLookupKeys] = await Promise.all([
|
||||
resolveCurrentSubscription(customerId),
|
||||
listAllActiveEntitlements(customerId),
|
||||
]);
|
||||
|
||||
const resolveEntitlementDrivenLimits = (
|
||||
organizationId: string,
|
||||
customerId: string,
|
||||
cloudPlan: TCloudBillingPlan,
|
||||
featureLookupKeys: string[],
|
||||
previousLimits: TOrganizationBilling["limits"]
|
||||
) => {
|
||||
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
|
||||
const subscriptionStatus = resolveSubscriptionStatus(subscription);
|
||||
const usageCycleAnchor = resolveUsageCycleAnchor(subscription);
|
||||
const previousLimits = billing.limits;
|
||||
const workspaceLimitFromEntitlements = parseEntitlementLimit(featureLookupKeys, "workspace-limit-");
|
||||
const responsesIncludedFromEntitlements = parseEntitlementLimit(featureLookupKeys, "responses-included-");
|
||||
|
||||
@@ -1079,90 +614,22 @@ const resolveEntitlementDrivenLimits = (
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
projects: projectsLimit,
|
||||
monthly: {
|
||||
responses: responsesIncludedLimit,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resolvePendingPlanChange = async (subscription: Stripe.Subscription | null) => {
|
||||
const pendingChangeEffectiveAt = resolvePendingChangeEffectiveAt(subscription);
|
||||
|
||||
const scheduledPlanChange = await getPendingPlanChangeFromSchedule(subscription);
|
||||
if (scheduledPlanChange) {
|
||||
return scheduledPlanChange;
|
||||
}
|
||||
|
||||
if (subscription?.cancel_at_period_end && pendingChangeEffectiveAt) {
|
||||
return {
|
||||
type: "plan_change" as const,
|
||||
targetPlan: "hobby" as const,
|
||||
targetInterval: "monthly" as const,
|
||||
effectiveAt: pendingChangeEffectiveAt,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const syncOrganizationBillingFromStripe = async (
|
||||
organizationId: string,
|
||||
event?: { id: string; created: number }
|
||||
): Promise<TOrganizationBilling | null> => {
|
||||
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const billing = await ensureOrganizationBillingRecord(organizationId);
|
||||
if (!billing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customerId = billing.stripeCustomerId;
|
||||
if (!customerId) return billing;
|
||||
|
||||
const existingStripeSnapshot = billing.stripe;
|
||||
const { shouldSkip, previousEventDate, incomingEventDate } = shouldSkipStripeSyncForEvent(
|
||||
existingStripeSnapshot,
|
||||
event
|
||||
);
|
||||
if (shouldSkip) {
|
||||
return billing;
|
||||
}
|
||||
|
||||
const [subscription, featureLookupKeys] = await Promise.all([
|
||||
resolveCurrentSubscription(customerId),
|
||||
listAllActiveEntitlements(customerId),
|
||||
]);
|
||||
|
||||
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
|
||||
const billingInterval = resolveSubscriptionInterval(subscription);
|
||||
const subscriptionStatus = resolveSubscriptionStatus(subscription);
|
||||
const usageCycleAnchor = resolveUsageCycleAnchor(subscription);
|
||||
const pendingChange = await resolvePendingPlanChange(subscription);
|
||||
const limits = resolveEntitlementDrivenLimits(
|
||||
organizationId,
|
||||
customerId,
|
||||
cloudPlan,
|
||||
featureLookupKeys,
|
||||
billing.limits
|
||||
);
|
||||
|
||||
const updatedBilling: TOrganizationBilling = {
|
||||
stripeCustomerId: customerId,
|
||||
limits,
|
||||
limits: {
|
||||
projects: projectsLimit,
|
||||
monthly: {
|
||||
responses: responsesIncludedLimit,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor,
|
||||
stripe: {
|
||||
...billing.stripe,
|
||||
plan: cloudPlan,
|
||||
interval: billingInterval,
|
||||
subscriptionStatus,
|
||||
subscriptionId: subscription?.id ?? null,
|
||||
hasPaymentMethod: subscription?.default_payment_method != null,
|
||||
features: featureLookupKeys,
|
||||
pendingChange,
|
||||
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
lastSyncedEventId: event?.id ?? existingStripeSnapshot?.lastSyncedEventId ?? null,
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pricesList: vi.fn(),
|
||||
cacheWithCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./stripe-client", () => ({
|
||||
stripeClient: {
|
||||
prices: {
|
||||
list: mocks.pricesList,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const cacheStore = vi.hoisted(() => new Map<string, unknown>());
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: mocks.cacheWithCache,
|
||||
},
|
||||
}));
|
||||
|
||||
const createPrice = ({
|
||||
id,
|
||||
plan,
|
||||
kind,
|
||||
interval,
|
||||
}: {
|
||||
id: string;
|
||||
plan: "hobby" | "pro" | "scale";
|
||||
kind: "base" | "responses";
|
||||
interval: "monthly" | "yearly";
|
||||
}) => ({
|
||||
id,
|
||||
active: true,
|
||||
currency: "usd",
|
||||
unit_amount: kind === "responses" ? 0 : interval === "monthly" ? 1000 : 10000,
|
||||
metadata: {
|
||||
formbricks_plan: plan,
|
||||
formbricks_price_kind: kind,
|
||||
formbricks_interval: interval,
|
||||
},
|
||||
recurring: {
|
||||
usage_type: kind === "base" ? "licensed" : "metered",
|
||||
interval: interval === "monthly" ? "month" : "year",
|
||||
},
|
||||
product: {
|
||||
id: `prod_${plan}`,
|
||||
active: true,
|
||||
metadata: {
|
||||
formbricks_plan: plan,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("stripe-billing-catalog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
cacheStore.clear();
|
||||
|
||||
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>, key: string) => {
|
||||
if (cacheStore.has(key)) {
|
||||
return cacheStore.get(key);
|
||||
}
|
||||
|
||||
const value = await fn();
|
||||
cacheStore.set(key, value);
|
||||
return value;
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves the metadata-backed billing catalog", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { getCatalogItemsForPlan, getStripeBillingCatalogDisplay } =
|
||||
await import("./stripe-billing-catalog");
|
||||
|
||||
await expect(getCatalogItemsForPlan("hobby", "monthly")).resolves.toEqual([
|
||||
{ price: "price_hobby_monthly", quantity: 1 },
|
||||
]);
|
||||
await expect(getCatalogItemsForPlan("pro", "yearly")).resolves.toEqual([
|
||||
{ price: "price_pro_yearly", quantity: 1 },
|
||||
{ price: "price_pro_responses" },
|
||||
]);
|
||||
await expect(getStripeBillingCatalogDisplay()).resolves.toEqual({
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
currency: "usd",
|
||||
unitAmount: 1000,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
monthly: {
|
||||
plan: "pro",
|
||||
interval: "monthly",
|
||||
currency: "usd",
|
||||
unitAmount: 1000,
|
||||
},
|
||||
yearly: {
|
||||
plan: "pro",
|
||||
interval: "yearly",
|
||||
currency: "usd",
|
||||
unitAmount: 10000,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
monthly: {
|
||||
plan: "scale",
|
||||
interval: "monthly",
|
||||
currency: "usd",
|
||||
unitAmount: 1000,
|
||||
},
|
||||
yearly: {
|
||||
plan: "scale",
|
||||
interval: "yearly",
|
||||
currency: "usd",
|
||||
unitAmount: 10000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("fails fast when the catalog is incomplete", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" })],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog");
|
||||
|
||||
await expect(getCatalogItemsForPlan("pro", "monthly")).rejects.toThrow(
|
||||
"Expected exactly one Stripe price for pro/base/monthly, but found 0"
|
||||
);
|
||||
});
|
||||
|
||||
test("reuses the shared cached catalog across module reloads", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const firstModule = await import("./stripe-billing-catalog");
|
||||
await firstModule.getStripeBillingCatalogDisplay();
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
const secondModule = await import("./stripe-billing-catalog");
|
||||
await secondModule.getStripeBillingCatalogDisplay();
|
||||
|
||||
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cacheWithCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("falls back to direct Stripe fetch when shared cache is unavailable", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
mocks.cacheWithCache.mockImplementationOnce(async (fn: () => Promise<unknown>) => await fn());
|
||||
|
||||
const { getStripeBillingCatalogDisplay } = await import("./stripe-billing-catalog");
|
||||
|
||||
await expect(getStripeBillingCatalogDisplay()).resolves.toMatchObject({
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,337 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache as reactCache } from "react";
|
||||
import Stripe from "stripe";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import type { TCloudBillingInterval } from "@formbricks/types/organizations";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { stripeClient } from "./stripe-client";
|
||||
|
||||
export type TStandardCloudPlan = "hobby" | "pro" | "scale";
|
||||
type TStripePriceKind = "base" | "responses";
|
||||
|
||||
type TStripeCatalogPrice = Stripe.Price & {
|
||||
product: Stripe.Product | Stripe.DeletedProduct;
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalogItem = {
|
||||
plan: TStandardCloudPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
basePrice: TStripeCatalogPrice;
|
||||
responsePrice: TStripeCatalogPrice | null;
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalog = {
|
||||
hobby: {
|
||||
monthly: TStripeBillingCatalogItem;
|
||||
};
|
||||
pro: {
|
||||
monthly: TStripeBillingCatalogItem;
|
||||
yearly: TStripeBillingCatalogItem;
|
||||
};
|
||||
scale: {
|
||||
monthly: TStripeBillingCatalogItem;
|
||||
yearly: TStripeBillingCatalogItem;
|
||||
};
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalogDisplayItem = {
|
||||
plan: TStandardCloudPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
currency: string;
|
||||
unitAmount: number | null;
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalogDisplay = {
|
||||
hobby: {
|
||||
monthly: TStripeBillingCatalogDisplayItem;
|
||||
};
|
||||
pro: {
|
||||
monthly: TStripeBillingCatalogDisplayItem;
|
||||
yearly: TStripeBillingCatalogDisplayItem;
|
||||
};
|
||||
scale: {
|
||||
monthly: TStripeBillingCatalogDisplayItem;
|
||||
yearly: TStripeBillingCatalogDisplayItem;
|
||||
};
|
||||
};
|
||||
|
||||
const STANDARD_CLOUD_PLANS = new Set<TStandardCloudPlan>(["hobby", "pro", "scale"]);
|
||||
const STRIPE_BILLING_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const STRIPE_BILLING_CATALOG_CACHE_VERSION = "v1";
|
||||
|
||||
const getStripeBillingCatalogCacheKey = () =>
|
||||
createCacheKey.custom(
|
||||
"billing",
|
||||
"stripe_catalog",
|
||||
`${hashString(env.STRIPE_SECRET_KEY ?? "stripe-unconfigured")}-${STRIPE_BILLING_CATALOG_CACHE_VERSION}`
|
||||
);
|
||||
|
||||
const getPriceProduct = (price: Stripe.Price): Stripe.Product | Stripe.DeletedProduct | null => {
|
||||
if (typeof price.product === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return price.product;
|
||||
};
|
||||
|
||||
const getPricePlan = (price: Stripe.Price): TStandardCloudPlan | null => {
|
||||
const product = getPriceProduct(price);
|
||||
const plan =
|
||||
price.metadata?.formbricks_plan ??
|
||||
(!product || product.deleted ? undefined : product.metadata?.formbricks_plan);
|
||||
|
||||
if (!plan || !STANDARD_CLOUD_PLANS.has(plan as TStandardCloudPlan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return plan as TStandardCloudPlan;
|
||||
};
|
||||
|
||||
const normalizeInterval = (interval: string | null | undefined): TCloudBillingInterval | null => {
|
||||
if (interval === "month" || interval === "monthly") return "monthly";
|
||||
if (interval === "year" || interval === "yearly") return "yearly";
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPriceInterval = (price: Stripe.Price): TCloudBillingInterval | null => {
|
||||
const metadataInterval = normalizeInterval(price.metadata?.formbricks_interval);
|
||||
if (metadataInterval) {
|
||||
return metadataInterval;
|
||||
}
|
||||
|
||||
return normalizeInterval(price.recurring?.interval);
|
||||
};
|
||||
|
||||
const getPriceKind = (price: Stripe.Price): TStripePriceKind | null => {
|
||||
const metadataKind = price.metadata?.formbricks_price_kind;
|
||||
if (metadataKind === "base" || metadataKind === "responses") {
|
||||
return metadataKind;
|
||||
}
|
||||
|
||||
if (price.recurring?.usage_type === "licensed") {
|
||||
return "base";
|
||||
}
|
||||
|
||||
if (price.recurring?.usage_type === "metered") {
|
||||
return "responses";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isCatalogCandidate = (price: Stripe.Price): price is TStripeCatalogPrice => {
|
||||
if (!price.active || !price.recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const product = getPriceProduct(price);
|
||||
if (!product || product.deleted || !product.active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getPricePlan(price) !== null && getPriceKind(price) !== null && getPriceInterval(price) !== null;
|
||||
};
|
||||
|
||||
const listAllActivePrices = async (): Promise<TStripeCatalogPrice[]> => {
|
||||
if (!stripeClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prices: TStripeCatalogPrice[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await stripeClient.prices.list({
|
||||
active: true,
|
||||
limit: 100,
|
||||
expand: ["data.product"],
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
|
||||
for (const price of result.data) {
|
||||
if (isCatalogCandidate(price)) {
|
||||
prices.push(price);
|
||||
}
|
||||
}
|
||||
|
||||
const lastItem = result.data.at(-1);
|
||||
startingAfter = result.has_more && lastItem ? lastItem.id : undefined;
|
||||
} while (startingAfter);
|
||||
|
||||
return prices;
|
||||
};
|
||||
|
||||
const getSinglePrice = (
|
||||
prices: TStripeCatalogPrice[],
|
||||
plan: TStandardCloudPlan,
|
||||
kind: TStripePriceKind,
|
||||
interval: TCloudBillingInterval
|
||||
): TStripeCatalogPrice => {
|
||||
const matches = prices.filter(
|
||||
(price) =>
|
||||
getPricePlan(price) === plan && getPriceKind(price) === kind && getPriceInterval(price) === interval
|
||||
);
|
||||
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one Stripe price for ${plan}/${kind}/${interval}, but found ${matches.length}`
|
||||
);
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
const fetchStripeBillingCatalog = async (): Promise<TStripeBillingCatalog> => {
|
||||
if (!stripeClient) {
|
||||
throw new Error("Stripe is not configured");
|
||||
}
|
||||
|
||||
const prices = await listAllActivePrices();
|
||||
|
||||
if (prices.length === 0) {
|
||||
throw new Error("No active Stripe billing catalog prices found");
|
||||
}
|
||||
|
||||
return {
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
basePrice: getSinglePrice(prices, "hobby", "base", "monthly"),
|
||||
responsePrice: null,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
monthly: {
|
||||
plan: "pro",
|
||||
interval: "monthly",
|
||||
basePrice: getSinglePrice(prices, "pro", "base", "monthly"),
|
||||
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
|
||||
},
|
||||
yearly: {
|
||||
plan: "pro",
|
||||
interval: "yearly",
|
||||
basePrice: getSinglePrice(prices, "pro", "base", "yearly"),
|
||||
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
monthly: {
|
||||
plan: "scale",
|
||||
interval: "monthly",
|
||||
basePrice: getSinglePrice(prices, "scale", "base", "monthly"),
|
||||
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
|
||||
},
|
||||
yearly: {
|
||||
plan: "scale",
|
||||
interval: "yearly",
|
||||
basePrice: getSinglePrice(prices, "scale", "base", "yearly"),
|
||||
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getStripeBillingCatalog = reactCache(async (): Promise<TStripeBillingCatalog> => {
|
||||
return await cache.withCache(
|
||||
fetchStripeBillingCatalog,
|
||||
getStripeBillingCatalogCacheKey(),
|
||||
STRIPE_BILLING_CATALOG_CACHE_TTL_MS
|
||||
);
|
||||
});
|
||||
|
||||
export const getStripeBillingCatalogDisplay = reactCache(async (): Promise<TStripeBillingCatalogDisplay> => {
|
||||
const catalog = await getStripeBillingCatalog();
|
||||
|
||||
return {
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
currency: catalog.hobby.monthly.basePrice.currency,
|
||||
unitAmount: catalog.hobby.monthly.basePrice.unit_amount,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
monthly: {
|
||||
plan: "pro",
|
||||
interval: "monthly",
|
||||
currency: catalog.pro.monthly.basePrice.currency,
|
||||
unitAmount: catalog.pro.monthly.basePrice.unit_amount,
|
||||
},
|
||||
yearly: {
|
||||
plan: "pro",
|
||||
interval: "yearly",
|
||||
currency: catalog.pro.yearly.basePrice.currency,
|
||||
unitAmount: catalog.pro.yearly.basePrice.unit_amount,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
monthly: {
|
||||
plan: "scale",
|
||||
interval: "monthly",
|
||||
currency: catalog.scale.monthly.basePrice.currency,
|
||||
unitAmount: catalog.scale.monthly.basePrice.unit_amount,
|
||||
},
|
||||
yearly: {
|
||||
plan: "scale",
|
||||
interval: "yearly",
|
||||
currency: catalog.scale.yearly.basePrice.currency,
|
||||
unitAmount: catalog.scale.yearly.basePrice.unit_amount,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const getCatalogItemForPlan = async (
|
||||
plan: TStandardCloudPlan,
|
||||
interval: TCloudBillingInterval
|
||||
): Promise<TStripeBillingCatalogItem> => {
|
||||
const catalog = await getStripeBillingCatalog();
|
||||
|
||||
if (plan === "hobby") {
|
||||
return catalog.hobby.monthly;
|
||||
}
|
||||
|
||||
return catalog[plan][interval];
|
||||
};
|
||||
|
||||
export const getCatalogItemsForPlan = async (
|
||||
plan: TStandardCloudPlan,
|
||||
interval: TCloudBillingInterval
|
||||
): Promise<Array<{ price: string; quantity?: number }>> => {
|
||||
const item = await getCatalogItemForPlan(plan, interval);
|
||||
|
||||
return [
|
||||
{ price: item.basePrice.id, quantity: 1 },
|
||||
...(item.responsePrice ? [{ price: item.responsePrice.id }] : []),
|
||||
];
|
||||
};
|
||||
|
||||
export const getIntervalFromPrice = (
|
||||
price: Stripe.Price | null | undefined
|
||||
): TCloudBillingInterval | null => {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPriceInterval(price);
|
||||
};
|
||||
|
||||
export const getPlanFromPrice = (price: Stripe.Price | null | undefined): TStandardCloudPlan | null => {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPricePlan(price);
|
||||
};
|
||||
|
||||
export const getPriceKindFromPrice = (price: Stripe.Price | null | undefined): TStripePriceKind | null => {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPriceKind(price);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||
import { getStripeBillingCatalogDisplay } from "@/modules/ee/billing/lib/stripe-billing-catalog";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -21,10 +21,7 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([
|
||||
getCloudBillingDisplayContext(organization.id),
|
||||
getStripeBillingCatalogDisplay(),
|
||||
]);
|
||||
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||
|
||||
const organizationWithSyncedBilling = {
|
||||
...organization,
|
||||
@@ -56,14 +53,13 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
|
||||
projectCount={projectCount}
|
||||
hasBillingRights={hasBillingRights}
|
||||
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
|
||||
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
|
||||
pendingChange={cloudBillingDisplayContext.pendingChange}
|
||||
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
|
||||
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
|
||||
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
|
||||
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
|
||||
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
|
||||
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
|
||||
billingCatalog={billingCatalog}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -163,9 +163,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
}
|
||||
} else {
|
||||
setIsMultiLanguageActivated(true);
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
2
packages/cache/types/keys.test.ts
vendored
2
packages/cache/types/keys.test.ts
vendored
@@ -94,9 +94,7 @@ describe("@formbricks/cache types/keys", () => {
|
||||
test("should include expected namespaces", () => {
|
||||
// Type test - this will fail at compile time if types don't match
|
||||
const analyticsNamespace: CustomCacheNamespace = "analytics";
|
||||
const billingNamespace: CustomCacheNamespace = "billing";
|
||||
expect(analyticsNamespace).toBe("analytics");
|
||||
expect(billingNamespace).toBe("billing");
|
||||
});
|
||||
|
||||
test("should be usable in cache key construction", () => {
|
||||
|
||||
2
packages/cache/types/keys.ts
vendored
2
packages/cache/types/keys.ts
vendored
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
|
||||
* Possible namespaces for custom cache keys
|
||||
* Add new namespaces here as they are introduced
|
||||
*/
|
||||
export type CustomCacheNamespace = "analytics" | "billing";
|
||||
export type CustomCacheNamespace = "analytics";
|
||||
|
||||
@@ -28,7 +28,6 @@ export const ZOrganizationBilling = z.object({
|
||||
stripe: z
|
||||
.object({
|
||||
plan: z.enum(["hobby", "pro", "scale", "custom", "unknown"]).optional(),
|
||||
interval: z.enum(["monthly", "yearly"]).nullable().optional(),
|
||||
subscriptionStatus: z
|
||||
.enum([
|
||||
"trialing",
|
||||
@@ -43,21 +42,10 @@ export const ZOrganizationBilling = z.object({
|
||||
.nullable()
|
||||
.optional(),
|
||||
subscriptionId: z.string().nullable().optional(),
|
||||
hasPaymentMethod: z.boolean().optional(),
|
||||
features: z.array(z.string()).optional(),
|
||||
lastStripeEventCreatedAt: z.string().nullable().optional(),
|
||||
lastSyncedAt: z.string().nullable().optional(),
|
||||
lastSyncedEventId: z.string().nullable().optional(),
|
||||
trialEnd: z.string().nullable().optional(),
|
||||
pendingChange: z
|
||||
.object({
|
||||
type: z.literal("plan_change"),
|
||||
targetPlan: z.enum(["hobby", "pro", "scale"]),
|
||||
targetInterval: z.enum(["monthly", "yearly"]).nullable(),
|
||||
effectiveAt: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
@@ -55,8 +55,6 @@ interface FileUploadProps {
|
||||
imageAltText?: string;
|
||||
/** Placeholder text for the file upload */
|
||||
placeholderText?: string;
|
||||
/** Text to display while uploading */
|
||||
uploadingText?: string;
|
||||
}
|
||||
|
||||
interface UploadedFileItemProps {
|
||||
@@ -232,7 +230,6 @@ function FileUpload({
|
||||
videoUrl,
|
||||
imageAltText,
|
||||
placeholderText = "Click or drag to upload files",
|
||||
uploadingText = "Uploading...",
|
||||
}: Readonly<FileUploadProps>): React.JSX.Element {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -309,7 +306,7 @@ function FileUpload({
|
||||
<p
|
||||
className="text-muted-foreground font-medium"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}>
|
||||
{uploadingText}
|
||||
Uploading...
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -41,9 +41,7 @@ checksums:
|
||||
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
|
||||
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
|
||||
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
|
||||
errors/file_input/placeholder_text: 15b61e390b6c5501d3e3b9da9f6c7930
|
||||
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
|
||||
errors/file_input/uploading: baef62e2015a34d6747ed6e4192a27b1
|
||||
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
|
||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||
errors/invalid_device_error/title: ea7dbb9970c717e4d466f8e1211bd461
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "يجب أن يكون حجم الملف أقل من {maxSizeInMB} ميجابايت",
|
||||
"no_valid_file_types_selected": "لم يتم اختيار أنواع ملفات صالحة. يرجى اختيار نوع ملف صالح.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
|
||||
"placeholder_text": "انقر أو اسحب لرفع الملفات",
|
||||
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
|
||||
"uploading": "جارٍ الرفع...",
|
||||
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Filen skal være mindre end {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Ingen gyldige filtyper valgt. Vælg venligst en gyldig filtype.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Du kan kun uploade én fil ad gangen.",
|
||||
"placeholder_text": "Klik eller træk for at uploade filer",
|
||||
"upload_failed": "Upload mislykkedes! Prøv venligst igen.",
|
||||
"uploading": "Uploader...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
|
||||
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
|
||||
"placeholder_text": "Klicke oder ziehe Dateien hierher zum Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
|
||||
"placeholder_text": "Click or drag to upload files",
|
||||
"upload_failed": "Upload failed! Please try again.",
|
||||
"uploading": "Uploading...",
|
||||
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "El archivo debe ser menor de {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "No se han seleccionado tipos de archivo válidos. Por favor, selecciona un tipo de archivo válido.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Solo se puede subir un archivo a la vez.",
|
||||
"placeholder_text": "Haz clic o arrastra para subir archivos",
|
||||
"upload_failed": "¡Subida fallida! Por favor, inténtalo de nuevo.",
|
||||
"uploading": "Subiendo...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Le fichier doit être inférieur à {maxSizeInMB} Mo",
|
||||
"no_valid_file_types_selected": "Aucun type de fichier valide sélectionné. Veuillez sélectionner un type de fichier valide.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Un seul fichier peut être téléchargé à la fois.",
|
||||
"placeholder_text": "Cliquez ou glissez pour télécharger des fichiers",
|
||||
"upload_failed": "Échec du téléchargement ! Veuillez réessayer.",
|
||||
"uploading": "Téléchargement en cours...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Vous ne pouvez télécharger qu'un maximum de {FILE_LIMIT} fichiers."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "फ़ाइल {maxSizeInMB} MB से कम होनी चाहिए",
|
||||
"no_valid_file_types_selected": "कोई मान्य फ़ाइल प्रकार नहीं चुना गया है। कृपया एक मान्य फ़ाइल प्रकार चुनें।",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
|
||||
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
|
||||
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
|
||||
"uploading": "अपलोड हो रहा है...",
|
||||
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
|
||||
"placeholder_text": "Kattints vagy húzd ide a fájlokat a feltöltéshez",
|
||||
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
|
||||
"uploading": "Feltöltés...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Il file deve essere inferiore a {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nessun tipo di file valido selezionato. Seleziona un tipo di file valido.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "È possibile caricare solo un file alla volta.",
|
||||
"placeholder_text": "Clicca o trascina per caricare i file",
|
||||
"upload_failed": "Caricamento fallito! Riprova.",
|
||||
"uploading": "Caricamento in corso...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "ファイルは{maxSizeInMB}MB未満である必要があります",
|
||||
"no_valid_file_types_selected": "有効なファイルタイプが選択されていません。有効なファイルタイプを選択してください。",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
|
||||
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
|
||||
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
|
||||
"uploading": "アップロード中...",
|
||||
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Het bestand moet kleiner zijn dan {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Geen geldige bestandstypen geselecteerd. Selecteer een geldig bestandstype.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Er kan slechts één bestand tegelijk worden geüpload.",
|
||||
"placeholder_text": "Klik of sleep bestanden om te uploaden",
|
||||
"upload_failed": "Uploaden mislukt! Probeer het opnieuw.",
|
||||
"uploading": "Uploaden...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "O arquivo deve ter menos de {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nenhum tipo de arquivo válido selecionado. Por favor, selecione um tipo de arquivo válido.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Apenas um arquivo pode ser carregado de cada vez.",
|
||||
"placeholder_text": "Clique ou arraste para enviar ficheiros",
|
||||
"upload_failed": "Falha no carregamento! Por favor, tente novamente.",
|
||||
"uploading": "A enviar...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Você só pode carregar um máximo de {FILE_LIMIT} arquivos."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Fișierul trebuie să fie mai mic de {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nu au fost selectate tipuri de fișiere valide. Te rugăm să selectezi un tip de fișier valid.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Poți încărca doar un singur fișier odată.",
|
||||
"placeholder_text": "Apasă sau trage pentru a încărca fișiere",
|
||||
"upload_failed": "Încărcarea a eșuat! Te rugăm să încerci din nou.",
|
||||
"uploading": "Se încarcă...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Poți încărca un număr maxim de {FILE_LIMIT} fișiere."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Файл должен быть меньше {maxSizeInMB} МБ",
|
||||
"no_valid_file_types_selected": "Не выбраны допустимые типы файлов. Пожалуйста, выберите допустимый тип файла.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Можно загрузить только один файл за раз.",
|
||||
"placeholder_text": "Нажмите или перетащите файлы для загрузки",
|
||||
"upload_failed": "Ошибка загрузки! Пожалуйста, попробуйте снова.",
|
||||
"uploading": "Загрузка...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Вы можете загрузить максимум {FILE_LIMIT} файлов."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
|
||||
"placeholder_text": "Klicka eller dra för att ladda upp filer",
|
||||
"upload_failed": "Uppladdning misslyckades! Försök igen.",
|
||||
"uploading": "Laddar upp...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "Fayl hajmi {maxSizeInMB} MB dan kam bo'lishi kerak",
|
||||
"no_valid_file_types_selected": "Hech qanday yaroqli fayl turi tanlanmadi. Iltimos, yaroqli fayl turini tanlang.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Bir vaqtning o'zida faqat bitta fayl yuklanishi mumkin.",
|
||||
"placeholder_text": "Fayllarni yuklash uchun bosing yoki sudrab olib keling",
|
||||
"upload_failed": "Yuklash muvaffaqiyatsiz tugadi! Iltimos, qayta urinib ko'ring.",
|
||||
"uploading": "Yuklanmoqda...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Siz faqat {FILE_LIMIT} ta faylni maksimal yuklashingiz mumkin."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"file_size_exceeded_alert": "文件应小于 {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "未选择有效的文件类型。请选择有效的文件类型。",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "一次只能上传一个文件。",
|
||||
"placeholder_text": "点击或拖拽上传文件",
|
||||
"upload_failed": "上传失败!请重试。",
|
||||
"uploading": "上传中...",
|
||||
"you_can_only_upload_a_maximum_of_files": "您最多只能上传 {FILE_LIMIT} 个文件。"
|
||||
},
|
||||
"invalid_device_error": {
|
||||
|
||||
@@ -357,8 +357,6 @@ export function FileUploadElement({
|
||||
isUploading={isUploading}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
placeholderText={t("errors.file_input.placeholder_text")}
|
||||
uploadingText={t("errors.file_input.uploading")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,6 @@ import { ZStorageUrl } from "./common";
|
||||
|
||||
export const ZCloudBillingPlan = z.enum(["hobby", "pro", "scale", "custom", "unknown"]);
|
||||
export type TCloudBillingPlan = z.infer<typeof ZCloudBillingPlan>;
|
||||
export const ZCloudBillingInterval = z.enum(["monthly", "yearly"]);
|
||||
export type TCloudBillingInterval = z.infer<typeof ZCloudBillingInterval>;
|
||||
export const ZOrganizationStripeSubscriptionStatus = z.enum([
|
||||
"trialing",
|
||||
"active",
|
||||
@@ -17,17 +15,8 @@ export const ZOrganizationStripeSubscriptionStatus = z.enum([
|
||||
]);
|
||||
export type TOrganizationStripeSubscriptionStatus = z.infer<typeof ZOrganizationStripeSubscriptionStatus>;
|
||||
|
||||
export const ZOrganizationStripePendingChange = z.object({
|
||||
type: z.literal("plan_change"),
|
||||
targetPlan: z.enum(["hobby", "pro", "scale"]),
|
||||
targetInterval: ZCloudBillingInterval.nullable(),
|
||||
effectiveAt: z.string(),
|
||||
});
|
||||
export type TOrganizationStripePendingChange = z.infer<typeof ZOrganizationStripePendingChange>;
|
||||
|
||||
export const ZOrganizationStripeBilling = z.object({
|
||||
plan: ZCloudBillingPlan.optional(),
|
||||
interval: ZCloudBillingInterval.nullable().optional(),
|
||||
subscriptionStatus: ZOrganizationStripeSubscriptionStatus.nullable().optional(),
|
||||
subscriptionId: z.string().nullable().optional(),
|
||||
hasPaymentMethod: z.boolean().optional(),
|
||||
@@ -36,7 +25,6 @@ export const ZOrganizationStripeBilling = z.object({
|
||||
lastSyncedAt: z.string().nullable().optional(),
|
||||
lastSyncedEventId: z.string().nullable().optional(),
|
||||
trialEnd: z.string().nullable().optional(),
|
||||
pendingChange: ZOrganizationStripePendingChange.nullable().optional(),
|
||||
});
|
||||
export type TOrganizationStripeBilling = z.infer<typeof ZOrganizationStripeBilling>;
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"STRIPE_PUBLISHABLE_KEY",
|
||||
"STRIPE_PRICING_TABLE_ID",
|
||||
"SURVEYS_PACKAGE_MODE",
|
||||
"SURVEYS_PACKAGE_BUILD",
|
||||
"PUBLIC_URL",
|
||||
|
||||
Reference in New Issue
Block a user