Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions backend/app/presenters/user_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def logged_in_user
primaryAdminName: company.primary_admin.user.name,
completedPaymentMethodSetup: company.bank_account_ready?,
isTrusted: company.is_trusted,
taxId: company.tax_id,
checklistItems: company.checklist_items(user),
checklistCompletionPercentage: company.checklist_completion_percentage(user),
externalId: company.external_id,
Expand Down
1 change: 1 addition & 0 deletions backend/app/services/create_dividend_round.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def initialize(dividend_computation)

def process
return { success: false, error: "Dividend computation is already finalized" } if @dividend_computation.finalized?
return { success: false, error: "EIN must be configured before issuing dividends" } unless @dividend_computation.company.tax_id.present?

ApplicationRecord.transaction do
dividend_round = @dividend_computation.finalize_and_create_dividend_round
Expand Down
4 changes: 4 additions & 0 deletions backend/app/sidekiq/pay_invoice_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ def perform(invoice_id)
Rails.logger.info("PayInvoiceJob: Skipping payment for non-trusted company #{invoice.company.id}")
return
end
unless invoice.company.tax_id.present?
Rails.logger.info("PayInvoiceJob: Skipping payment for company #{invoice.company.id} without EIN")
return
end
PayInvoice.new(invoice_id).process
end
end
1 change: 1 addition & 0 deletions backend/spec/factories/companies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
factory :company do
name { Faker::Company.name }
email { Faker::Internet.unique.email }
tax_id { Faker::Number.number(digits: 9).to_s }
registration_number { Faker::Company.duns_number }
registration_state { "DE" }
street_address { Faker::Address.street_address }
Expand Down
16 changes: 16 additions & 0 deletions backend/spec/services/create_dividend_round_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@
end
end

context "when company EIN is not configured" do
before do
dividend_computation_output
company.update!(tax_id: nil)
end

it "returns error and does not create dividend round or finalize computation" do
result = service.process

expect(result[:success]).to be false
expect(result[:error]).to eq("EIN must be configured before issuing dividends")
expect(DividendRound.count).to eq(0)
expect(dividend_computation.reload.finalized_at).to be_nil
end
end

context "when unexpected errors occur" do
before do
dividend_computation_output
Expand Down
1 change: 1 addition & 0 deletions e2e/factories/companies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const companiesFactory = {
.values({
name: faker.company.name(),
email: faker.internet.email(),
taxId: faker.string.numeric(9),
registrationNumber: faker.string.numeric(9),
registrationState: "DE",
streetAddress: faker.location.streetAddress(),
Expand Down
36 changes: 36 additions & 0 deletions e2e/tests/company/equity/dividend-computations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,40 @@ test.describe("Dividend Computations", () => {
await expect(tableFooter).toContainText("$3,000");
await expect(tableFooter).toContainText("$60,000");
});

test("prevents finalizing distribution when company EIN is not configured", async ({ page }) => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding({
equityEnabled: true,
taxId: null,
});

const dividendComputation = await dividendComputationsFactory.create({
companyId: company.id,
});

await login(page, adminUser);
await page.getByRole("button", { name: "Equity" }).click();
await page.getByRole("link", { name: "Dividends" }).first().click();

const draftRow = page
.getByRole("row")
.filter({
has: page.getByText("Draft"),
})
.filter({
has: page.getByText(formatMoney(dividendComputation.totalAmountInUsd)),
});

await expect(draftRow).toBeVisible();
await draftRow.click();

await expect(page.getByRole("heading", { name: "Dividend" })).toBeVisible();
await expect(page.getByText("Dividend distribution is still a draft")).toBeVisible();

// Should show EIN warning alert
await expect(page.getByText(/EIN required to finalize distribution/iu)).toBeVisible();

// Button should be disabled when EIN is missing
await expect(page.getByRole("button", { name: "Finalize distribution" })).toBeDisabled();
});
});
43 changes: 43 additions & 0 deletions e2e/tests/company/invoices/complete-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,5 +231,48 @@ test.describe("Invoice submission, approval and rejection", () => {
await expect(locateOpenInvoicesBadge(page)).not.toBeVisible();
});

