From 9188b129649f203fad28ffcc306755d8cd64f7b4 Mon Sep 17 00:00:00 2001 From: Kaihua Date: Thu, 23 Jul 2026 09:45:41 +0800 Subject: [PATCH] FIX --- package-lock.json | 7 ++++ package.json | 1 + scripts/collect-prices.mjs | 84 ++++++++++++++++++++++++++++++++++---- src/types.ts | 1 + tests/collector.test.mjs | 26 ++++++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index fdb47b3..7ad47cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "chatgpt-business-price-radar", "version": "1.0.0", "dependencies": { + "country-to-currency": "^3.0.1", "lucide-react": "^0.468.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1929,6 +1930,12 @@ "dev": true, "license": "MIT" }, + "node_modules/country-to-currency": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/country-to-currency/-/country-to-currency-3.0.1.tgz", + "integrity": "sha512-FAo2ga8296d0Ra6vj9TB4Kax36AmqrGEc0aoLflCx5ijMtifNwA8lCqQsFfTLP2lRVKHWh/DedSpqI6v9VHyjw==", + "license": "MIT" + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", diff --git a/package.json b/package.json index aabe442..aaf2e14 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "preview": "vite preview" }, "dependencies": { + "country-to-currency": "^3.0.1", "lucide-react": "^0.468.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/scripts/collect-prices.mjs b/scripts/collect-prices.mjs index f314a3b..ab16dd2 100644 --- a/scripts/collect-prices.mjs +++ b/scripts/collect-prices.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import countryToCurrency from "country-to-currency"; import { z } from "zod"; export const ISO_COUNTRY_CODES = ` @@ -34,11 +35,22 @@ const upstreamSchema = z.object({ amount: z.number().positive().finite(), tax: z.enum(["inclusive", "exclusive"]), }), + year: z.object({ + amount: z.number().positive().finite(), + }).passthrough().optional(), }), - }), - symbol_code: z.string().length(3), - symbol: z.string().min(1), - minor_unit_exponent: z.number().int().min(0).max(4).optional().default(2), + symbol_code: z.string().length(3).optional(), + currency_code: z.string().length(3).optional(), + currency: z.string().length(3).optional(), + symbol: z.string().min(1).optional(), + }).passthrough(), + symbol_code: z.string().length(3).optional(), + currency_code: z.string().length(3).optional(), + currency: z.string().length(3).optional(), + symbol: z.string().min(1).optional(), + minor_unit_exponent: z.number().int().min(0).max(4).optional(), + pricing_rollout_gate: z.string().optional(), + amount_per_credit: z.number().finite().optional(), tax_type: z.string().min(1).nullable().optional(), tax_percent: z.number().finite().nullable().optional(), }).passthrough(); @@ -77,12 +89,14 @@ export function parsePriceResponse(raw, requestedCode, fetchedAt = new Date().to throw new Error(`Country mismatch: requested ${countryCode}, received ${data.country_code}`); } + const currency = resolveCurrency(data, countryCode); return { countryCode, countryName: countryNames.of(countryCode) || countryCode, - currencyCode: data.symbol_code.toUpperCase(), - symbol: data.symbol, - minorUnitExponent: data.minor_unit_exponent, + currencyCode: currency.code, + currencySource: currency.source, + symbol: data.symbol || data.currency_config.symbol || currencySymbol(currency.code), + minorUnitExponent: data.minor_unit_exponent ?? currencyDigits(currency.code), localAmount: data.currency_config.business.month.amount, taxTreatment: data.currency_config.business.month.tax, taxType: data.tax_type ?? null, @@ -188,7 +202,57 @@ export async function fetchJsonWithRetry(url, options = {}) { async function collectCountry(countryCode, fetchedAt) { const result = await fetchJsonWithRetry(sourceUrl(countryCode)); if (result.kind === "unsupported") return { kind: "unsupported", countryCode }; - return { kind: "success", countryCode, row: parsePriceResponse(result.data, countryCode, fetchedAt) }; + return { + kind: "success", + countryCode, + responseKeys: Object.keys(result.data || {}).sort(), + row: parsePriceResponse(result.data, countryCode, fetchedAt), + }; +} + +function resolveCurrency(data, countryCode) { + const explicit = data.symbol_code + || data.currency_code + || data.currency + || data.currency_config.symbol_code + || data.currency_config.currency_code + || data.currency_config.currency; + if (explicit) return { code: explicit.toUpperCase(), source: "api" }; + + const rolloutCurrency = data.pricing_rollout_gate?.match(/pricing_enabled_for_([a-z]{3})/i)?.[1]; + if (rolloutCurrency) return { code: rolloutCurrency.toUpperCase(), source: "rollout" }; + + const monthAmount = data.currency_config.business.month.amount; + const yearAmount = data.currency_config.business.year?.amount; + const oneDollarAmount = data.promos?.business_one_dollar?.amount; + const looksLikeUsdProfile = monthAmount === 25 + && (yearAmount === 20 || data.amount_per_credit === 0.04 || oneDollarAmount === 1); + if (looksLikeUsdProfile) return { code: "USD", source: "usd-profile" }; + + const countryCurrency = countryToCurrency[countryCode]; + if (countryCurrency) return { code: countryCurrency, source: "country-default" }; + throw new Error(`Currency metadata is missing for ${countryCode} and no safe fallback is available.`); +} + +function currencySymbol(currencyCode) { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode, + currencyDisplay: "narrowSymbol", + }).formatToParts(0).find((part) => part.type === "currency")?.value || currencyCode; + } catch { + return currencyCode; + } +} + +function currencyDigits(currencyCode) { + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency: currencyCode }) + .resolvedOptions().maximumFractionDigits; + } catch { + return 2; + } } function requestHeaders(url) { @@ -391,7 +455,9 @@ async function runCli() { if (probeCode) { const result = await collectCountry(probeCode.toUpperCase(), new Date().toISOString()); if (result.kind !== "success") throw new Error(`Pricing probe ${probeCode.toUpperCase()} returned ${result.kind}.`); - console.log(`[pricing] probe ${result.countryCode} ok: ${result.row.currencyCode} ${result.row.localAmount}`); + console.log( + `[pricing] probe ${result.countryCode} ok: ${result.row.currencyCode} ${result.row.localAmount}; currency-source=${result.row.currencySource}; response-keys=${result.responseKeys.join(",")}`, + ); return; } const outputPath = cliValue("--output") || process.env.OUTPUT_PATH || "public/data/prices.json"; diff --git a/src/types.ts b/src/types.ts index c907c40..a4608cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export type PriceRow = { countryCode: string; countryName: string; currencyCode: string; + currencySource?: "api" | "rollout" | "usd-profile" | "country-default"; symbol: string; minorUnitExponent: number; localAmount: number; diff --git a/tests/collector.test.mjs b/tests/collector.test.mjs index 32f4f32..39fc329 100644 --- a/tests/collector.test.mjs +++ b/tests/collector.test.mjs @@ -53,6 +53,32 @@ describe("official Business monthly parser", () => { expect(() => parsePriceResponse(raw, "SG")).toThrow(/Country mismatch/); }); + it("recovers omitted currency metadata from safe fallbacks", async () => { + const us = await fixture("us.json"); + delete us.symbol_code; + delete us.symbol; + delete us.minor_unit_exponent; + us.currency_config.business.year = { amount: 20 }; + us.amount_per_credit = 0.04; + expect(parsePriceResponse(us, "US")).toMatchObject({ + currencyCode: "USD", + currencySource: "usd-profile", + symbol: "$", + minorUnitExponent: 2, + }); + + const sg = await fixture("sg.json"); + delete sg.symbol_code; + delete sg.symbol; + sg.pricing_rollout_gate = "is_pricing_enabled_for_sgd"; + expect(parsePriceResponse(sg, "SG")).toMatchObject({ currencyCode: "SGD", currencySource: "rollout" }); + + const br = await fixture("br.json"); + delete br.symbol_code; + delete br.symbol; + expect(parsePriceResponse(br, "BR")).toMatchObject({ currencyCode: "BRL", currencySource: "country-default" }); + }); + it("classifies a 404 response as unsupported", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 })); await expect(fetchJsonWithRetry("https://example.com/ZZ", { retries: 1 })).resolves.toEqual({