feat: enhance payment preparation logic and add rinnovo handling

- Refactor `preparePayment` function to handle multiple order types including Rinnovo.
- Introduce new `RinnovoSolver` function to calculate renewal packs based on duration.
- Add `createRinnovo` service to manage the creation of renewal orders.
- Update `whIntentSucceededHandler` to handle payment confirmations for Rinnovo and other order types.
- Introduce default conditions for packs in `prezziario.service.ts`.
- Add new utility functions for managing renewal durations and permanenza.
- Update database interactions to ensure proper handling of orders and renewals.
- Enhance error handling and logging throughout the payment and renewal processes.
This commit is contained in:
Marco Pedone 2026-03-17 18:46:43 +01:00
parent 7e57f128f4
commit 1307f352a4
25 changed files with 1770 additions and 447 deletions

View file

@ -0,0 +1,261 @@
ALTER TABLE IF EXISTS public.ordini
ALTER COLUMN servizio_id
DROP NOT NULL;
ALTER TABLE IF EXISTS public.ordini
ADD COLUMN IF NOT EXISTS rinnovo_id UUID;
CREATE TABLE IF NOT EXISTS public.rinnovi (
id UUID NOT NULL DEFAULT gen_random_uuid (),
"userId" UUID NOT NULL,
codice TEXT NOT NULL,
decorrenza TIMESTAMP WITHOUT TIME ZONE NOT NULL,
scadenza timestamp without time zone NOT NULL,
gestionale_id TEXT
);
DO $$ BEGIN IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'rinnovo_user_fkey'
) THEN
ALTER TABLE public.rinnovi
ADD CONSTRAINT rinnovo_user_fkey
FOREIGN KEY ("userId") REFERENCES public.users (id)
ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;
-- Primary Key
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'rinnovi_pkey'
AND conrelid = 'public.rinnovi'::regclass
) THEN
ALTER TABLE public.rinnovi
ADD CONSTRAINT rinnovi_pkey PRIMARY KEY (id);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ordine_rinnovo_fkey') THEN
ALTER TABLE public.ordini
ADD CONSTRAINT ordine_rinnovo_fkey FOREIGN KEY (rinnovo_id) REFERENCES public.rinnovi (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE NOT VALID;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'only_one_service') THEN
ALTER TABLE public.ordini
ADD CONSTRAINT only_one_service CHECK (
(
servizio_id IS NOT NULL
AND rinnovo_id IS NULL
)
OR (
servizio_id IS NULL
AND rinnovo_id IS NOT NULL
)
) NOT VALID;
END IF;
END;
$$;
ALTER TABLE IF EXISTS public.prezziario
ADD COLUMN IF NOT EXISTS order_type "OrderTypeEnum";
ALTER TABLE IF EXISTS public.prezziario
ADD COLUMN IF NOT EXISTS service_type "TipologiaPosizioneEnum";
UPDATE public.prezziario
SET
service_type = 'Transitorio'
WHERE
idprezziario = 'ACCONTO_BD'
OR idprezziario LIKE 'SALDOBD%'
OR idprezziario = 'CONS_TRANS'
OR idprezziario = 'CONS_30GG';
UPDATE public.prezziario
SET
service_type = 'Stabile'
WHERE
idprezziario = 'ACCONTO_CA'
OR idprezziario LIKE 'SALDOCA%'
OR idprezziario = 'CONS_COMM'
OR idprezziario = 'CONS_4+4'
OR idprezziario = 'CONS_3+2';
UPDATE public.prezziario
SET
order_type = 'Acconto'
WHERE
idprezziario LIKE 'ACCONTO%';
UPDATE public.prezziario
SET
order_type = 'Saldo'
WHERE
idprezziario LIKE 'SALDO%';
UPDATE public.prezziario
SET
order_type = 'Consulenza'
WHERE
idprezziario LIKE 'CONS%';
ALTER TABLE IF EXISTS public.prezziario
DROP COLUMN IF EXISTS "isAcconto";
ALTER TABLE IF EXISTS public.prezziario
DROP COLUMN IF EXISTS "isSaldo";
ALTER TABLE IF EXISTS public.prezziario
DROP COLUMN IF EXISTS "isConsulenza";
ALTER TABLE IF EXISTS public.prezziario
DROP COLUMN IF EXISTS "isStabile";
ALTER TABLE IF EXISTS public.prezziario
DROP COLUMN IF EXISTS "isTransitorio";
ALTER TABLE IF EXISTS public.prezziario
ADD COLUMN IF NOT EXISTS service_variant integer;
UPDATE public.prezziario
SET
service_variant = 4
WHERE
idprezziario = 'SALDOBD_12MESI';
UPDATE public.prezziario
SET
service_variant = 0
WHERE
idprezziario = 'SALDOBD_1MESE';
UPDATE public.prezziario
SET
service_variant = 1
WHERE
idprezziario = 'SALDOBD_3MESI';
UPDATE public.prezziario
SET
service_variant = 2
WHERE
idprezziario = 'SALDOBD_6MESI';
UPDATE public.prezziario
SET
service_variant = 3
WHERE
idprezziario = 'SALDOBD_9MESI';
UPDATE public.prezziario
SET
service_variant = 0
WHERE
idprezziario = 'SALDOCA500';
UPDATE public.prezziario
SET
service_variant = 1
WHERE
idprezziario = 'SALDOCA500+';
UPDATE public.prezziario
SET
service_variant = 2
WHERE
idprezziario = 'SALDOCA700+';
INSERT INTO
public.prezziario (
idprezziario,
prezzo_cent,
testo_condizioni,
"isActive",
nome_it,
nome_en,
desc_it,
desc_en,
sconto,
order_type,
service_type,
service_variant
)
VALUES (
'CERCO_BD365',
46000,
'CONDIZIONI_CERCHI',
TRUE,
'Rinnovo permanenza 12 mesi',
'Renewal 12 months',
'Rinnovo permanenza 12 mesi',
'Renewal 12 months',
0,
'Rinnovo',
'Transitorio',
4
),
(
'CERCO_BD30',
8000,
'CONDIZIONI_CERCHI',
TRUE,
'Rinnovo permanenza 1 mese',
'Renewal 1 month',
'Rinnovo permanenza 1 mese',
'Renewal 1 month',
0,
'Rinnovo',
'Transitorio',
0
),
(
'CERCO_BD90',
25000,
'CONDIZIONI_CERCHI',
TRUE,
'Rinnovo permanenza 3 mesi',
'Renewal 3 months',
'Rinnovo permanenza 3 mesi',
'Renewal 3 months',
0,
'Rinnovo',
'Transitorio',
1
),
(
'CERCO_BD180',
36000,
'CONDIZIONI_CERCHI',
TRUE,
'Rinnovo permanenza 6 mesi',
'Renewal 6 months',
'Rinnovo permanenza 6 mesi',
'Renewal 6 months',
0,
'Rinnovo',
'Transitorio',
2
),
(
'CERCO_BD270',
41000,
'CONDIZIONI_CERCHI',
TRUE,
'Rinnovo permanenza 9 mesi',
'Renewal 9 months',
'Rinnovo permanenza 9 mesi',
'Renewal 9 months',
0,
'Rinnovo',
'Transitorio',
3
)
ON CONFLICT (idprezziario) DO NOTHING;

View file