test("prevents approving invoice when company EIN is not configured", async ({ page }) => {
const companyWithoutEin = await companiesFactory.create({
requiredInvoiceApprovalCount: 1,
isTrusted: true,
taxId: null,
});
const adminUserLocal = (await usersFactory.create()).user;
const workerUserLocal = (await usersFactory.create()).user;

await companyAdministratorsFactory.create({
companyId: companyWithoutEin.company.id,
userId: adminUserLocal.id,
});
await companyContractorsFactory.create({
companyId: companyWithoutEin.company.id,
userId: workerUserLocal.id,
});

// Contractor submits an invoice
await login(page, workerUserLocal);
await page.locator("header").getByRole("link", { name: "New invoice" }).click();
await page.getByLabel("Invoice ID").fill("TEST-001");
await fillDatePicker(page, "Date", "11/01/2024");
await page.getByPlaceholder("Description").fill("Test work");
await fillByLabel(page, "Hours / Qty", "10:00", { index: 0 });
await page.getByRole("button", { name: "Send invoice" }).click();

await expect(page.getByRole("cell", { name: "TEST-001" })).toBeVisible();
await expect(page.getByText("Awaiting approval")).toBeVisible();

// Admin views invoices page - should see EIN warning
await logout(page);
await login(page, adminUserLocal);

// Should show EIN warning alert
await expect(page.getByText(/EIN required to initiate payments/iu)).toBeVisible();

// Pay Now button should be disabled
const invoiceRow = page.locator("tbody tr").first();
await expect(invoiceRow).toContainText("Awaiting approval");
await expect(invoiceRow.getByRole("button", { name: /Approve/u })).toBeDisabled();
});

const locateOpenInvoicesBadge = (page: Page) => page.getByRole("link", { name: "Invoices" }).getByRole("status");
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ const FinalizeDistributionModal = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="primary">Finalize distribution</Button>
<Button variant="primary" disabled={!company.taxId}>
Finalize distribution
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useQuery } from "@tanstack/react-query";
import { getFilteredRowModel, getSortedRowModel } from "@tanstack/react-table";
import { Circle, Info } from "lucide-react";
import { AlertTriangle, Circle, Info } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { z } from "zod";
Expand Down Expand Up @@ -217,7 +218,20 @@ const DividendComputation = ({ id }: { id: string }) => {

return (
<>
<DistributionDraftNotice />
{!company.taxId && (
<Alert className="mx-4" variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>EIN required to finalize distribution.</AlertTitle>
<AlertDescription>
You cannot finalize this distribution until your EIN is configured. Please add your EIN in{" "}
<Link href="/settings/administrator/details" className="underline">
Settings → Company details
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just company details should be okay here -

Suggested change
Settings Company details
Company details

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated
image

</Link>
.
</AlertDescription>
</Alert>
)}
{company.taxId ? <DistributionDraftNotice /> : null}
<DataTable
table={table}
actions={
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/(dashboard)/invoices/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const ApproveButton = ({
param={{ [pay ? "pay_ids" : "approve_ids"]: [invoice.id] }}
successText={pay ? "Payment initiated" : "Approved!"}
loadingText={pay ? "Sending payment..." : "Approving..."}
disabled={!!pay && (!company.completedPaymentMethodSetup || !company.isTrusted)}
disabled={!!pay && (!company.completedPaymentMethodSetup || !company.isTrusted || !company.taxId)}
>
Approve
</MutationButton>
Expand Down
19 changes: 17 additions & 2 deletions frontend/app/(dashboard)/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,9 @@ export default function InvoicesPage() {
const isPayNowDisabled = useCallback(
(invoice: Invoice) => {
const payable = isPayable(invoice);
return payable && (!company.completedPaymentMethodSetup || !company.isTrusted);
return payable && (!company.completedPaymentMethodSetup || !company.isTrusted || !company.taxId);
},
[isPayable, company.completedPaymentMethodSetup, company.isTrusted],
[isPayable, company.completedPaymentMethodSetup, company.isTrusted, company.taxId],
);
const actionConfig = useMemo(
(): ActionConfig<Invoice> => ({
Expand Down Expand Up @@ -533,6 +533,21 @@ export default function InvoicesPage() {
</AlertDescription>
</Alert>
) : null}

{company.completedPaymentMethodSetup && company.isTrusted && !company.taxId ? (
<Alert className="mx-4" variant="destructive">
<AlertTriangle className="my-auto max-h-4 max-w-4" />
<AlertTitle>EIN required to initiate payments.</AlertTitle>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used EIN instead of TIN for consistency, since the Company Details settings use the EIN term

Image

<AlertDescription>
You can approve invoices, but cannot initiate immediate payments until your EIN is configured. Please
add your EIN in{" "}
<Link href="/settings/administrator/details" className={linkClasses}>
Settings → Company details
</Link>
.
</AlertDescription>
</Alert>
) : null}
</>
) : null}

Expand Down
1 change: 1 addition & 0 deletions frontend/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const companySchema = z.object({
investorCount: z.number().nullable(),
primaryAdminName: z.string().nullable(),
isTrusted: z.boolean(),
taxId: z.string().nullable(),
externalId: z.string(),
checklistItems: z.array(
z.object({
Expand Down
Loading