This commit is contained in:
Kaihua
2026-07-23 09:45:41 +08:00
parent 32b56aeb69
commit 9188b12964
5 changed files with 110 additions and 9 deletions
+7
View File
@@ -8,6 +8,7 @@
"name": "chatgpt-business-price-radar", "name": "chatgpt-business-price-radar",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"country-to-currency": "^3.0.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -1929,6 +1930,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+1
View File
@@ -16,6 +16,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"country-to-currency": "^3.0.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
+75 -9
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import countryToCurrency from "country-to-currency";
import { z } from "zod"; import { z } from "zod";
export const ISO_COUNTRY_CODES = ` export const ISO_COUNTRY_CODES = `
@@ -34,11 +35,22 @@ const upstreamSchema = z.object({
amount: z.number().positive().finite(), amount: z.number().positive().finite(),
tax: z.enum(["inclusive", "exclusive"]), tax: z.enum(["inclusive", "exclusive"]),
}), }),
year: z.object({
amount: z.number().positive().finite(),
}).passthrough().optional(),
}), }),
}), symbol_code: z.string().length(3).optional(),
symbol_code: z.string().length(3), currency_code: z.string().length(3).optional(),
symbol: z.string().min(1), currency: z.string().length(3).optional(),
minor_unit_exponent: z.number().int().min(0).max(4).optional().default(2), 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_type: z.string().min(1).nullable().optional(),
tax_percent: z.number().finite().nullable().optional(), tax_percent: z.number().finite().nullable().optional(),
}).passthrough(); }).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}`); throw new Error(`Country mismatch: requested ${countryCode}, received ${data.country_code}`);
} }
const currency = resolveCurrency(data, countryCode);
return { return {
countryCode, countryCode,
countryName: countryNames.of(countryCode) || countryCode, countryName: countryNames.of(countryCode) || countryCode,
currencyCode: data.symbol_code.toUpperCase(), currencyCode: currency.code,
symbol: data.symbol, currencySource: currency.source,
minorUnitExponent: data.minor_unit_exponent, 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, localAmount: data.currency_config.business.month.amount,
taxTreatment: data.currency_config.business.month.tax, taxTreatment: data.currency_config.business.month.tax,
taxType: data.tax_type ?? null, taxType: data.tax_type ?? null,
@@ -188,7 +202,57 @@ export async function fetchJsonWithRetry(url, options = {}) {
async function collectCountry(countryCode, fetchedAt) { async function collectCountry(countryCode, fetchedAt) {
const result = await fetchJsonWithRetry(sourceUrl(countryCode)); const result = await fetchJsonWithRetry(sourceUrl(countryCode));
if (result.kind === "unsupported") return { kind: "unsupported", 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) { function requestHeaders(url) {
@@ -391,7 +455,9 @@ async function runCli() {
if (probeCode) { if (probeCode) {
const result = await collectCountry(probeCode.toUpperCase(), new Date().toISOString()); const result = await collectCountry(probeCode.toUpperCase(), new Date().toISOString());
if (result.kind !== "success") throw new Error(`Pricing probe ${probeCode.toUpperCase()} returned ${result.kind}.`); 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; return;
} }
const outputPath = cliValue("--output") || process.env.OUTPUT_PATH || "public/data/prices.json"; const outputPath = cliValue("--output") || process.env.OUTPUT_PATH || "public/data/prices.json";
+1
View File
@@ -6,6 +6,7 @@ export type PriceRow = {
countryCode: string; countryCode: string;
countryName: string; countryName: string;
currencyCode: string; currencyCode: string;
currencySource?: "api" | "rollout" | "usd-profile" | "country-default";
symbol: string; symbol: string;
minorUnitExponent: number; minorUnitExponent: number;
localAmount: number; localAmount: number;
+26
View File
@@ -53,6 +53,32 @@ describe("official Business monthly parser", () => {
expect(() => parsePriceResponse(raw, "SG")).toThrow(/Country mismatch/); 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 () => { it("classifies a 404 response as unsupported", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 })); vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
await expect(fetchJsonWithRetry("https://example.com/ZZ", { retries: 1 })).resolves.toEqual({ await expect(fetchJsonWithRetry("https://example.com/ZZ", { retries: 1 })).resolves.toEqual({