@ -59,7 +59,7 @@ export const AdminDashboard = () => {
};
export const UserDashboard = ({ userId }: { userId: UsersId }) => {
const { data, isLoading } = api.servizio.getAllServizioAnnunci.useQuery({
const { data, isLoading } = api.servizio.getServizioRinnovi.useQuery({
userId,
});
@ -70,7 +70,7 @@ export const UserDashboard = ({ userId }: { userId: UsersId }) => {
<h3 className="font-bold text-2xl">Le tue ricerche</h3>
<div className="flex w-full flex-1 grow flex-col space-y-5">
<ServizioList isAdmin={false} servizi={data || []} userId={userId} />
<ServizioList data={data || []} isAdmin={false} userId={userId} />
</div>
<div className="mx-auto">
<UserDashboardFaq />

View file

@ -465,7 +465,7 @@ export const SaldoButton = ({
return (
<Link
className="w-full sm:w-fit"
href={`/servizio/pagamento/${servizioId}?type=${OrderTypeEnum.Saldo}`}
href={`/servizio/pagamento?servizioId=${servizioId}&type=${OrderTypeEnum.Saldo}`}
>
<Button
aria-label={locale === "it" ? "Procedi al saldo" : "Proceed to payment"}
@ -492,7 +492,7 @@ const SaldoConsulenzaButton = ({
return (
<Link
className="w-full sm:w-fit"
href={`/servizio/pagamento/${servizioId}?type=${OrderTypeEnum.Consulenza}`}
href={`/servizio/pagamento?servizioId=${servizioId}&type=${OrderTypeEnum.Consulenza}`}
>
<Button
aria-label={

View file

@ -0,0 +1,179 @@
import { add, formatDuration, intervalToDuration } from "date-fns";
import { it } from "date-fns/locale";
import { ArrowRight, CalendarClock, Clock, PackageCheck } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import toast from "react-hot-toast";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { FormRinnovo } from "~/forms/FormRinnovo";
import { useRinnovo } from "~/providers/RinnovoProvider";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
export const Rinnovo = () => {
const { rinnovo, isAdmin } = useRinnovo();
const hasPaid = rinnovo.ordini
.filter((o) => o.type === OrderTypeEnum.Rinnovo)
.find((o) => o.isActive);
return (
<div className="w-full space-y-8">
<div className="flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row">
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-2xl">
Rinnovo permanenza {rinnovo.codice}
</h3>
<div className="flex items-center gap-2">
<Clock className="size-5" />
<span>
Decorrenza: {rinnovo.decorrenza.toLocaleDateString("it-IT")}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<CalendarClock className="size-5" />
<span>
Scadenza: {rinnovo.scadenza.toLocaleDateString("it-IT")}
</span>
</div>
</div>
<p>
Permanenza:{" "}
{formatDuration(
intervalToDuration({
start: new Date(rinnovo.decorrenza),
end: add(new Date(rinnovo.scadenza), { days: 1 }),
}),
{ locale: it, format: ["years", "months", "days"] },
)}
</p>
</div>
<div className="flex items-center gap-2">
{isAdmin && <EditRinnovoModal />}
</div>
</div>
<div className="flex w-full justify-center">
{hasPaid ? (
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<PackageCheck className="size-46 stroke-1" />
<p className="font-medium text-green-600 text-lg">
Pagamento ricevuto!
</p>
</div>
) : (
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<PackageCheck className="size-46 stroke-1" />
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/pagamento?rinnovoId=${rinnovo.id}&type=${OrderTypeEnum.Rinnovo}`}
>
<Button className="w-full text-lg" variant="info">
<span>Procedi al pagamento</span>
<ArrowRight />
</Button>
</Link>
</div>
)}
</div>
</div>
);
};
const EditRinnovoModal = () => {
const { rinnovo, userId } = useRinnovo();
const utils = api.useUtils();
const router = useRouter();
const { mutate: edit } = api.servizio.updateRinnovo.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Rinnovo modificato con successo");
await utils.servizio.invalidate();
},
});
const { mutate: remove } = api.servizio.removeRinnovo.useMutation({
onError: (error) => {
toast.error(error.message);
},
onMutate: async () => {
await router.push(`/area-riservata/admin/user-view/ricerca/${userId}`);
},
onSuccess: async () => {
toast.success("Rinnovo modificato con successo");
await utils.servizio.invalidate();
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Modifica Rinnovo</Button>
</DialogTrigger>
<DialogContent className="w-full px-4 py-10 sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Modifica Rinnovo</DialogTitle>
<DialogDescription className="sr-only">desc</DialogDescription>
</DialogHeader>
<FormRinnovo
initialData={rinnovo}
onDelete={() => remove({ rinnovoId: rinnovo.id })}
onSubmit={(v) => edit({ rinnovoId: rinnovo.id, data: v })}
/>
</DialogContent>
</Dialog>
);
};
export const NewRinnovoModal = ({ userId }: { userId: UsersId }) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { mutate: add } = api.servizio.createRinnovo.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Rinnovo creato con successo");
await utils.servizio.invalidate();
setOpen(false);
},
});
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button
onClick={() => {
setOpen(true);
}}
variant="outline"
>
Nuovo Rinnovo
</Button>
</DialogTrigger>
<DialogContent className="w-full px-4 py-10 sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Creazione Rinnovo</DialogTitle>
<DialogDescription className="sr-only">desc</DialogDescription>
</DialogHeader>
<FormRinnovo onSubmit={(v) => add({ data: { userId, ...v } })} />
</DialogContent>
</Dialog>
);
};

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { NewRinnovoModal } from "~/components/servizio/rinnovo";
import { FormServizio, type FormValues } from "~/forms/FormServizio";
import type { Servizio } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { Button } from "../ui/button";
@ -15,37 +15,19 @@ import {
} from "../ui/dialog";
export const ServiziHeader = ({ userId }: { userId: UsersId }) => {
const [open, setOpen] = useState(false);
const [editData, setEditData] = useState<Servizio | undefined>(undefined);
return (
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold text-xl">Servizi cliente</h3>
<NewServizioModal
editData={editData}
open={open}
setEditData={setEditData}
setOpen={setOpen}
userId={userId}
/>
<div className="flex items-center gap-2">
<NewServizioModal userId={userId} />
<NewRinnovoModal userId={userId} />
</div>
</div>
);
};
const NewServizioModal = ({
userId,
editData,
setEditData,
open,
setOpen,
}: {
userId: UsersId;
editData: Servizio | undefined;
setEditData: (data: Servizio | undefined) => void;
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const NewServizioModal = ({ userId }: { userId: UsersId }) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { mutate: add } = api.servizio.addServizio.useMutation({
@ -56,40 +38,18 @@ const NewServizioModal = ({
toast.success("Servizio creato con successo");
await utils.servizio.invalidate();
setEditData(undefined);
setOpen(false);
},
});
const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.invalidate();
setEditData(undefined);
setOpen(false);
},
});
function onSubmit(fields: FormValues) {
if (editData) {
update({
data: {
...fields,
},
servizioId: editData.servizio_id,
});
} else {
add({ data: { ...fields, user_id: userId } });
}
}
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button
onClick={() => {
setEditData(undefined);
setOpen(true);
}}
variant="outline"
@ -104,7 +64,7 @@ const NewServizioModal = ({
</DialogHeader>
<div className="overflow-y-auto">
<FormServizio
initialData={editData}
initialData={undefined}
isLimited={false}
onSubmit={onSubmit}
/>

View file

@ -429,7 +429,7 @@ const Main = () => {
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/pagamento/${servizio.servizio_id}?type=${OrderTypeEnum.Acconto}`}
href={`/servizio/pagamento?servizioId=${servizio.servizio_id}&type=${OrderTypeEnum.Acconto}`}
>
<Button className="w-full text-lg" variant="info">
<span>Procedi al pagamento</span>

View file

@ -0,0 +1,246 @@
import { add, format, formatDuration, intervalToDuration } from "date-fns";
import { it } from "date-fns/locale";
import { CalendarIcon, Trash2 } from "lucide-react";
import { z } from "zod/v4";
import { Confirm } from "~/components/confirm";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import Input from "~/components/custom_ui/input";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import type { Rinnovi } from "~/schemas/public/Rinnovi";
const Schema = z.object({
codice: z.string().min(4, "Il codice è obbligatorio"),
decorrenza: z.date(),
scadenza: z.date(),
gestionale_id: z.string().nullable(),
});
export type FormValues = z.infer<typeof Schema>;
export const FormRinnovo = ({
initialData,
onSubmit,
onDelete,
}: {
initialData?: Rinnovi;
onSubmit: (fields: FormValues) => void;
onDelete?: () => void;
}) => {
const { locale, t } = useTranslation();
z.config(z.locales[locale]());
const defaultValues: FormValues = initialData
? {
codice: initialData.codice,
decorrenza: initialData.decorrenza,
scadenza: initialData.scadenza,
gestionale_id: initialData.gestionale_id,
}
: {
codice: "",
decorrenza: new Date(),
scadenza: new Date(),
gestionale_id: null,
};
const form = useZodForm(Schema, {
defaultValues: defaultValues,
});
const [decorrenza, scadenza] = form.watch(["decorrenza", "scadenza"]);
return (
<Form {...form}>
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="codice"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="codice">Codice</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input placeholder="" {...field} id="codice" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="decorrenza"
render={({ field }) => (
<FormItem className="flex w-full flex-col">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="decorrenza">Decorrenza</FormLabel>
<FormMessage />
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
className={cn(
"h-10 w-full bg-card pl-3 text-left font-medium",
!field.value && "text-muted-foreground",
"rounded-lg border border-neutral-300 shadow-xs outline-hidden file:border-0 file:bg-transparent file:font-medium file:text-sm disabled:cursor-not-allowed disabled:opacity-50 dark:focus:border-transparent",
)}
id="decorrenza"
variant={"outline"}
>
{field.value ? (
format(field.value, "PPP", {
locale: it,
})
) : (
<span>{t.anagrafica.scegli_data}</span>
)}
<CalendarIcon className="ml-auto size-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto p-0">
<Calendar
autoFocus
captionLayout="dropdown"
defaultMonth={field.value || new Date()}
endMonth={new Date(2050, 12)}
locale={it}
mode="single"
onSelect={field.onChange}
selected={field.value || undefined}
/>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="scadenza"
render={({ field }) => (
<FormItem className="flex w-full flex-col">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="scadenza">Scadenza</FormLabel>
<FormMessage />
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
className={cn(
"h-10 w-full bg-card pl-3 text-left font-medium",
!field.value && "text-muted-foreground",
"rounded-lg border border-neutral-300 shadow-xs outline-hidden file:border-0 file:bg-transparent file:font-medium file:text-sm disabled:cursor-not-allowed disabled:opacity-50 dark:focus:border-transparent",
)}
id="scadenza"
variant={"outline"}
>
{field.value ? (
format(field.value, "PPP", {
locale: it,
})
) : (
<span>{t.anagrafica.scegli_data}</span>
)}
<CalendarIcon className="ml-auto size-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto p-0">
<Calendar
autoFocus
captionLayout="dropdown"
defaultMonth={field.value || new Date()}
endMonth={new Date(2050, 12)}
locale={it}
mode="single"
onSelect={field.onChange}
selected={field.value || undefined}
/>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
<p>
Permanenza:{" "}
{formatDuration(
intervalToDuration({
start: new Date(decorrenza),
end: add(new Date(scadenza), { days: 1 }),
}),
{ locale: it, format: ["years", "months", "days"] },
)}
</p>
<p>
Attenzione: un cambiamento nella permanenza andrà ad aggiornare
automaticamente l'ordine associato se non ancora pagato.
</p>
<FormField
control={form.control}
name="gestionale_id"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="gestionale_id">Gestionale ID</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input
placeholder=""
{...field}
id="gestionale_id"
value={field.value || undefined}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-center justify-end gap-2">
{initialData && (
<Confirm
description="Sei sicuro di voler eliminare l'annuncio?"
onConfirm={() => {
if (onDelete) {
onDelete();
}
}}
title="Elimina annuncio"
>
<Button
aria-label="Delete Annuncio"
className="w-fit"
variant="destructive"
>
<Trash2 />
<span>Elimina</span>
</Button>
</Confirm>
)}
<Button type="submit">Salva</Button>
</div>
</form>
</Form>
);
};

View file

@ -20,7 +20,7 @@ type RicercaUserProps = {
const RicercaUser: NextPageWithLayout<RicercaUserProps> = ({
userId,
}: RicercaUserProps) => {
const { data, isLoading } = api.servizio.getAllServizioAnnunci.useQuery({
const { data, isLoading } = api.servizio.getServizioRinnovi.useQuery({
userId,
});
if (isLoading) return <LoadingPage />;
@ -29,8 +29,7 @@ const RicercaUser: NextPageWithLayout<RicercaUserProps> = ({
<div className="flex flex-col gap-5">
<ServiziHeader userId={userId} />
<AnnunciRichiesti userId={userId} />
<ServizioList isAdmin servizi={data || []} userId={userId} />
<ServizioList data={data || []} isAdmin userId={userId} />
</div>
);
};

View file

@ -0,0 +1,94 @@
import type { GetServerSideProps } from "next";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Rinnovo } from "~/components/servizio/rinnovo";
import { Status500 } from "~/components/status-page";
import { redirectTo500 } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { RinnovoProvider } from "~/providers/RinnovoProvider";
import type { RinnoviId } from "~/schemas/public/Rinnovi";
import type { UsersId } from "~/schemas/public/Users";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zRinnovoId, zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type RinnovoUserProps = {
rinnovoId: RinnoviId;
userId: UsersId;
};
/**
* Pagina di visualizzazione rinnovo per admin: /area-riservata/admin/user-view/rinnovo/[userId]/[rinnovoId]
*/
const RinnovoUser: NextPageWithLayout<RinnovoUserProps> = ({
rinnovoId,
userId,
}: RinnovoUserProps) => {
const { data, isLoading } = api.servizio.getRinnovo.useQuery({
rinnovoId,
});
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
}
return (
<div className="mx-auto max-w-8xl grow space-y-4">
<RinnovoProvider
isAdmin={true}
rinnovo={data}
rinnovoId={rinnovoId}
userId={userId}
>
<Rinnovo />
</RinnovoProvider>
</div>
);
};
export default RinnovoUser;
export const getServerSideProps = (async (context) => {
const user_id = context.params?.slug?.[0];
const rinnovo_id = context.params?.slug?.[1];
if (typeof rinnovo_id !== "string" || typeof user_id !== "string") {
console.error("Error: rinnovoId or userId is not a string");
return redirectTo500
}
const parsedUserId = zUserId.safeParse(user_id);
if (!parsedUserId.success) {
console.error("Error parsing userId", parsedUserId.error);
return redirectTo500
}
const parsedRinnovoId = zRinnovoId.safeParse(rinnovo_id);
if (!parsedRinnovoId.success) {
console.error("Error parsing rinnovoId", parsedRinnovoId.error);
return redirectTo500
}
const rinnovoId = parsedRinnovoId.data;
const userId = parsedUserId.data;
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
await helper.trpc.servizio.getRinnovo.prefetch({
rinnovoId,
});
return {
props: {
rinnovoId,
trpcState: helper.trpc.dehydrate(),
userId,
},
};
}
return redirectTo500
}) satisfies GetServerSideProps<RinnovoUserProps>;
RinnovoUser.getLayout = function getLayout(page) {
return <AreaRiservataLayoutUserView>{page}</AreaRiservataLayoutUserView>;
};

View file

@ -0,0 +1,92 @@
import type { GetServerSideProps } from "next";
import { UserDashboardFaq } from "~/components/area-riservata/dashboard";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Rinnovo } from "~/components/servizio/rinnovo";
import { Status500 } from "~/components/status-page";
import { redirectTo500 } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { RinnovoProvider } from "~/providers/RinnovoProvider";
import { useEnforcedSession } from "~/providers/SessionProvider";
import type { RinnoviId } from "~/schemas/public/Rinnovi";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zRinnovoId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type RinnovoPageProps = {
rinnovoId: RinnoviId;
};
/**
* Pagina del rinnovo: /area-riservata/rinnovo/[rinnovoId]
*/
const RinnovoPage: NextPageWithLayout<RinnovoPageProps> = ({
rinnovoId,
}: RinnovoPageProps) => {
const { user } = useEnforcedSession();
const { data, isLoading } = api.servizio.getRinnovo.useQuery({
rinnovoId,
});
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
}
return (
<div className="flex w-full flex-1 flex-col items-start justify-center gap-3 overflow-auto p-2 md:gap-6">
<div className="flex w-full flex-1 grow flex-col space-y-5">
<RinnovoProvider
isAdmin={false}
rinnovo={data}
rinnovoId={rinnovoId}
userId={user.id}
>
<Rinnovo />
</RinnovoProvider>
</div>
<div className="mx-auto">
<UserDashboardFaq />
</div>
</div>
);
};
export default RinnovoPage;
export const getServerSideProps = (async (context) => {
const rinnovo_id = context.params?.rinnovoId;
if (typeof rinnovo_id !== "string") {
console.error("Error: rinnovoId is not a string");
return redirectTo500;
}
const parsedRinnovoId = zRinnovoId.safeParse(rinnovo_id);
if (!parsedRinnovoId.success) {
console.error("Error parsing rinnovoId", parsedRinnovoId.error);
return redirectTo500;
}
const rinnovoId = parsedRinnovoId.data;
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
await helper.trpc.servizio.getRinnovo.prefetch({
rinnovoId,
});
return {
props: {
rinnovoId,
trpcState: helper.trpc.dehydrate(),
},
};
}
return redirectTo500;
}) satisfies GetServerSideProps<RinnovoPageProps>;
RinnovoPage.getLayout = function getLayout(page) {
return <AreaRiservataLayout>{page}</AreaRiservataLayout>;
};

View file

@ -2,6 +2,7 @@ import type { GetServerSideProps } from "next";
import CheckoutForm from "~/components/checkout";
import { AreaRiservataLayout } from "~/components/Layout";
import stripe from "~/lib/stripe";
import { redirectTo500 } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zOrdineId } from "~/server/utils/zod_types";
@ -48,23 +49,13 @@ export const getServerSideProps = (async (context) => {
const id = context.params?.ordineId;
if (typeof id !== "string") {
console.error("Error: ordineId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
return redirectTo500;
}
const parsed = zOrdineId.safeParse(id);
if (!parsed.success) {
console.error("Error parsing ordineId", parsed.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
return redirectTo500;
}
const access_token = context.req.cookies.access_token;
@ -94,9 +85,7 @@ export const getServerSideProps = (async (context) => {
customer: customer.id,
description: paymentData.nome_it,
metadata: {
packId: paymentData.packid,
ordineId: paymentData.ordine_id,
servizioId: paymentData.servizio_id,
userId: paymentData.userid,
},
@ -104,12 +93,7 @@ export const getServerSideProps = (async (context) => {
});
if (!clientSecret) {
return {
redirect: {
destination: "/500",
permanent: false,
},
};
return redirectTo500;
}
return {
@ -121,10 +105,5 @@ export const getServerSideProps = (async (context) => {
},
};
}
return {
redirect: {
destination: "/500",
permanent: false,
},
};
return redirectTo500;
}) satisfies GetServerSideProps<PagamentoPageProps>;

View file

@ -5,20 +5,20 @@ import { useRouter } from "next/router";
import z from "zod";
import { AreaRiservataLayout } from "~/components/Layout";
import { Button } from "~/components/ui/button";
import { formatCurrency } from "~/lib/utils";
import { formatCurrency, redirectTo500 } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { PreparedPaymentData } from "~/server/controllers/pagamenti.controller";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zServizioId } from "~/server/utils/zod_types";
import { zRinnovoId, zServizioId } from "~/server/utils/zod_types";
type PreviewPurchaseProps = {
stripeDisabled: boolean;
data: PreparedPaymentData;
};
/**
* /servizio/pagamento/[servizioId]?type=<OrderTypeEnum> \
* /servizio/pagamento?servizioId=<servizioId>|rinnovoId=<rinnovoId>&type=<OrderTypeEnum> \
* Pagina di preparazione al pagamento.
*/
const PreviewPurchasePage: NextPageWithLayout<PreviewPurchaseProps> = ({
@ -81,83 +81,76 @@ PreviewPurchasePage.getLayout = function getLayout(page) {
export default PreviewPurchasePage;
export const getServerSideProps = (async (context) => {
const id = context.params?.servizioId;
if (typeof id !== "string") {
console.error("Error: servizioId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedServizioId = zServizioId.safeParse(id);
if (!parsedServizioId.success) {
console.error("Error parsing servizioId", parsedServizioId.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const payment_type = context.query?.type;
if (typeof payment_type !== "string") {
console.error("Error: payment type is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedType = z.enum(OrderTypeEnum).safeParse(payment_type);
if (!parsedType.success) {
console.error("Error parsing payment type", parsedType.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
const data = await helper.trpc.pagamenti.preparePayment.fetch({
type: parsedType.data,
servizioId: parsedServizioId.data,
});
if (!data.success) {
console.error("Error preparing payment", data.message);
return {
redirect: {
destination: `/500`,
permanent: false,
},
};
if (!helper) {
return redirectTo500;
}
const flag = await helper.trpc.flags.GetFlagValue.fetch({
id: "STRIPE_DISABLED",
});
const payment_type = context.query?.type;
if (typeof payment_type !== "string") {
console.error("Error: payment type is not a string");
return redirectTo500;
}
const parsedType = z.enum(OrderTypeEnum).safeParse(payment_type);
if (!parsedType.success) {
console.error("Error parsing payment type", parsedType.error);
return redirectTo500;
}
const type = parsedType.data;
if (type === OrderTypeEnum.Rinnovo) {
const id = context.query?.rinnovoId;
if (typeof id !== "string") {
console.error("Error: rinnovoId is not a string");
return redirectTo500;
}
const parsedRinnovoId = zRinnovoId.safeParse(id);
if (!parsedRinnovoId.success) {
console.error("Error parsing rinnovoId", parsedRinnovoId.error);
return redirectTo500;
}
const result = await helper.trpc.pagamenti.preparePaymentRinnovi.fetch({
rinnovoId: parsedRinnovoId.data,
});
if (!result.success) {
console.error("Error preparing payment", result.message);
return redirectTo500;
}
return {
props: {
stripeDisabled: flag || false,
data: data.data,
data: result.data,
},
};
}
// Non è un rinnovo, deve essere un servizio normale
const id = context.query?.servizioId;
if (typeof id !== "string") {
console.error("Error: servizioId is not a string");
return redirectTo500;
}
const parsedServizioId = zServizioId.safeParse(id);
if (!parsedServizioId.success) {
console.error("Error parsing servizioId", parsedServizioId.error);
return redirectTo500;
}
const result = await helper.trpc.pagamenti.preparePayment.fetch({
type,
servizioId: parsedServizioId.data,
});
if (!result.success) {
console.error("Error preparing payment", result.message);
return redirectTo500;
}
return {
redirect: {
destination: "/500",
permanent: false,
props: {
stripeDisabled: flag || false,
data: result.data,
},
};
}) satisfies GetServerSideProps<PreviewPurchaseProps>;

View file

@ -0,0 +1,39 @@
import { createContext, useContext } from "react";
import type { RinnoviId } from "~/schemas/public/Rinnovi";
import type { UsersId } from "~/schemas/public/Users";
import type { RinnovoData } from "~/server/services/rinnovi.service";
type RinnovoProviderProps = {
isAdmin: boolean;
userId: UsersId;
rinnovoId: RinnoviId;
rinnovo: RinnovoData;
children: React.ReactNode;
};
type RinnovoContextProps = {
isAdmin: boolean;
userId: UsersId;
rinnovoId: RinnoviId;
rinnovo: RinnovoData;
};
const RinnovoContext = createContext<RinnovoContextProps | null>(null);
export const RinnovoProvider = ({
children,
...props
}: RinnovoProviderProps) => {
return (
<RinnovoContext.Provider value={{ ...props }}>
{children}
</RinnovoContext.Provider>
);
};
export const useRinnovo = () => {
const rinnovoContext = useContext(RinnovoContext);
if (!rinnovoContext) {
throw new Error("useRinnovo must be used within a RinnovoProvider");
}
return rinnovoContext;
};

View file

@ -6,6 +6,7 @@ import type { PrezziarioIdprezziario } from './Prezziario';
import type { ServizioServizioId } from './Servizio';
import type { default as OrderTypeEnum } from './OrderTypeEnum';
import type { default as PaymentStatusEnum } from './PaymentStatusEnum';
import type { RinnoviId } from './Rinnovi';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.ordini */
@ -21,7 +22,7 @@ export default interface OrdiniTable {
packid: ColumnType<PrezziarioIdprezziario, PrezziarioIdprezziario, PrezziarioIdprezziario>;
servizio_id: ColumnType<ServizioServizioId, ServizioServizioId, ServizioServizioId>;
servizio_id: ColumnType<ServizioServizioId | null, ServizioServizioId | null, ServizioServizioId | null>;
type: ColumnType<OrderTypeEnum, OrderTypeEnum, OrderTypeEnum>;
@ -38,6 +39,8 @@ export default interface OrdiniTable {
paymentstatus: ColumnType<PaymentStatusEnum | null, PaymentStatusEnum | null, PaymentStatusEnum | null>;
sconto: ColumnType<number, number | undefined, number>;
rinnovo_id: ColumnType<RinnoviId | null, RinnoviId | null, RinnoviId | null>;
}
export type Ordini = Selectable<OrdiniTable>;

View file

@ -14,6 +14,7 @@ import type { default as UserInvitesTable } from './UserInvites';
import type { default as EmailsTable } from './Emails';
import type { default as PotenzialiGroupsTable } from './PotenzialiGroups';
import type { default as ComuniTable } from './Comuni';
import type { default as RinnoviTable } from './Rinnovi';
import type { default as EventQueueTable } from './EventQueue';
import type { default as OrdiniTable } from './Ordini';
import type { default as PrezziarioTable } from './Prezziario';
@ -58,6 +59,8 @@ export default interface PublicSchema {
comuni: ComuniTable;
rinnovi: RinnoviTable;
event_queue: EventQueueTable;
ordini: OrdiniTable;

View file

@ -0,0 +1,29 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { UsersId } from './Users';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.rinnovi */
export type RinnoviId = string & { __brand: 'public.rinnovi' };
/** Represents the table public.rinnovi */
export default interface RinnoviTable {
id: ColumnType<RinnoviId, RinnoviId | undefined, RinnoviId>;
userId: ColumnType<UsersId, UsersId, UsersId>;
codice: ColumnType<string, string, string>;
decorrenza: ColumnType<Date, Date | string, Date | string>;
gestionale_id: ColumnType<string | null, string | null, string | null>;
scadenza: ColumnType<Date, Date | string, Date | string>;
}
export type Rinnovi = Selectable<RinnoviTable>;
export type NewRinnovi = Insertable<RinnoviTable>;
export type RinnoviUpdate = Updateable<RinnoviTable>;

View file

@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
getPagamentoDataForCheckout,
@ -10,7 +10,7 @@ import {
import { db } from "~/server/db";
import { genPdfRicevutaCortesia } from "~/server/services/puppeteer.service";
import { zOrdineId, 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
/*
const ratelimit = new RateLimiterHandler({
@ -42,7 +42,7 @@ export const pagamentiRouter = createTRPCRouter({
.input(
z.object({
servizioId: zServizioId,
type: z.custom<OrderTypeEnum>(),
type: z.enum(OrderTypeEnum),
}),
)
.output(
@ -58,7 +58,40 @@ export const pagamentiRouter = createTRPCRouter({
]),
)
.query(async ({ input }) => {
return await preparePayment(input.servizioId, input.type);
if (input.type === OrderTypeEnum.Rinnovo) {
return {
success: false,
message: "Use preparePaymentRinnovi for Rinnovo payments",
};
}
return await preparePayment({
type: input.type,
servizioId: input.servizioId,
});
}),
preparePaymentRinnovi: protectedProcedure
.input(
z.object({
rinnovoId: zRinnovoId,
}),
)
.output(
z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
data: z.custom<PreparedPaymentData>(),
}),
z.object({
success: z.literal(false),
message: z.string(),
}),
]),
)
.query(async ({ input }) => {
return await preparePayment({
type: OrderTypeEnum.Rinnovo,
rinnovoId: input.rinnovoId,
});
}),
getPagamentoDataForCheckout: protectedProcedure

View file

@ -1,5 +1,10 @@
import z from "zod";
import type { NewOrdini, OrdiniUpdate } from "~/schemas/public/Ordini";
import type {
NewRinnovi,
Rinnovi,
RinnoviUpdate,
} from "~/schemas/public/Rinnovi";
import type { NewServizio, ServizioUpdate } from "~/schemas/public/Servizio";
import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci";
import type { Users } from "~/schemas/public/Users";
@ -27,6 +32,7 @@ import {
interruzioneServizio,
processServizioOnboard,
SbloccaContatti,
type ServizioData,
userAccettaConferma,
userSendConfermaIntent,
} from "~/server/controllers/servizio.controller";
@ -37,6 +43,13 @@ import {
removeOrder,
updateOrder,
} from "~/server/services/ordini.service";
import {
createRinnovo,
deleteRinnovo,
getRinnoviUser,
getRinnovoData,
updateRinnovo,
} from "~/server/services/rinnovi.service";
import {
addServizio,
deleteServizio,
@ -49,6 +62,7 @@ import {
import {
zAnnuncioId,
zOrdineId,
zRinnovoId,
zServizioId,
zUserId,
} from "~/server/utils/zod_types";
@ -406,4 +420,75 @@ export const servizioRouter = createTRPCRouter({
servizioId: input.servizioId,
});
}),
getServizioRinnovi: protectedProcedure
.input(
z.object({
userId: zUserId,
}),
)
.query(async ({ input }) => {
const data: (
| { d: Date; type: "servizio"; data: ServizioData }
| { d: Date; type: "rinnovo"; data: Rinnovi }
)[] = [];
const servizio = await getAllServizioAnnunci(input.userId);
const rinnovi = await getRinnoviUser(input.userId);
servizio.forEach((s) => {
data.push({
d: s.created_at,
type: "servizio",
data: s,
});
});
rinnovi.forEach((r) => {
data.push({
d: r.decorrenza,
type: "rinnovo",
data: r,
});
});
data.sort((a, b) => b.d.getTime() - a.d.getTime());
return data;
}),
createRinnovo: adminProcedure
.input(
z.object({
data: z.custom<NewRinnovi>(),
}),
)
.mutation(async ({ input }) => {
return await createRinnovo(input.data);
}),
updateRinnovo: adminProcedure
.input(
z.object({
data: z.custom<RinnoviUpdate>(),
rinnovoId: zRinnovoId,
}),
)
.mutation(async ({ input }) => {
return await updateRinnovo({
rinnovoId: input.rinnovoId,
data: input.data,
});
}),
getRinnovo: protectedProcedure
.input(
z.object({
rinnovoId: zRinnovoId,
}),
)
.query(async ({ input }) => {
return await getRinnovoData(input.rinnovoId);
}),
removeRinnovo: adminProcedure
.input(
z.object({
rinnovoId: zRinnovoId,
}),
)
.mutation(async ({ input }) => {
return await deleteRinnovo(input.rinnovoId);
}),
});

View file

@ -1,15 +1,20 @@
import { TRPCError } from "@trpc/server";
import { add, intervalToDuration } from "date-fns";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type {
Prezziario,
PrezziarioIdprezziario,
} from "~/schemas/public/Prezziario";
import type { RinnoviId } from "~/schemas/public/Rinnovi";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { db } from "../db";
import { createOrdine } from "../services/ordini.service";
import { getPrezziarioByIdHandler } from "../services/prezziario.service";
import {
CONDIZIONI_DEFAULT,
getPrezziarioByIdHandler,
} from "../services/prezziario.service";
import { getServizioById } from "../services/servizio.service";
export type PreparedPaymentData = {
@ -21,14 +26,29 @@ export type PreparedPaymentData = {
packDesc: string;
condizioni: string;
};
export type preparePaymentInput =
| {
servizioId: ServizioServizioId;
type: Exclude<OrderTypeEnum, OrderTypeEnum.Rinnovo>;
}
| {
rinnovoId: RinnoviId;
type: OrderTypeEnum.Rinnovo;
};
export const preparePayment = async (
servizioId: ServizioServizioId,
type: OrderTypeEnum,
props: preparePaymentInput,
): Promise<
| { success: true; data: PreparedPaymentData }
| { success: false; message: string }
> => {
try {
switch (props.type) {
case OrderTypeEnum.Acconto:
case OrderTypeEnum.Saldo:
case OrderTypeEnum.Consulenza:
case OrderTypeEnum.Altro: {
const { servizioId, type } = props;
const servizio = await getServizioById(servizioId);
const inactiveOrders = await db
.selectFrom("ordini")
@ -48,12 +68,6 @@ export const preparePayment = async (
message: `Acconto non trovato per il servizio: ${acconto.message}`,
};
}
if (!acconto.pack.testo_condizioni) {
return {
success: false,
message: `Condizioni non trovate per acconto: ${acconto.pack.nome_it}`,
};
}
// servizio sta aspettando attivazione acconto
@ -72,7 +86,8 @@ export const preparePayment = async (
sconto: preExistingAcconto.sconto,
packName: acconto.pack.nome_it,
packDesc: acconto.pack.desc_it,
condizioni: acconto.pack.testo_condizioni,
condizioni:
acconto.pack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
@ -94,7 +109,8 @@ export const preparePayment = async (
sconto: 0,
packName: acconto.pack.nome_it,
packDesc: acconto.pack.desc_it,
condizioni: acconto.pack.testo_condizioni,
condizioni:
acconto.pack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
@ -121,12 +137,7 @@ export const preparePayment = async (
message: `Saldo non trovato per il servizio: ${saldo.message}`,
};
}
if (!saldo.pack.testo_condizioni) {
return {
success: false,
message: `Condizioni non trovate per saldo: ${saldo.pack.nome_it}`,
};
}
const preExistingSaldo = inactiveOrders.find(
(ordine) => ordine.type === OrderTypeEnum.Saldo,
);
@ -141,7 +152,8 @@ export const preparePayment = async (
sconto: preExistingSaldo.sconto,
packName: saldo.pack.nome_it,
packDesc: saldo.pack.desc_it,
condizioni: saldo.pack.testo_condizioni,
condizioni:
saldo.pack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
@ -162,7 +174,7 @@ export const preparePayment = async (
sconto: 0,
packName: saldo.pack.nome_it,
packDesc: saldo.pack.desc_it,
condizioni: saldo.pack.testo_condizioni,
condizioni: saldo.pack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
@ -196,12 +208,7 @@ export const preparePayment = async (
message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`,
};
}
if (!consulenzaPack.testo_condizioni) {
return {
success: false,
message: `Condizioni non trovate per consulenza: ${consulenzaPack.nome_it}`,
};
}
return {
success: true,
data: {
@ -211,7 +218,8 @@ export const preparePayment = async (
sconto: existingConsulenza.sconto,
packName: consulenzaPack.nome_it,
packDesc: consulenzaPack.desc_it,
condizioni: consulenzaPack.testo_condizioni,
condizioni:
consulenzaPack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
@ -229,46 +237,6 @@ export const preparePayment = async (
"Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento",
};
}
case OrderTypeEnum.Rinnovo: {
const existingRinnovo = inactiveOrders.find(
(ordine) => ordine.type === OrderTypeEnum.Rinnovo,
);
if (existingRinnovo) {
// rinnovo già creato, procedere con questo
const rinnovoPack = await getPrezziarioByIdHandler(
existingRinnovo.packid,
);
if (!rinnovoPack) {
return {
success: false,
message: `Pack del rinnovo non trovato: ${existingRinnovo.packid}`,
};
}
if (!rinnovoPack.testo_condizioni) {
return {
success: false,
message: `Condizioni non trovate per rinnovo: ${rinnovoPack.nome_it}`,
};
}
return {
success: true,
data: {
ordine_id: existingRinnovo.ordine_id,
packid: existingRinnovo.packid,
amount_cent: existingRinnovo.amount_cent,
sconto: existingRinnovo.sconto,
packName: rinnovoPack.nome_it,
packDesc: rinnovoPack.desc_it,
condizioni: rinnovoPack.testo_condizioni,
},
};
}
return {
success: false,
message:
"Il servizio non ha un pacchetto di rinnovo, è necessario inserirlo prima di procedere",
};
}
case OrderTypeEnum.Altro: {
const existingAltro = inactiveOrders.find(
(ordine) => ordine.type === OrderTypeEnum.Altro,
@ -284,12 +252,7 @@ export const preparePayment = async (
message: `Pack dell'altro non trovato: ${existingAltro.packid}`,
};
}
if (!altroPack.testo_condizioni) {
return {
success: false,
message: `Condizioni non trovate per altro: ${altroPack.nome_it}`,
};
}
return {
success: true,
data: {
@ -299,7 +262,7 @@ export const preparePayment = async (
sconto: existingAltro.sconto,
packName: altroPack.nome_it,
packDesc: altroPack.desc_it,
condizioni: altroPack.testo_condizioni,
condizioni: altroPack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
@ -311,7 +274,92 @@ export const preparePayment = async (
};
}
default:
throw new Error(`Tipo di ordine non valido: ${type satisfies never}`);
throw new Error(
`Tipo di ordine non valido: ${type satisfies never}`,
);
}
}
case OrderTypeEnum.Rinnovo: {
const { rinnovoId } = props;
const ordiniRinnovo = await db
.selectFrom("ordini")
.selectAll()
.where("rinnovo_id", "=", rinnovoId)
.where("type", "=", OrderTypeEnum.Rinnovo)
.execute();
const alreadyPaid = ordiniRinnovo.some((ordine) => ordine.isActive);
if (alreadyPaid) {
return {
success: false,
message:
"Il rinnovo ha già un ordine attivo, non è possibile preparare un nuovo pagamento",
};
}
const unusedOrdine = ordiniRinnovo.find((ordine) => !ordine.isActive);
if (unusedOrdine) {
// rinnovo già creato, procedere con questo
const rinnovoPack = await getPrezziarioByIdHandler(
unusedOrdine.packid,
);
if (!rinnovoPack) {
return {
success: false,
message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`,
};
}
return {
success: true,
data: {
ordine_id: unusedOrdine.ordine_id,
packid: unusedOrdine.packid,
amount_cent: unusedOrdine.amount_cent,
sconto: unusedOrdine.sconto,
packName: rinnovoPack.nome_it,
packDesc: rinnovoPack.desc_it,
condizioni: rinnovoPack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
// nessun rinnovo preesistente, crearne uno nuovo
const rinnovo = await db
.selectFrom("rinnovi")
.selectAll()
.where("id", "=", rinnovoId)
.executeTakeFirstOrThrow(
() => new Error(`Rinnovo non trovato - id: ${rinnovoId}`),
);
const pack = await RinnovoSolver(rinnovo.decorrenza, rinnovo.scadenza);
if (!pack.success) {
return {
success: false,
message: `Pack non trovato per il rinnovo: ${pack.message}`,
};
}
const ordine = await createOrdine({
rinnovo_id: rinnovoId,
amount_cent: pack.pack.prezzo_cent,
packid: pack.pack.idprezziario,
type: OrderTypeEnum.Rinnovo,
userid: rinnovo.userId,
});
return {
success: true,
data: {
ordine_id: ordine.ordine_id,
packid: pack.pack.idprezziario,
amount_cent: pack.pack.prezzo_cent,
sconto: 0,
packName: pack.pack.nome_it,
packDesc: pack.pack.desc_it,
condizioni: pack.pack.testo_condizioni || CONDIZIONI_DEFAULT,
},
};
}
}
} catch (e) {
throw new TRPCError({
@ -479,6 +527,51 @@ export const SaldoSolver = async ({
return { success: false, message: "Seleziona tipologia" };
}
};
function rinnovoPermanenzaVariant(permanenza: number): number {
if (permanenza <= 1) return 0;
if (permanenza <= 3) return 1;
if (permanenza <= 6) return 2;
if (permanenza <= 9) return 3;
return 4;
}
const rinnovoDurationToPermanenza = (decorrenza: Date, scadenza: Date) => {
const dur = intervalToDuration({
start: new Date(decorrenza),
end: add(new Date(scadenza), { days: 1 }),
});
return (dur.years || 0) * 12 + (dur.months || 0) + (dur.days ? 1 : 0);
};
export const RinnovoSolver = async (
decorrenza: Date,
scadenza: Date,
): Promise<GetPackResult | GetPackError> => {
try {
const permanenza = rinnovoDurationToPermanenza(decorrenza, scadenza);
const pack = await db
.selectFrom("prezziario")
.selectAll()
.where("isActive", "=", true)
.where("order_type", "=", OrderTypeEnum.Rinnovo)
.where("service_type", "=", TipologiaPosizioneEnum.Transitorio)
.where("service_variant", "=", rinnovoPermanenzaVariant(permanenza))
.limit(1)
.executeTakeFirst();
if (!pack) {
return { success: false, message: "Pack non trovato" };
}
return { success: true, pack };
} catch (error) {
return {
success: false,
message: `Errore nel calcolo del rinnovo: ${(error as Error).message}`,
};
}
};
export const getPacksPerServizio = async (
servizioId: ServizioServizioId,

View file

@ -810,7 +810,7 @@ export const SbloccaContatti = async ({
},
tipo: "email",
},
lock_expires: new Date(Date.now() + 1000),
lock_expires: new Date(Date.now() + 3000),
});
if (canSendNotification()) {

View file

@ -9,6 +9,7 @@ import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { db } from "~/server/db";
import { NewMail } from "~/server/services/mailer";
import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service";
import { getUser } from "~/server/services/user.service";
import { addEventToQueue } from "./event_queue.controller";
@ -80,30 +81,13 @@ export const whIntentSucceededHandler = async ({
})
.where("intent_id", "=", pIntentId)
.where("ordine_id", "=", ordineId)
.returning([
"servizio_id",
"ordine_id",
"userid",
"type",
"created_at",
"packid",
])
.returningAll()
.executeTakeFirstOrThrow(() => {
throw new Error(
`Payment not found - intent_id: ${pIntentId}, ordineId: ${ordineId}`,
);
});
const servizio = await trx
.selectFrom("servizio")
.select(["servizio.tipologia", "servizio.servizio_id"])
.where("servizio_id", "=", ordine.servizio_id)
.executeTakeFirstOrThrow(() => {
throw new Error(
`Servizio not found - servizio_id: ${ordine.servizio_id}`,
);
});
const condizioni = await trx
.selectFrom("prezziario")
.select("testo_condizioni")
@ -114,13 +98,19 @@ export const whIntentSucceededHandler = async ({
db: trx,
userId: ordine.userid,
});
const lock_expires = new Date(Date.now() + 60 * 1000); // Lock for 1 minute
const lock_expires = new Date(Date.now() + 3000); // Lock for 30 sec
switch (ordine.type) {
case OrderTypeEnum.Acconto:
case OrderTypeEnum.Acconto: {
if (!ordine.servizio_id) {
throw new Error(
`No servizio_id for ordine_id: ${ordine.ordine_id}`,
);
}
await trx
.updateTable("servizio")
.where("servizio_id", "=", servizio.servizio_id)
.where("servizio_id", "=", ordine.servizio_id)
.set({
decorrenza: new Date(),
isOkAcconto: true,
@ -140,19 +130,16 @@ export const whIntentSucceededHandler = async ({
attachments: [
{
filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`,
//ATTENTION: hardcoded condizioni key
generate: {
type: "condizioni",
stringId: (condizioni?.testo_condizioni ||
"CONDIZIONI_CERCHI") as TestiEStringheStingaId,
CONDIZIONI_DEFAULT) as TestiEStringheStingaId,
},
encoding: "base64",
contentType: "application/pdf",
},
{
filename: `ricevuta_cortesia_${ordine.ordine_id}.pdf`,
generate: {
type: "ricevuta_cortesia",
ordineId: ordine.ordine_id,
@ -170,11 +157,27 @@ export const whIntentSucceededHandler = async ({
//SERVIZIO ATTIVATO
break;
}
case OrderTypeEnum.Saldo: {
if (!ordine.servizio_id) {
throw new Error(
`No servizio_id for ordine_id: ${ordine.ordine_id}`,
);
}
const servizio = await trx
.selectFrom("servizio")
.select(["servizio.tipologia"])
.where("servizio_id", "=", ordine.servizio_id)
.executeTakeFirstOrThrow(() => {
throw new Error(
`Servizio not found - servizio_id: ${ordine.servizio_id}`,
);
});
case OrderTypeEnum.Saldo:
await trx
.updateTable("servizio")
.where("servizio_id", "=", servizio.servizio_id)
.where("servizio_id", "=", ordine.servizio_id)
.set({
isOkConsulenza:
servizio.tipologia === TipologiaPosizioneEnum.Transitorio,
@ -198,7 +201,6 @@ export const whIntentSucceededHandler = async ({
type: "ricevuta_cortesia",
ordineId: ordine.ordine_id,
},
encoding: "base64",
contentType: "application/pdf",
},
@ -209,12 +211,49 @@ export const whIntentSucceededHandler = async ({
},
lock_expires,
});
//PAGAMENTO SALDO CONFERMATO
break;
case OrderTypeEnum.Consulenza:
}
case OrderTypeEnum.Rinnovo: {
// nessuna azione aggiuntiva richiesta
await addEventToQueue({
data: {
tipo: "email",
data: {
template: {
mailType: "pagamentoConferma",
},
mail: {
subject: "Pagamento Confermato - Rinnovo Infoalloggi.it",
to: utente.email,
attachments: [
{
filename: `ricevuta_cortesia_${ordine.ordine_id}.pdf`,
generate: {
type: "ricevuta_cortesia",
ordineId: ordine.ordine_id,
},
encoding: "base64",
contentType: "application/pdf",
},
],
},
userId: utente.id,
},
},
lock_expires,
});
break;
}
case OrderTypeEnum.Consulenza: {
if (!ordine.servizio_id) {
throw new Error(
`No servizio_id for ordine_id: ${ordine.ordine_id}`,
);
}
await trx
.updateTable("servizio")
.where("servizio_id", "=", servizio.servizio_id)
.where("servizio_id", "=", ordine.servizio_id)
.set({
isOkConsulenza: true,
})
@ -232,7 +271,6 @@ export const whIntentSucceededHandler = async ({
attachments: [
{
filename: `ricevuta_cortesia_${ordine.ordine_id}.pdf`,
generate: {
type: "ricevuta_cortesia",
ordineId: ordine.ordine_id,
@ -247,38 +285,38 @@ export const whIntentSucceededHandler = async ({
},
lock_expires,
});
//CONSULENZA ATTIVATA
break;
}
case OrderTypeEnum.Altro:
// TODO: Handle other types
await addEventToQueue({
data: {
tipo: "email",
data: {
template: {
mailType: "pagamentoConferma",
},
mail: {
subject: "Pagamento Confermato - Infoalloggi.it",
to: utente.email,
attachments: [
{
filename: `ricevuta_cortesia_${ordine.ordine_id}.pdf`,
// Handle "Altro" type if needed
// await addEventToQueue({
// data: {
// tipo: "email",
// data: {
// template: {
// mailType: "pagamentoConferma",
// },
// mail: {
// subject: "Pagamento Confermato - Infoalloggi.it",
// to: utente.email,
// attachments: [
// {
// filename: `ricevuta_cortesia_${ordine.ordine_id}.pdf`,
generate: {
type: "ricevuta_cortesia",
ordineId: ordine.ordine_id,
},
encoding: "base64",
contentType: "application/pdf",
},
],
},
userId: utente.id,
},
},
lock_expires,
});
// generate: {
// type: "ricevuta_cortesia",
// ordineId: ordine.ordine_id,
// },
// encoding: "base64",
// contentType: "application/pdf",
// },
// ],
// },
// userId: utente.id,
// },
// },
// lock_expires,
// });
break;
}
await addEventToQueue({
@ -315,7 +353,6 @@ export const whIntentSucceededHandler = async ({
});
return true;
} catch (error) {
// Enhanced error logging
console.error("whIntentSucceededHandler error:", {
pIntentId,
ordineId,

View file

@ -1,6 +1,7 @@
import { TRPCError } from "@trpc/server";
import type { Insertable, Selectable, Updateable } from "kysely";
import type OrdiniTable from "~/schemas/public/Ordini";
import type {
NewOrdini,
Ordini,
OrdiniOrdineId,
OrdiniUpdate,
@ -8,6 +9,22 @@ import type {
import type { Users, UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db";
export type ServizioOrdiniTable = Omit<OrdiniTable, "rinnovo_id">;
export type ServizioOrdini = Selectable<ServizioOrdiniTable>;
export type NewServizioOrdini = Insertable<ServizioOrdiniTable>;
export type ServizioOrdiniUpdate = Updateable<ServizioOrdiniTable>;
export type RinnovoOrdiniTable = Omit<OrdiniTable, "servizio_id">;
export type RinnovoOrdini = Selectable<RinnovoOrdiniTable>;
export type NewRinnovoOrdini = Insertable<RinnovoOrdiniTable>;
export type RinnovoOrdiniUpdate = Updateable<RinnovoOrdiniTable>;
export type Ordini_w_Utente = Ordini & Pick<Users, "username">;
export const getOrdini = async (
userId: UsersId | undefined,
@ -51,7 +68,9 @@ export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
}
};
export const createOrdine = async (data: NewOrdini) => {
export const createOrdine = async (
data: NewServizioOrdini | NewRinnovoOrdini,
) => {
try {
return await db
.insertInto("ordini")

View file

@ -6,9 +6,11 @@ import type {
PrezziarioIdprezziario,
PrezziarioUpdate,
} from "~/schemas/public/Prezziario";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { db, type Querier } from "~/server/db";
export const CONDIZIONI_DEFAULT = "CONDIZIONI_CERCHI" as TestiEStringheStingaId;
export const getPrezziario = async () => {
try {
return await db

View file

@ -0,0 +1,171 @@
import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type {
NewRinnovi,
RinnoviId,
RinnoviUpdate,
} from "~/schemas/public/Rinnovi";
import type { UsersId } from "~/schemas/public/Users";
import { RinnovoSolver } from "~/server/controllers/pagamenti.controller";
import { db } from "~/server/db";
import {
createOrdine,
type NewRinnovoOrdini,
} from "~/server/services/ordini.service";
export const getRinnoviUser = async (userId: UsersId) => {
try {
return await db
.selectFrom("rinnovi")
.where("userId", "=", userId)
.selectAll()
.execute();
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Could not fetch rinnovi for user: ${(error as Error).message}`,
});
}
};
export type RinnovoData = Awaited<ReturnType<typeof getRinnovoData>>;
export const getRinnovoData = async (rinnovoId: RinnoviId) => {
try {
return await db
.selectFrom("rinnovi")
.where("id", "=", rinnovoId)
.selectAll("rinnovi")
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("ordini")
.whereRef("ordini.rinnovo_id", "=", "rinnovi.id")
.selectAll("ordini"),
).as("ordini"),
)
.executeTakeFirstOrThrow(
() => new Error(`Rinnovo not found - id: ${rinnovoId}`),
);
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Error getting Rinnovo by id: ${(error as Error).message}`,
});
}
};
export const createRinnovo = async (data: NewRinnovi) => {
try {
const rinnovo = await db
.insertInto("rinnovi")
.values(data)
.returningAll()
.executeTakeFirstOrThrow(() => new Error("Failed to create rinnovo"));
const pack = await RinnovoSolver(rinnovo.decorrenza, rinnovo.scadenza);
if (!pack.success) {
throw new Error("No suitable pack found for rinnovo");
}
const ordineData: NewRinnovoOrdini = {
rinnovo_id: rinnovo.id,
userid: rinnovo.userId,
type: OrderTypeEnum.Rinnovo,
amount_cent: pack.pack.prezzo_cent,
packid: pack.pack.idprezziario,
isActive: false,
};
await createOrdine(ordineData);
return rinnovo;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Could not create rinnovo: ${(error as Error).message}`,
});
}
};
export const updateRinnovo = async ({
rinnovoId,
data,
}: {
rinnovoId: RinnoviId;
data: RinnoviUpdate;
}) => {
try {
if (data.decorrenza && data.scadenza) {
const rinnovo = await db
.selectFrom("rinnovi")
.where("id", "=", rinnovoId)
.selectAll()
.executeTakeFirstOrThrow(
() => new Error(`Rinnovo not found - id: ${rinnovoId}`),
);
if (
new Date(data.decorrenza).getTime() !==
new Date(rinnovo.decorrenza).getTime() ||
new Date(data.scadenza).getTime() !==
new Date(rinnovo.scadenza).getTime()
) {
//no need to update ordine if permanenza is not being updated
const existingPack = await RinnovoSolver(
rinnovo.decorrenza,
rinnovo.scadenza,
);
if (!existingPack.success) {
throw new Error("No suitable pack found for rinnovo");
}
const newPack = await RinnovoSolver(
new Date(data.decorrenza),
new Date(data.scadenza),
);
if (!newPack.success) {
throw new Error("No suitable pack found for rinnovo");
}
if (existingPack.pack.idprezziario !== newPack.pack.idprezziario) {
//update ordine with new pack and price if permanenza is being updated and ordine is not active yet
await db
.updateTable("ordini")
.set({
amount_cent: newPack.pack.prezzo_cent,
packid: newPack.pack.idprezziario,
})
.where("rinnovo_id", "=", rinnovoId)
.where("isActive", "=", false)
.execute();
}
}
}
return await db
.updateTable("rinnovi")
.set(data)
.where("id", "=", rinnovoId)
.returningAll()
.executeTakeFirstOrThrow(
() => new Error(`Failed to update rinnovo - id: ${rinnovoId}`),
);
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error updating rinnovo: ${(error as Error).message}`,
});
}
};
export const deleteRinnovo = async (rinnovoId: RinnoviId) => {
try {
await db.deleteFrom("rinnovi").where("id", "=", rinnovoId).execute();
return true;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error deleting rinnovo: ${(error as Error).message}`,
});
}
};

View file

@ -8,6 +8,7 @@ import type { FlagsId } from "~/schemas/public/Flags";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
import type { RinnoviId } from "~/schemas/public/Rinnovi";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import type { UsersId } from "~/schemas/public/Users";
@ -85,3 +86,8 @@ export const zServizioId = z.custom<ServizioServizioId>(
// (val) => typeof val === "number",
// "falied to validate NazioneId",
// );
export const zRinnovoId = z.custom<RinnoviId>(
(val) => typeof val === "string",
"falied to validate RinnovoId",
);