FIX
This commit is contained in:
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user