| 1 | import { createHmac, randomBytes } from "node:crypto"; |
| 2 | import { MerchError, object } from "./model"; |
| 3 | import type { MerchEnv } from "./types"; |
| 4 | import { readBoundedBody } from "../bounded-body"; |
| 5 | |
| 6 | const API = "https://www.yoycol.com"; |
| 7 | export function yoycolHeaders(method: string, path: string, query: Record<string, string>, accessKey: string, secretKey: string, timestamp = String(Date.now()), nonce = randomBytes(16).toString("hex")) { |
| 8 | const lines = ["method=" + method.toUpperCase(), "path=" + path, "timestamp=" + timestamp, "nonce=" + nonce, "accessKey=" + accessKey, "algorithm=HmacSHA256", "version=4.0"]; |
| 9 | const params = Object.keys(query).sort().map(key => key + "=" + query[key]).join("&"); |
| 10 | if (params) lines.push("params=" + params); |
| 11 | return { "X-API-Access-Key": accessKey, "X-API-Timestamp": timestamp, "X-API-Nonce": nonce, "X-API-Algorithm": "HmacSHA256", "X-API-Version": "4.0", "X-API-Signature": createHmac("sha256", secretKey).update(lines.join("\n")).digest("base64") }; |
| 12 | } |
| 13 | /** Official v4 API. POST orders is never retried: its external idempotency is undocumented. */ |
| 14 | export async function yoycolRequest(env: MerchEnv, method: "GET" | "POST", path: string, query: Record<string, string> = {}, body?: unknown, send: typeof fetch = fetch): Promise<unknown> { |
| 15 | if (!env.YOYCOL_ACCESS_KEY || !env.YOYCOL_SECRET_KEY || !/^\/api\/2025\/open\/v4\/(shipping\/(sku_quotes|levels)|orders(\/\d+(\/tracking)?)?)$/.test(path) || (method === "POST" && path !== "/api/2025/open/v4/orders")) throw new MerchError(503, "vendor_unavailable", "Fulfillment is not configured yet."); |
| 16 | const url = new URL(path, API); Object.entries(query).forEach(([k,v]) => url.searchParams.set(k,v)); |
| 17 | const response = await send(url, { method, headers: { ...yoycolHeaders(method, path, query, env.YOYCOL_ACCESS_KEY, env.YOYCOL_SECRET_KEY), "Content-Type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(12000), redirect: "error" }); |
| 18 | if (!response.ok) throw new MerchError(503, "vendor_unavailable", "Fulfillment prices are temporarily unavailable."); |
| 19 | const envelope = object(JSON.parse(new TextDecoder().decode(await readBoundedBody(response, 262144)))); |
| 20 | if (String(envelope.code) !== "100000") throw new MerchError(503, "vendor_unavailable", "The supplier could not confirm this request."); |
| 21 | return envelope.data; |
| 22 | } |
| 23 | export function destinationRate(data: unknown, regionCode: string, levelCode: string, quantity: number) { |
| 24 | if (!Array.isArray(data)) throw new MerchError(503, "quote_unavailable", "The supplier could not confirm a delivery rate."); |
| 25 | const region = data.map(object).find(r => r.regionCode === regionCode); |
| 26 | const levels = region?.levels; |
| 27 | const level = Array.isArray(levels) ? levels.map(object).find(l => l.levelCode === levelCode) : null; |
| 28 | if (!level || typeof level.firstPrice !== "number" || typeof level.additionalPrice !== "number" || !Number.isFinite(level.firstPrice) || !Number.isFinite(level.additionalPrice) || level.firstPrice < 0 || level.additionalPrice < 0 || !Number.isInteger(quantity) || quantity < 1 || quantity > 5) throw new MerchError(409, "address_unavailable", "No reviewed delivery service is available for this destination."); |
| 29 | return { shippingUsd: level.firstPrice + (quantity - 1) * level.additionalPrice, name: typeof level.levelName === "string" ? level.levelName : levelCode }; |
| 30 | } |
| 31 |