Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions components/dashboard/transaction-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { toast } from "sonner";

import { NewCategoryDialog } from "@/components/dashboard/new-category-dialog";
import { CompactInput } from "@/components/dashboard/form-inputs/compact-input";
import { CompactSelect } from "@/components/dashboard/form-inputs/compact-select";
import { CompactTextarea } from "@/components/dashboard/form-inputs/compact-textarea";
import { CurrencyInput } from "@/components/dashboard/form-inputs/currency-input";
import { withSelectedMonth } from "@/components/dashboard/month-route";
Expand Down Expand Up @@ -335,17 +334,16 @@ function usePaymentMethodInstallmentEligibility({
return { canInstallment, handlePaymentMethodChange };
}

const INSTALLMENT_COUNTS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 24];

const SECTION_LABEL_CLASS = "mb-2.5 block text-xs font-medium uppercase tracking-widest text-muted-foreground";
const CHIP_BASE_CLASS = "flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 cursor-pointer max-w-full";
const CHIP_ADD_ACTION_CLASS = "border-dashed border-border/40 text-foreground/50 hover:border-border/60 hover:text-foreground/70";

function getInstallmentOptions(t: (key: string) => string) {
return [
{ value: "1", label: t("transaction.installmentOption.full") },
...INSTALLMENT_COUNTS.map((count) => ({ value: String(count), label: `${count}x` })),
];
const MAX_INSTALLMENT_COUNT = 120;

function clampInstallmentCount(rawValue: string) {
const parsed = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsed)) return 1;
return Math.min(MAX_INSTALLMENT_COUNT, Math.max(1, parsed));
}

