diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 9c03fbd7ad0..0e3058e85c1 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -310,9 +310,19 @@ export async function invoicePaid({ if (!productId) return null; + // Credit grants sit on the line, not in line.amount — subtract so + // productId rewards commission on the portion actually charged. + const creditGrantAmount = (line.pretax_credit_amounts ?? []).reduce( + (sum, credit) => + credit.type === "credit_balance_transaction" + ? sum + credit.amount + : sum, + 0, + ); + return { id: productId, - amount: line.amount, + amount: Math.max(line.amount - creditGrantAmount, 0), quantity: line.quantity ?? 0, }; }) diff --git a/apps/web/lib/api/scrape-creators/schema.ts b/apps/web/lib/api/scrape-creators/schema.ts index 8125409d023..532da3cc86d 100644 --- a/apps/web/lib/api/scrape-creators/schema.ts +++ b/apps/web/lib/api/scrape-creators/schema.ts @@ -1,5 +1,10 @@ import * as z from "zod/v4"; +export const scrapedCountSchema = z + .number() + .nullish() + .transform((val) => Math.round(val ?? 0)); + export const socialProfileSchema = z.preprocess( (data: any) => { if (typeof data === "object" && data !== null) { @@ -65,18 +70,9 @@ export const socialProfileSchema = z.preprocess( platform: z.literal("youtube"), description: z.string(), channelId: z.string(), - videoCount: z - .number() - .nullish() - .transform((val) => val ?? 0), - subscriberCount: z - .number() - .nullish() - .transform((val) => val ?? 0), - viewCount: z - .number() - .nullish() - .transform((val) => val ?? 0), + videoCount: scrapedCountSchema, + subscriberCount: scrapedCountSchema, + viewCount: scrapedCountSchema, avatar: z.object({ image: z.object({ sources: z.array( @@ -96,16 +92,10 @@ export const socialProfileSchema = z.preprocess( user: z.object({ biography: z.string(), edge_followed_by: z.object({ - count: z - .number() - .nullish() - .transform((val) => val ?? 0), + count: scrapedCountSchema, }), edge_owner_to_timeline_media: z.object({ - count: z - .number() - .nullish() - .transform((val) => val ?? 0), + count: scrapedCountSchema, }), profile_pic_url: z.url().nullish().default(null), }), @@ -121,18 +111,9 @@ export const socialProfileSchema = z.preprocess( avatarThumb: z.url().nullish().default(null), }), stats: z.object({ - followerCount: z - .number() - .nullish() - .transform((val) => val ?? 0), - videoCount: z - .number() - .nullish() - .transform((val) => val ?? 0), - heartCount: z - .number() - .nullish() - .transform((val) => val ?? 0), + followerCount: scrapedCountSchema, + videoCount: scrapedCountSchema, + heartCount: scrapedCountSchema, }), }), @@ -141,14 +122,8 @@ export const socialProfileSchema = z.preprocess( rest_id: z.string(), legacy: z.object({ description: z.string(), - followers_count: z - .number() - .nullish() - .transform((val) => val ?? 0), - statuses_count: z - .number() - .nullish() - .transform((val) => val ?? 0), + followers_count: scrapedCountSchema, + statuses_count: scrapedCountSchema, }), avatar: z.object({ image_url: z.url().nullish().default(null), @@ -211,14 +186,8 @@ export const socialContentSchema = z.preprocess( id: z.string(), handle: z.string(), }), - viewCountInt: z - .number() - .nullable() - .transform((val) => val ?? 0), - likeCountInt: z - .number() - .nullable() - .transform((val) => val ?? 0), + viewCountInt: scrapedCountSchema, + likeCountInt: scrapedCountSchema, title: z.string().nullish(), description: z.string().nullish(), thumbnailUrl: z.string().nullish(), @@ -230,19 +199,10 @@ export const socialContentSchema = z.preprocess( owner: z.object({ username: z.string(), }), - video_play_count: z - .number() - .nullish() - .transform((val) => val ?? 0), - video_view_count: z - .number() - .nullish() - .transform((val) => val ?? 0), + video_play_count: scrapedCountSchema, + video_view_count: scrapedCountSchema, edge_media_preview_like: z.object({ - count: z - .number() - .nullable() - .transform((val) => val ?? 0), + count: scrapedCountSchema, }), edge_media_to_caption: z .object({ @@ -285,14 +245,11 @@ export const socialContentSchema = z.preprocess( count: z .string() .nullable() - .transform((val) => (val == null ? 0 : Number(val))), + .transform((val) => (val == null ? 0 : Math.round(Number(val)))), }), legacy: z.object({ created_at: z.string(), - favorite_count: z - .number() - .nullable() - .transform((val) => val ?? 0), + favorite_count: scrapedCountSchema, full_text: z.string().optional(), }), }), @@ -304,14 +261,8 @@ export const socialContentSchema = z.preprocess( unique_id: z.string(), }), statistics: z.object({ - play_count: z - .number() - .nullable() - .transform((val) => val ?? 0), - digg_count: z - .number() - .nullable() - .transform((val) => val ?? 0), + play_count: scrapedCountSchema, + digg_count: scrapedCountSchema, }), desc: z.string().optional(), video: z @@ -331,16 +282,10 @@ export const socialContentSchema = z.preprocess( description: z.string().nullish(), headline: z.string().nullish(), datePublished: z.string().nullish(), - likeCount: z - .number() - .nullish() - .transform((val) => val ?? 0), + likeCount: scrapedCountSchema, author: z.object({ url: z.string(), - followers: z - .number() - .nullish() - .transform((val) => val ?? 0), + followers: scrapedCountSchema, }), }), ]), diff --git a/apps/web/tests/misc/scraped-count-schema.test.ts b/apps/web/tests/misc/scraped-count-schema.test.ts new file mode 100644 index 00000000000..09da077b9ef --- /dev/null +++ b/apps/web/tests/misc/scraped-count-schema.test.ts @@ -0,0 +1,21 @@ +import { scrapedCountSchema } from "@/lib/api/scrape-creators/schema"; +import { describe, expect, it } from "vitest"; + +describe("scrapedCountSchema", () => { + it("rounds abbreviated-count float artifacts to the nearest integer", () => { + expect(scrapedCountSchema.parse(16.1 * 1000)).toBe(16100); + expect(scrapedCountSchema.parse(16099.999999999998)).toBe(16100); + expect(BigInt(scrapedCountSchema.parse(16.1 * 1000))).toBe(16100n); + }); + + it("passes integers through unchanged", () => { + expect(scrapedCountSchema.parse(0)).toBe(0); + expect(scrapedCountSchema.parse(16100)).toBe(16100); + expect(scrapedCountSchema.parse(1_000_000)).toBe(1_000_000); + }); + + it("coerces null and undefined to 0", () => { + expect(scrapedCountSchema.parse(null)).toBe(0); + expect(scrapedCountSchema.parse(undefined)).toBe(0); + }); +}); diff --git a/apps/web/tests/misc/smart-truncate.test.ts b/apps/web/tests/misc/smart-truncate.test.ts new file mode 100644 index 00000000000..5fbb838dec5 --- /dev/null +++ b/apps/web/tests/misc/smart-truncate.test.ts @@ -0,0 +1,175 @@ +import { smartTruncate } from "@dub/utils"; +import { describe, expect, it } from "vitest"; + +const LIMIT = 33; + +describe("smartTruncate", () => { + describe("protocol handling", () => { + it("strips https:// and returns the pretty URL when it fits", () => { + expect(smartTruncate("https://refer.acme.com/partner", LIMIT)).toBe( + "refer.acme.com/partner", + ); + expect(smartTruncate("https://refer.acme.com/f6s", LIMIT)).toBe( + "refer.acme.com/f6s", + ); + expect(smartTruncate("https://refer.acme.com/twitter", LIMIT)).toBe( + "refer.acme.com/twitter", + ); + }); + + it("strips http:// the same way", () => { + expect(smartTruncate("http://refer.acme.com/partner", LIMIT)).toBe( + "refer.acme.com/partner", + ); + }); + + it("leaves already-pretty URLs unchanged when they fit", () => { + expect(smartTruncate("refer.acme.com/partner", LIMIT)).toBe( + "refer.acme.com/partner", + ); + }); + + it("produces the same output for stored and pretty inputs", () => { + const stored = + "https://acme.com/super-long-path-that-is-way-too-long-and-should-be-truncated"; + const pretty = + "acme.com/super-long-path-that-is-way-too-long-and-should-be-truncated"; + + expect(smartTruncate(stored, LIMIT)).toBe(smartTruncate(pretty, LIMIT)); + }); + + it("preserves www. on custom domains", () => { + expect(smartTruncate("https://www.acme.com/launch", LIMIT)).toBe( + "www.acme.com/launch", + ); + }); + }); + + describe("short links that fit after stripping the protocol", () => { + it("keeps default nanoid, dub.link, .dub.link, and branded domains", () => { + expect(smartTruncate("https://dub.sh/xYz9AbC", LIMIT)).toBe( + "dub.sh/xYz9AbC", + ); + expect(smartTruncate("https://dub.link/abcde", LIMIT)).toBe( + "dub.link/abcde", + ); + expect(smartTruncate("https://acme.dub.link/promo", LIMIT)).toBe( + "acme.dub.link/promo", + ); + expect(smartTruncate("https://git.new/dub", LIMIT)).toBe("git.new/dub"); + }); + + it("keeps dotted, hyphenated, and underscored keys", () => { + expect(smartTruncate("https://acme.com/file.name", LIMIT)).toBe( + "acme.com/file.name", + ); + expect(smartTruncate("https://acme.com/launch-2024", LIMIT)).toBe( + "acme.com/launch-2024", + ); + expect(smartTruncate("https://acme.com/my_link", LIMIT)).toBe( + "acme.com/my_link", + ); + }); + + it("keeps prefixed and nested keys that fit", () => { + expect(smartTruncate("https://dub.sh/gh/xYz9AbC", LIMIT)).toBe( + "dub.sh/gh/xYz9AbC", + ); + expect(smartTruncate("https://acme.com/linkedin/more/path", LIMIT)).toBe( + "acme.com/linkedin/more/path", + ); + }); + }); + + describe("root domain links", () => { + it("strips the protocol and does not append a slash", () => { + expect(smartTruncate("https://acme.com", LIMIT)).toBe("acme.com"); + }); + + it("truncates a long apex while preserving the TLD", () => { + expect( + smartTruncate("https://verylongcustomapexdomainnamehere.com", LIMIT), + ).toBe("verylongcustomapexdomainnam...com"); + }); + }); + + describe("path-first truncation", () => { + it("keeps a short key and TLD-truncates a long domain", () => { + expect( + smartTruncate("https://superlongcustomdomainnamehere.com/x", LIMIT), + ).toBe("superlongcustomdomainname...com/x"); + }); + + it("keeps a short domain and left-truncates a long key", () => { + expect( + smartTruncate( + "https://acme.com/super-long-path-that-is-way-too-long-and-should-be-truncated", + LIMIT, + ), + ).toBe("acme.com/super-long-path-that-..."); + }); + + it("truncates both when domain and path overflow", () => { + expect( + smartTruncate( + "https://acmesuperlongdomain.com/super-long-path-that-is-way-too-long-and-should-be-truncated", + LIMIT, + ), + ).toBe("ac...com/super-long-path-that-..."); + }); + + it("left-truncates nested keys from the start", () => { + expect( + smartTruncate( + "https://acme.com/linkedin/more/path/that/is/very/long", + LIMIT, + ), + ).toBe("acme.com/linkedin/more/path/th..."); + }); + }); + + describe("punycode and case-sensitive keys", () => { + it("parses punycode hosts and keys without decoding them", () => { + expect(smartTruncate("https://xn--n3h.com/xn--fsq", LIMIT)).toBe( + "xn--n3h.com/xn--fsq", + ); + expect( + smartTruncate( + "https://xn--n3h.com/xn--longpunycodekeythatexceedsthelimit", + LIMIT, + ), + ).toBe("xn...com/xn--longpunycodekeyth..."); + }); + + it("treats case-sensitive encoded keys as a normal path", () => { + expect(smartTruncate("https://acme.co/cAsE-sensitive-TeSt", LIMIT)).toBe( + "acme.co/cAsE-sensitive-TeSt", + ); + expect( + smartTruncate( + "https://acme.co/VeryLongCaseSensitiveEncodedKeyValueHere", + LIMIT, + ), + ).toBe("acme.co/VeryLongCaseSensitive..."); + }); + }); + + describe("length budget", () => { + it("never exceeds maxLength when truncation runs", () => { + const inputs = [ + "https://refer.acme.com/partner-name-that-is-quite-long", + "https://acmesuperlongdomain.com/super-long-path-that-is-way-too-long-and-should-be-truncated", + "https://verylongcustomapexdomainnamehere.com", + "https://superlongcustomdomainnamehere.com/x", + "https://acme.com/linkedin/more/path/that/is/very/long", + "https://xn--n3h.com/xn--longpunycodekeythatexceedsthelimit", + "http://acmesuperlongdomain.com/gh/prefixed-key-that-is-also-very-long", + ]; + + for (const input of inputs) { + const output = smartTruncate(input, LIMIT); + expect(output.length).toBeLessThanOrEqual(LIMIT); + } + }); + }); +}); diff --git a/packages/utils/src/functions/smart-truncate.ts b/packages/utils/src/functions/smart-truncate.ts index e5815c2a000..4b1b27add68 100644 --- a/packages/utils/src/functions/smart-truncate.ts +++ b/packages/utils/src/functions/smart-truncate.ts @@ -1,40 +1,51 @@ -/* +/* smart truncation algorithm that dynamically adjusts based on the length of the domain and the path it gives priority to the path and truncates the domain if it's too long at minimum the domain should still show 8 characters though + strips http(s):// so stored shortLinks parse as domain/key */ import { truncate } from "./truncate"; -// Function to truncate domain while preserving TLD +// Truncate domain while preserving TLD. maxLength is the total output budget. const truncateDomain = (domain: string, maxLength: number): string => { + if (domain.length <= maxLength) { + return domain; + } + const parts = domain.split("."); const tld = parts.pop() || ""; const rest = parts.join("."); + const restBudget = maxLength - 3 - tld.length; - return `${rest.slice(0, maxLength)}...${tld}`; + if (!rest || restBudget <= 0) { + return truncate(domain, maxLength) ?? domain; + } + + return `${rest.slice(0, restBudget)}...${tld}`; }; export const smartTruncate = (link: string, maxLength: number): string => { - if (link.length <= maxLength) { - return link; - } + const pretty = link.replace(/^https?:\/\//, ""); - const [domain, ...pathParts] = link.split("/"); - const path = pathParts.join("/"); - const minDomainLength = 8; + if (pretty.length <= maxLength) return pretty; - // calculate max path length - const maxPathLength = maxLength - minDomainLength; + const [domain, ...pathParts] = pretty.split("/"); + const path = pathParts.join("/"); - // Truncate path - const truncatedPath = truncate(path, maxPathLength)!; + // Root domain links have no path — never append "/" + if (!path) return truncateDomain(domain, maxLength); - // Truncate domain if necessary, preserving TLD - const truncatedDomain = truncateDomain( - domain, - maxLength - truncatedPath.length, - ); + const minDomainLength = 8; + const maxPathLength = Math.max(maxLength - minDomainLength - 1, 0); + const truncatedPath = + maxPathLength > 0 ? truncate(path, maxPathLength) ?? "" : ""; + + const domainBudget = Math.max(maxLength - truncatedPath.length - 1, 0); + const truncatedDomain = + domain.length <= domainBudget + ? domain + : truncateDomain(domain, domainBudget); return `${truncatedDomain}/${truncatedPath}`; };