diff --git a/tools/test-js-with-node b/tools/test-js-with-node index a2a73594be..b967924cb2 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -54,9 +54,9 @@ EXEMPT_FILES = make_set( "web/src/assets.d.ts", "web/src/attachments_ui.js", "web/src/avatar.js", - "web/src/billing/event_status.js", - "web/src/billing/helpers.js", - "web/src/billing/upgrade.js", + "web/src/billing/event_status.ts", + "web/src/billing/helpers.ts", + "web/src/billing/upgrade.ts", "web/src/blueslip.ts", "web/src/blueslip_stacktrace.ts", "web/src/click_handlers.js", diff --git a/web/src/billing/billing.js b/web/src/billing/billing.ts similarity index 75% rename from web/src/billing/billing.js rename to web/src/billing/billing.ts index 2c49380ebe..4c0a6ed4b4 100644 --- a/web/src/billing/billing.js +++ b/web/src/billing/billing.ts @@ -2,7 +2,7 @@ import $ from "jquery"; import * as helpers from "./helpers"; -export function create_update_license_request() { +export function create_update_license_request(): void { helpers.create_ajax_request( "/json/billing/plan", "licensechange", @@ -12,31 +12,31 @@ export function create_update_license_request() { ); } -export function initialize() { +export function initialize(): void { helpers.set_tab("billing"); helpers.set_sponsorship_form(); $("#update-card-button").on("click", (e) => { - const success_callback = (response) => { - window.location.replace(response.stripe_session_url); - }; helpers.create_ajax_request( "/json/billing/session/start_card_update_session", "cardchange", [], "POST", - success_callback, + (response) => { + const response_data = helpers.stripe_session_url_schema.parse(response); + window.location.replace(response_data.stripe_session_url); + }, ); e.preventDefault(); }); $("#update-licenses-button").on("click", (e) => { - if (helpers.is_valid_input($("#new_licenses_input")) === false) { + if (!helpers.is_valid_input($("#new_licenses_input"))) { return; } e.preventDefault(); - const current_licenses = $("#licensechange-input-section").data("licenses"); - const new_licenses = $("#new_licenses_input").val(); + const current_licenses: number = $("#licensechange-input-section").data("licenses"); + const new_licenses: number = Number.parseInt($("#new_licenses_input").val() as string, 10); if (new_licenses > current_licenses) { $("#new_license_count_holder").text(new_licenses); $("#current_license_count_holder").text(current_licenses); diff --git a/web/src/billing/event_status.js b/web/src/billing/event_status.ts similarity index 51% rename from web/src/billing/event_status.js rename to web/src/billing/event_status.ts index cbc9aeba57..632136470a 100644 --- a/web/src/billing/event_status.js +++ b/web/src/billing/event_status.ts @@ -1,10 +1,31 @@ import $ from "jquery"; +import {z} from "zod"; import * as loading from "../loading"; import * as helpers from "./helpers"; -function update_status_and_redirect(status_message, redirect_to) { +const stripe_response_schema = z.object({ + session: z.object({ + type: z.string(), + stripe_payment_intent_id: z.string().optional(), + status: z.string(), + event_handler: z + .object({ + status: z.string(), + error: z + .object({ + message: z.string(), + }) + .optional(), + }) + .optional(), + }), +}); + +type StripeSession = z.infer["session"]; + +function update_status_and_redirect(status_message: string, redirect_to: string): void { $("#webhook-loading").hide(); $("#webhook-success").show(); $("#webhook-success").text(status_message); @@ -13,26 +34,26 @@ function update_status_and_redirect(status_message, redirect_to) { }, 5000); } -function show_error_message(message) { +function show_error_message(message: string): void { $("#webhook-loading").hide(); $("#webhook-error").show(); $("#webhook-error").text(message); } -function show_html_error_message(rendered_message) { +function show_html_error_message(rendered_message: string): void { $("#webhook-loading").hide(); $("#webhook-error").show(); $("#webhook-error").html(rendered_message); } -function handle_session_complete_event(session) { +function handle_session_complete_event(session: StripeSession): void { let message = ""; let redirect_to = ""; switch (session.type) { case "upgrade_from_billing_page": case "retry_upgrade_with_another_payment_method": message = "We have received your billing details. Attempting to create charge..."; - redirect_to = `/billing/event_status?stripe_payment_intent_id=${session.stripe_payment_intent_id}`; + redirect_to = `/billing/event_status?stripe_payment_intent_id=${session.stripe_payment_intent_id!}`; break; case "free_trial_upgrade_from_billing_page": message = @@ -52,27 +73,29 @@ function handle_session_complete_event(session) { update_status_and_redirect(message, redirect_to); } -async function stripe_checkout_session_status_check(stripe_session_id) { - const response = await $.get("/json/billing/event/status", {stripe_session_id}); - if (response.session.status === "created") { +async function stripe_checkout_session_status_check(stripe_session_id: string): Promise { + const response: unknown = await $.get("/json/billing/event/status", {stripe_session_id}); + const response_data = stripe_response_schema.parse(response); + + if (response_data.session.status === "created") { return false; } - if (response.session.event_handler.status === "started") { + if (response_data.session.event_handler!.status === "started") { return false; } - if (response.session.event_handler.status === "succeeded") { - handle_session_complete_event(response.session); + if (response_data.session.event_handler!.status === "succeeded") { + handle_session_complete_event(response_data.session); return true; } - if (response.session.event_handler.status === "failed") { - show_error_message(response.session.event_handler.error.message); + if (response_data.session.event_handler!.status === "failed") { + show_error_message(response_data.session.event_handler!.error!.message); return true; } return false; } -export function initialize_retry_with_another_card_link_click_handler() { +export function initialize_retry_with_another_card_link_click_handler(): void { $("#retry-with-another-card-link").on("click", (e) => { e.preventDefault(); $("#webhook-error").hide(); @@ -82,41 +105,67 @@ export function initialize_retry_with_another_card_link_click_handler() { [], "POST", (response) => { - window.location.replace(response.stripe_session_url); + const response_data = helpers.stripe_session_url_schema.parse(response); + + window.location.replace(response_data.stripe_session_url); }, ); }); } -export async function stripe_payment_intent_status_check(stripe_payment_intent_id) { - const response = await $.get("/json/billing/event/status", {stripe_payment_intent_id}); +export async function stripe_payment_intent_status_check( + stripe_payment_intent_id: string, +): Promise { + const response: unknown = await $.get("/json/billing/event/status", {stripe_payment_intent_id}); - switch (response.payment_intent.status) { + const response_schema = z.object({ + payment_intent: z.object({ + status: z.string(), + event_handler: z + .object({ + status: z.string(), + error: z + .object({ + message: z.string(), + }) + .optional(), + }) + .optional(), + last_payment_error: z + .object({ + message: z.string(), + }) + .optional(), + }), + }); + const response_data = response_schema.parse(response); + + switch (response_data.payment_intent.status) { case "requires_payment_method": - if (response.payment_intent.event_handler.status === "succeeded") { + if (response_data.payment_intent.event_handler!.status === "succeeded") { show_html_error_message( - response.payment_intent.last_payment_error.message + + response_data.payment_intent.last_payment_error!.message + "
" + 'You can try adding another card or or retry the upgrade.', ); initialize_retry_with_another_card_link_click_handler(); return true; } - if (response.payment_intent.event_handler.status === "failed") { - show_error_message(response.payment_intent.event_handler.error.message); + if (response_data.payment_intent.event_handler!.status === "failed") { + show_error_message(response_data.payment_intent.event_handler!.error!.message); return true; } return false; case "succeeded": - if (response.payment_intent.event_handler.status === "succeeded") { + if (response_data.payment_intent.event_handler!.status === "succeeded") { update_status_and_redirect( "Charge created successfully. Your organization has been upgraded. Redirecting to billing page...", "/billing/", ); return true; } - if (response.payment_intent.event_handler.status === "failed") { - show_error_message(response.payment_intent.event_handler.error.message); + if (response_data.payment_intent.event_handler!.status === "failed") { + show_error_message(response_data.payment_intent.event_handler!.error!.message); return true; } return false; @@ -125,30 +174,30 @@ export async function stripe_payment_intent_status_check(stripe_payment_intent_i } } -export async function check_status() { +export async function check_status(): Promise { if ($("#data").attr("data-stripe-session-id")) { return await stripe_checkout_session_status_check( - $("#data").attr("data-stripe-session-id"), + $("#data").attr("data-stripe-session-id")!, ); } return await stripe_payment_intent_status_check( - $("#data").attr("data-stripe-payment-intent-id"), + $("#data").attr("data-stripe-payment-intent-id")!, ); } -async function start_status_polling() { +async function start_status_polling(): Promise { let completed = false; try { completed = await check_status(); } catch { - setTimeout(start_status_polling, 5000); + setTimeout(() => void start_status_polling(), 5000); } if (!completed) { - setTimeout(start_status_polling, 5000); + setTimeout(() => void start_status_polling(), 5000); } } -async function initialize() { +async function initialize(): Promise { const form_loading = "#webhook-loading"; const form_loading_indicator = "#webhook_loading_indicator"; @@ -161,5 +210,5 @@ async function initialize() { } $(() => { - initialize(); + void initialize(); }); diff --git a/web/src/billing/helpers.js b/web/src/billing/helpers.ts similarity index 77% rename from web/src/billing/helpers.js rename to web/src/billing/helpers.ts index 39d4e04e1b..00473c1d8e 100644 --- a/web/src/billing/helpers.js +++ b/web/src/billing/helpers.ts @@ -1,16 +1,37 @@ import $ from "jquery"; +import {z} from "zod"; import * as loading from "../loading"; import {page_params} from "./page_params"; +type FormDataObject = Record; + +export type Prices = { + monthly: number; + annual: number; +}; + +export type DiscountDetails = { + opensource: string; + research: string; + nonprofit: string; + event: string; + education: string; + education_nonprofit: string; +}; + +export const stripe_session_url_schema = z.object({ + stripe_session_url: z.string(), +}); + export function create_ajax_request( - url, - form_name, - ignored_inputs = [], + url: string, + form_name: string, + ignored_inputs: string[] = [], type = "POST", - success_callback, -) { + success_callback: (response: unknown) => void, +): void { const $form = $(`#${CSS.escape(form_name)}-form`); const form_loading_indicator = `#${CSS.escape(form_name)}_loading_indicator`; const form_input_section = `#${CSS.escape(form_name)}-input-section`; @@ -31,7 +52,7 @@ export function create_ajax_request( $(zulip_limited_section).hide(); $(free_trial_alert_message).hide(); - const data = {}; + const data: FormDataObject = {}; for (const item of $form.serializeArray()) { if (ignored_inputs.includes(item.name)) { @@ -40,11 +61,11 @@ export function create_ajax_request( data[item.name] = item.value; } - $.ajax({ + void $.ajax({ type, url, data, - success(response) { + success(response: unknown) { $(form_loading).hide(); $(form_error).hide(); $(form_success).show(); @@ -67,7 +88,7 @@ export function create_ajax_request( }); } -export function format_money(cents) { +export function format_money(cents: number): string { // allow for small floating point errors cents = Math.ceil(cents - 0.001); let precision; @@ -79,17 +100,17 @@ export function format_money(cents) { return new Intl.NumberFormat("en-US", { minimumFractionDigits: precision, maximumFractionDigits: precision, - }).format((cents / 100).toFixed(precision)); + }).format(Number.parseFloat((cents / 100).toFixed(precision))); } -export function update_charged_amount(prices, schedule) { +export function update_charged_amount(prices: Prices, schedule: keyof Prices): void { $("#charged_amount").text(format_money(page_params.seat_count * prices[schedule])); } -export function update_discount_details(organization_type) { +export function update_discount_details(organization_type: keyof DiscountDetails): void { let discount_notice = "Your organization may be eligible for a discount on Zulip Cloud Standard. Organizations whose members are not employees are generally eligible."; - const discount_details = { + const discount_details: DiscountDetails = { opensource: "Zulip Cloud Standard is free for open-source projects.", research: "Zulip Cloud Standard is free for academic research.", nonprofit: "Zulip Cloud Standard is discounted 85%+ for registered non-profits.", @@ -104,7 +125,7 @@ export function update_discount_details(organization_type) { $("#sponsorship-discount-details").text(discount_notice); } -export function show_license_section(license) { +export function show_license_section(license: string): void { $("#license-automatic-section").hide(); $("#license-manual-section").hide(); @@ -117,14 +138,14 @@ export function show_license_section(license) { $(input_id).prop("disabled", false); } -let current_page; +let current_page: string; -function handle_hashchange() { +function handle_hashchange(): void { $(`#${CSS.escape(current_page)}-tabs.nav a[href="${CSS.escape(location.hash)}"]`).tab("show"); $("html").scrollTop(0); } -export function set_tab(page) { +export function set_tab(page: string): void { const hash = location.hash; if (hash) { $(`#${CSS.escape(page)}-tabs.nav a[href="${CSS.escape(hash)}"]`).tab("show"); @@ -132,14 +153,14 @@ export function set_tab(page) { } $(`#${CSS.escape(page)}-tabs.nav-tabs a`).on("click", function () { - location.hash = this.hash; + location.hash = (this as HTMLAnchorElement).hash; }); current_page = page; window.addEventListener("hashchange", handle_hashchange); } -export function set_sponsorship_form() { +export function set_sponsorship_form(): void { $("#sponsorship-button").on("click", (e) => { if (!is_valid_input($("#sponsorship-form"))) { return; @@ -151,6 +172,6 @@ export function set_sponsorship_form() { }); } -export function is_valid_input(elem) { +export function is_valid_input(elem: JQuery): boolean { return elem[0].checkValidity(); } diff --git a/web/src/billing/upgrade.js b/web/src/billing/upgrade.js deleted file mode 100644 index ecf37fad4e..0000000000 --- a/web/src/billing/upgrade.js +++ /dev/null @@ -1,68 +0,0 @@ -import $ from "jquery"; - -import * as helpers from "./helpers"; -import {page_params} from "./page_params"; - -export const initialize = () => { - helpers.set_tab("upgrade"); - helpers.set_sponsorship_form(); - $("#add-card-button").on("click", (e) => { - const license_management = $("input[type=radio][name=license_management]:checked").val(); - if ( - helpers.is_valid_input($(`#${CSS.escape(license_management)}_license_count`)) === false - ) { - return; - } - e.preventDefault(); - const success_callback = (response) => { - window.location.replace(response.stripe_session_url); - }; - helpers.create_ajax_request( - "/json/billing/upgrade", - "autopay", - [], - "POST", - success_callback, - ); - }); - - $("#invoice-button").on("click", (e) => { - if (helpers.is_valid_input($("#invoiced_licenses")) === false) { - return; - } - e.preventDefault(); - helpers.create_ajax_request("/json/billing/upgrade", "invoice", [], "POST", () => - window.location.replace("/billing/"), - ); - }); - - const prices = {}; - prices.annual = page_params.annual_price * (1 - page_params.percent_off / 100); - prices.monthly = page_params.monthly_price * (1 - page_params.percent_off / 100); - - $("input[type=radio][name=license_management]").on("change", function () { - helpers.show_license_section(this.value); - }); - - $("input[type=radio][name=schedule]").on("change", function () { - helpers.update_charged_amount(prices, this.value); - }); - - $("select[name=organization-type]").on("change", (e) => { - const string_value = $(e.currentTarget.selectedOptions).attr("data-string-value"); - helpers.update_discount_details(string_value); - }); - - $("#autopay_annual_price").text(helpers.format_money(prices.annual)); - $("#autopay_annual_price_per_month").text(helpers.format_money(prices.annual / 12)); - $("#autopay_monthly_price").text(helpers.format_money(prices.monthly)); - $("#invoice_annual_price").text(helpers.format_money(prices.annual)); - $("#invoice_annual_price_per_month").text(helpers.format_money(prices.annual / 12)); - - helpers.show_license_section($("input[type=radio][name=license_management]:checked").val()); - helpers.update_charged_amount(prices, $("input[type=radio][name=schedule]:checked").val()); -}; - -$(() => { - initialize(); -}); diff --git a/web/src/billing/upgrade.ts b/web/src/billing/upgrade.ts new file mode 100644 index 0000000000..f14e6f4722 --- /dev/null +++ b/web/src/billing/upgrade.ts @@ -0,0 +1,72 @@ +import $ from "jquery"; + +import * as helpers from "./helpers"; +import type {DiscountDetails, Prices} from "./helpers"; +import {page_params} from "./page_params"; + +export const initialize = (): void => { + helpers.set_tab("upgrade"); + helpers.set_sponsorship_form(); + $("#add-card-button").on("click", (e) => { + const license_management: string = $( + "input[type=radio][name=license_management]:checked", + ).val() as string; + if (!helpers.is_valid_input($(`#${CSS.escape(license_management)}_license_count`))) { + return; + } + e.preventDefault(); + + helpers.create_ajax_request("/json/billing/upgrade", "autopay", [], "POST", (response) => { + const response_data = helpers.stripe_session_url_schema.parse(response); + window.location.replace(response_data.stripe_session_url); + }); + }); + + $("#invoice-button").on("click", (e) => { + if (!helpers.is_valid_input($("#invoiced_licenses"))) { + return; + } + e.preventDefault(); + helpers.create_ajax_request("/json/billing/upgrade", "invoice", [], "POST", () => + window.location.replace("/billing/"), + ); + }); + + const prices: Prices = { + annual: page_params.annual_price * (1 - page_params.percent_off / 100), + monthly: page_params.monthly_price * (1 - page_params.percent_off / 100), + }; + + $("input[type=radio][name=license_management]").on("change", function (this: HTMLInputElement) { + helpers.show_license_section(this.value); + }); + + $("input[type=radio][name=schedule]").on("change", function (this: HTMLInputElement) { + helpers.update_charged_amount(prices, this.value as keyof Prices); + }); + + $("select[name=organization-type]").on("change", (e) => { + const string_value = $((e.currentTarget as HTMLSelectElement).selectedOptions).attr( + "data-string-value", + ); + helpers.update_discount_details(string_value as keyof DiscountDetails); + }); + + $("#autopay_annual_price").text(helpers.format_money(prices.annual)); + $("#autopay_annual_price_per_month").text(helpers.format_money(prices.annual / 12)); + $("#autopay_monthly_price").text(helpers.format_money(prices.monthly)); + $("#invoice_annual_price").text(helpers.format_money(prices.annual)); + $("#invoice_annual_price_per_month").text(helpers.format_money(prices.annual / 12)); + + helpers.show_license_section( + $("input[type=radio][name=license_management]:checked").val() as string, + ); + helpers.update_charged_amount( + prices, + $("input[type=radio][name=schedule]:checked").val() as keyof Prices, + ); +}; + +$(() => { + initialize(); +}); diff --git a/web/src/global.d.ts b/web/src/global.d.ts index fb57733d30..43869d8aaf 100644 --- a/web/src/global.d.ts +++ b/web/src/global.d.ts @@ -16,6 +16,7 @@ type JQueryCaretRange = { interface JQuery { expectOne(): JQuery; tab(action?: string): this; // From web/third/bootstrap + modal(action?: string): this; // From web/third/bootstrap // Types for jquery-caret-plugin caret(): number; diff --git a/web/tests/billing.test.js b/web/tests/billing.test.js index dfe425e146..3102480043 100644 --- a/web/tests/billing.test.js +++ b/web/tests/billing.test.js @@ -42,6 +42,11 @@ run_test("initialize", ({override}) => { run_test("card_update", ({override}) => { override(helpers, "set_tab", () => {}); + override(helpers, "stripe_session_url_schema", { + parse(obj) { + return obj; + }, + }); let create_ajax_request_called = false; function card_change_ajax(url, form_name, ignored_inputs, method, success_callback) { assert.equal(url, "/json/billing/session/start_card_update_session"); diff --git a/web/tests/event_status.test.js b/web/tests/event_status.test.js index a9585450cf..e6c7d5322c 100644 --- a/web/tests/event_status.test.js +++ b/web/tests/event_status.test.js @@ -24,6 +24,11 @@ run_test("initialize_retry_with_another_card_link_click_handler", ({override}) = }); callback({stripe_session_url: "stripe_session_url"}); }); + override(helpers, "stripe_session_url_schema", { + parse(obj) { + return obj; + }, + }); event_status.initialize_retry_with_another_card_link_click_handler(); const retry_click_handler = $("#retry-with-another-card-link").get_on_handler("click"); retry_click_handler({preventDefault() {}}); @@ -37,6 +42,7 @@ run_test("check_status", async ({override}) => { return { session: { status: "created", + type: "upgrade_from_billing_page", }, }; }); @@ -49,6 +55,7 @@ run_test("check_status", async ({override}) => { return { session: { status: "completed", + type: "upgrade_from_billing_page", event_handler: { status: "started", }, diff --git a/web/webpack.assets.json b/web/webpack.assets.json index c3ee2ad715..64d0ae9185 100644 --- a/web/webpack.assets.json +++ b/web/webpack.assets.json @@ -32,7 +32,7 @@ "./src/billing/page_params", "./src/bundles/portico", "./styles/portico/landing_page.css", - "./src/billing/event_status.js", + "./src/billing/event_status", "./src/billing/helpers", "./styles/portico/billing.css" ],