feat: implement Stripe reports management for admin panel

- Add new page for managing Stripe reports with functionality to generate and view reports.
- Create API router for handling Stripe report operations including listing, setting up, and generating reports.
- Introduce caching service for managing cache invalidation related to reports.
- Update Typst templates for formatting financial reports in EUR.
- Enhance utility functions for price formatting and error handling.
- Implement a script to check for unused tRPC procedures in the codebase.
This commit is contained in:
Marco Pedone 2026-04-03 18:57:39 +02:00
parent 964abfbf46
commit f52dd96c11
44 changed files with 1850 additions and 2217 deletions

View file

@ -1,5 +1,7 @@
TODOS: TODOS:
- Log email fallite e retry - Log email fallite e retry
- piu info negli annunci_servizio sheet
- override magico per saltare inserimento strighe caparra e contratto
AFTER MVP: AFTER MVP:
- TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app - TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app

View file

@ -0,0 +1,116 @@
import * as fs from "fs";
import * as path from "path";
// --- CONFIGURATION ---
const ROOT_DIR: string = process.cwd();
const ROUTERS_DIR: string = path.join(ROOT_DIR, "src/server/api/routers");
const SCAN_DIRS: string[] = [path.join(ROOT_DIR, "src")];
const IGNORE_DIRS: string[] = ["node_modules", ".next", "dist"];
// Regex to find tRPC procedure definitions
const PROCEDURE_REGEX: RegExp =
/([a-zA-Z0-9_]+)\s*:\s*[a-zA-Z0-9_.]+\.(?:query|mutation|subscription)\s*\(/g;
interface ProcedureDefinition {
name: string;
file: string;
line: number;
}
function getAllFiles(dir: string, ext: string[] = [".ts", ".tsx"]): string[] {
let results: string[] = [];
if (!fs.existsSync(dir)) return results;
const list: string[] = fs.readdirSync(dir);
list.forEach((file: string) => {
const fullPath: string = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat?.isDirectory()) {
if (!IGNORE_DIRS.includes(file)) {
results = results.concat(getAllFiles(fullPath, ext));
}
} else {
if (ext.some((e) => file.endsWith(e))) {
results.push(fullPath);
}
}
});
return results;
}
/**
* Calculates the line number of a specific index in a string
*/
function getLineNumber(content: string, index: number): number {
const linesBefore = content.substring(0, index).split("\n");
return linesBefore.length;
}
function run(): void {
console.log("🔍 Scanning for tRPC procedures...\n");
const routerFiles: string[] = getAllFiles(ROUTERS_DIR);
const procedures: ProcedureDefinition[] = [];
// 1. Extract procedure names and their specific line numbers
routerFiles.forEach((filePath: string) => {
const content: string = fs.readFileSync(filePath, "utf8");
let match: RegExpExecArray | null;
match = PROCEDURE_REGEX.exec(content);
while (match !== null) {
if (match[1]) {
procedures.push({
name: match[1],
file: filePath,
line: getLineNumber(content, match.index),
});
}
match = PROCEDURE_REGEX.exec(content);
}
});
console.log(`Found ${procedures.length} procedures. Checking for usage...\n`);
// 2. Get all files to scan
let scanFiles: string[] = [];
SCAN_DIRS.forEach((dir: string) => {
scanFiles = scanFiles.concat(getAllFiles(dir));
});
// 3. Check for usage
const usedNames = new Set<string>();
scanFiles.forEach((filePath: string) => {
const content: string = fs.readFileSync(filePath, "utf8");
procedures.forEach((proc) => {
if (filePath === proc.file) return;
// Search for name with word boundaries
const usageRegex = new RegExp(`\\b${proc.name}\\b`);
if (usageRegex.test(content)) {
usedNames.add(proc.name);
}
});
});
// 4. Report results
const unused = procedures.filter((p) => !usedNames.has(p.name));
if (unused.length === 0) {
console.log("✅ All good! No unused tRPC procedures found.");
} else {
console.log(
`🚨 Found ${unused.length} potentially unused tRPC procedures:\n`,
);
unused.forEach((p) => {
const relativePath: string = path.relative(ROOT_DIR, p.file);
// Format: path/to/file.ts:line
// Most IDEs (VS Code, WebStorm) make this clickable in the terminal
console.log(`- ${p.name} -> ${relativePath}:${p.line}`);
});
}
}
run();

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,8 @@
"types": "tsc --noEmit", "types": "tsc --noEmit",
"analyze": "cross-env ANALYZE=true next build", "analyze": "cross-env ANALYZE=true next build",
"vitest": "vitest run", "vitest": "vitest run",
"flow": "ts-node flow-gen.ts" "flow": "ts-node flow-gen.ts",
"unused-trpc": "ts-node check-unused-trpc.ts"
}, },
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
@ -76,10 +77,10 @@
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"nodemailer-html-to-text": "^3.2.0", "nodemailer-html-to-text": "^3.2.0",
"nuqs": "^2.8.6", "nuqs": "^2.8.6",
"papaparse": "^5.5.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pg": "^8.19.0", "pg": "^8.19.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"puppeteer-core": "^24.37.5",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.3", "react": "^19.2.3",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
@ -91,7 +92,7 @@
"react-is": "^19.2.3", "react-is": "^19.2.3",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-pdf": "^10.2.0", "react-pdf": "^10.4.0",
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"react-reverse-portal": "^2.3.0", "react-reverse-portal": "^2.3.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
@ -118,6 +119,7 @@
"@types/node": "^24.2.0", "@types/node": "^24.2.0",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/nodemailer-html-to-text": "^3.1.3", "@types/nodemailer-html-to-text": "^3.1.3",
"@types/papaparse": "^5.5.2",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "^19.2.9", "@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View file

