From 7274d0fa4f4e85250c4e8d7b98676b6949d5b38e Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Tue, 18 Aug 2026 17:19:58 -0300 Subject: [PATCH 1/3] exclude credit grants from product line amounts --- .../api/stripe/integration/webhook/invoice-paid.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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, }; }) From 71b49ee97d0d4179608f9355f953c39856bc1bf4 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 20 Aug 2026 10:08:28 -0300 Subject: [PATCH 2/3] keep short link keys readable when truncating --- apps/web/tests/misc/smart-truncate.test.ts | 175 ++++++++++++++++++ .../utils/src/functions/smart-truncate.ts | 47 +++-- 2 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 apps/web/tests/misc/smart-truncate.test.ts 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}`; }; From 9eb512dd096d20d59c425716e34e91a54545c514 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 20 Aug 2026 11:14:15 -0300 Subject: [PATCH 3/3] round scraped social counts so does not crash on BigInt --- apps/web/lib/api/scrape-creators/schema.ts | 107 +++++------------- .../tests/misc/scraped-count-schema.test.ts | 21 ++++ 2 files changed, 47 insertions(+), 81 deletions(-) create mode 100644 apps/web/tests/misc/scraped-count-schema.test.ts 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); + }); +});