function getDisplayedCategoryOptions({
Expand Down Expand Up @@ -586,7 +584,6 @@ function TransactionPaymentMethodChips({
type TransactionDateAndInstallmentsProps = TransactionFormFieldProps & {
readonly error: string | undefined;
readonly canInstallment: boolean;
readonly installmentOptions: ReturnType<typeof getInstallmentOptions>;
readonly todayDateInputValue: string;
readonly t: (key: string) => string;
};
Expand All @@ -596,7 +593,6 @@ function TransactionDateAndInstallments({
setFormData,
error,
canInstallment,
installmentOptions,
todayDateInputValue,
t,
}: TransactionDateAndInstallmentsProps) {
Expand All @@ -615,12 +611,20 @@ function TransactionDateAndInstallments({
inputClassName="w-full max-w-full min-w-0 appearance-none overflow-hidden pr-3 text-left [&::-webkit-calendar-picker-indicator]:shrink-0 [&::-webkit-date-and-time-value]:min-w-0 [&::-webkit-date-and-time-value]:overflow-hidden [&::-webkit-date-and-time-value]:text-left"
/>
{canInstallment ? (
<CompactSelect
label={t("transaction.installmentFrequency")}
<CompactInput
label={t("transaction.installmentCount")}
id="installment"
value={formData.installmentCount.toString()}
onChange={(value) => setFormData({ ...formData, installmentCount: Number.parseInt(value, 10) })}
options={installmentOptions}
type="number"
inputMode="numeric"
min="1"
max={MAX_INSTALLMENT_COUNT.toString()}
value={formData.installmentCount}
onChange={(event) =>
setFormData({
...formData,
installmentCount: clampInstallmentCount(event.target.value),
})
}
/>
) : null}
</div>
Expand Down Expand Up @@ -813,7 +817,7 @@ function useTransactionFormHandlers({
t,
});

return { handleSubmit, canInstallment, handlePaymentMethodChange, installmentOptions: getInstallmentOptions(t) };
return { handleSubmit, canInstallment, handlePaymentMethodChange };
}

function useTransactionFormState({
Expand Down
85 changes: 61 additions & 24 deletions tests/dashboard/transaction-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

import {
Expand Down Expand Up @@ -50,8 +49,7 @@ const translations: Record<string, string> = {
"transaction.paymentMethod": "Payment method",
"transaction.addPaymentMethod": "Add payment method",
"transaction.date": "Date",
"transaction.installmentFrequency": "Installments",
"transaction.installmentOption.full": "Full payment",
"transaction.installmentCount": "Number of installments",
"transaction.notes": "Notes",
"transaction.notesPlaceholder": "Add a note",
"transaction.saving": "Saving...",
Expand Down Expand Up @@ -344,48 +342,87 @@ describe("handlePaymentMethodChange defensive fallback", () => {
});

describe("installment eligibility across payment method types", () => {
it("shows the installment select for credit/boleto/label-contains-boleto methods and hides it for debit, resetting the count on non-installment methods", async () => {
const user = userEvent.setup();
const installmentInput = () =>
screen.getByLabelText("Number of installments") as HTMLInputElement;

it("shows the installment input for credit/boleto/label-contains-boleto methods and hides it for debit, resetting the count on non-installment methods", () => {
render(<TransactionForm {...baseProps({ paymentMethods: paymentMethodsAllTypes })} />);

// Default payment method is debit -> no installment select.
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
// Default payment method is debit -> no installment input.
expect(screen.queryByLabelText("Number of installments")).not.toBeInTheDocument();

// Switch to credit -> select appears (type === "credit" branch).
// Switch to credit -> input appears (type === "credit" branch).
fireEvent.click(screen.getByText("Credit Card"));
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(installmentInput().value).toBe("1");

// Bump installment count to 3x while on the credit method.
await user.click(screen.getByRole("combobox"));
const listbox = await screen.findByRole("listbox");
await user.click(within(listbox).getByText("3x"));
expect(screen.getByRole("combobox")).toHaveTextContent("3x");
// Bump installment count to 3 while on the credit method.
fireEvent.change(installmentInput(), { target: { value: "3" } });
expect(installmentInput().value).toBe("3");

// Re-selecting an installment-capable method preserves the count.
fireEvent.click(screen.getByText("Boleto"));
expect(screen.getByRole("combobox")).toHaveTextContent("3x");
expect(installmentInput().value).toBe("3");

// Label-contains-"boleto" branch: type is "other" but label includes "boleto".
fireEvent.click(screen.getByText("Store Boleto Plan"));
expect(screen.getByRole("combobox")).toHaveTextContent("3x");
expect(installmentInput().value).toBe("3");

// Switching to a non-installment method hides the select and resets the count.
// Switching to a non-installment method hides the input and resets the count.
fireEvent.click(screen.getByText("Debit Card"));
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Number of installments")).not.toBeInTheDocument();

// Switching back to credit confirms the count was reset to 1 ("Full payment").
// Switching back to credit confirms the count was reset to 1 (lump sum).
fireEvent.click(screen.getByText("Credit Card"));
expect(screen.getByRole("combobox")).toHaveTextContent("Full payment");
expect(installmentInput().value).toBe("1");
});

it("hides the installment select outside of expense type even with a credit payment method", () => {
it("hides the installment input outside of expense type even with a credit payment method", () => {
render(<TransactionForm {...baseProps({ paymentMethods: paymentMethodsAllTypes })} />);

fireEvent.click(screen.getByText("Credit Card"));
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByLabelText("Number of installments")).toBeInTheDocument();

fireEvent.click(screen.getByText("Saving"));
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Number of installments")).not.toBeInTheDocument();
});

it("accepts a value above the old 24-installment ceiling, e.g. 36", () => {
render(<TransactionForm {...baseProps({ paymentMethods: paymentMethodsAllTypes })} />);
fireEvent.click(screen.getByText("Credit Card"));

fireEvent.change(installmentInput(), { target: { value: "36" } });
expect(installmentInput().value).toBe("36");
});

it("clamps a value above the 120-installment cap down to 120", () => {
render(<TransactionForm {...baseProps({ paymentMethods: paymentMethodsAllTypes })} />);
fireEvent.click(screen.getByText("Credit Card"));

fireEvent.change(installmentInput(), { target: { value: "121" } });
expect(installmentInput().value).toBe("120");
});

it("falls back to 1 for non-numeric or empty input instead of crashing or producing NaN", () => {
render(<TransactionForm {...baseProps({ paymentMethods: paymentMethodsAllTypes })} />);
fireEvent.click(screen.getByText("Credit Card"));

fireEvent.change(installmentInput(), { target: { value: "abc" } });
expect(installmentInput().value).toBe("1");

fireEvent.change(installmentInput(), { target: { value: "3" } });
fireEvent.change(installmentInput(), { target: { value: "" } });
expect(installmentInput().value).toBe("1");
});

it("clamps a zero or negative value up to 1", () => {
render(<TransactionForm {...baseProps({ paymentMethods: paymentMethodsAllTypes })} />);
fireEvent.click(screen.getByText("Credit Card"));

fireEvent.change(installmentInput(), { target: { value: "0" } });
expect(installmentInput().value).toBe("1");

fireEvent.change(installmentInput(), { target: { value: "-5" } });
expect(installmentInput().value).toBe("1");
});
});

Expand Down
Loading