@ -1,226 +0,0 @@
"use client";
import { format } from "date-fns";
import { it } from "date-fns/locale";
import Image from "next/image";
import toast from "react-hot-toast";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe";
import { formatCurrency } from "~/lib/utils";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { Prezziario } from "~/schemas/public/Prezziario";
import { api } from "~/utils/api";
export function ReceiptGenerator({
data,
raw,
}: {
data: PurchaseData;
raw: boolean;
}) {
const { mutate: generatePdf } =
api.pagamenti.getRicevutaCortesiaPdf.useMutation({
onMutate: () => {
toast.loading("Generazione PDF in corso...", {
id: "generate-pdf",
});
},
onSuccess: (res) => {
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
const blob = new Blob([res as unknown as ArrayBuffer], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `ricevuta-cortesia-${data.clienteNome.replace(/\s+/g, "-")}-${format(data.date, "dd-MM-yyyy")}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (error) => {
toast.error(`Errore durante la generazione del PDF: ${error.message}`, {
id: "generate-pdf",
});
},
});
if (raw) {
return <Receipt data={data} />;
}
return (
<div className="mx-auto max-w-4xl">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="font-bold text-2xl">Ricevuta di cortesia</h1>
<Button onClick={() => generatePdf({ ordineId: data.ordineId })}>
Stampa
</Button>
</div>
<div>
<Card className="p-4">
<Receipt data={data} />
</Card>
</div>
</div>
</div>
);
}
export type PurchaseData = {
ordineId: OrdiniOrdineId;
date: Date;
clienteNome: string;
codiceFiscale: string | null;
clienteIndirizzo: string;
paymentMethod: PaymentType;
packName: string;
sconto: number;
amount_cent: number;
previewSaldo: Prezziario | null;
};
interface ReceiptProps {
data: PurchaseData;
}
function Receipt({ data }: ReceiptProps) {
const companyName = "Arcenia S.r.l.";
const companyAddress =
"Via Beata Giovanna 1\nBassano del Grappa (VI) 36061\nItalia";
const note =
"La presente non costituisce ricevuta o fattura fiscale ai sensi dell'Art.21, DPR 633/72.\nSeguirà fattura elettronica direttamente al tuo indirizzo di posta.";
const footer =
"Arcenia Srl. | Tel. +39 0424529869 | Email: arca@infoalloggi.it";
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="font-bold text-2xl">{companyName}</h1>
<p className="whitespace-pre-line text-sm">{companyAddress}</p>
</div>
<div className="relative size-24">
<Image
alt={`${companyName} logo`}
className="object-contain"
fill
src={"/favicon/favicon.png"}
/>
</div>
</div>
<div className="border-b pb-4">
<h2 className="text-center font-semibold text-xl">
Ricevuta di cortesia
</h2>
<div className="mt-2 flex justify-between">
<div>
<p className="text-muted-foreground text-sm">ID Ordine</p>
<p className="text-sm">{data.ordineId}</p>
</div>
<div className="text-right">
<p className="text-muted-foreground text-sm">Data</p>
<p className="font-medium">
{format(data.date, "PPP", {
locale: it,
})}
</p>
<p className="text-sm">
{format(data.date, "p", {
locale: it,
})}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="mb-1 font-semibold">Intestatario:</h3>
<p className="font-medium">{data.clienteNome}</p>
<p className="text-sm">{data.codiceFiscale}</p>
<p className="whitespace-pre-line text-sm">{data.clienteIndirizzo}</p>
</div>
</div>
<div>
<table className="w-full table-auto">
<thead className="flex w-full">
<tr className="grid w-full grow grid-cols-[1fr_auto_auto] gap-2 border-b">
<th className="py-2 text-left">Oggetto</th>
<th className="py-2 text-right">Sconto applicato</th>
<th className="py-2 text-right">Importo</th>
</tr>
</thead>
<tbody>
<tr className="grid w-full grow grid-cols-[1fr_auto_auto] gap-2 border-b">
<td className="py-2">{data.packName}</td>
<td className="py-2 text-right">
{data.sconto > 0 && `${data.sconto}%`}
</td>
<td className="py-2 text-right">
{formatCurrency(data.amount_cent / 100)}
</td>
</tr>
{data.previewSaldo && (
<tr className="grid w-full grow grid-cols-[1fr_auto_auto] gap-2 border-b">
<td className="py-2">
{data.previewSaldo.nome_it}{" "}
<span className="text-sm">
pagamento differito, non è dovuto in questo momento
</span>
</td>
<td className="py-2 text-right">&nbsp;</td>
<td className="py-2 text-right text-muted-foreground line-through">
{formatCurrency(data.previewSaldo.prezzo_cent / 100)}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex justify-end">
<div className="w-64">
<div className="mt-1 flex justify-between pt-2 font-bold">
<span>Totale:</span>
<span>{formatCurrency(data.amount_cent / 100)}</span>
</div>
<div className="flex justify-between py-1">
<span>di cui IVA (22%):</span>
<span>{formatCurrency((data.amount_cent / 100) * 0.22)}</span>
</div>
</div>
</div>
<div className="border-t pt-4">
<div className="flex justify-between">
<div>
<h3 className="mb-1 font-semibold">Metodo di pagamento</h3>
<p>{PaymentMethodToString(data.paymentMethod)}</p>
</div>
<div className="text-right">
<h3 className="mb-1 font-semibold">Status pagamento</h3>
<p className="font-medium text-green-600">Pagato</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="mb-1 font-semibold">Note</h3>
<p className="whitespace-pre-line text-sm">{note}</p>
</div>
<div className="pt-6 text-center text-muted-foreground text-sm">
<p>{footer}</p>
</div>
</div>
);
}

View file

@ -78,7 +78,7 @@ const AdminOrdiniModal = ({
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5"> <CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p> <p>Id Ordine: {data.ordine_id}</p>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{data.isActive && ( {data.isActive && (
<div> <div>
<Link <Link
@ -148,6 +148,44 @@ export const UserOrdiniModal = ({
const { data } = api.servizio.getOrdineById.useQuery({ const { data } = api.servizio.getOrdineById.useQuery({
ordineId, ordineId,
}); });
const { mutate: generatePdf } =
api.pagamenti.getRicevutaCortesiaPdf.useMutation({
onMutate: () => {
toast.loading("Generazione PDF in corso...", {
id: "generate-pdf",
});
},
onSuccess: (data) => {
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
if (!data) {
toast.error("Nessun dato ricevuto per la generazione del PDF.", {
id: "generate-pdf",
});
return;
}
const binaryString = atob(data.pdf);
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const blob = new Blob([bytes], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = data.title;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (error) => {
toast.error(`Errore durante la generazione del PDF: ${error.message}`, {
id: "generate-pdf",
});
},
});
if (!data) return null; if (!data) return null;
return ( return (
@ -191,14 +229,12 @@ export const UserOrdiniModal = ({
</p> </p>
{data.isActive && ( {data.isActive && (
<div> <div>
<Link <Button
href={`/servizio/ricevuta-cortesia/${data.ordine_id}`} onClick={() => generatePdf({ ordineId: data.ordine_id })}
target="_blank" variant="outline"
> >
<Button variant="outline"> Ricevuta di cortesia <ReceiptText />
Ricevuta di cortesia <ReceiptText /> </Button>
</Button>
</Link>
</div> </div>
)} )}
</CredenzaBody> </CredenzaBody>

View file

@ -1,30 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "react-pdf/dist/Page/AnnotationLayer.css"; import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/TextLayer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { LoadingPage } from "./loading"; import { LoadingPage } from "./loading";
import "pdfjs-dist/build/pdf.worker.min.mjs"; pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
import dynamic from "next/dynamic";
const Document = dynamic(
() => import("react-pdf").then((mod) => ({ default: mod.Document })),
{ ssr: false },
);
const Page = dynamic(
() => import("react-pdf").then((mod) => ({ default: mod.Page })),
{ ssr: false },
);
//pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
// pdfjs.GlobalWorkerOptions.workerSrc = new URL(
// "pdfjs-dist/build/pdf.worker.min.mjs",
// import.meta.url,
// ).toString();
const options = { const options = {
cMapUrl: "/cmaps/", cMapUrl: "/cmaps/",
@ -37,7 +16,6 @@ export const PDFViewer = ({ url }: { url: string }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const file = useMemo(() => ({ url }), [url]); const file = useMemo(() => ({ url }), [url]);
const onResize = useCallback<ResizeObserverCallback>((entries) => { const onResize = useCallback<ResizeObserverCallback>((entries) => {
const [entry] = entries; const [entry] = entries;
@ -55,12 +33,6 @@ export const PDFViewer = ({ url }: { url: string }) => {
} }
}, []); }, []);
function onDocumentLoadSuccess({
numPages: nextNumPages,
}: PDFDocumentProxy): void {
setNumPages(nextNumPages);
}
return ( return (
<div <div
className="mx-1 flex w-full justify-center overflow-clip rounded-md shadow-lg outline" className="mx-1 flex w-full justify-center overflow-clip rounded-md shadow-lg outline"
@ -73,7 +45,7 @@ export const PDFViewer = ({ url }: { url: string }) => {
noData={ noData={
"Errore nel caricare il documento, riprova più tardi o Scaricalo" "Errore nel caricare il documento, riprova più tardi o Scaricalo"
} }
onLoadSuccess={onDocumentLoadSuccess} onLoadSuccess={({ numPages }) => setNumPages(numPages)}
options={options} options={options}
> >
{Array.from(new Array(numPages), (_el, index) => ( {Array.from(new Array(numPages), (_el, index) => (
@ -83,6 +55,8 @@ export const PDFViewer = ({ url }: { url: string }) => {
index + 1 index + 1
}`} }`}
pageNumber={index + 1} pageNumber={index + 1}
renderAnnotationLayer={false}
renderTextLayer={false}
width={width} width={width}
/> />
))} ))}

View file

@ -1,384 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import z from "zod";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import Input from "~/components/ui/input";
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import { it } from "~/i18n/it";
import { handleConsegna } from "~/lib/annuncio_details";
import { replaceWithBr } from "~/lib/newlineToBr";
import { cn, formatCurrency } from "~/lib/utils";
import type { Annunci } from "~/schemas/public/Annunci";
import { api } from "~/utils/api";
import { IconMatrix } from "./IconComponents";
import { GoogleMapsIcon, LogoSvg, WhatsAppIcon } from "./svgs";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
const [inputEmail, setInputEmail] = useState<string>("");
const [openPopover, setOpenPopover] = useState<boolean>(false);
const { mutate } = api.comunicazioni.sendSchedaAnnuncioEmail.useMutation({
onMutate: () => {
toast.loading("Invio email in corso...", {
id: "send-scheda-annuncio-email",
});
},
onSuccess: () => {
toast.success("Email inviata con successo!", {
id: "send-scheda-annuncio-email",
});
},
onError: (error) => {
toast.error(`Errore durante l'invio dell'email: ${error.message}`, {
id: "send-scheda-annuncio-email",
});
},
});
const sendPdf = async () => {
if (!data || !inputEmail || !data.locatore || !data.numero) {
toast.error("Dati annuncio mancanti.");
return;
}
mutate({
id: data.id,
to: inputEmail,
codice: data.codice,
numero: data.numero,
nominativo: `${data.locatore}${data.tipo_locatore ? ` (${data.tipo_locatore})` : ""}`,
indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`,
});
setInputEmail("");
};
const { mutate: generatePdf } = api.annunci.getSchedaAnnuncio.useMutation({
onMutate: () => {
toast.loading("Generazione PDF in corso...", {
id: "generate-pdf",
});
},
onSuccess: (res) => {
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
const blob = new Blob([res as unknown as ArrayBuffer], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `scheda-annuncio-${data.codice}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (error) => {
toast.error(`Errore durante la generazione del PDF: ${error.message}`, {
id: "generate-pdf",
});
},
});
return (
<div className="mx-auto max-w-4xl">
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
<div className="flex gap-2">
<Button onClick={() => generatePdf({ id: data.id })}>
Scarica Scheda
</Button>
<Popover onOpenChange={setOpenPopover} open={openPopover}>
<PopoverTrigger asChild>
<Button className="print:hidden" variant="outline">
Invia tramite email
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-80 flex-col gap-4 print:hidden">
<Label htmlFor="indirizzo">Indirizzo Email</Label>
<Input
id="indirizzo"
onChange={(e) => setInputEmail(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
z.safeParse(z.email(), inputEmail).success
) {
sendPdf();
}
}}
type="email"
/>
<Button
disabled={
z.safeParse(z.email(), inputEmail).success === false
}
onClick={sendPdf}
>
Invia
</Button>
</PopoverContent>
</Popover>
</div>
</div>
<SchedaAnnuncio data={data} />
</div>
</div>
);
}
export function SchedaAnnuncio({
data,
raw,
}: {
data: Annunci;
raw?: boolean;
}) {
const companyAddress =
"Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it";
const footer =
"Arcenia Srl. | Tel. +39 0424529869 | Email: arca@infoalloggi.it";
const [navigationUrl, setNavigationUrl] = useState<string | undefined>(
undefined,
);
const [printDate, setPrintDate] = useState<string>("");
const [disponibileDate, setDisponibileDate] = useState<string>("");
useEffect(() => {
setPrintDate(new Date().toLocaleDateString("it-IT"));
if (data.disponibile_da) {
setDisponibileDate(new Date(data.disponibile_da).toLocaleDateString());
} else if (data.consegna) {
setDisponibileDate(
handleConsegna({
aggiornamento: it.card.in_aggiornamento,
consegna: data.consegna,
consegna_da: it.card.consegna_da,
mesi: it.parametri.mesi,
subito: it.card.consegna_subito,
}),
);
} else {
setDisponibileDate("Non specificata");
}
if (!data) return;
if (typeof window === "undefined") return;
if (
navigator.userAgent.toUpperCase().includes("MAC") ||
navigator.userAgent.toUpperCase().includes("IPAD") ||
navigator.userAgent.toUpperCase().includes("IPHONE") ||
navigator.userAgent.toUpperCase().includes("IOS") ||
navigator.userAgent.toUpperCase().includes("IPOD")
) {
setNavigationUrl(
`maps://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${data.lat},${data.lon}`,
);
} else {
setNavigationUrl(
`https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${data.lat},${data.lon}`,
);
}
}, [data]);
return (
<Card
className={cn(
"p-4 print:border-none print:shadow-none",
raw && "border-none shadow-none",
)}
>
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<LogoSvg className="h-10 w-auto" />
</div>
<div className="relative">
<p className="text-right font-semibold text-lg">Arcenia Srl</p>
<p className="whitespace-pre-line text-right text-sm">
{companyAddress}
</p>
</div>
</div>
{/* Property Header */}
<div className="space-y-2 text-center">
<h2 className="font-bold text-primary text-xl">
{data.titolo_it || "Immobile in Affitto"}
</h2>
<p className="text-lg text-muted-foreground">
Riferimento: <strong>{data.codice}</strong>
</p>
</div>
<div className="rounded-lg bg-primary/5 p-4 outline-2 outline-green-500">
<h3 className="mb-2 inline-flex items-center gap-2 font-semibold text-lg text-primary">
<WhatsAppIcon className="size-6 fill-green-500 stroke-green-500" />{" "}
Contatti
<span className="text-base">
(mandare un messaggio WhatsApp prima di chiamare)
</span>
</h3>
<div className="space-y-1">
<p>
{data.locatore}{" "}
{data.tipo_locatore ? `(${data.tipo_locatore})` : ""}
</p>
{data.numero && <p className="font-semibold">Tel. {data.numero}</p>}
</div>
</div>
{/* Property Details Grid */}
<div className="grid grid-cols-1 gap-6 pb-1">
{/* Location Details */}
<div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">Indirizzo</h3>
<div className="flex items-center gap-8">
<p className="font-semibold">
{data.indirizzo}, {data.civico} {data.comune} {data.cap} (
{data.provincia})
</p>
{navigationUrl && (
<a
className="flex items-center gap-2 text-blue-600 underline underline-offset-2"
href={navigationUrl}
id="navigationLink"
rel="noreferrer"
target="_blank"
>
<span>Apri in Google Maps</span>
<GoogleMapsIcon className="size-6" />
</a>
)}
</div>
</div>
{/* Property Characteristics */}
{/* <div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">
🏠 Caratteristiche
</h3>
<div className="space-y-2 text-sm">
{data.mq && (
<p>
<span className="font-medium">mq:</span> {data.mq} mq
</p>
)}
{data.numero_vani && (
<p>
<span className="font-medium">Locali:</span>{" "}
{data.numero_vani}
</p>
)}
{data.numero_camere && (
<p>
<span className="font-medium">Camere:</span>{" "}
{data.numero_camere}
</p>
)}
{data.numero_bagni && (
<p>
<span className="font-medium">Bagni:</span>{" "}
{data.numero_bagni}
</p>
)}
</div>
</div> */}
</div>
{/* Pricing Information */}
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4 text-sm">
{data.tipo && (
<div className="rounded bg-gray-50 p-3">
<p className="font-medium text-primary">Tipologia</p>
<p className="font-bold text-xl">Affitto {data.tipo}</p>
</div>
)}
{data.prezzo && (
<div className="rounded bg-gray-50 p-3">
<p className="font-medium text-primary">Canone Mensile</p>
<p className="font-bold text-xl">
{formatCurrency(data.prezzo / 100)}
</p>
</div>
)}
{(data.disponibile_da || data.consegna) && (
<div className="rounded bg-gray-50 p-3">
<p className="font-medium text-primary">Disponibile da</p>
<p className="font-bold text-xl">{disponibileDate}</p>
</div>
)}
</div>
</div>
{/* Additional Features */}
<div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">
Dotazioni e Servizi
</h3>
<div className="grid grid-cols-4 gap-2 text-sm">
{data.caratteristiche &&
filteredCaratteristiche(data.caratteristiche).map((item) => {
if (!item.value) return null;
return (
<div
className="flex items-center justify-start gap-2"
key={item.text}
>
<span>
<IconMatrix type={item.icon} />
</span>
<span>
{item.text}: {item.value}
</span>
</div>
);
})}
</div>
</div>
{/* Description */}
{data.desc_it && (
<>
<div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">
Descrizione
</h3>
<div className="text-justify text-sm leading-relaxed">
<p
dangerouslySetInnerHTML={{
__html: replaceWithBr(data.desc_it),
}}
></p>
</div>
</div>
</>
)}
<div className="mx-auto max-w-2xl pt-2 text-center text-muted-foreground text-xs">
<p>
Informazione strettamente riservata ed indirizzata all'Utente
Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi
Obblighi e responsabilità delle Condizioni Generali di Contratto
(CGC) accettate
</p>
</div>
<div className="pt-2 text-center text-muted-foreground text-xs">
<p>{footer}</p>
<p className="mt-1">Documento generato in data: {printDate}</p>
</div>
</div>
</Card>
);
}

View file

