feat: refactor error handling and response structure in authentication and payment processes

This commit is contained in:
Marco Pedone 2026-04-17 17:15:13 +02:00
parent a03326f036
commit 2676dc61ed
13 changed files with 391 additions and 257 deletions

View file

@ -19,6 +19,7 @@ import { env } from "~/env";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import { api } from "~/utils/api";
import { assertNever } from "~/utils/utils";
export const FormLogin = () => {
const router = useRouter();
@ -46,26 +47,29 @@ export const FormLogin = () => {
const utils = api.useUtils();
const { mutate, isPending } = api.auth.loginUser.useMutation({
onError: ({ message }) => {
toast.error(`${t.auth.login.fail}\n${message}`);
onError: (error) => {
toast.error(`${t.auth.login.fail}\n${error.message}`);
},
onSuccess: async (data) => {
// FLOWS: login | 3 | Login Result Handling
switch (data.status) {
case "not-found":
setUserNotFound(true);
toast.error(data.message);
return;
case "fail":
toast.error(data.message);
return;
case "success":
await utils.auth.getSession.invalidate();
toast.success(t.auth.login.success);
await router.push(redirect ? (redirect as string) : "/");
//window.location.replace(redirect ? (redirect as string) : "/");
return;
if (!data.ok) {
switch (data.error._tag) {
case "NotFound":
setUserNotFound(true);
toast.error(`${t.auth.login.fail}\n${data.error.message}`);
return;
case "Fail":
toast.error(`${t.auth.login.fail}\n${data.error.message}`);
return;
default:
assertNever(data.error);
}
}
await utils.auth.getSession.invalidate();
toast.success(t.auth.login.success);
await router.push(redirect ? (redirect as string) : "/");
return;
},
});
const [userNotFound, setUserNotFound] = useState(false);

View file

@ -1,3 +0,0 @@
export type Result<T> =
| { success: true; data: T }
| { success: false; message: string };

View file

@ -58,7 +58,7 @@ import { useMediaQuery } from "~/hooks/use-media-query";
import { handleConsegna } from "~/lib/annuncio_details";
import { replaceWithBr } from "~/lib/newlineToBr";
import { getStorageUrl } from "~/lib/storage_utils";
import { cn } from "~/lib/utils";
import { cn, redirectTo500 } from "~/lib/utils";
import { AnnuncioContext, useAnnuncio } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider";
import { buildRicercaUrl } from "~/providers/RicercaProvider";
@ -176,8 +176,21 @@ export async function getStaticProps(
},
};
}
const annuncio = await ssg.annunci.getAnnuncioData.fetch({ cod: cod });
if (!annuncio.success || annuncio.data.stato === "Sospeso") {
const result = await ssg.annunci.getAnnuncioData.fetch({ cod: cod });
if (!result.ok) {
if (result.error._tag === "InternalError") {
return redirectTo500;
}
return {
redirect: {
destination: "/annuncio-non-trovato",
permanent: false,
},
};
}
if (result.value.stato === "Sospeso") {
return {
redirect: {
destination: "/annuncio-non-trovato",
@ -191,7 +204,7 @@ export async function getStaticProps(
});
return {
props: {
data: annuncio.data,
data: result.value,
//cod,
flag,
//meta,

View file

@ -23,6 +23,7 @@ import {
FormMessage,
} from "~/components/custom_ui/form";
import LoadingButton from "~/components/custom_ui/loading-button";
import { Status500 } from "~/components/status-page";
import { PasswordSVG } from "~/components/svgs";
import {
AlertDialog,
@ -38,27 +39,22 @@ import {
import { Button } from "~/components/ui/button";
import Input from "~/components/ui/input";
import { usePassword } from "~/hooks/usePassword";
import { cn } from "~/lib/utils";
import { cn, redirectTo500 } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import type {
InviteErrors,
TokenVerErrors,
} from "~/server/controllers/invites.controller";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
import type { Result } from "~/utils/result";
import { assertNever } from "~/utils/utils";
type InviteTokenProps = {
token: string;
verification:
| {
status: "valid";
email: string;
}
| {
status: "invalid";
reason: "not_found" | "expired";
}
| {
status: "already_user";
};
verification: Result<string, InviteErrors>;
};
/**
@ -68,16 +64,20 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
verification,
token,
}: InviteTokenProps) => {
switch (verification.status) {
case "valid":
return <ValidInvitePage email={verification.email} token={token} />;
case "invalid":
return <InvalidInvitePage reason={verification.reason} />;
case "already_user":
return <AlreadyUserPage />;
default:
return <InvalidInvitePage reason="not_found" />;
if (!verification.ok) {
switch (verification.error._tag) {
case "AlreadyUser":
return <AlreadyUserPage />;
case "Expired":
case "TokenNotFound":
return <InvalidInvitePage reason={verification.error._tag} />;
case "InternalError":
return <Status500 />;
default:
assertNever(verification.error);
}
}
return <ValidInvitePage email={verification.value} token={token} />;
};
const ValidInvitePage = ({
token,
@ -131,10 +131,28 @@ const ValidInvitePage = ({
const { mutate: acceptInvite, isPending } =
api.invite.acceptInvite.useMutation({
onSuccess: async () => {
toast.success("Account creato! Effettua il login.");
onSuccess: async (result) => {
if (!result.ok) {
switch (result.error._tag) {
case "UserNotFound":
toast.error(result.error.message);
return;
case "TokenNotFound":
case "Expired":
toast.error(
"Il token di invito non è più valido. Richiedi un nuovo invito.",
);
window.location.reload();
return;
default:
assertNever(result.error);
}
}
toast.success(
"Successo! La tua password è stata impostata. Reindirizzamento in corso...",
);
await router.push("/area-riservata/dashboard");
//await router.push("/login?redirect=/area-riservata/dashboard");
},
onError: (error) => {
toast.error(error.message);
@ -246,9 +264,9 @@ const AlreadyUserPage = () => {
);
};
const InvalidInvitePage = ({ reason }: { reason: "not_found" | "expired" }) => {
const InvalidInvitePage = ({ reason }: { reason: TokenVerErrors["_tag"] }) => {
const message =
reason === "expired"
reason === "Expired"
? "L'invito è scaduto, è passato troppo tempo dall'invio."
: "Non è stato trovato alcun invito valido per questo collegamento.";
@ -354,6 +372,10 @@ export const getServerSideProps = (async (context) => {
const verification = await helper.invite.verifyInvite.fetch({ token, email });
if (!verification.ok && verification.error._tag === "InternalError") {
return redirectTo500;
}
return {
props: {
verification,

View file

@ -149,14 +149,15 @@ export const getServerSideProps = (async (context) => {
const result = await helper.trpc.pagamenti.preparePaymentRinnovi.fetch({
rinnovoId: parsedRinnovoId.data,
});
if (!result.success) {
console.error("Error preparing payment", result.message);
if (!result.ok) {
// error type non usato prienamente per ora, logghiamo tutto con messaggio
console.error("Error preparing payment", result.error.message);
return redirectTo500;
}
return {
props: {
stripeDisabled: flag || false,
data: result.data,
data: result.value,
saldoPreview: null,
},
};
@ -176,8 +177,9 @@ export const getServerSideProps = (async (context) => {
type,
servizioId: parsedServizioId.data,
});
if (!result.success) {
console.error("Error preparing payment", result.message);
if (!result.ok) {
// error type non usato prienamente per ora, logghiamo tutto con messaggio
console.error("Error preparing payment", result.error.message);
return redirectTo500;
}
let saldoPreview: Prezziario | null = null;
@ -193,7 +195,7 @@ export const getServerSideProps = (async (context) => {
return {
props: {
stripeDisabled: flag || false,
data: result.data,
data: result.value,
saldoPreview,
},
};

View file

@ -6,16 +6,13 @@ import {
createTRPCRouter,
publicProcedure,
} from "~/server/api/trpc";
import { swapAuth } from "~/server/controllers/auth.controller";
import {
consumeInviteToken,
createInviteToken,
InviteAcceptance,
InviteVerification,
sendInvitoEmail,
verifyInviteToken,
} from "~/server/controllers/invites.controller";
import { editAccountHandler } from "~/server/controllers/user.controller";
import { db } from "~/server/db";
import { generateSalt, hashPassword } from "~/server/services/password.service";
import { findUser_byEmail, getUser } from "~/server/services/user.service";
import { zUserId } from "~/server/utils/zod_types";
@ -90,31 +87,8 @@ export const inviteRouter = createTRPCRouter({
// Verify invite token (public - user clicking the link)
verifyInvite: publicProcedure
.input(z.object({ token: z.string(), email: z.string().nullable() }))
.output(
z.discriminatedUnion("status", [
z.object({ status: z.literal("valid"), email: z.email() }),
z.object({
status: z.literal("invalid"),
reason: z.enum(["not_found", "expired"]),
}),
z.object({ status: z.literal("already_user") }),
]),
)
.query(async ({ input }) => {
if (input.email) {
const existingUser = await findUser_byEmail({ email: input.email });
if (existingUser?.usedInvite) {
return { status: "already_user" };
}
}
const ver = await verifyInviteToken(input.token);
if (!ver.status) {
return { status: "invalid", reason: ver.reason };
}
return { status: "valid", email: ver.invite.email };
return await InviteVerification(input.token, input.email);
}),
// Accept invite and set password
@ -127,39 +101,11 @@ export const inviteRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const user = await findUser_byEmail({ email: input.email });
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Accept Invite: Utente non trovato con email: ${input.email}`,
});
}
const ver = await verifyInviteToken(input.token);
if (!ver.status) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Accept Invite: Invito non valido o scaduto per email: ${input.email}`,
});
}
// Create user with password
const salt = generateSalt();
const hashedPassword = await hashPassword(input.password, salt);
await editAccountHandler({
id: user.id,
data: {
password: hashedPassword,
salt,
usedInvite: true,
},
});
// Mark invite as used
await consumeInviteToken(input.token);
await swapAuth({ ctx, userId: user.id });
return { success: true };
return await InviteAcceptance(
ctx,
input.token,
input.email,
input.password,
);
}),
});

View file

@ -6,6 +6,7 @@ import {
getPagamentoDataForCheckout,
getRicevutaData,
type PreparedPaymentData,
type PreparePaymentError,
preparePayment,
previewSaldo,
} from "~/server/controllers/pagamenti.controller";
@ -13,6 +14,7 @@ import {
import { db } from "~/server/db";
import { TypstGenerate } from "~/server/services/typst.service";
import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types";
import { zResult } from "~/utils/result";
// Create a new ratelimiter, that allows 10 requests per 10 seconds
/*
const ratelimit = new RateLimiterHandler({
@ -47,23 +49,15 @@ export const pagamentiRouter = createTRPCRouter({
type: z.enum(OrderTypeEnum),
}),
)
.output(
z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
data: z.custom<PreparedPaymentData>(),
}),
z.object({
success: z.literal(false),
message: z.string(),
}),
]),
)
.output(zResult<PreparedPaymentData, PreparePaymentError>())
.query(async ({ input }) => {
if (input.type === OrderTypeEnum.Rinnovo) {
return {
success: false,
message: "Use preparePaymentRinnovi for Rinnovo payments",
ok: false,
error: {
_tag: "InvalidInput",
message: "Use preparePaymentRinnovi for Rinnovo payments",
},
};
}
return await preparePayment({
@ -86,18 +80,7 @@ export const pagamentiRouter = createTRPCRouter({
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(),
}),
]),
)
.output(zResult<PreparedPaymentData, PreparePaymentError>())
.query(async ({ input }) => {
return await preparePayment({
type: OrderTypeEnum.Rinnovo,

View file

@ -1,7 +1,6 @@
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import type { Result } from "~/lib/result";
import { getStorageUrl } from "~/lib/storage_utils";
import type {
Annunci,
@ -14,6 +13,7 @@ import type { VideosRefs } from "~/schemas/public/VideosRefs";
import { db } from "~/server/db";
import type { AnnuncioTemplateData } from "~/server/services/typst.service";
import { withImages, withVideos } from "~/utils/kysely-helper";
import type { Result } from "~/utils/result";
import { revalidate } from "../utils/revalidationHelper";
// const ratelimit = new RateLimiterHandler({
@ -112,11 +112,16 @@ export type AnnuncioData = Pick<
ogUrl: string;
ogImage: string;
};
type getAnnuncioDataError =
| { _tag: "NoAnnuncio" }
| { _tag: "MalformedAnnuncio" }
| { _tag: "InternalError"; cause: unknown };
export const getAnnuncioData = async ({
cod,
}: {
cod: string;
}): Promise<Result<AnnuncioData>> => {
}): Promise<Result<AnnuncioData, getAnnuncioDataError>> => {
try {
const annuncio = await db
.selectFrom("annunci")
@ -157,11 +162,11 @@ export const getAnnuncioData = async ({
.where("codice", "=", cod)
.executeTakeFirst();
if (!annuncio) {
return { success: false, message: "Annuncio non trovato" };
return { ok: false, error: { _tag: "NoAnnuncio" } };
}
const { tipo, media_updated_at, ...rest } = annuncio;
if (tipo === null) {
return { success: false, message: "Annuncio non trovato" };
return { ok: false, error: { _tag: "MalformedAnnuncio" } };
}
const ogImage =
annuncio.images && annuncio.images.length > 0 && annuncio.images[0]
@ -175,8 +180,8 @@ export const getAnnuncioData = async ({
})
: `${env.BASE_URL}/og.jpg`;
return {
success: true,
data: {
ok: true,
value: {
...rest,
media_updated_at: media_updated_at?.toISOString() || null,
tipo,
@ -185,11 +190,7 @@ export const getAnnuncioData = async ({
},
};
} catch (e) {
throw new TRPCError({
cause: (e as Error).cause,
code: "INTERNAL_SERVER_ERROR",
message: `Errore query getAnnunciByCod: ${(e as Error).message}`,
});
return { ok: false, error: { _tag: "InternalError", cause: e } };
}
};

View file

@ -19,6 +19,7 @@ import {
findUser_byId_MINI,
} from "~/server/services/user.service";
import { checkCtx } from "~/server/utils/ctxChecker";
import type { Result } from "~/utils/result";
import { TOKEN_CONFIG } from "../auth/configs";
export const createUserAdmin = async ({
@ -68,6 +69,10 @@ export const createUserAdmin = async ({
return { status: "success" as const };
};
type LoginErrors =
| { _tag: "NotFound"; message: string }
| { _tag: "Fail"; message: string };
// FLOWS: login | 2 | Login function
export const logIn = async ({
email,
@ -77,7 +82,7 @@ export const logIn = async ({
email: string;
password: string;
ctx: Context;
}) => {
}): Promise<Result<null, LoginErrors>> => {
try {
checkCtx({ req, res });
@ -86,17 +91,23 @@ export const logIn = async ({
if (!user) {
return {
status: "not-found" as const,
message:
"Questo utente non esiste. Devi avere un account per effettuare il login.",
ok: false,
error: {
_tag: "NotFound",
message:
"Questo utente non esiste. Devi avere un account per effettuare il login.",
},
};
}
//Check if user is blocked or unverified
if (user.isBlocked) {
return {
status: "fail" as const,
message: "Errore in Login, non è possibile effettuare il login.",
ok: false,
error: {
_tag: "Fail",
message: "Errore in Login, non è possibile effettuare il login.",
},
};
}
@ -112,8 +123,11 @@ export const logIn = async ({
if (!passworIsMatch) {
return {
status: "fail" as const,
message: "Password errata, riprova.",
ok: false,
error: {
_tag: "Fail",
message: "Password errata, riprova.",
},
};
}
}
@ -139,37 +153,29 @@ export const logIn = async ({
"refreshToken",
);
try {
await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, {
...TOKEN_CONFIG.COOKIE_OPTIONS,
expires: add(new Date(), {
hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY,
}),
req,
res,
});
await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
...TOKEN_CONFIG.COOKIE_OPTIONS,
expires: add(new Date(), {
days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY,
}),
req,
res,
});
await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, {
...TOKEN_CONFIG.COOKIE_OPTIONS,
expires: add(new Date(), {
hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY,
}),
req,
res,
});
await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
...TOKEN_CONFIG.COOKIE_OPTIONS,
expires: add(new Date(), {
days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY,
}),
req,
res,
});
return { status: "success" as const };
} catch (cookieError) {
console.error("Error setting cookies:", cookieError);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante l'impostazione dei cookies: ${(cookieError as Error).message}`,
});
}
return { ok: true, value: null };
} catch (e) {
console.error("Login error:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore login: ${(e as Error).message}`,
message: `Errore durante il login: ${(e as Error).message}`,
});
}
};

View file

@ -2,7 +2,13 @@ import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { UserInvites } from "~/schemas/public/UserInvites";
import type { UsersId } from "~/schemas/public/Users";
import type { Context } from "~/server/api/trpc";
import { swapAuth } from "~/server/controllers/auth.controller";
import { editAccountHandler } from "~/server/controllers/user.controller";
import { db } from "~/server/db";
import { generateSalt, hashPassword } from "~/server/services/password.service";
import { findUser_byEmail } from "~/server/services/user.service";
import type { Result } from "~/utils/result";
import { NewMail } from "../services/mailer";
const INVITE_EXPIRY_HOURS = 48;
@ -31,19 +37,109 @@ export async function createInviteToken(email: string) {
}
return token;
}
export type InviteErrors =
| { _tag: "AlreadyUser" }
| { _tag: "InternalError"; message: string }
| TokenVerErrors;
type VerifyInviteResult =
export const InviteVerification = async (
token: string,
email: string | null,
): Promise<Result<string, InviteErrors>> => {
try {
if (email) {
const existingUser = await findUser_byEmail({ email });
if (existingUser?.usedInvite) {
return { ok: false, error: { _tag: "AlreadyUser" } };
}
}
const ver = await verifyInviteToken(token);
if (!ver.ok) {
return {
ok: false,
error: ver.error,
};
}
return { ok: true, value: ver.value.email };
} catch (e) {
console.error("Error verifying invite token:", e);
return {
ok: false,
error: {
_tag: "InternalError",
message: `Errore durante la verifica del token di invito.: ${(e as Error).message}`,
},
};
}
};
type InviteAcceptanceErrors =
| {
status: true;
invite: UserInvites;
_tag: "UserNotFound";
message: string;
}
| {
status: false;
reason: "not_found" | "expired";
};
| TokenVerErrors;
export const InviteAcceptance = async (
ctx: Context,
token: string,
email: string,
password: string,
): Promise<Result<UserInvites, InviteAcceptanceErrors>> => {
try {
const user = await findUser_byEmail({ email });
if (!user) {
return {
ok: false,
error: {
_tag: "UserNotFound",
message: `Accept Invite: Utente non trovato con email: ${email}`,
},
};
}
const ver = await verifyInviteToken(token);
if (!ver.ok) {
return {
ok: false,
error: ver.error,
};
}
// Create user with password
const salt = generateSalt();
const hashedPassword = await hashPassword(password, salt);
await editAccountHandler({
id: user.id,
data: {
password: hashedPassword,
salt,
usedInvite: true,
},
});
// Mark invite as used
await consumeInviteToken(token);
await swapAuth({ ctx, userId: user.id });
return { ok: true, value: ver.value };
} catch (e) {
console.error("Error verifying invite token:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante l'accettazione del token di invito.: ${(e as Error).message}`,
});
}
};
export type TokenVerErrors = { _tag: "TokenNotFound" } | { _tag: "Expired" };
export async function verifyInviteToken(
token: string,
): Promise<VerifyInviteResult> {
): Promise<Result<UserInvites, TokenVerErrors>> {
try {
const invite = await db
.selectFrom("user_invites")
@ -52,13 +148,13 @@ export async function verifyInviteToken(
//.where("expires_at", ">", new Date())
.executeTakeFirst();
if (!invite) {
return { status: false, reason: "not_found" };
return { ok: false, error: { _tag: "TokenNotFound" } };
}
if (invite.expires_at < new Date()) {
return { status: false, reason: "expired" };
return { ok: false, error: { _tag: "Expired" } };
}
return { status: true, invite };
return { ok: true, value: invite };
} catch (e) {
console.error("Error verifying invite token:", e);
throw new TRPCError({

View file

@ -13,6 +13,7 @@ import type { ServizioServizioId } from "~/schemas/public/Servizio";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { getComuni, getNazioni } from "~/server/controllers/catasto.controller";
import type { RicevutaProps } from "~/server/services/typst.service";
import type { Result } from "~/utils/result";
import { db } from "../db";
import { createOrdine } from "../services/ordini.service";
import {
@ -40,12 +41,14 @@ type preparePaymentInput =
type: OrderTypeEnum.Rinnovo;
};
export type PreparePaymentError =
| { _tag: "PrezziarioNotFound"; message: string }
| { _tag: "AlreadyPaid"; message: string }
| { _tag: "InvalidInput"; message: string }
| { _tag: "InternalError"; message: string };
export const preparePayment = async (
props: preparePaymentInput,
): Promise<
| { success: true; data: PreparedPaymentData }
| { success: false; message: string }
> => {
): Promise<Result<PreparedPaymentData, PreparePaymentError>> => {
try {
switch (props.type) {
case OrderTypeEnum.Acconto:
@ -68,8 +71,11 @@ export const preparePayment = async (
const acconto = await AccontoSolver(servizio.tipologia);
if (!acconto.success) {
return {
success: false,
message: `Acconto non trovato per il servizio: ${acconto.message}`,
ok: false,
error: {
_tag: "PrezziarioNotFound",
message: `Acconto non trovato per il servizio: ${acconto.message}`,
},
};
}
@ -82,8 +88,8 @@ export const preparePayment = async (
if (preExistingAcconto) {
// acconto già creato, procedere con questo
return {
success: true,
data: {
ok: true,
value: {
ordine_id: preExistingAcconto.ordine_id,
packid: preExistingAcconto.packid,
amount_cent: preExistingAcconto.amount_cent,
@ -105,8 +111,8 @@ export const preparePayment = async (
userid: servizio.user_id,
});
return {
success: true,
data: {
ok: true,
value: {
ordine_id: ordine.ordine_id,
packid: acconto.pack.idprezziario,
amount_cent: acconto.pack.prezzo_cent,
@ -119,17 +125,23 @@ export const preparePayment = async (
};
}
return {
success: false,
message:
"Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento",
ok: false,
error: {
_tag: "AlreadyPaid",
message:
"Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento",
},
};
}
case OrderTypeEnum.Saldo: {
if (!servizio.isOkAcconto) {
return {
success: false,
message:
"Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto",
ok: false,
error: {
_tag: "InvalidInput",
message:
"Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto",
},
};
}
if (!servizio.isOkSaldo) {
@ -141,8 +153,11 @@ export const preparePayment = async (
});
if (!saldo.success) {
return {
success: false,
message: `Saldo non trovato per il servizio: ${saldo.message}`,
ok: false,
error: {
_tag: "PrezziarioNotFound",
message: `Saldo non trovato per il servizio: ${saldo.message}`,
},
};
}
@ -152,8 +167,8 @@ export const preparePayment = async (
if (preExistingSaldo) {
// saldo già creato, procedere con questo
return {
success: true,
data: {
ok: true,
value: {
ordine_id: preExistingSaldo.ordine_id,
packid: preExistingSaldo.packid,
amount_cent: preExistingSaldo.amount_cent,
@ -174,8 +189,8 @@ export const preparePayment = async (
userid: servizio.user_id,
});
return {
success: true,
data: {
ok: true,
value: {
ordine_id: ordine.ordine_id,
packid: saldo.pack.idprezziario,
amount_cent: saldo.pack.prezzo_cent,
@ -187,18 +202,24 @@ export const preparePayment = async (
};
}
return {
success: false,
message:
"Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento",
ok: false,
error: {
_tag: "AlreadyPaid",
message:
"Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento",
},
};
}
case OrderTypeEnum.Consulenza: {
if (!servizio.isOkAcconto || !servizio.isOkSaldo) {
return {
success: false,
message:
"Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo",
ok: false,
error: {
_tag: "InvalidInput",
message:
"Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo",
},
};
}
if (!servizio.isOkConsulenza) {
@ -212,14 +233,17 @@ export const preparePayment = async (
);
if (!consulenzaPack) {
return {
success: false,
message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`,
ok: false,
error: {
_tag: "PrezziarioNotFound",
message: `Pack della consulenza non trovato: ${existingConsulenza.packid}`,
},
};
}
return {
success: true,
data: {
ok: true,
value: {
ordine_id: existingConsulenza.ordine_id,
packid: existingConsulenza.packid,
amount_cent: existingConsulenza.amount_cent,
@ -233,16 +257,22 @@ export const preparePayment = async (
}
return {
success: false,
message:
"Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere",
ok: false,
error: {
_tag: "InvalidInput",
message:
"Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere",
},
};
}
return {
success: false,
message:
"Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento",
ok: false,
error: {
_tag: "AlreadyPaid",
message:
"Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento",
},
};
}
case OrderTypeEnum.Altro: {
@ -256,14 +286,17 @@ export const preparePayment = async (
);
if (!altroPack) {
return {
success: false,
message: `Pack dell'altro non trovato: ${existingAltro.packid}`,
ok: false,
error: {
_tag: "PrezziarioNotFound",
message: `Pack dell'altro non trovato: ${existingAltro.packid}`,
},
};
}
return {
success: true,
data: {
ok: true,
value: {
ordine_id: existingAltro.ordine_id,
packid: existingAltro.packid,
amount_cent: existingAltro.amount_cent,
@ -276,9 +309,12 @@ export const preparePayment = async (
}
return {
success: false,
message:
"La preparazione del pagamento per questa tipologia non è supportata",
ok: false,
error: {
_tag: "InvalidInput",
message:
"La preparazione del pagamento per questa tipologia non è supportata",
},
};
}
default:
@ -300,9 +336,12 @@ export const preparePayment = async (
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",
ok: false,
error: {
_tag: "AlreadyPaid",
message:
"Il rinnovo ha già un ordine attivo, non è possibile preparare un nuovo pagamento",
},
};
}
@ -314,14 +353,17 @@ export const preparePayment = async (
);
if (!rinnovoPack) {
return {
success: false,
message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`,
ok: false,
error: {
_tag: "PrezziarioNotFound",
message: `Pack del rinnovo non trovato: ${unusedOrdine.packid}`,
},
};
}
return {
success: true,
data: {
ok: true,
value: {
ordine_id: unusedOrdine.ordine_id,
packid: unusedOrdine.packid,
amount_cent: unusedOrdine.amount_cent,
@ -343,8 +385,11 @@ export const preparePayment = async (
const pack = await RinnovoSolver(rinnovo.decorrenza, rinnovo.scadenza);
if (!pack.success) {
return {
success: false,
message: `Pack non trovato per il rinnovo: ${pack.message}`,
ok: false,
error: {
_tag: "PrezziarioNotFound",
message: `Pack non trovato per il rinnovo: ${pack.message}`,
},
};
}
@ -356,8 +401,8 @@ export const preparePayment = async (
userid: rinnovo.userId,
});
return {
success: true,
data: {
ok: true,
value: {
ordine_id: ordine.ordine_id,
packid: pack.pack.idprezziario,
amount_cent: pack.pack.prezzo_cent,

View file

@ -0,0 +1,15 @@
import z from "zod";
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export const zResult = <T, E>() =>
z.discriminatedUnion("ok", [
z.object({
ok: z.literal(true),
value: z.custom<T>(),
}),
z.object({
ok: z.literal(false),
error: z.custom<E>(),
}),
]);

View file

@ -31,3 +31,7 @@ export function titleCase(str: string | null | undefined): string {
}
export const startsWithVowel = (s: string) => /^[aeiouàèéìòù]/i.test(s);
export function assertNever(x: never): never {
throw new Error(`Unexpected: ${JSON.stringify(x)}`);
}