feat: init
This commit is contained in:
11
server/api/v1/rates/[code].get.ts
Normal file
11
server/api/v1/rates/[code].get.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const code = getRouterParam(event, 'code') ?? ''
|
||||
const rate = await getRateByCode(code)
|
||||
|
||||
if (!rate) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Country not found', data: { error: 'Country not found' } })
|
||||
}
|
||||
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=3600')
|
||||
return rate
|
||||
})
|
||||
5
server/api/v1/rates/index.get.ts
Normal file
5
server/api/v1/rates/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rates = await getAllRates()
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=3600')
|
||||
return rates
|
||||
})
|
||||
32
server/middleware/rateLimit.ts
Normal file
32
server/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const WINDOW_MS = 6000;
|
||||
const MAX_REQUESTS = 60;
|
||||
|
||||
const hits = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = getRequestURL(event).pathname;
|
||||
if (!path.startsWith("/api/")) return;
|
||||
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "127.0.0.1";
|
||||
const now = Date.now();
|
||||
|
||||
let entry = hits.get(ip);
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
entry = { count: 0, resetAt: now + WINDOW_MS };
|
||||
hits.set(ip, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
|
||||
setHeader(event, "X-RateLimit-Limit", MAX_REQUESTS);
|
||||
setHeader(
|
||||
event,
|
||||
"X-RateLimit-Remaining",
|
||||
Math.max(0, MAX_REQUESTS - entry.count),
|
||||
);
|
||||
|
||||
if (entry.count > MAX_REQUESTS) {
|
||||
setHeader(event, "Retry-After", "1");
|
||||
throw createError({ statusCode: 429, statusMessage: "Too Many Requests" });
|
||||
}
|
||||
});
|
||||
258
server/utils/vatRates.ts
Normal file
258
server/utils/vatRates.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
export interface VatRateResponse {
|
||||
country: string;
|
||||
country_code: string;
|
||||
standard_rate: number;
|
||||
reduced_rates: number[];
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const FALLBACK_RATES: VatRateResponse[] = [
|
||||
{
|
||||
country: "Austria",
|
||||
country_code: "AT",
|
||||
standard_rate: 20,
|
||||
reduced_rates: [10, 13],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Belgium",
|
||||
country_code: "BE",
|
||||
standard_rate: 21,
|
||||
reduced_rates: [6, 12],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Bulgaria",
|
||||
country_code: "BG",
|
||||
standard_rate: 20,
|
||||
reduced_rates: [9],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Croatia",
|
||||
country_code: "HR",
|
||||
standard_rate: 25,
|
||||
reduced_rates: [5, 13],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Cyprus",
|
||||
country_code: "CY",
|
||||
standard_rate: 19,
|
||||
reduced_rates: [5, 9],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Czech Republic",
|
||||
country_code: "CZ",
|
||||
standard_rate: 21,
|
||||
reduced_rates: [12, 15],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Denmark",
|
||||
country_code: "DK",
|
||||
standard_rate: 25,
|
||||
reduced_rates: [],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Estonia",
|
||||
country_code: "EE",
|
||||
standard_rate: 22,
|
||||
reduced_rates: [9],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Finland",
|
||||
country_code: "FI",
|
||||
standard_rate: 25.5,
|
||||
reduced_rates: [10, 14],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "France",
|
||||
country_code: "FR",
|
||||
standard_rate: 20,
|
||||
reduced_rates: [5.5, 10],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Germany",
|
||||
country_code: "DE",
|
||||
standard_rate: 19,
|
||||
reduced_rates: [7],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Greece",
|
||||
country_code: "GR",
|
||||
standard_rate: 24,
|
||||
reduced_rates: [6, 13],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Hungary",
|
||||
country_code: "HU",
|
||||
standard_rate: 27,
|
||||
reduced_rates: [5, 18],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Ireland",
|
||||
country_code: "IE",
|
||||
standard_rate: 23,
|
||||
reduced_rates: [9, 13.5],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Italy",
|
||||
country_code: "IT",
|
||||
standard_rate: 22,
|
||||
reduced_rates: [5, 10],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Latvia",
|
||||
country_code: "LV",
|
||||
standard_rate: 21,
|
||||
reduced_rates: [5, 12],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Lithuania",
|
||||
country_code: "LT",
|
||||
standard_rate: 21,
|
||||
reduced_rates: [5, 9],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Luxembourg",
|
||||
country_code: "LU",
|
||||
standard_rate: 17,
|
||||
reduced_rates: [8],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Malta",
|
||||
country_code: "MT",
|
||||
standard_rate: 18,
|
||||
reduced_rates: [5, 7],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Netherlands",
|
||||
country_code: "NL",
|
||||
standard_rate: 21,
|
||||
reduced_rates: [9],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Poland",
|
||||
country_code: "PL",
|
||||
standard_rate: 23,
|
||||
reduced_rates: [5, 8],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Portugal",
|
||||
country_code: "PT",
|
||||
standard_rate: 23,
|
||||
reduced_rates: [6, 13],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Romania",
|
||||
country_code: "RO",
|
||||
standard_rate: 19,
|
||||
reduced_rates: [5, 9],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Slovakia",
|
||||
country_code: "SK",
|
||||
standard_rate: 23,
|
||||
reduced_rates: [5, 10],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Slovenia",
|
||||
country_code: "SI",
|
||||
standard_rate: 22,
|
||||
reduced_rates: [5, 9.5],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Spain",
|
||||
country_code: "ES",
|
||||
standard_rate: 21,
|
||||
reduced_rates: [4, 10],
|
||||
currency: "EUR",
|
||||
},
|
||||
{
|
||||
country: "Sweden",
|
||||
country_code: "SE",
|
||||
standard_rate: 25,
|
||||
reduced_rates: [6, 12],
|
||||
currency: "EUR",
|
||||
},
|
||||
];
|
||||
|
||||
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let cache: { data: VatRateResponse[]; date: string; fetchedAt: number } | null =
|
||||
null;
|
||||
|
||||
function todayDateString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function isCacheValid(): boolean {
|
||||
if (!cache) return false;
|
||||
const age = Date.now() - cache.fetchedAt;
|
||||
return age < TWENTY_FOUR_HOURS && cache.date === todayDateString();
|
||||
}
|
||||
|
||||
function transformVatstackResponse(vatstackRates: any[]): VatRateResponse[] {
|
||||
return vatstackRates.map((r: any) => ({
|
||||
country: r.country_name ?? "",
|
||||
country_code: r.country_code ?? "",
|
||||
standard_rate: r.standard_rate ?? 0,
|
||||
reduced_rates: r.reduced_rates ?? [],
|
||||
currency: "EUR",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAllRates(): Promise<VatRateResponse[]> {
|
||||
if (isCacheValid()) return cache!.data;
|
||||
|
||||
const apiKey = "pk_live_3038ce089ee7197879fcf95e624da019";
|
||||
|
||||
try {
|
||||
const response: any = await $fetch("https://api.vatstack.com/v1/rates", {
|
||||
headers: { "X-API-Key": apiKey },
|
||||
query: { member_state: true, limit: 100 },
|
||||
});
|
||||
|
||||
const items: any[] = response.rates ?? response;
|
||||
const euOnly = items.filter((r: any) => r.member_state === true);
|
||||
const rates = transformVatstackResponse(euOnly);
|
||||
cache = { data: rates, date: todayDateString(), fetchedAt: Date.now() };
|
||||
return rates;
|
||||
} catch {
|
||||
// Fall back to hardcoded rates on fetch failure
|
||||
cache = {
|
||||
data: FALLBACK_RATES,
|
||||
date: todayDateString(),
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
return FALLBACK_RATES;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRateByCode(
|
||||
code: string,
|
||||
): Promise<VatRateResponse | undefined> {
|
||||
const rates = await getAllRates();
|
||||
return rates.find((r) => r.country_code === code.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user