@ -68,7 +68,7 @@ export const AnnuncioCard = ({ className }: { className?: string }) => {
); );
}; };
export const AnnuncioCardContent = ({ const AnnuncioCardContent = ({
interactions, interactions,
className, className,
data, data,

View file

@ -696,7 +696,7 @@ export const WhatsAppIcon = (props: SVGProps<SVGSVGElement>) => (
/> />
</svg> </svg>
); );
/*
export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => ( export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
<svg <svg
height="1em" height="1em"
@ -728,7 +728,7 @@ export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
/> />
</svg> </svg>
); );
*/
export const PasswordSVG = (props: SVGProps<SVGSVGElement>) => ( export const PasswordSVG = (props: SVGProps<SVGSVGElement>) => (
<svg <svg
height={800} height={800}

View file

@ -541,11 +541,27 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
{ {
accessorKey: "ordini", accessorKey: "ordini",
cell: ({ row }) => { cell: ({ row }) => {
const paidOrders = row.original.ordini_servizio.filter(
(o) => o.isActive,
).length;
const unpaidOrders = row.original.ordini_servizio.length - paidOrders;
return ( return (
<OrdersModal isAdmin={true} ordini={row.original.ordini_servizio}> <OrdersModal isAdmin={true} ordini={row.original.ordini_servizio}>
<Button className="flex-1" size="sm" variant="outline"> <div className="relative inline-flex">
<Logs /> <Button className="flex-1" size="sm" variant="outline">
</Button> <Logs />
</Button>
{unpaidOrders > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex size-3.5 items-center justify-center rounded-full bg-red-600 text-white text-xs">
{unpaidOrders}
</span>
)}
{paidOrders > 0 && (
<span className="absolute -top-1.5 -left-1.5 flex size-3.5 items-center justify-center rounded-full bg-green-600 text-white text-xs">
{paidOrders}
</span>
)}
</div>
</OrdersModal> </OrdersModal>
); );
}, },

View file

@ -31,7 +31,7 @@ const Schema = z.object({
gestionale_id: z.string().nullable(), gestionale_id: z.string().nullable(),
}); });
export type FormValues = z.infer<typeof Schema>; type FormValues = z.infer<typeof Schema>;
export const FormRinnovo = ({ export const FormRinnovo = ({
initialData, initialData,

View file

@ -3,9 +3,6 @@
import type { IconType } from "~/components/IconComponents"; import type { IconType } from "~/components/IconComponents";
import type { Caratteristiche } from "~/utils/kanel-types"; import type { Caratteristiche } from "~/utils/kanel-types";
export type CaratteristicheFiltered = ReturnType<
typeof filteredCaratteristiche
>;
export const filteredCaratteristiche = (c: Caratteristiche) => { export const filteredCaratteristiche = (c: Caratteristiche) => {
const filtered = [ const filtered = [
{ {

View file

@ -663,6 +663,10 @@ export const en: LangDict = {
{ href: "/area-riservata/admin/incroci", title: "Incroci" }, { href: "/area-riservata/admin/incroci", title: "Incroci" },
{ href: "/area-riservata/admin/annunci", title: "Ads" }, { href: "/area-riservata/admin/annunci", title: "Ads" },
{ href: "/area-riservata/admin/prezziario", title: "Pricing" }, { href: "/area-riservata/admin/prezziario", title: "Pricing" },
{
href: "/area-riservata/admin/stripe-reports",
title: "Account Statements",
},
], ],
title: "Management", title: "Management",
}, },

View file

@ -668,6 +668,10 @@ export const it: LangDict = {
{ href: "/area-riservata/admin/incroci", title: "Incroci" }, { href: "/area-riservata/admin/incroci", title: "Incroci" },
{ href: "/area-riservata/admin/annunci", title: "Annunci" }, { href: "/area-riservata/admin/annunci", title: "Annunci" },
{ href: "/area-riservata/admin/prezziario", title: "Prezziario" }, { href: "/area-riservata/admin/prezziario", title: "Prezziario" },
{
href: "/area-riservata/admin/stripe-reports",
title: "Estratti conto",
},
], ],
title: "Gestione", title: "Gestione",
}, },

View file

@ -0,0 +1,320 @@
import {
add,
differenceInMonths,
endOfMonth,
format,
fromUnixTime,
getUnixTime,
isBefore,
} from "date-fns";
import { it } from "date-fns/locale";
import { RefreshCcw } from "lucide-react";
import type { GetServerSideProps } from "next";
import Head from "next/head";
import toast from "react-hot-toast";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import type { NextPageWithLayout } from "~/pages/_app";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
const reportTypeTitles: Record<string, string> = {
"balance_change_from_activity.itemized": "Movimenti",
"payouts.itemized": "Pagamenti",
"balance.summary": "Riepilogo Saldo",
};
const reportStatusTitles: Record<string, string> = {
succeeded: "Completato",
pending: "In lavorazione",
failed: "Fallito",
};
const START_DATE = new Date("2026-01-01");
/**
* Pagina di gestione reports stripe per admin: /area-riservata/admin/stripe-reports
*/
const StripeReports: NextPageWithLayout = () => {
const { data, isLoading, refetch } =
api.stripe_reports.listReports.useQuery();
const utils = api.useUtils();
const { mutate: setupEstrattoConto } =
api.stripe_reports.setupEstrattoConto.useMutation({
onSuccess: async () => {
toast.success("Report richiesto con successo");
await utils.stripe_reports.listReports.invalidate();
},
onError: (error) => {
toast.error(`Errore nella richiesta del report: ${error.message}`);
},
});
const { mutate: generateEstrattoConto } =
api.stripe_reports.generateEstrattoConto.useMutation({
onMutate: () => {
toast.loading("Generazione estratto conto in corso...", {
id: "generate-pdf",
});
},
onSuccess: async (res) => {
if (!res.data) {
toast.error("PDF non disponibile al momento. Riprova più tardi.", {
id: "generate-pdf",
});
return;
}
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
const binaryString = atob(res.data.pdf);
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const blob = new Blob([bytes], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = res.data.title;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.dismiss("generate-pdf");
},
onError: (error) => {
toast.error(`Errore nella generazione del report: ${error.message}`, {
id: "generate-pdf",
});
},
});
const now = new Date();
const monthsToShow = differenceInMonths(now, START_DATE) + 1;
if (isLoading) return <LoadingPage />;
if (!data) return <Status500 />;
return (
<>
<Head>
<title>Stripe Reports</title>
</Head>
<div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto space-y-8 pt-4">
<div className="mx-3 flex-wrap items-center justify-between sm:flex">
<div className="flex max-w-lg items-center gap-3">
<h3 className="font-bold text-accent-foreground text-xl sm:text-2xl">
Estratti Conto Stripe
</h3>
<button
className="cursor-pointer"
onClick={async () => await refetch()}
type="button"
>
<RefreshCcw className="size-6" />
</button>
</div>
</div>
<div className="mx-4 flex flex-col">
<h3 className="font-semibold text-lg">Estratti Conto</h3>
<Table>
<TableHeader>
<TableRow className="grid grid-cols-2">
<TableHead>Intervallo</TableHead>
<TableHead>Stato</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: monthsToShow }, (_, i) => {
const m = add(START_DATE, { months: i });
const em = endOfMonth(m);
const hasReports = data.filter((report) => {
if (
!report.parameters?.interval_start ||
!report.parameters?.interval_end
) {
return false;
}
const um = getUnixTime(m);
const uem = getUnixTime(em);
// controlla se il report è allineato al mese in esame
return (
um >= report.parameters.interval_start &&
um <= report.parameters.interval_end &&
uem >= report.parameters.interval_start &&
uem <= report.parameters.interval_end
);
});
const hasBudgetReport = hasReports
.sort((a, b) => b.created - a.created)
.filter((report) =>
report.report_type.startsWith(
"balance_change_from_activity.itemized",
),
);
const hasValidBudgetReport = hasBudgetReport.find(
(report) =>
report.status === "succeeded" &&
isBefore(
now,
fromUnixTime(report.result?.expires_at ?? 0),
),
);
const hasSummaryReport = hasReports
.sort((a, b) => b.created - a.created)
.filter((report) =>
report.report_type.startsWith("balance.summary"),
);
const hasValidSummaryReport = hasSummaryReport.find(
(report) =>
report.status === "succeeded" &&
isBefore(
now,
fromUnixTime(report.result?.expires_at ?? 0),
),
);
const hasReport =
hasBudgetReport.length > 0 && hasSummaryReport.length > 0;
const hasReportValid =
hasValidBudgetReport && hasValidSummaryReport;
return (
<TableRow className="grid grid-cols-2" key={m.getTime()}>
<TableCell>
{format(m, "MMMM yyyy", { locale: it })}
</TableCell>
<TableCell>
{!hasReportValid ? (
<Button
onClick={() => setupEstrattoConto({ start: m })}
size="sm"
variant={hasReport ? "destructive" : "default"}
>
{hasReport ? "Scaduto - Rigenera" : "Genera Report"}
</Button>
) : (
<div className="flex items-center gap-2">
<Button
onClick={() => {
if (
hasValidBudgetReport.result?.url &&
hasValidSummaryReport.result?.url
) {
generateEstrattoConto({
movimentiUrl:
hasValidBudgetReport.result.url,
saldoUrl: hasValidSummaryReport.result.url,
});
}
}}
size="sm"
variant="success"
>
Disponibile - Apri Estratto Conto
</Button>
<Button
onClick={() => setupEstrattoConto({ start: m })}
size="sm"
variant="outline"
>
Rigenera Stripe Report
</Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<hr />
<div className="mx-4 flex flex-col">
<h3 className="font-semibold text-lg">Report Generati da Stripe</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Creato il</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Intervallo</TableHead>
<TableHead>Stato</TableHead>
<TableHead>Scadenza</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((report) => {
const createdAt = fromUnixTime(report.created);
const expiresAt = report.result?.expires_at
? fromUnixTime(report.result.expires_at)
: null;
const intervalStart = report.parameters.interval_start
? fromUnixTime(report.parameters.interval_start)
: null;
const intervalEnd = report.parameters.interval_end
? fromUnixTime(report.parameters.interval_end)
: null;
const reportType = report.report_type.replace(/\.\d/gm, "");
return (
<TableRow key={report.id}>
<TableCell>{format(createdAt, "dd/MM/yyyy")}</TableCell>
<TableCell>
{reportTypeTitles[reportType] || "Report Sconosciuto"}
</TableCell>
<TableCell>
{intervalStart && intervalEnd
? `${format(intervalStart, "dd/MM/yyyy")} - ${format(intervalEnd, "dd/MM/yyyy")}`
: "N/A"}
</TableCell>
<TableCell>
{reportStatusTitles[report.status] ||
"Stato Sconosciuto"}
</TableCell>
<TableCell>
{expiresAt ? format(expiresAt, "dd/MM/yyyy") : "N/A"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
</>
);
};
export const getServerSideProps = (async (context) => {
const access_token = context.req.cookies.access_token;
const helpers = await TrpcAuthedFetchingIstance({ access_token });
if (helpers) {
await helpers.trpc.stripe_reports.listReports.prefetch();
return {
props: {
trpcState: helpers.trpc.dehydrate(),
},
};
}
return {
props: {},
};
}) satisfies GetServerSideProps;
export default StripeReports;

View file

@ -1,48 +1,229 @@
import { Dot } from "lucide-react";
import type { GetServerSideProps } from "next"; import type { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import toast from "react-hot-toast";
import { AreaRiservataLayout } from "~/components/Layout"; import { AreaRiservataLayout } from "~/components/Layout";
import { import { LoadingPage } from "~/components/loading";
SchedaAnnuncio, import { Status500 } from "~/components/status-page";
SchedaAnnuncioStampabile, import { LogoSvg, WhatsAppIcon } from "~/components/svgs";
} from "~/components/schedaAnnuncioStampabile"; import { Button } from "~/components/ui/button";
import { redirectTo500 } from "~/lib/utils"; import { Card } from "~/components/ui/card";
import { replaceWithBr } from "~/lib/newlineToBr";
import { formatCurrency, redirectTo500 } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller"; import type { AnnunciId } from "~/schemas/public/Annunci";
import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { zAnnuncioId } from "~/server/utils/zod_types"; import { zAnnuncioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type PageProps = { type PageProps = {
raw: boolean; id: AnnunciId;
data: StringifiedAnnunciWithMedia;
}; };
/** /**
* Pagina della scheda annuncio stampabile: /area-riservata/scheda-annuncio-stampa/[id]?raw=true * Pagina della scheda annuncio stampabile: /area-riservata/scheda-annuncio-stampa/[id]?raw=true
*/ */
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({ const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
raw, id,
data,
}: PageProps) => { }: PageProps) => {
const annuncioData = StringifiedToAnnuncio(data); const { data, isLoading } = api.annunci.getSchedaAnnuncioData.useQuery({
if (raw) { id,
return <SchedaAnnuncio data={annuncioData} raw />; });
const { mutate: generatePdf } = api.annunci.getSchedaAnnuncioPdf.useMutation({
onMutate: () => {
toast.loading("Generazione PDF in corso...", {
id: "generate-pdf",
});
},
onSuccess: (data) => {
if (!data) {
toast.error("PDF non disponibile al momento. Riprova più tardi.", {
id: "generate-pdf",
});
return;
}
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
const binaryString = atob(data.pdf);
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const blob = new Blob([bytes], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = data.title;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.dismiss("generate-pdf");
},
onError: (error) => {
toast.error(`Errore durante la generazione del PDF: ${error.message}`, {
id: "generate-pdf",
});
},
});
const companyAddress =
"Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it";
if (isLoading) {
return <LoadingPage />;
} }
if (!data) {
return <Status500 />;
}
return ( return (
<> <>
<Head> <Head>
<title>Scheda Annuncio {annuncioData.codice}</title> <title>Scheda Annuncio {data.codice}</title>
</Head> </Head>
<div className="mx-auto w-full p-4"> <div className="mx-auto w-full max-w-4xl p-4">
<SchedaAnnuncioStampabile data={annuncioData} /> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
<div className="flex gap-2">
<Button onClick={() => generatePdf({ id })}>
Scarica Scheda
</Button>
</div>
</div>
<Card className="p-4">
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<LogoSvg className="h-10 w-auto" />
</div>
<div className="relative">
<p className="text-right font-semibold text-lg">
Arcenia Srl
</p>
<p className="whitespace-pre-line text-right text-sm">
{companyAddress}
</p>
</div>
</div>
<div className="space-y-2 text-center">
<h2 className="font-bold text-primary text-xl">
{data.titolo_it || "Immobile in Affitto"}
</h2>
<p className="text-lg text-muted-foreground">
Riferimento: <strong>{data.codice}</strong>
</p>
</div>
<div className="rounded-lg bg-primary/5 p-4 outline-2 outline-green-500">
<h3 className="mb-2 inline-flex items-center gap-2 font-semibold text-lg text-primary">
<WhatsAppIcon className="size-6 fill-green-500 stroke-green-500" />{" "}
Contatti
<span className="text-base">
(mandare un messaggio WhatsApp prima di chiamare)
</span>
</h3>
<div className="space-y-1">
<p>{data.nominativo}</p>
{data.numero && (
<p className="font-semibold">Tel. {data.numero}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-6 pb-1">
<div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">
Indirizzo
</h3>
<div className="flex items-center gap-8">
<p className="font-semibold">{data.indirizzo}</p>
</div>
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4 text-sm">
{data.tipo && (
<div className="rounded bg-gray-100 p-3">
<p className="font-medium text-primary">Tipologia</p>
<p className="font-bold text-xl">Affitto {data.tipo}</p>
</div>
)}
{data.prezzo && (
<div className="rounded bg-gray-100 p-3">
<p className="font-medium text-primary">Canone Mensile</p>
<p className="font-bold text-xl">
{formatCurrency(data.prezzo / 100)}
</p>
</div>
)}
<div className="rounded bg-gray-100 p-3">
<p className="font-medium text-primary">Disponibile da</p>
<p className="font-bold text-xl">{data.disponibile_da}</p>
</div>
</div>
</div>
<div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">
Dotazioni e Servizi
</h3>
<div className="grid grid-cols-3 gap-2 text-sm">
{data.caratteristiche.map((item) => {
if (!item[1]) return null;
return (
<div
className="flex items-center justify-start gap-1"
key={item[0]}
>
<Dot className="size-5" />
<span className="font-semibold">{item[0]}:</span>
<span>{item[1]}</span>
</div>
);
})}
</div>
</div>
{/* Description */}
{data.desc_it && (
<>
<div className="space-y-3">
<h3 className="border-b pb-1 font-semibold text-lg">
Descrizione
</h3>
<div className="text-justify text-sm leading-relaxed">
<p
dangerouslySetInnerHTML={{
__html: replaceWithBr(data.desc_it),
}}
></p>
</div>
</div>
</>
)}
<div className="mx-auto max-w-2xl pt-2 text-center text-muted-foreground text-xs">
<p>
Informazione strettamente riservata ed indirizzata all'Utente
Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei
Tuoi Obblighi e responsabilità delle Condizioni Generali di
Contratto (CGC) accettate
</p>
</div>
</div>
</Card>
</div>
</div> </div>
</> </>
); );
}; };
SchedaAnnuncioPage.getLayout = function getLayout(page) { SchedaAnnuncioPage.getLayout = function getLayout(page) {
if (page.props.raw) {
return <>{page}</>;
}
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>; return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
}; };
@ -50,7 +231,6 @@ export default SchedaAnnuncioPage;
export const getServerSideProps = (async (context) => { export const getServerSideProps = (async (context) => {
const id = context.params?.id; const id = context.params?.id;
const raw = context.query?.raw;
if (typeof id !== "string") { if (typeof id !== "string") {
console.error("Error: id is not a string"); console.error("Error: id is not a string");
@ -65,56 +245,14 @@ export const getServerSideProps = (async (context) => {
const helper = generateSSGHelper(); const helper = generateSSGHelper();
const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({ await helper.annunci.getSchedaAnnuncioData.prefetch({
id: parsed.data, id: parsed.data,
}); });
if (!data) {
return {
notFound: true,
};
}
return { return {
props: { props: {
raw: raw === "true", id: parsed.data,
trpcState: helper.dehydrate(), trpcState: helper.dehydrate(),
data: AnnuncioToStringified(data),
}, },
}; };
}) satisfies GetServerSideProps<PageProps>; }) satisfies GetServerSideProps<PageProps>;
type StringifiedAnnunciWithMedia = Omit<
AnnunciWithMedia,
"creato_il" | "disponibile_da" | "media_updated_at" | "modificato_il"
> & {
creato_il: string | undefined;
disponibile_da: string | undefined;
media_updated_at: string | undefined;
modificato_il: string | undefined;
};
function AnnuncioToStringified(
data: AnnunciWithMedia,
): StringifiedAnnunciWithMedia {
return {
...data,
creato_il: data?.creato_il?.toISOString(),
disponibile_da: data?.disponibile_da?.toISOString(),
media_updated_at: data?.media_updated_at?.toISOString(),
modificato_il: data?.modificato_il?.toISOString(),
};
}
function StringifiedToAnnuncio(
data: StringifiedAnnunciWithMedia,
): AnnunciWithMedia {
return {
...data,
creato_il: data?.creato_il ? new Date(data.creato_il) : null,
disponibile_da: data?.disponibile_da ? new Date(data.disponibile_da) : null,
media_updated_at: data?.media_updated_at
? new Date(data.media_updated_at)
: null,
modificato_il: data?.modificato_il ? new Date(data.modificato_il) : null,
};
}

View file

@ -70,6 +70,7 @@ export const getServerSideProps = (async (context) => {
const customer = await stripe.customers.create({ const customer = await stripe.customers.create({
email: paymentData.email, email: paymentData.email,
name: paymentData.username, name: paymentData.username,
description: paymentData.username,
metadata: { metadata: {
userId: paymentData.userid, userId: paymentData.userid,
}, },

View file

@ -1,8 +1,10 @@
import type { GetServerSideProps } from "next"; import type { GetServerSideProps } from "next";
import { ReceiptGenerator } from "~/components/acquisto_receipt"; import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { AreaRiservataLayout } from "~/components/Layout"; import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page"; import { Status500 } from "~/components/status-page";
import { Button } from "~/components/ui/button";
import { redirectTo500 } from "~/lib/utils"; import { redirectTo500 } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
@ -10,8 +12,14 @@ import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { zOrdineId } from "~/server/utils/zod_types"; import { zOrdineId } from "~/server/utils/zod_types";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
const PDFViewer = dynamic(
() => import("~/components/pdf-viewer").then((mod) => mod.PDFViewer),
{
ssr: false,
loading: () => <LoadingPage />,
},
);
type PagamentoRecapProps = { type PagamentoRecapProps = {
raw: boolean;
ordineId: OrdiniOrdineId; ordineId: OrdiniOrdineId;
}; };
@ -19,22 +27,89 @@ type PagamentoRecapProps = {
* Pagina di ricevuta di cortesia dopo un acquisto: /servizio/ricevuta-cortesia/[ordineId]?raw=true|false * Pagina di ricevuta di cortesia dopo un acquisto: /servizio/ricevuta-cortesia/[ordineId]?raw=true|false
*/ */
const PaymentRecapPage: NextPageWithLayout<PagamentoRecapProps> = ({ const PaymentRecapPage: NextPageWithLayout<PagamentoRecapProps> = ({
raw,
ordineId, ordineId,
}: PagamentoRecapProps) => { }: PagamentoRecapProps) => {
const { data, isLoading } = api.servizio.getOrdineRicevutaData.useQuery({ const { data, isLoading } =
ordineId, api.pagamenti.getRicevutaCortesiaPdfQuery.useQuery({
}); ordineId,
});
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const handleDownload = (pdfData: string, title: string) => {
const binaryString = atob(pdfData);
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const blob = new Blob([bytes], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = title;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
const handlePrint = (pdfUrl: string) => {
// 1. Create a hidden iframe
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = pdfUrl;
// 2. Wait for the PDF to load inside the iframe
iframe.onload = () => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
// 3. Clean up (optional: wait a bit to ensure print dialog opened)
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
};
document.body.appendChild(iframe);
};
useEffect(() => {
if (data) {
const binaryString = atob(data.pdf);
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const blob = new Blob([bytes], {
type: "application/pdf",
});
const url = window.URL.createObjectURL(blob);
setPdfUrl(url);
}
}, [data]);
if (isLoading) { if (isLoading) {
return <LoadingPage />; return <LoadingPage />;
} }
if (!data) { if (!data || !pdfUrl) {
return <Status500 />; return <Status500 />;
} }
return ( return (
<div className="mx-auto w-full p-4"> <div className="mx-auto w-full max-w-4xl p-4">
<ReceiptGenerator data={data} raw={raw} /> <div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="font-bold text-2xl">Ricevuta di cortesia</h1>
<div className="flex gap-2">
<Button
className="hidden sm:flex"
onClick={() => handlePrint(pdfUrl)}
variant="outline"
>
Stampa
</Button>
<Button onClick={() => handleDownload(data.pdf, data.title)}>
Scarica
</Button>
</div>
</div>
<PDFViewer url={pdfUrl} />
</div>
</div> </div>
); );
}; };
@ -50,7 +125,7 @@ export default PaymentRecapPage;
export const getServerSideProps = (async (context) => { export const getServerSideProps = (async (context) => {
const id = context.params?.ordineId; const id = context.params?.ordineId;
const raw = context.query?.raw;
if (typeof id !== "string") { if (typeof id !== "string") {
console.error("Error: ordineId is not a string"); console.error("Error: ordineId is not a string");
return redirectTo500; return redirectTo500;
@ -59,18 +134,17 @@ export const getServerSideProps = (async (context) => {
const parsed = zOrdineId.safeParse(id); const parsed = zOrdineId.safeParse(id);
if (!parsed.success) { if (!parsed.success) {
console.error("Error parsing ordineId", parsed.error); console.error("Error parsing ordineId", parsed.error);
return redirectTo500 return redirectTo500;
} }
const helper = generateSSGHelper(); const helper = generateSSGHelper();
if (helper) { if (helper) {
await helper.servizio.getOrdineRicevutaData.prefetch({ await helper.pagamenti.getRicevutaCortesiaPdfQuery.prefetch({
ordineId: parsed.data, ordineId: parsed.data,
}); });
return { return {
props: { props: {
raw: raw === "true",
ordineId: parsed.data, ordineId: parsed.data,
trpcState: helper.dehydrate(), trpcState: helper.dehydrate(),
}, },

View file

@ -1,7 +1,6 @@
import { import {
ClientsApi, ClientsApi,
Configuration, Configuration,
InfoApi,
} from "@fattureincloud/fattureincloud-ts-sdk"; } from "@fattureincloud/fattureincloud-ts-sdk";
import { env } from "~/env"; import { env } from "~/env";
@ -12,7 +11,6 @@ const apiConfig = new Configuration({
export const companyId = Number.parseInt(env.FIC_COMPANY_ID); export const companyId = Number.parseInt(env.FIC_COMPANY_ID);
export const clientsApi = new ClientsApi(apiConfig); export const clientsApi = new ClientsApi(apiConfig);
export const infosApi = new InfoApi(apiConfig);
/* /*
const FIC_COUNTRIES = [ const FIC_COUNTRIES = [
"Italia", "Italia",
@ -266,4 +264,4 @@ const FIC_COUNTRIES = [
"Zambia", "Zambia",
"Zimbabwe", "Zimbabwe",
"Irlanda del Nord", "Irlanda del Nord",
];*/ ];*/

View file

@ -14,6 +14,7 @@ import { servizioRouter } from "~/server/api/routers/servizio";
import { statsRouter } from "~/server/api/routers/stats"; import { statsRouter } from "~/server/api/routers/stats";
import { storageRouter } from "~/server/api/routers/storage"; import { storageRouter } from "~/server/api/routers/storage";
import { stripeRouter } from "~/server/api/routers/stripe"; import { stripeRouter } from "~/server/api/routers/stripe";
import { stripeReportsRouter } from "~/server/api/routers/stripe_reports";
import { testRouter } from "~/server/api/routers/test"; import { testRouter } from "~/server/api/routers/test";
import { usersRouter } from "~/server/api/routers/users"; import { usersRouter } from "~/server/api/routers/users";
import { createTRPCRouter } from "~/server/api/trpc"; import { createTRPCRouter } from "~/server/api/trpc";
@ -46,6 +47,7 @@ export const appRouter = createTRPCRouter({
stats: statsRouter, stats: statsRouter,
storage: storageRouter, storage: storageRouter,
stripe: stripeRouter, stripe: stripeRouter,
stripe_reports: stripeReportsRouter,
test: testRouter, test: testRouter,
users: usersRouter, users: usersRouter,
//sync: syncRouter, //sync: syncRouter,

View file

@ -1,4 +1,3 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { AnnunciUpdate } from "~/schemas/public/Annunci"; import type { AnnunciUpdate } from "~/schemas/public/Annunci";
import { import {
@ -15,6 +14,7 @@ import {
getAnnunciListHandler, getAnnunciListHandler,
getAnnuncioData, getAnnuncioData,
getAnnunciRicerca, getAnnunciRicerca,
getAnnunciSchedaData,
getCodici_AnnunciHandler, getCodici_AnnunciHandler,
getOptions_AnnunciHandler, getOptions_AnnunciHandler,
getProprietarioDataHandler, getProprietarioDataHandler,
@ -23,12 +23,12 @@ import {
type VideoToRemove, type VideoToRemove,
} from "~/server/controllers/annunci.controller"; } from "~/server/controllers/annunci.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { NewMail } from "~/server/services/mailer";
import { import {
genPdfSchedaAnnuncio,
invalidateCache, invalidateCache,
SCHEDA_ANNUNCIO_CACHE_PREFIX, SCHEDA_ANNUNCIO_CACHE_PREFIX,
} from "~/server/services/puppeteer.service"; } from "~/server/services/cache.service";
import { NewMail } from "~/server/services/mailer";
import { TypstGenerate } from "~/server/services/typst.service";
import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types"; import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types";
export const annunciRouter = createTRPCRouter({ export const annunciRouter = createTRPCRouter({
@ -83,6 +83,32 @@ export const annunciRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getAnnunciById_rawImgUrls({ ...input }); return await getAnnunciById_rawImgUrls({ ...input });
}), }),
getSchedaAnnuncioData: adminProcedure
.input(
z.object({
id: zAnnuncioId,
}),
)
.query(async ({ input }) => {
return await getAnnunciSchedaData(input.id);
}),
getSchedaAnnuncioPdf: adminProcedure
.input(
z.object({
id: zAnnuncioId,
}),
)
.mutation(async ({ input }) => {
const data = await getAnnunciSchedaData(input.id);
const pdfBuffer = await TypstGenerate({
templateId: "annuncio.typ",
data,
});
return {
pdf: pdfBuffer.toString("base64"),
title: `scheda-annuncio-${data.codice}.pdf`,
};
}),
getCodici: publicProcedure.query(async () => { getCodici: publicProcedure.query(async () => {
return await getCodici_AnnunciHandler(); return await getCodici_AnnunciHandler();
}), }),
@ -162,20 +188,4 @@ export const annunciRouter = createTRPCRouter({
...input, ...input,
}); });
}), }),
getSchedaAnnuncio: adminProcedure
.input(
z.object({
id: zAnnuncioId,
}),
)
.mutation(async ({ input }) => {
try {
return await genPdfSchedaAnnuncio(input.id);
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nella generazione della scheda: ${(e as Error).message}`,
});
}
}),
}); });

View file

@ -23,15 +23,10 @@ import {
deleteEmail, deleteEmail,
getEmails, getEmails,
} from "~/server/services/email.service"; } from "~/server/services/email.service";
import { type MailsTemplates, NewMail } from "~/server/services/mailer"; import type { MailsTemplates } from "~/server/services/mail-templates";
import { NewMail } from "~/server/services/mailer";
import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service"; import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service";
import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service"; import { zEventId, zOrdineId, zUserId } from "~/server/utils/zod_types";
import {
zAnnuncioId,
zEventId,
zOrdineId,
zUserId,
} from "~/server/utils/zod_types";
export const comunicazioniRouter = createTRPCRouter({ export const comunicazioniRouter = createTRPCRouter({
getEmails: protectedProcedure getEmails: protectedProcedure
@ -88,65 +83,6 @@ export const comunicazioniRouter = createTRPCRouter({
}); });
return { success: true }; return { success: true };
}), }),
sendSchedaAnnuncioEmail: adminProcedure
.input(
z.object({
id: zAnnuncioId,
codice: z.string(),
numero: z.string(),
nominativo: z.string(),
indirizzo: z.string(),
to: z.email(),
}),
)
.mutation(async ({ input }) => {
try {
const scheda = await genPdfSchedaAnnuncioBase64(input.id);
await NewMail({
template: {
mailType: "emailSchedaContatto",
props: {
nominativo: input.nominativo,
telefono: input.numero,
indirizzo: input.indirizzo,
},
},
mail: {
subject: `Scheda Annuncio ${input.codice} - Infoalloggi.it`,
to: input.to,
attachments: [
{
filename: `scheda annuncio ${input.codice}.pdf`,
content: scheda,
encoding: "base64",
contentType: "application/pdf",
},
],
},
});
await NewMail({
template: {
mailType: "generic",
props: {
title: `Ricevuta invio Scheda ${input.codice} a ${input.to}`,
testo: `La scheda dell'annuncio ${input.codice} è stata inviata a ${input.to} con successo.`,
},
},
mail: {
subject: `Ricevuta invio Scheda ${input.codice} a ${input.to}`,
to: "web@infoalloggi.it",
},
});
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nell'invio dell'email: ${(e as Error).message}`,
cause: e instanceof Error ? e : undefined,
});
}
}),
sendUtenteInteressatoAdAnnuncioEmail: publicProcedure sendUtenteInteressatoAdAnnuncioEmail: publicProcedure
.input( .input(

View file

@ -4,13 +4,14 @@ import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { import {
getPagamentoDataForCheckout, getPagamentoDataForCheckout,
getRicevutaData,
type PreparedPaymentData, type PreparedPaymentData,
preparePayment, preparePayment,
previewSaldo, previewSaldo,
} from "~/server/controllers/pagamenti.controller"; } from "~/server/controllers/pagamenti.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { genPdfRicevutaCortesia } from "~/server/services/puppeteer.service"; import { TypstGenerate } from "~/server/services/typst.service";
import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types"; import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types";
// Create a new ratelimiter, that allows 10 requests per 10 seconds // Create a new ratelimiter, that allows 10 requests per 10 seconds
/* /*
@ -113,7 +114,29 @@ export const pagamentiRouter = createTRPCRouter({
.input(z.object({ ordineId: zOrdineId })) .input(z.object({ ordineId: zOrdineId }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
return await genPdfRicevutaCortesia(input.ordineId); const ricData = await getRicevutaData(input.ordineId);
const pdf = await TypstGenerate({
templateId: "receipt.typ",
data: ricData.data,
});
return { title: ricData.title, pdf: pdf.toString("base64") };
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nella generazione della ricevuta di cortesia: ${(e as Error).message}`,
});
}
}),
getRicevutaCortesiaPdfQuery: protectedProcedure
.input(z.object({ ordineId: zOrdineId }))
.query(async ({ input }) => {
try {
const ricData = await getRicevutaData(input.ordineId);
const pdf = await TypstGenerate({
templateId: "receipt.typ",
data: ricData.data,
});
return { title: ricData.title, pdf: pdf.toString("base64") };
} catch (e) { } catch (e) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",

View file

@ -6,7 +6,7 @@ import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controlle
import { import {
getCacheStats, getCacheStats,
invalidateAllCaches, invalidateAllCaches,
} from "~/server/services/puppeteer.service"; } from "~/server/services/cache.service";
import { import {
revalidate, revalidate,
revalidateMultiple, revalidateMultiple,

View file

@ -12,7 +12,6 @@ import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import { import {
adminProcedure, adminProcedure,
createTRPCRouter, createTRPCRouter,
overridableProcedure,
protectedProcedure, protectedProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller"; import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller";
@ -25,7 +24,6 @@ import {
getAnnunciAvailableToAdd, getAnnunciAvailableToAdd,
getCompatibileAnnunci, getCompatibileAnnunci,
getDataPerAcquisto, getDataPerAcquisto,
getOrdineRicevutaData,
getRichieste, getRichieste,
getServiziByUserId, getServiziByUserId,
getServizioDataById, getServizioDataById,
@ -166,15 +164,6 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getOrdineById(input.ordineId); return await getOrdineById(input.ordineId);
}), }),
getOrdineRicevutaData: overridableProcedure
.input(
z.object({
ordineId: zOrdineId,
}),
)
.query(async ({ input }) => {
return await getOrdineRicevutaData({ ordineId: input.ordineId });
}),
getOrdini: protectedProcedure getOrdini: protectedProcedure
.input( .input(

View file

@ -0,0 +1,226 @@
import { TRPCError } from "@trpc/server";
import { endOfMonth, format, getUnixTime } from "date-fns";
import { it } from "date-fns/locale";
import Papa from "papaparse";
import { z } from "zod/v4";
import stripe from "~/lib/stripe";
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
import { TypstGenerate } from "~/server/services/typst.service";
export const stripeReportsRouter = createTRPCRouter({
listReports: adminProcedure.query(async () => {
try {
const reports = await stripe.reporting.reportRuns.list({ limit: 20 });
return reports.data;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error listing reports: ${(error as Error).message}`,
cause: error,
});
}
}),
setupEstrattoConto: adminProcedure
.input(
z.object({
start: z.date(),
}),
)
.mutation(async ({ input }) => {
const start = getUnixTime(input.start);
const end = getUnixTime(endOfMonth(input.start));
try {
await stripe.reporting.reportRuns.create({
report_type: "balance_change_from_activity.itemized.1",
parameters: {
interval_start: start,
interval_end: end,
columns: [
"balance_transaction_id",
"created_utc",
"currency",
"customer_email",
"customer_name",
"description",
"fee",
"gross",
"net",
"payment_method_type",
],
},
});
await stripe.reporting.reportRuns.create({
report_type: "balance.summary.1",
parameters: {
interval_start: start,
interval_end: end,
},
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error generating estratto conto: ${(error as Error).message}`,
cause: error,
});
}
}),
generateEstrattoConto: adminProcedure
.input(
z.object({
movimentiUrl: z.string(),
saldoUrl: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
const movimentiCSV = await fetch(input.movimentiUrl, {
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
},
}).then((res) => res.text());
const saldoCSV = await fetch(input.saldoUrl, {
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
},
}).then((res) => res.text());
const { data: movimentiData, errors: movimentiErrors } = Papa.parse(
movimentiCSV,
{
header: true, // Uses first row as keys
skipEmptyLines: true,
dynamicTyping: true, // Handles numbers/booleans
},
);
if (movimentiErrors.length > 0) {
return {
success: false,
message: movimentiErrors.map((e) => e.message).join(", "),
};
}
const { data: saldoData, errors: saldoErrors } = Papa.parse(saldoCSV, {
header: true, // Uses first row as keys
skipEmptyLines: true,
dynamicTyping: true, // Handles numbers/booleans
});
if (saldoErrors.length > 0) {
return {
success: false,
message: saldoErrors.map((e) => e.message).join(", "),
};
}
const movimenti = z.array(movimentiSchema).safeParse(movimentiData);
const saldi = z.array(saldoSchema).safeParse(saldoData);
if (!movimenti.success) {
console.error("Movimenti validation errors:", movimenti.error);
return {
success: false,
message: `Movimenti CSV validation error: ${movimenti.error.message}`,
};
}
if (!saldi.success) {
console.error("Saldo validation errors:", saldi.error);
return {
success: false,
message: `Saldo CSV validation error: ${saldi.error.message}`,
};
}
const startingBalance = saldi.data.find(
(s) => s.category === "starting_balance",
);
const endingBalance = saldi.data.find(
(s) => s.category === "ending_balance",
);
if (!startingBalance || !endingBalance) {
return {
success: false,
message: "Missing starting or ending balance in saldo report",
};
}
const data: EstrattoContoData = {
interval: `${format(new Date(startingBalance.description.replace(/[^\d-]/gm, "")), "dd/MM/yyyy", { locale: it }) || "N/A"} - ${format(new Date(endingBalance.description.replace(/[^\d-]/gm, "")), "dd/MM/yyyy", { locale: it }) || "N/A"}`,
emissione: format(new Date(), "dd/MM/yyyy", { locale: it }),
starting_balance: startingBalance?.net_amount * 100 || 0,
ending_balance: endingBalance?.net_amount * 100 || 0,
activity_gross:
(saldi.data.find((s) => s.category === "activity_gross")
?.net_amount || 0) * 100,
activity_fee:
(saldi.data.find((s) => s.category === "activity_fee")
?.net_amount || 0) * -100,
payouts:
saldi.data.find((s) => s.category === "payouts")?.net_amount || 0,
movimenti: movimenti.data.map((m) => ({
date: format(new Date(m.created_utc), "dd/MM/yyyy", {
locale: it,
}),
description: m.description || "",
gross: m.gross * 100,
net: m.net * 100,
fees: m.fee * 100,
name: m.customer_name || "",
email: m.customer_email || "",
})),
};
const pdf = await TypstGenerate({ templateId: "estratto.typ", data });
return {
success: true,
data: {
pdf: pdf.toString("base64"),
title: `Estratto Conto ${data.interval}.pdf`,
},
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error generating estratto conto: ${(error as Error).message}`,
cause: error,
});
}
}),
});
//stripe reporting report_runs create --report-type "balance.summary.1" --parameters.interval-start 1772323200 --parameters.interval-end 1775001599
const movimentiSchema = z.object({
balance_transaction_id: z.string(),
created_utc: z.string(),
currency: z.string(),
customer_email: z.email().nullable(),
customer_name: z.string().nullable(),
description: z.string().nullable(),
fee: z.number(),
gross: z.number(),
net: z.number(),
payment_method_type: z.string().nullable(),
});
const saldoSchema = z.object({
category: z.string(),
currency: z.string(),
description: z.string(),
net_amount: z.number(),
});
export type EstrattoContoData = {
interval: string;
emissione: string;
starting_balance: number;
ending_balance: number;
activity_gross: number; // Entrate lorde
activity_fee: number; // Commissioni
payouts: number; // Prelievi verso conto bancario
movimenti: {
date: string;
description: string;
gross: number;
net: number;
fees: number;
name: string;
email: string;
}[];
};

View file

@ -3,7 +3,6 @@ import {
protectedApiProcedure, protectedApiProcedure,
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { TypstRunner, TypstRunner2 } from "~/server/services/typst.service";
export const testRouter = createTRPCRouter({ export const testRouter = createTRPCRouter({
testApi: protectedApiProcedure.query(async () => { testApi: protectedApiProcedure.query(async () => {
@ -15,9 +14,9 @@ export const testRouter = createTRPCRouter({
console.error("test console.error"); console.error("test console.error");
throw new Error("This is a test error"); throw new Error("This is a test error");
}), }),
testTypst: publicProcedure.query(async () => { // testTypst: publicProcedure.query(async () => {
return await TypstRunner2(); // return await genTesti();
}), // }),
}); });
/* /*
PROTECTED API CALL EXAMPLE PROTECTED API CALL EXAMPLE

View file

@ -1,5 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { env } from "~/env"; import { env } from "~/env";
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import type { Result } from "~/lib/result"; import type { Result } from "~/lib/result";
import { getStorageUrl } from "~/lib/storage_utils"; import { getStorageUrl } from "~/lib/storage_utils";
import type { import type {
@ -11,6 +12,7 @@ import type { ImagesRefs } from "~/schemas/public/ImagesRefs";
import type { ServizioServizioId } from "~/schemas/public/Servizio"; import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { VideosRefs } from "~/schemas/public/VideosRefs"; import type { VideosRefs } from "~/schemas/public/VideosRefs";
import { db } from "~/server/db"; import { db } from "~/server/db";
import type { AnnuncioTemplateData } from "~/server/services/typst.service";
import { withImages, withVideos } from "~/utils/kysely-helper"; import { withImages, withVideos } from "~/utils/kysely-helper";
import { revalidate } from "../utils/revalidationHelper"; import { revalidate } from "../utils/revalidationHelper";
@ -555,3 +557,75 @@ export const removeAnnuncioMedia = async ({
}); });
} }
}; };
export const getAnnunciSchedaData = async (
id: AnnunciId,
): Promise<AnnuncioTemplateData> => {
try {
const data = await db
.selectFrom("annunci")
.select([
"codice",
"locatore",
"numero",
"indirizzo",
"civico",
"comune",
"provincia",
"cap",
"lat",
"lon",
"tipo",
"prezzo",
"titolo_it",
"desc_it",
"disponibile_da",
"tipo_locatore",
"caratteristiche",
])
.where("id", "=", id)
.executeTakeFirstOrThrow(
() => new Error(`Annuncio non trovato - id: ${id}`),
);
if (
!data.locatore ||
!data.numero ||
!data.indirizzo ||
!data.lat ||
!data.lon ||
!data.tipo ||
!data.prezzo ||
!data.titolo_it ||
!data.desc_it ||
!data.disponibile_da
) {
throw new Error(
`Dati incompleti per generazione scheda annuncio - id: ${id}`,
);
}
return {
codice: data.codice,
numero: data.numero,
nominativo: `${data.locatore}${data.tipo_locatore ? ` (${data.tipo_locatore})` : ""}`,
indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`,
lat: data.lat,
lon: data.lon,
tipo: data.tipo,
prezzo: data.prezzo,
titolo_it: data.titolo_it,
desc_it: data.desc_it,
disponibile_da: data.disponibile_da.toLocaleDateString("it-IT"),
caratteristiche: data.caratteristiche
? filteredCaratteristiche(data.caratteristiche)
.filter((c) => c.value !== null)
.map((c) => [c.text, c.value || ""])
: [],
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore recupero dati scheda annuncio: ${(e as Error).message}`,
});
}
};

View file

@ -12,6 +12,8 @@ import type {
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { getAnnunciSchedaData } from "~/server/controllers/annunci.controller";
import { getRicevutaData } from "~/server/controllers/pagamenti.controller";
import { import {
type MinimalSMSData, type MinimalSMSData,
SendMessage, SendMessage,
@ -19,10 +21,14 @@ import {
import { db } from "~/server/db"; import { db } from "~/server/db";
import { NewMail, type NewMailProps } from "~/server/services/mailer"; import { NewMail, type NewMailProps } from "~/server/services/mailer";
import { import {
genPdfCondizioniBase64, convertHtmlToTypst,
genPdfRicevutaCortesiaBase64, TypstGenerate,
genPdfSchedaAnnuncioBase64, } from "~/server/services/typst.service";
} from "../services/puppeteer.service"; import { getKeydbClient } from "~/utils/keydb";
import {
CONDIZIONI_CACHE_PREFIX,
SCHEDA_ANNUNCIO_CACHE_PREFIX,
} from "../services/cache.service";
type GeneratedAttachmentTypes = type GeneratedAttachmentTypes =
| { type: "scheda_annuncio"; annuncioId: AnnunciId } | { type: "scheda_annuncio"; annuncioId: AnnunciId }
@ -41,12 +47,68 @@ export type Email_EventData = {
const generatePdfContent = async (params: GeneratedAttachmentTypes) => { const generatePdfContent = async (params: GeneratedAttachmentTypes) => {
switch (params.type) { switch (params.type) {
case "ricevuta_cortesia": case "ricevuta_cortesia": {
return await genPdfRicevutaCortesiaBase64(params.ordineId); const ricData = await getRicevutaData(params.ordineId);
case "condizioni": const pdf = await TypstGenerate({
return await genPdfCondizioniBase64(params.stringId); templateId: "receipt.typ",
case "scheda_annuncio": data: ricData.data,
return await genPdfSchedaAnnuncioBase64(params.annuncioId); });
return pdf.toString("base64");
}
case "condizioni": {
const keybd = getKeydbClient();
const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${params.stringId}`;
if (keybd) {
const cached = await keybd.get(cacheKey);
if (cached) {
return cached;
}
}
const data = await db
.selectFrom("testi_e_stringhe")
.selectAll()
.where("stinga_id", "=", params.stringId)
.executeTakeFirst();
if (!data || !data.stringa_value) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Stringa non trovata",
});
}
const stringa = convertHtmlToTypst(data.stringa_value);
const pdf = await TypstGenerate({
templateId: "testi.typ",
data: { stringa },
});
const pdfBase64 = pdf.toString("base64");
if (keybd) {
await keybd.set(cacheKey, pdfBase64, "EX", 3600 * 24 * 5);
}
return pdfBase64;
}
case "scheda_annuncio": {
const keybd = getKeydbClient();
const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${params.annuncioId}`;
if (keybd) {
const cached = await keybd.get(cacheKey);
if (cached) {
return cached;
}
}
const data = await getAnnunciSchedaData(params.annuncioId);
const pdf = await TypstGenerate({
templateId: "annuncio.typ",
data,
});
const pdfBase64 = pdf.toString("base64");
if (keybd) {
await keybd.set(cacheKey, pdfBase64, "EX", 3600 * 5);
}
return pdfBase64;
}
default: default:
throw new Error(`Unknown PDF type: `); throw new Error(`Unknown PDF type: `);
} }

View file

@ -44,11 +44,6 @@ export const getClientFromCF = async (cf: string) => {
return clients.data; return clients.data;
}; };
export const addClient = async (client: Client) => {
const newClient = await clientsApi.createClient(companyId, { data: client });
return newClient.data;
};
export const exportUserToFIC = async (userId: UsersId) => { export const exportUserToFIC = async (userId: UsersId) => {
try { try {
const user = await db const user = await db

View file

@ -1,5 +1,7 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { add, intervalToDuration } from "date-fns"; import { add, format, intervalToDuration } from "date-fns";
import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe";
import { parseDBComune, parseDBNazione } from "~/lib/catasto";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { import type {
@ -9,6 +11,8 @@ import type {
import type { RinnoviId } from "~/schemas/public/Rinnovi"; import type { RinnoviId } from "~/schemas/public/Rinnovi";
import type { ServizioServizioId } from "~/schemas/public/Servizio"; import type { ServizioServizioId } from "~/schemas/public/Servizio";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { getComuni, getNazioni } from "~/server/controllers/catasto.controller";
import type { RicevutaProps } from "~/server/services/typst.service";
import { db } from "../db"; import { db } from "../db";
import { createOrdine } from "../services/ordini.service"; import { createOrdine } from "../services/ordini.service";
import { import {
@ -615,3 +619,124 @@ export const previewSaldo = async (
return saldo; return saldo;
}; };
export const getRicevutaData = async (
ordineId: OrdiniOrdineId,
): Promise<{ title: string; data: RicevutaProps }> => {
try {
const ordine = await db
.selectFrom("ordini")
.select([
"ordini.ordine_id",
"ordini.userid",
"ordini.amount_cent",
"ordini.sconto",
"ordini.created_at",
"ordini.paymentmethod",
"ordini.type",
"ordini.servizio_id",
])
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid")
.select("prezziario.nome_it")
.where("ordini.ordine_id", "=", ordineId)
.executeTakeFirstOrThrow(
() => new Error(`Ordine not found - ordineId: ${ordineId}`),
);
const anagrafica = await db
.selectFrom("users")
.select("users.username")
.innerJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
.select([
"users_anagrafica.via_residenza",
"users_anagrafica.civico_residenza",
"users_anagrafica.comune_residenza",
"users_anagrafica.provincia_residenza",
"users_anagrafica.cap_residenza",
"users_anagrafica.nazione_residenza",
"users_anagrafica.codice_fiscale",
])
.where("users.id", "=", ordine.userid)
.executeTakeFirstOrThrow(
() => new Error(`Anagrafica not found - userid: ${ordine.userid}`),
);
const comuni = await getComuni();
const nazioni = await getNazioni();
const parsedComune = parseDBComune(anagrafica.comune_residenza, comuni);
const parsedNazione = parseDBNazione(anagrafica.nazione_residenza, nazioni);
let previewSaldo: Prezziario | null = null;
if (ordine.type === OrderTypeEnum.Acconto && ordine.servizio_id) {
const servizio = await getServizioById(ordine.servizio_id);
if (servizio) {
const saldo = await SaldoSolver({
tipologia: servizio.tipologia,
permanenza: servizio.permanenza,
budget: servizio.budget,
});
if (saldo.success) {
previewSaldo = saldo.pack;
} else {
console.error(
`Errore nel calcolo del saldo per il servizio ${ordine.servizio_id}: ${saldo.message}, procedura getRicevutaData`,
);
}
} else {
console.error(
`Servizio non trovato per ordine ${ordineId} con servizio_id ${ordine.servizio_id}, procedura getRicevutaData`,
);
}
}
if (!anagrafica.codice_fiscale) {
throw new Error(`Codice fiscale mancante per utente ${ordine.userid}`);
}
console.log(previewSaldo);
const data = {
ordineId: ordine.ordine_id,
datetime: format(ordine.created_at, "HH:mm"),
date: format(ordine.created_at, "dd/MM/yyyy"),
nominativo: anagrafica.username,
codicefiscale: anagrafica.codice_fiscale,
indirizzo: `${anagrafica.via_residenza} ${anagrafica.civico_residenza}\n${parsedComune?.nome} (${anagrafica.provincia_residenza}) ${anagrafica.cap_residenza}`,
nazione: parsedNazione?.nome || "N/A",
method: PaymentMethodToString(
(ordine.paymentmethod || "manual") as PaymentType,
),
items: [
{
title: ordine.nome_it,
amount: ordine.amount_cent,
discount: ordine.sconto,
strike: false,
desc: null,
},
...(previewSaldo
? [
{
title: previewSaldo.nome_it,
amount: previewSaldo.prezzo_cent,
discount: null,
strike: true,
desc: "Pagamento differito, non è dovuto in questo momento",
},
]
: []),
],
total: ordine.amount_cent - ordine.sconto,
vat: (ordine.amount_cent - ordine.sconto) * 0.22,
};
return {
title: `ricevuta-cortesia-${anagrafica.username.replace(/\s+/g, "-")}-${format(ordine.created_at, "dd-MM-yyyy")}.pdf`,
data,
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching ordine ricevuta data: ${(e as Error).message}`,
});
}
};

View file

@ -2,10 +2,7 @@ import { TRPCError } from "@trpc/server";
import { add, isBefore } from "date-fns"; import { add, isBefore } from "date-fns";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { PurchaseData } from "~/components/acquisto_receipt";
import { env } from "~/env"; import { env } from "~/env";
import type { PaymentType } from "~/i18n/stripe";
import { parseDBComune, parseDBNazione } from "~/lib/catasto";
import { withParsedDates } from "~/lib/dateParserJsonKysely"; import { withParsedDates } from "~/lib/dateParserJsonKysely";
import type { Annunci, AnnunciId } from "~/schemas/public/Annunci"; import type { Annunci, AnnunciId } from "~/schemas/public/Annunci";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
@ -42,10 +39,8 @@ import {
} from "~/utils/kysely-helper"; } from "~/utils/kysely-helper";
import { GetUserInterests } from "../services/interests.service"; import { GetUserInterests } from "../services/interests.service";
import { createOrdine } from "../services/ordini.service"; import { createOrdine } from "../services/ordini.service";
import { genPdfCondizioniBase64 } from "../services/puppeteer.service";
import { getServizioById } from "../services/servizio.service"; import { getServizioById } from "../services/servizio.service";
import type { AnnuncioRicerca } from "./annunci.controller"; import type { AnnuncioRicerca } from "./annunci.controller";
import { getComuni, getNazioni } from "./catasto.controller";
import { SaldoSolver } from "./pagamenti.controller"; import { SaldoSolver } from "./pagamenti.controller";
export const getServiziByUserId = async (userId: UsersId) => { export const getServiziByUserId = async (userId: UsersId) => {
@ -243,30 +238,38 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
); );
if (user.comms_enabled) { if (user.comms_enabled) {
await NewMail({ await addEventToQueue({
template: { data: {
mailType: "servizioAttivato", tipo: "email",
}, data: {
mail: { template: {
subject: "Pagamento Confermato - Acconto Infoalloggi.it", mailType: "servizioAttivato",
to: ordine.email, },
attachments: ordine.testo_condizioni mail: {
? [ subject: "Pagamento Confermato - Acconto Infoalloggi.it",
{ to: ordine.email,
filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`, attachments: ordine.testo_condizioni
? [
{
filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`,
generate: {
type: "condizioni",
stringId:
ordine.testo_condizioni as TestiEStringheStingaId,
},
content: await genPdfCondizioniBase64( encoding: "base64",
ordine.testo_condizioni as TestiEStringheStingaId, contentType: "application/pdf",
), },
]
encoding: "base64", : [],
contentType: "application/pdf", },
}, userId: ordine.userId,
] },
: [],
}, },
userId: ordine.userId, lock_expires: add(new Date(), { seconds: 30 }),
}); });
await addEventToQueue({ await addEventToQueue({
data: { data: {
tipo: "email", tipo: "email",
@ -1398,101 +1401,6 @@ export const getCompatibileAnnunci = async ({
} }
}; };
export const getOrdineRicevutaData = async ({
ordineId,
}: {
ordineId: OrdiniOrdineId;
}): Promise<PurchaseData> => {
try {
const data = await db
.selectFrom("ordini")
.select([
"ordini.ordine_id",
"ordini.userid",
"ordini.amount_cent",
"ordini.sconto",
"ordini.created_at",
"ordini.paymentmethod",
"ordini.type",
"ordini.servizio_id",
])
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid")
.select("prezziario.nome_it")
.where("ordini.ordine_id", "=", ordineId)
//.where("ordini.paymentstatus", "=", PaymentStatusEnum.success)
.executeTakeFirstOrThrow(
() => new Error(`Ordine not found - ordineId: ${ordineId}`),
);
const anagrafica = await db
.selectFrom("users")
.select("users.username")
.innerJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
.select([
"users_anagrafica.via_residenza",
"users_anagrafica.civico_residenza",
"users_anagrafica.comune_residenza",
"users_anagrafica.provincia_residenza",
"users_anagrafica.cap_residenza",
"users_anagrafica.nazione_residenza",
"users_anagrafica.codice_fiscale",
])
.where("users.id", "=", data.userid)
.executeTakeFirstOrThrow(
() => new Error(`Anagrafica not found - userid: ${data.userid}`),
);
const comuni = await getComuni();
const nazioni = await getNazioni();
const parsedComune = parseDBComune(anagrafica.comune_residenza, comuni);
const parsedNazione = parseDBNazione(anagrafica.nazione_residenza, nazioni);
let previewSaldo: Prezziario | null = null;
if (data.type === OrderTypeEnum.Acconto && data.servizio_id) {
const servizio = await getServizioById(data.servizio_id);
if (servizio) {
const saldo = await SaldoSolver({
tipologia: servizio.tipologia,
permanenza: servizio.permanenza,
budget: servizio.budget,
});
if (saldo.success) {
previewSaldo = saldo.pack;
} else {
console.error(
`Errore nel calcolo del saldo per il servizio ${data.servizio_id}: ${saldo.message}, procedura getOrdineRicevutaData`,
);
}
} else {
console.error(
`Servizio non trovato per ordine ${ordineId} con servizio_id ${data.servizio_id}, procedura getOrdineRicevutaData`,
);
}
}
return {
clienteIndirizzo: `${anagrafica.via_residenza} ${anagrafica.civico_residenza}\n${parsedComune?.nome} (${anagrafica.provincia_residenza}) ${anagrafica.cap_residenza}\n${parsedNazione?.nome}`,
clienteNome: anagrafica.username,
codiceFiscale: anagrafica.codice_fiscale,
date: data.created_at,
packName: data.nome_it,
sconto: data.sconto,
amount_cent: data.amount_cent,
ordineId: data.ordine_id,
paymentMethod: (data.paymentmethod || "manual") as PaymentType,
previewSaldo,
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching ordine ricevuta data: ${(e as Error).message}`,
});
}
};
export type RichiesteData = Servizio & export type RichiesteData = Servizio &
Pick<Users, "username" | "note" | "telefono"> & { Pick<Users, "username" | "note" | "telefono"> & {
annunci_servizio: ServizioAnnuncioData[]; annunci_servizio: ServizioAnnuncioData[];

View file

@ -0,0 +1,64 @@
import type Redis from "ioredis";
import { getKeydbClient } from "~/utils/keydb";
export const SCHEDA_ANNUNCIO_CACHE_PREFIX = "scheda-annuncio-";
export const CONDIZIONI_CACHE_PREFIX = "condizioni-";
export const invalidateCache = async (cacheKey: string) => {
const keybd = getKeydbClient();
if (keybd) {
await keybd.del(cacheKey);
}
};
async function deleteByPrefix(redis: Redis, prefix: string) {
// Create a stream to find keys matching the pattern
const stream = redis.scanStream({
match: `${prefix}*`,
count: 100, // Process 100 keys per iteration
});
stream.on("data", async (keys: string[]) => {
if (keys.length > 0) {
// Create a pipeline to delete keys in a single network round-trip
const pipeline = redis.pipeline();
// Use UNLINK instead of DEL for better performance on large keys
keys.forEach((key) => pipeline.unlink(key));
await pipeline.exec();
console.log(`Deleted ${keys.length} keys`);
}
});
stream.on("error", (err) => {
console.error(
`Error scanning/unlinking keys with prefix ${prefix}: ${err.message}`,
);
});
}
export const invalidateAllCaches = async () => {
const keybd = getKeydbClient();
if (keybd) {
await deleteByPrefix(keybd, SCHEDA_ANNUNCIO_CACHE_PREFIX);
await deleteByPrefix(keybd, CONDIZIONI_CACHE_PREFIX);
}
};
export const getCacheStats = async () => {
const keybd = getKeydbClient();
if (keybd) {
const schedaKeys = await keybd.keys(`${SCHEDA_ANNUNCIO_CACHE_PREFIX}*`);
const condizioniKeys = await keybd.keys(`${CONDIZIONI_CACHE_PREFIX}*`);
return {
success: true,
schedaCount: schedaKeys.length,
condizioniCount: condizioniKeys.length,
};
}
return {
success: false,
schedaCount: 0,
condizioniCount: 0,
};
};

View file

@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
import type { EmailsIdEmail } from "~/schemas/public/Emails"; import type { EmailsIdEmail } from "~/schemas/public/Emails";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { genMail, type MailsTemplates } from "~/server/services/mailer"; import type { MailsTemplates } from "~/server/services/mail-templates";
import { genMail } from "~/server/services/mailer";
export const storeEmail = async ({ export const storeEmail = async ({
userId, userId,

View file

@ -1,6 +1,4 @@
import { import { AvvisoInterruzioneMailTag } from "emails/avviso-interruzione";
AvvisoInterruzioneMailTag,
} from "emails/avviso-interruzione";
import { import {
ContattoAdminEmailPropsSchema, ContattoAdminEmailPropsSchema,
ContattoAdminMailTag, ContattoAdminMailTag,
@ -13,36 +11,23 @@ import {
ContattoInteressatoMailTag, ContattoInteressatoMailTag,
ContattoInteressatoPropsSchema, ContattoInteressatoPropsSchema,
} from "emails/email-interessato"; } from "emails/email-interessato";
import { import { GenericMailTag, GenericPropsSchema } from "emails/gereric-email";
GenericMailTag,
GenericPropsSchema,
} from "emails/gereric-email";
import { InterruzioneMailTag } from "emails/interruzione"; import { InterruzioneMailTag } from "emails/interruzione";
import { InvitoMailTag, InvitoPropsSchema } from "emails/invito"; import { InvitoMailTag, InvitoPropsSchema } from "emails/invito";
import { import { PagamentoConfermaMailTag } from "emails/pagamento-conferma";
PagamentoConfermaMailTag, import { PagamentoErroreMailTag } from "emails/pagamento-errore";
} from "emails/pagamento-conferma"; import { PagamentoRicezioneMailTag } from "emails/pagamento-ricezione";
import {
PagamentoErroreMailTag,
} from "emails/pagamento-errore";
import {
PagamentoRicezioneMailTag,
} from "emails/pagamento-ricezione";
import { import {
PwResetLinkMailTag, PwResetLinkMailTag,
PwResetLinkPropsSchema, PwResetLinkPropsSchema,
} from "emails/pw-reset-link"; } from "emails/pw-reset-link";
import { RecessoMailTag } from "emails/recesso"; import { RecessoMailTag } from "emails/recesso";
import { import { RegistrazioneAvvenutaMailTag } from "emails/registrazione-avvenuta";
RegistrazioneAvvenutaMailTag,
} from "emails/registrazione-avvenuta";
import { import {
SchedaContattoMailTag, SchedaContattoMailTag,
SchedaContattoPropsSchema, SchedaContattoPropsSchema,
} from "emails/scheda-annuncio"; } from "emails/scheda-annuncio";
import { import { ServizioAttivatoMailTag } from "emails/servizio-attivato";
ServizioAttivatoMailTag,
} from "emails/servizio-attivato";
import { import {
VerificaEmailMailTag, VerificaEmailMailTag,
VerificaEmailPropsSchema, VerificaEmailPropsSchema,
@ -93,9 +78,9 @@ export const TemplateKeys = Object.keys(Templates) as Array<
keyof typeof Templates keyof typeof Templates
>; >;
export const TemplateSchemas = Object.entries(Templates) // const TemplateSchemas = Object.entries(Templates)
.map(([_, value]) => value.props) // .map(([_, value]) => value.props)
.filter((schema) => schema !== null); // .filter((schema) => schema !== null);
export type TemplateType = keyof typeof Templates; export type TemplateType = keyof typeof Templates;

View file

@ -26,14 +26,6 @@ import { GetFlagValueHandler } from "~/server/controllers/flags.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { storeEmail } from "~/server/services/email.service"; import { storeEmail } from "~/server/services/email.service";
export {
type MailsTemplates,
TemplateKeys,
TemplateSchemas,
Templates,
type TemplateType,
} from "~/server/services/mail-templates";
import { import {
type MailsTemplates, type MailsTemplates,
Templates, Templates,

View file

@ -1,229 +0,0 @@
import { TRPCError } from "@trpc/server";
import type Redis from "ioredis";
import puppeteer, { type PDFOptions } from "puppeteer-core";
import { env } from "~/env";
import type { AnnunciId } from "~/schemas/public/Annunci";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import { getKeydbClient } from "~/utils/keydb";
import { TOKEN_CONFIG } from "../auth/configs";
export const SCHEDA_ANNUNCIO_CACHE_PREFIX = "scheda-annuncio-";
const CONDIZIONI_CACHE_PREFIX = "condizioni-";
export const invalidateCache = async (cacheKey: string) => {
const keybd = getKeydbClient();
if (keybd) {
await keybd.del(cacheKey);
}
};
async function deleteByPrefix(redis: Redis, prefix: string) {
// Create a stream to find keys matching the pattern
const stream = redis.scanStream({
match: `${prefix}*`,
count: 100, // Process 100 keys per iteration
});
stream.on("data", async (keys: string[]) => {
if (keys.length > 0) {
// Create a pipeline to delete keys in a single network round-trip
const pipeline = redis.pipeline();
// Use UNLINK instead of DEL for better performance on large keys
keys.forEach((key) => pipeline.unlink(key));
await pipeline.exec();
console.log(`Deleted ${keys.length} keys`);
}
});
stream.on("error", (err) => {
console.error(
`Error scanning/unlinking keys with prefix ${prefix}: ${err.message}`,
);
});
}
export const invalidateAllCaches = async () => {
const keybd = getKeydbClient();
if (keybd) {
await deleteByPrefix(keybd, SCHEDA_ANNUNCIO_CACHE_PREFIX);
await deleteByPrefix(keybd, CONDIZIONI_CACHE_PREFIX);
}
};
export const getCacheStats = async () => {
const keybd = getKeydbClient();
if (keybd) {
const schedaKeys = await keybd.keys(`${SCHEDA_ANNUNCIO_CACHE_PREFIX}*`);
const condizioniKeys = await keybd.keys(`${CONDIZIONI_CACHE_PREFIX}*`);
return {
success: true,
schedaCount: schedaKeys.length,
condizioniCount: condizioniKeys.length,
};
}
return {
success: false,
schedaCount: 0,
condizioniCount: 0,
};
};
export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => {
const pdf = await genPdfSchedaAnnuncio(annuncioId);
const pdfBase64 = Buffer.from(pdf).toString("base64");
const base64Content = pdfBase64.includes("base64,")
? pdfBase64.split("base64,")[1]
: pdfBase64;
if (!base64Content) {
throw new Error("Failed to convert PDF to base64");
}
return base64Content;
};
export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => {
const keybd = getKeydbClient();
const url = `/area-riservata/scheda-annuncio-stampa/${annuncioId}?raw=true`;
const options: PDFOptions = { printBackground: true };
if (keybd) {
const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`;
const cached = await keybd.get(cacheKey);
if (cached) {
return new Uint8Array(Buffer.from(cached, "base64"));
}
const scheda = await puppeteerGenPdf({
url,
options,
});
await keybd.set(
cacheKey,
Buffer.from(scheda).toString("base64"),
"EX",
3600 * 5,
);
return scheda;
}
return await puppeteerGenPdf({ url, options });
};
export const genPdfCondizioniBase64 = async (
condizioniId: TestiEStringheStingaId,
) => {
const pdf = await genPdfCondizioni(condizioniId);
const pdfBase64 = Buffer.from(pdf).toString("base64");
const base64Content = pdfBase64.includes("base64,")
? pdfBase64.split("base64,")[1]
: pdfBase64;
if (!base64Content) {
throw new Error("Failed to convert PDF to base64");
}
return base64Content;
};
const genPdfCondizioni = async (condizioniId: TestiEStringheStingaId) => {
const keybd = getKeydbClient();
const url = `/servizio/condizioni/${condizioniId}?raw=true`;
if (keybd) {
const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${condizioniId}`;
const cached = await keybd.get(cacheKey);
if (cached) {
return new Uint8Array(Buffer.from(cached, "base64"));
}
const scheda = await puppeteerGenPdf({ url });
await keybd.set(
cacheKey,
Buffer.from(scheda).toString("base64"),
"EX",
3600 * 24 * 5,
);
return scheda;
}
return await puppeteerGenPdf({ url });
};
export const genPdfRicevutaCortesia = async (ordineId: OrdiniOrdineId) => {
const url = `/servizio/ricevuta-cortesia/${ordineId}?raw=true`;
return await puppeteerGenPdf({ url });
};
export const genPdfRicevutaCortesiaBase64 = async (
ordineId: OrdiniOrdineId,
) => {
const pdf = await genPdfRicevutaCortesia(ordineId);
const pdfBase64 = Buffer.from(pdf).toString("base64");
const base64Content = pdfBase64.includes("base64,")
? pdfBase64.split("base64,")[1]
: pdfBase64;
if (!base64Content) {
throw new Error("Failed to convert PDF to base64");
}
return base64Content;
};
const puppeteerGenPdf = async ({
url,
options,
}: {
url: string;
options?: PDFOptions;
}) => {
let browser = null;
try {
browser = await puppeteer.connect({
browserWSEndpoint: `${env.BROWSER_WS_URL}`,
});
const page = await browser.newPage();
const c = await browser.cookies();
if (
c.length === 0 ||
!c.some(
(cookie) => cookie.name === TOKEN_CONFIG.OVERRIDE_TOKEN_COOKIE_NAME,
)
) {
const productionDomain = new URL(env.INTERNAL_BASE_URL).hostname;
const newCookies = [
{
name: TOKEN_CONFIG.OVERRIDE_TOKEN_COOKIE_NAME,
value: env.EXP_API_PASS,
domain:
env.NODE_ENV === "production"
? productionDomain
: "host.docker.internal",
path: "/",
},
];
await browser.setCookie(...newCookies);
}
const baseUrl =
env.NODE_ENV === "production"
? env.INTERNAL_BASE_URL
: `http://host.docker.internal:3000`;
const targetUrl = `${baseUrl}${url}`;
try {
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 60000 });
} catch (e) {
if ((e as Error).message.includes("detached")) {
await page.goto(targetUrl, { waitUntil: "load", timeout: 60000 });
} else throw e;
}
const pdf = await page.pdf({
format: "A4",
...options,
});
return pdf;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nella generazione del PDF, URL: ${url}, ${(error as Error).message}`,
});
} finally {
// 'disconnect' tells browserless you're done with this tab
if (browser) await browser.disconnect();
}
};

View file

@ -3,9 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { EstrattoContoData } from "~/server/api/routers/stripe_reports";
import { db } from "~/server/db";
import { getStringa } from "~/server/services/testi_stringhe.service";
const execPromise = promisify(exec); const execPromise = promisify(exec);
const projectRoot = path.resolve("."); const projectRoot = path.resolve(".");
@ -13,140 +11,11 @@ const fontsPath = path.join(projectRoot, "typst", "fonts");
const tmpDir = path.join(projectRoot, "tmp"); const tmpDir = path.join(projectRoot, "tmp");
type RicevutaProps = {
ordineId: string;
date: string;
datetime: string;
nominativo: string;
codicefiscale: string;
indirizzo: string;
nazione: string;
items: {
title: string;
desc: string | null;
discount: number | null;
amount: number;
strike: boolean;
}[];
total: number;
vat: number;
method: string;
};
export const TypstRunner = async () => {
const timestamp = Date.now();
await fs.mkdir(tmpDir, { recursive: true });
const jsonFileName = `data-${timestamp}.json`;
const jsonPath = path.join(tmpDir, jsonFileName);
const templatePath = path.join(projectRoot, "typst", "receipt.typ");
const pdfPath = path.join(tmpDir, `receipt-${timestamp}.pdf`);
const props: RicevutaProps = {
ordineId: "dniuawndiandioawmodmawodmawdaw",
date: "10 gennaio 2026",
datetime: "14:55",
nominativo: "Di Grigoli Angelo Damiano",
codicefiscale: "DGRNLD97C13B602Z",
indirizzo: "Via Aldo Fantina 15, PIEVE DEL GRAPPA (TV) 31017,",
nazione: "Italia",
items: [
{
title: "Acconto Cerco Transitorio",
desc: null,
discount: null,
amount: 3500,
strike: false,
},
{
title: "Saldo Cerco Affitto Transitorio (6 mesi)",
desc: "Pagamento differito, non è dovuto in questo momento",
discount: null,
amount: 37500,
strike: true,
},
],
total: 3500,
vat: 3500 * 0.22,
method: "Carta di credito",
};
await fs.writeFile(jsonPath, JSON.stringify(props));
try {
const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfPath}"`;
await execPromise(command);
const pdfBuffer = await fs.readFile(pdfPath);
return new Response(pdfBuffer, {
headers: { "Content-Type": "application/pdf" },
});
} catch (err) {
console.error("typst compile failed:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF with Typst",
cause: err instanceof Error ? err : undefined,
});
} finally {
// Cleanup — runs even on failure
await Promise.all([
fs.unlink(jsonPath).catch(() => {}),
//fs.unlink(pdfFile).catch(() => {}),
]);
}
};
export const TypstRunner2 = async () => {
const s = await getStringa({
db: db,
stringaId: "CONDIZIONI_CERCHI" as TestiEStringheStingaId,
});
if (!s || !s.stringa_value) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Stringa non trovata",
});
}
const timestamp = Date.now();
await fs.mkdir(tmpDir, { recursive: true });
const jsonFileName = `tmp-${timestamp}.json`;
const jsonPath = path.join(tmpDir, jsonFileName);
const templatePath = path.join(projectRoot, "typst", "testi.typ");
const pdfFile = path.join(tmpDir, `doc-${timestamp}.pdf`);
const p = convertHtmlToTypst(s.stringa_value);
await fs.writeFile(jsonPath, JSON.stringify({ stringa: p }));
try {
const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfFile}"`;
await execPromise(command);
const pdfBuffer = await fs.readFile(pdfFile);
return new Response(pdfBuffer, {
headers: { "Content-Type": "application/pdf" },
});
} catch (err) {
console.error("typst compile failed:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF with Typst",
cause: err instanceof Error ? err : undefined,
});
} finally {
// Cleanup — runs even on failure
await Promise.all([
fs.unlink(jsonPath).catch(() => {}),
//fs.unlink(pdfFile).catch(() => {}),
]);
}
};
/** /**
* Converts HTML to Typst markup using Pandoc. * Converts HTML to Typst markup using Pandoc.
* works on Windows & Linux. * works on Windows & Linux.
*/ */
function convertHtmlToTypst(html: string): string { export function convertHtmlToTypst(html: string): string {
try { try {
// We pass the HTML via 'input' which writes to the process stdin // We pass the HTML via 'input' which writes to the process stdin
const output = execSync( const output = execSync(
@ -165,3 +34,95 @@ function convertHtmlToTypst(html: string): string {
}); });
} }
} }
type EstrattoContoTemplate = {
templateId: "estratto.typ";
data: EstrattoContoData;
};
export type RicevutaProps = {
ordineId: string;
date: string;
datetime: string;
nominativo: string;
codicefiscale: string;
indirizzo: string;
nazione: string;
items: {
title: string;
desc: string | null;
discount: number | null;
amount: number;
strike: boolean;
}[];
total: number;
vat: number;
method: string;
};
type RicevutaTemplate = {
templateId: "receipt.typ";
data: RicevutaProps;
};
type TestiTemplate = {
templateId: "testi.typ";
data: {
/**
* const striga = convertHtmlToTypst(stringa_value);
*/
stringa: string;
};
};
export type AnnuncioTemplateData = {
codice: string;
nominativo: string;
numero: string;
indirizzo: string;
lat: string;
lon: string;
tipo: string;
prezzo: number;
titolo_it: string;
desc_it: string;
disponibile_da: string;
caratteristiche: [string, string][];
};
type AnnuncioTemplate = {
templateId: "annuncio.typ";
data: AnnuncioTemplateData;
};
type TypstInput =
| EstrattoContoTemplate
| RicevutaTemplate
| TestiTemplate
| AnnuncioTemplate;
export const TypstGenerate = async ({ templateId, data }: TypstInput) => {
const timestamp = Date.now();
await fs.mkdir(tmpDir, { recursive: true });
const tmpDataFile = `data-${timestamp}.json`;
const tmpDataPath = path.join(tmpDir, tmpDataFile);
const templatePath = path.join(projectRoot, "typst", templateId);
const resultPath = path.join(tmpDir, `doc-${timestamp}.pdf`);
await fs.writeFile(tmpDataPath, JSON.stringify(data));
try {
const command = `typst compile --input data_path=/tmp/${tmpDataFile} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${resultPath}"`;
await execPromise(command);
return await fs.readFile(resultPath);
} catch (err) {
console.error(`typst compile failed, template: ${templateId}:`, err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to generate PDF with Typst, template: ${templateId}`,
cause: err instanceof Error ? err : undefined,
});
} finally {
await Promise.all([
fs.unlink(tmpDataPath).catch(() => {}),
fs.unlink(resultPath).catch(() => {}),
]);
}
};

View file

@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".", "baseUrl": ".",
"checkJs": true, "checkJs": true,
/* Base Options: */ /* Base Options: */
@ -51,7 +52,8 @@
"emails", "emails",
".next/types/**/*.ts", ".next/types/**/*.ts",
"tests", "tests",
"flow-gen.ts" "flow-gen.ts",
"check-unused-trpc.ts"
], ],
"ts-node": { "ts-node": {
// these options are overrides used only by ts-node // these options are overrides used only by ts-node

View file

@ -1,15 +1,35 @@
#import "@preview/iconic-salmon-fa:1.1.0": fa-location-pin, fa-whatsapp #import "@preview/iconic-salmon-fa:1.1.0": fa-location-pin, fa-whatsapp
#import "utils.typ": format-eur
#import "@preview/oxifmt:1.0.0": strfmt
#let data_file = sys.inputs.at("data_path", default: "none")
#let data = if data_file != "none" { json(data_file) } else {
(
codice: "3786B",
nominativo: "INFOALLOGGI (delegato)",
numero: "+393453944827",
indirizzo: "VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)",
lat: "45.7705",
lon: "11.74959",
tipo: "Transitorio",
prezzo: 54000,
titolo_it: "BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/05/26",
desc_it: "CODICE: 3786B - LIBERO DA MAGGIO - AFFITTO TRANSITORIO -\n\nConfortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio. Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F.\n\nIl bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 e spese condominiali di 80,00 €/mese.\n\nQuesto monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita.\n\nRICHIESTE DEL PROPRIETARIO:\n\nTipologia contratto di locazione: TRANSITORIO (No stabile)\nRegime fiscale: CEDOLARE SECCA (Senza imposte di registro e bolli)\nData disponibilità: 1 MAGGIO\nPermanenza: DA 6 a 12 MESI\nPreferenza: SINGLE\nContratto di lavoro: A TEMPO DETERMINATO (o altra motivazione prevista per legge)\nCanone mensile: 540,00 \nSpese condominiali e accessorie: 80,00 €/MESE\nDeposito cauzionale: IN BASE ALLA PERMANENZA\nConsumi utenze: Gas/Riscaldamento - Luce - Acqua (Rimborso a CONSUMO)\nFumatori: NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI\nAnimali: NON E' CONSENTITO\n\nCOSA FARE PER VISITARE L'IMMOBILE\n \nInfoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca. \nLa geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio.\n",
disponibile_da: "01/05/2026",
caratteristiche: (
("Tipo Impianto", "Radiatori"),
("Riscaldamento", "Autonomo"),
("Arredi", "Arredato"),
("Piani Edificio", "Uno"),
("Internet", "No"),
),
)
}
#set page( #set page(
paper: "a4", paper: "a4",
margin: (bottom: 1.5cm, rest: 1cm), margin: (bottom: 1.5cm, rest: 1cm),
footer: [
#line(length: 100%, stroke: 0.5pt + luma(200))
#set text(size: 8pt, fill: luma(100))
#grid(
columns: (1fr, auto),
[Arcenia Srl. Tel. +39 0424529869 | Email: arca\@infoalloggi.it], [Documento generato in data: 31/3/2026],
)
],
) )
#set text(font: "Inter 18pt", size: 10pt, lang: "it", fill: rgb("#1f1f1f")) #set text(font: "Inter 18pt", size: 10pt, lang: "it", fill: rgb("#1f1f1f"))
@ -28,112 +48,90 @@
Email: arca\@infoalloggi.it Email: arca\@infoalloggi.it
], ],
) )
#v(1em) #v(0.5em)
#line(length: 100%, stroke: 0.5pt + luma(200))
#v(1em)
// --- TITLE & REFERENCE ---
#align(center)[#text( #align(center)[#text(
size: 16pt, size: 14pt,
weight: "bold", weight: "semibold",
fill: rgb("#63493f"), fill: rgb("#63493f"),
)[BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/06/26]] )[#data.titolo_it]]
#v(0.5em) #v(0.5em)
#align(center)[#text( #align(center)[#text(
size: 14pt, size: 13pt,
)[Riferimento: *3786B*]] )[Riferimento: *#data.codice*]]
#v(1.5em) #v(.5em)
#box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt)[ #box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt, fill: rgb("#fcfcfc"))[
#grid( #grid(
columns: 1fr, columns: 1fr,
gutter: 14pt, gutter: 14pt,
[#fa-whatsapp(size: 20pt, fill: rgb("#25d366"))#h(1em)#text( [#fa-whatsapp(size: 16pt, fill: rgb("#25d366"))#h(1em)#text(
size: 16pt, size: 14pt,
weight: "bold", weight: "semibold",
fill: rgb("#63493f"), fill: rgb("#63493f"),
)[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]], )[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]],
[INFOALLOGGI (delegato)], [#data.nominativo],
[*Tel. +393453944827*] [*Tel. #data.numero*]
) )
] ]
#v(1.5em) #v(.5em)
#grid( #text(weight: "semibold")[Indirizzo]
columns: 1fr, #line(length: 100%, stroke: 0.5pt + luma(200))
gutter: 10pt, [ #text(weight: "semibold")[#data.indirizzo]#h(3em)#link(
#text(size: 10pt, weight: "bold")[Indirizzo]], [ strfmt(
#line(length: 100%, stroke: 0.5pt + luma(200))], [ "https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination={},{}",
#text(weight: "bold", size: 12pt)[VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)]#h(3em)#link( data.lat,
"https://maps.google.com", data.lon,
)[#fa-location-pin(size: 20pt, fill: red)#text(fill: blue)[Apri in Google Maps]] ),
] )[#fa-location-pin(size: 18pt, fill: red)#text(fill: blue)[Apri in Google Maps]]
)
#v(0.5em) #v(1em)
#v(0.5em)
#v(1.5em)
// --- KEY DETAILS TABLE --- // --- KEY DETAILS TABLE ---
#table( #table(
columns: (1fr, 1fr, 1fr), columns: (1fr, 1fr, 1fr),
stroke: 0.5pt + luma(200), stroke: none,
inset: 10pt, //inset: 10pt,
[*Tipologia* \ Affitto Transitorio], [*Canone Mensile* \ 540,00 ], [*Disponibile da* \ 31/05/2026], [#box(
inset: 0.8em,
fill: luma(245),
height: auto,
width: 100%,
)[Tipologia \ #text(weight: "semibold", size: 12pt)[Affitto #data.tipo]] ],
[#box(
inset: 0.8em,
fill: luma(245),
height: auto,
width: 100%,
)[Canone Mensile \ #text(weight: "semibold", size: 12pt)[#format-eur(data.prezzo)]]],
[#box(
inset: 0.8em,
fill: luma(245),
height: auto,
width: 100%,
)[Disponibile da \ #text(weight: "semibold", size: 12pt)[#data.disponibile_da]]],
) )
#v(1.5em) #v(.5em)
// --- FEATURES --- // --- FEATURES ---
#text(size: 12pt, weight: "bold")[Dotazioni e Servizi] #text(size: 12pt, weight: "semibold")[Dotazioni e Servizi]
#v(0.5em) #v(0.5em)
#grid( #grid(
columns: (1fr, 1fr, 1fr), columns: (1fr, 1fr, 1fr),
gutter: 10pt, gutter: 14pt,
[- *Tipo Impianto:* Radiatori], [- *Riscaldamento:* Autonomo], [- *Arredi:* Arredato],
[- *Piani Edificio:* Uno], [- *Internet:* No], ..data.caratteristiche.map(pair => [- *#pair.at(0)*: #pair.at(1)])
) )
#v(1.5em) #v(1.5em)
// --- DESCRIPTION --- // --- DESCRIPTION ---
#text(size: 12pt, weight: "bold")[Descrizione] #text(size: 12pt, weight: "semibold")[Descrizione]
#v(0.5em) #line(length: 100%, stroke: 0.5pt + luma(200))
CODICE: 3786B LIBERO DA GIUGNO AFFITTO TRANSITORIO - Confortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio. #text(size: 9pt)[#data.desc_it]
Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F.
Il bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 e spese condominiali di 80,00 €/mese.
Questo monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita.
#v(1.5em) #v(1.5em)
// --- OWNER REQUESTS --- #text(size: 8pt, fill: rgb("#636363"))[
#rect(fill: rgb("#f5f5f5"), width: 100%, inset: 15pt, stroke: none)[ Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate
#text(size: 12pt, weight: "bold")[RICHIESTE DEL PROPRIETARIO:]
#v(0.5em)
#list(
[*Tipologia contratto di locazione:* TRANSITORIO (No stabile)],
[*Regime fiscale:* CEDOLARE SECCA (Senza imposte di registro e bolli)],
[*Data disponibilità:* 1 GIUGNO],
[*Permanenza:* DA 6 a 12 MESI],
[*Preferenza:* SINGLE],
[*Contratto di lavoro:* A TEMPO DETERMINATO (o altra motivazione prevista per legge)],
[*Canone mensile:* 540,00 ],
[*Spese condominiali e accessorie:* 80,00 €/MESE],
[*Deposito cauzionale:* IN BASE ALLA PERMANENZA],
[*Consumi utenze:* Gas/Riscaldamento - Luce Acqua (Rimborso a CONSUMO)],
[*Fumatori:* NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI],
[*Animali:* NON E' CONSENTITO],
)
] ]
#v(1.5em)
// --- HOW TO VISIT ---
#text(size: 12pt, weight: "bold")[COSA FARE PER VISITARE L'IMMOBILE]
#v(0.5em)
Infoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca.
La geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio. Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate.

View file

@ -0,0 +1,122 @@
#import "utils.typ": format-eur
#let data_file = sys.inputs.at("data_path", default: "none")
#let data = if data_file != "none" { json(data_file) } else {
(
interval: "01/03/2026 - 31/03/2026",
emissione: "03/04/2026",
starting_balance: 0,
ending_balance: 0,
activity_gross: 184500, // Entrate lorde
activity_fee: -6187, // Commissioni
payouts: -178313, // Prelievi verso conto bancario
movimenti: (
(
"date": "17/03/2026",
"description": "Acconto Cerco Transitorio",
"gross": 3500,
"net": 3361,
"fees": -139,
"name": "Rossi Mario",
"email": "killmyfootwithsniper@gmail.com",
),
(
"date": "17/03/2026",
"description": "Rinnovo permanenza 6 mesi",
"gross": 36000,
"net": 34805,
"fees": -1195.0000004,
"name": "Rossi Mario",
"email": "killmyfootwithsniper@gmail.com",
),
),
)
}
#set page(paper: "a4", margin: (x: .5cm, y: 1.5cm))
#set text(font: "Inter 18pt", size: 10pt)
#let stripe-blue = rgb("#635BFF")
#let text-dark = rgb("#30313D")
#let text-muted = rgb("#687385")
#let border-color = rgb("#E3E8EE")
#let bg-light = rgb("#F7FAFC")
#grid(
columns: (1fr, 1fr),
align(left)[
// Simulazione del logo Stripe
#text(fill: stripe-blue, size: 28pt, weight: "bold")[Stripe]
],
align(right)[
#text(fill: text-muted, size: 14pt, weight: "bold")[ESTRATTO CONTO] \
#v(4pt)
#text(fill: text-dark, size: 10pt)[*Periodo:* #data.interval] \
#text(fill: text-dark, size: 10pt)[*Generato il:* #data.emissione]
],
)
#v(.5cm)
#text(fill: text-muted, size: 9pt, weight: "bold")[INTESTARIO DEL CONTO:] \
#v(2pt)
#text(weight: "bold", size: 11pt, fill: text-dark)[Arcenia S.r.l.] \
Via Beata Giovanna, 1 \
36061 Bassano del Grappa (VI) \
Italia \
P.IVA: 00924740244
#v(.5cm)
#text(size: 14pt, weight: "bold", fill: stripe-blue)[Riepilogo del Conto]
#v(0.3cm)
#table(
columns: (1fr, 1fr, 1fr, 1fr, 1fr),
stroke: border-color,
fill: (col, row) => if row == 0 { bg-light } else { none },
align: center + horizon,
[*Saldo Iniziale*], [*Totale Entrate*], [*Commissioni*], [*Prelievi*], [*Saldo Finale*],
[#format-eur(data.starting_balance)],
[#format-eur(data.activity_gross)],
[#format-eur(data.activity_fee)],
[#format-eur(data.payouts)],
[#format-eur(data.ending_balance)],
)
#v(.5cm)
#text(size: 14pt, weight: "bold", fill: stripe-blue)[Dettaglio Movimenti]
#v(0.3cm)
#table(
columns: (auto, 1fr, 1fr, auto, auto, auto),
stroke: (x, y) => if y == 0 {
(bottom: 1pt + text-muted)
} else {
(bottom: 0.5pt + border-color)
},
fill: (col, row) => if row == 0 { bg-light } else { none },
align: (left, left, left, left, left, left),
table.header([*Data*], [*Descrizione*], [*Cliente*], [*Lordo*], [*Comm.*], [*Netto*]),
..data
.movimenti
.map(item => {
(
item.date,
item.description,
item.name + "\n" + text(size: 8pt)[#item.email],
format-eur(item.gross),
format-eur(item.fees),
format-eur(item.net),
)
})
.flatten(),
)

View file

@ -1,4 +1,4 @@
#import "utils.typ": format-price #import "utils.typ": format-eur
#let data_file = sys.inputs.at("data_path", default: "none") #let data_file = sys.inputs.at("data_path", default: "none")
@ -77,7 +77,7 @@
], ],
align(right)[ align(right)[
#text(fill: luma(120), size: 9pt)[Data] \ #text(fill: luma(120), size: 9pt)[Data] \
#data.date \ #text(size: 12pt)[#data.date] \
#data.datetime #data.datetime
], ],
) )
@ -111,11 +111,15 @@
.map(item => { .map(item => {
( (
item.title + if item.desc != none { "\n" + text(size: 9pt, fill: luma(100))[#item.desc] } else { "" }, item.title + if item.desc != none { "\n" + text(size: 9pt, fill: luma(100))[#item.desc] } else { "" },
if item.at("discount", default: none) != none { [#item.discount %] } else [], if item.at("discount", default: none) != none {
if item.discount > 0 {
[#item.discount %]
}
} else [],
if item.at("strike", default: false) { if item.at("strike", default: false) {
strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-price(item.amount)]] strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-eur(item.amount)]]
} else { } else {
[#format-price(item.amount)] [#format-eur(item.amount)]
}, },
table.hline(stroke: 0.5pt + luma(200)), table.hline(stroke: 0.5pt + luma(200)),
) )
@ -130,8 +134,8 @@
columns: (1fr, auto), columns: (1fr, auto),
align: (right, right), align: (right, right),
gutter: 1.5em, gutter: 1.5em,
[*Totale:*], [*#format-price(data.total)*], [*Totale:*], [*#format-eur(data.total)*],
[#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-price(data.vat)]], [#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-eur(data.vat)]],
) )
] ]

View file

@ -1,15 +1,38 @@
#import "@preview/oxifmt:1.0.0": strfmt #import "@preview/oxifmt:1.0.0": strfmt
#let format-price(cents) = {
// Handle case where cents might be null or not a number
if cents == none { return "0,00 €" }
let euros = int(cents / 100)
let decimal = calc.rem(cents, 100)
// Create the decimal string and pad with a leading zero if needed (e.g., 5 cents -> "05") #let format-eur(cents) = {
let decimal-str = if decimal < 10 { "0" + str(decimal) } else { str(decimal) } // 1. Gestione del segno
let is-negative = cents < 0
let abs-cents = calc.abs(cents)
return str(euros) + "," + decimal-str + " €" // 2. Calcolo parte intera e frazionaria
// Usiamo calc.round per sicurezza se l'input non fosse un intero puro
let total-abs = calc.round(abs-cents)
let whole = calc.trunc(total-abs / 100)
let frac = calc.rem(total-abs, 100)
// 3. Formattazione Migliaia (punti)
let chars = str(whole).clusters()
let formatted-whole = ""
let count = 0
for c in chars.rev() {
if count > 0 and calc.rem(count, 3) == 0 {
formatted-whole = "." + formatted-whole
}
formatted-whole = c + formatted-whole
count += 1
}
// 4. Formattazione Centesimi (sempre 2 cifre)
// Usiamo lo zero-padding per casi come 5 centesimi -> "05"
let frac-str = if frac < 10 { "0" + str(frac) } else { str(frac) }
// 5. Output finale
let sign = if is-negative { "-" } else { "" }
// Ritorna il testo formattato
[#sign #formatted-whole,#frac-str ]
} }