mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-07-09 11:26:00 +00:00
feat(transactions): edição cooperativa e visibilidade de divisões
Adiciona splitGroupId para vincular as duas shares de um lançamento dividido (schema + índice + migration 0026). Habilita: - Edição de par dividido com escolha de escopo (apenas este lado ou ambos) via novo SplitPairDialog e updateTransactionSplitPairAction - Filtro "Somente divididos" (isDivided) na tabela de lançamentos - Visibilidade de anexos para pessoas com acesso compartilhado via payerShares; upload e detach em massa expandem para shares irmãs - Cópia independente de anexos no fluxo "Importar para Minha Conta" (novo fileKey, novo userId, S3 CopyObject) com seção read-only "Anexos que serão copiados" no dialog de importação - Ícone de clipe na tabela de lançamentos da página da pessoa via EXISTS em fetchPagadorLancamentos Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import crypto, { randomUUID } from "node:crypto";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import { and, count, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import { z } from "zod/v4";
|
||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
createPresignedGetUrl,
|
||||
createPresignedPutUrl,
|
||||
deleteS3Object,
|
||||
headS3Object,
|
||||
@@ -98,6 +97,46 @@ function signUploadToken(payload: UploadTokenPayload): string {
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
async function expandSplitSiblings(
|
||||
transactionIds: string[],
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
if (transactionIds.length === 0) return transactionIds;
|
||||
|
||||
const groupRows = await db
|
||||
.select({ splitGroupId: transactions.splitGroupId })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
inArray(transactions.id, transactionIds),
|
||||
eq(transactions.userId, userId),
|
||||
isNotNull(transactions.splitGroupId),
|
||||
),
|
||||
);
|
||||
|
||||
const splitGroupIds = [
|
||||
...new Set(
|
||||
groupRows
|
||||
.map((r) => r.splitGroupId)
|
||||
.filter((v): v is string => v !== null),
|
||||
),
|
||||
];
|
||||
|
||||
if (splitGroupIds.length === 0) return transactionIds;
|
||||
|
||||
const siblingRows = await db
|
||||
.select({ id: transactions.id })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
inArray(transactions.splitGroupId, splitGroupIds),
|
||||
eq(transactions.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
return [...new Set([...transactionIds, ...siblingRows.map((r) => r.id)])];
|
||||
}
|
||||
|
||||
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
||||
try {
|
||||
const [encodedPayload, signature] = token.split(".");
|
||||
@@ -281,6 +320,8 @@ export async function confirmAttachmentUploadAction(input: {
|
||||
}
|
||||
}
|
||||
|
||||
transactionIds = await expandSplitSiblings(transactionIds, user.id);
|
||||
|
||||
await db.insert(transactionAttachments).values(
|
||||
transactionIds.map((tid) => ({
|
||||
transactionId: tid,
|
||||
@@ -359,69 +400,6 @@ export async function detachTransactionAttachmentAction(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTransactionAttachmentsAction(
|
||||
transactionId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: Date;
|
||||
url: string;
|
||||
}>
|
||||
> {
|
||||
const user = await getUser();
|
||||
|
||||
const [transaction] = await db
|
||||
.select({ id: transactions.id })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(eq(transactions.id, transactionId), eq(transactions.userId, user.id)),
|
||||
);
|
||||
|
||||
if (!transaction) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
attachmentId: transactionAttachments.attachmentId,
|
||||
fileName: attachments.fileName,
|
||||
fileSize: attachments.fileSize,
|
||||
mimeType: attachments.mimeType,
|
||||
fileKey: attachments.fileKey,
|
||||
createdAt: attachments.createdAt,
|
||||
})
|
||||
.from(transactionAttachments)
|
||||
.innerJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactionAttachments.transactionId, transactions.id),
|
||||
eq(transactions.userId, user.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
attachments,
|
||||
and(
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
eq(attachments.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
attachmentId: row.attachmentId,
|
||||
fileName: row.fileName,
|
||||
fileSize: row.fileSize,
|
||||
mimeType: row.mimeType,
|
||||
createdAt: row.createdAt,
|
||||
url: await createPresignedGetUrl(row.fileKey),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const detachBulkSchema = z.object({
|
||||
attachmentId: z.string().uuid(),
|
||||
transactionId: z.string().uuid(),
|
||||
@@ -497,6 +475,11 @@ export async function detachAttachmentBulkAction(input: {
|
||||
}
|
||||
}
|
||||
|
||||
targetTransactionIds = await expandSplitSiblings(
|
||||
targetTransactionIds,
|
||||
user.id,
|
||||
);
|
||||
|
||||
if (targetTransactionIds.length > 0) {
|
||||
await db
|
||||
.delete(transactionAttachments)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -394,7 +395,11 @@ const refineLancamento = (
|
||||
}
|
||||
};
|
||||
|
||||
export const createSchema = baseFields.superRefine(refineLancamento);
|
||||
export const createSchema = baseFields
|
||||
.extend({
|
||||
importFromTransactionId: uuidSchema("Lançamento fonte").optional(),
|
||||
})
|
||||
.superRefine(refineLancamento);
|
||||
export const updateSchema = baseFields
|
||||
.extend({
|
||||
id: uuidSchema("Lançamento"),
|
||||
@@ -544,6 +549,7 @@ export const buildLancamentoRecords = ({
|
||||
seriesId,
|
||||
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
||||
const records: TransactionInsert[] = [];
|
||||
const isSplit = (data.isSplit ?? false) && shares.length > 1;
|
||||
|
||||
const basePayload = {
|
||||
name: data.name,
|
||||
@@ -562,6 +568,8 @@ export const buildLancamentoRecords = ({
|
||||
seriesId,
|
||||
};
|
||||
|
||||
const cycleSplitGroupId = () => (isSplit ? randomUUID() : null);
|
||||
|
||||
const resolveSettledValue = (cycleIndex: number) => {
|
||||
if (shouldNullifySettled) {
|
||||
return null;
|
||||
@@ -588,6 +596,7 @@ export const buildLancamentoRecords = ({
|
||||
const installmentDueDate = dueDate
|
||||
? addMonthsToDate(dueDate, installment)
|
||||
: null;
|
||||
const splitGroupId = cycleSplitGroupId();
|
||||
|
||||
shares.forEach((share, shareIndex) => {
|
||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
||||
@@ -603,6 +612,7 @@ export const buildLancamentoRecords = ({
|
||||
currentInstallment: installment + 1,
|
||||
recurrenceCount: null,
|
||||
dueDate: installmentDueDate,
|
||||
splitGroupId,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled
|
||||
? boletoPaymentDate
|
||||
@@ -623,6 +633,7 @@ export const buildLancamentoRecords = ({
|
||||
const recurrenceDueDate = dueDate
|
||||
? addMonthsToDate(dueDate, index)
|
||||
: null;
|
||||
const splitGroupId = cycleSplitGroupId();
|
||||
|
||||
shares.forEach((share) => {
|
||||
const settled = resolveSettledValue(index);
|
||||
@@ -635,6 +646,7 @@ export const buildLancamentoRecords = ({
|
||||
isSettled: settled,
|
||||
recurrenceCount: recurrenceTotal,
|
||||
dueDate: recurrenceDueDate,
|
||||
splitGroupId,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled
|
||||
? boletoPaymentDate
|
||||
@@ -646,6 +658,8 @@ export const buildLancamentoRecords = ({
|
||||
return records;
|
||||
}
|
||||
|
||||
const splitGroupId = cycleSplitGroupId();
|
||||
|
||||
shares.forEach((share) => {
|
||||
const settled = resolveSettledValue(0);
|
||||
records.push({
|
||||
@@ -656,6 +670,7 @@ export const buildLancamentoRecords = ({
|
||||
period,
|
||||
isSettled: settled,
|
||||
dueDate,
|
||||
splitGroupId,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
searchFilter: z.string().nullable(),
|
||||
settledFilter: z.string().nullable(),
|
||||
attachmentFilter: z.string().nullable(),
|
||||
dividedFilter: z.string().nullable(),
|
||||
}),
|
||||
accountId: z.string().min(1).nullable().optional(),
|
||||
cardId: z.string().min(1).nullable().optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import {
|
||||
attachments,
|
||||
financialAccounts,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getBusinessTodayDate,
|
||||
parseLocalDateString,
|
||||
} from "@/shared/utils/date";
|
||||
import { copyAttachmentsForImport } from "../attachment-copy";
|
||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||
import {
|
||||
buildLancamentoRecords,
|
||||
@@ -138,6 +139,14 @@ export async function createTransactionAction(
|
||||
.values(records)
|
||||
.returning({ id: transactions.id });
|
||||
|
||||
if (data.importFromTransactionId && inserted.length > 0) {
|
||||
await copyAttachmentsForImport({
|
||||
sourceTransactionId: data.importFromTransactionId,
|
||||
targetTransactionIds: inserted.map((r) => r.id),
|
||||
targetUserId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const notificationEntries = buildEntriesByPayer(
|
||||
records.map((record) => ({
|
||||
payerId: record.payerId ?? null,
|
||||
@@ -437,6 +446,134 @@ export async function deleteTransactionAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTransactionSplitPairAction(
|
||||
input: UpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateSchema.parse(input);
|
||||
|
||||
const ownershipError = await validateAllOwnership(user.id, {
|
||||
payerId: data.payerId,
|
||||
categoryId: data.categoryId,
|
||||
accountId: data.accountId,
|
||||
cardId: data.cardId,
|
||||
});
|
||||
if (ownershipError) {
|
||||
return { success: false, error: ownershipError };
|
||||
}
|
||||
|
||||
const existing = await db.query.transactions.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
period: true,
|
||||
transactionType: true,
|
||||
condition: true,
|
||||
paymentMethod: true,
|
||||
accountId: true,
|
||||
cardId: true,
|
||||
categoryId: true,
|
||||
splitGroupId: true,
|
||||
},
|
||||
where: and(
|
||||
eq(transactions.id, data.id),
|
||||
eq(transactions.userId, user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Lançamento não encontrado." };
|
||||
}
|
||||
|
||||
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||
const amountCents = Math.round(Math.abs(data.amount) * 100);
|
||||
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
|
||||
const normalizedSettled =
|
||||
data.paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: (data.isSettled ?? false);
|
||||
const shouldSetBoletoPaymentDate =
|
||||
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||
? data.boletoPaymentDate
|
||||
? parseLocalDateString(data.boletoPaymentDate)
|
||||
: getBusinessTodayDate()
|
||||
: null;
|
||||
const targetCardId = data.cardId ?? existing.cardId;
|
||||
const movedInvoice =
|
||||
data.paymentMethod === "Cartão de crédito" &&
|
||||
targetCardId &&
|
||||
(targetCardId !== existing.cardId || period !== existing.period);
|
||||
|
||||
if (movedInvoice) {
|
||||
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
|
||||
period,
|
||||
]);
|
||||
if (paidPeriods.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `As faturas dos meses ${formatPaidInvoicePeriods(
|
||||
paidPeriods,
|
||||
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const purchaseDate = parseLocalDateString(data.purchaseDate);
|
||||
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
|
||||
|
||||
const sharedPayload = {
|
||||
name: data.name,
|
||||
purchaseDate,
|
||||
transactionType: data.transactionType,
|
||||
condition: data.condition,
|
||||
paymentMethod: data.paymentMethod,
|
||||
accountId: data.accountId ?? null,
|
||||
cardId: data.cardId ?? null,
|
||||
categoryId: data.categoryId ?? null,
|
||||
note: data.note ?? null,
|
||||
dueDate,
|
||||
period,
|
||||
isSettled: normalizedSettled,
|
||||
boletoPaymentDate: boletoPaymentDateValue,
|
||||
};
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
await tx
|
||||
.update(transactions)
|
||||
.set({
|
||||
...sharedPayload,
|
||||
amount: normalizedAmount,
|
||||
payerId: data.payerId ?? null,
|
||||
installmentCount: data.installmentCount ?? null,
|
||||
recurrenceCount: data.recurrenceCount ?? null,
|
||||
})
|
||||
.where(
|
||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||
);
|
||||
|
||||
if (existing.splitGroupId) {
|
||||
await tx
|
||||
.update(transactions)
|
||||
.set(sharedPayload)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.splitGroupId, existing.splitGroupId),
|
||||
eq(transactions.userId, user.id),
|
||||
ne(transactions.id, data.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
revalidate(user.id);
|
||||
return { success: true, message: "Lançamentos atualizados com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleTransactionSettlementAction(
|
||||
input: ToggleSettlementInput,
|
||||
): Promise<ActionResult> {
|
||||
|
||||
Reference in New Issue
Block a user