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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ def create
head :created
end

def signed
Copy link
Member Author

Choose a reason for hiding this comment

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

TRPC calls this endpoint to generate a PDF for the signed document.

document = Current.company.documents.find(params[:id])
authorize document

CreateDocumentPdfJob.perform_sync(document.id, document.text)
head :no_content
end

private
def document_params
params.require(:document).permit(:name, :document_type, :text)
Expand Down
4 changes: 3 additions & 1 deletion backend/app/models/company.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Company < ApplicationRecord
has_many :administrators, through: :company_administrators, source: :user
has_many :company_lawyers
has_many :lawyers, through: :company_lawyers, source: :user
has_one :primary_admin, -> { order(id: :asc) }, class_name: "CompanyAdministrator"
belongs_to :primary_admin, class_name: "CompanyAdministrator", optional: true
has_many :company_workers
has_many :company_investor_entities
has_many :contracts
Expand Down Expand Up @@ -120,6 +120,8 @@ def deactivate! = update!(deactivated_at: Time.current)

def active? = deactivated_at.nil?

def primary_admin = super || company_administrators.order(:id).first

def logo_url
return logo.url if logo.attached?

Expand Down
4 changes: 4 additions & 0 deletions backend/app/policies/document_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ class DocumentPolicy < ApplicationPolicy
def create?
company_administrator?
end

def signed?
company_administrator? || record.signatures.any? { |signature| signature.user == user }
end
end
6 changes: 5 additions & 1 deletion backend/app/sidekiq/create_document_pdf_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ class CreateDocumentPdfJob

def perform(document_id, document_text)
document = Document.find(document_id)
pdf = CreatePdf.new(body_html: ActionController::Base.helpers.sanitize(document_text)).perform
body_html = ApplicationController.render template: "templates/signed_document",
locals: { body_html: ActionController::Base.helpers.sanitize(document_text), document: },
layout: false,
formats: [:html]
pdf = CreatePdf.new(body_html:).perform
document.attachments.attach(
io: StringIO.new(pdf),
filename: "#{document.name}.pdf",
Expand Down
111 changes: 111 additions & 0 deletions backend/app/views/templates/signed_document.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<style>
Copy link
Member Author

Choose a reason for hiding this comment

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

We don't have Tailwind in PDFs. Also note that webfonts don't seem to work in this context, so the default sans and cursive fonts are used. This happens in share certificates too:

Image

body {
font-family: "ABC Whyte", sans-serif;
}

.column {
display: grid;
gap: 1rem;
}

.item {
display: grid;
gap: 0.5rem;
border-bottom: 1px solid #1D1E172E;
}

.label {
text-transform: uppercase;
color: #1D1E17A1;
font-weight: 500;
font-size: 0.75rem;
}

.signature {
font-size: 1.25rem;
font-style: italic;
border: solid 1px #1D1E172E;
padding: 1rem;
font-family: "Caveat", cursive;
text-align: center;
}
</style>

<%= body_html %>
<br /><br /><br />
<b>IN WITNESS WHEREOF</b>, the parties have executed this Agreement as of the Effective Date.
<%
company_representative = document.company.primary_admin.user
signature = document.signatures.first
signatory = signature&.user
%>
<div style="display: grid; grid-template-columns: 1fr 1fr; margin-top: 1.25rem; gap: 2rem">
<div class="column">
<b>CLIENT</b>
<div class="item" style="border-bottom: none;">
<div class="label">Signature</div>
<div class="signature">
<%= company_representative.legal_name %>
</div>
</div>
<div class="item">
<div class="label">Name</div>
<%= company_representative.legal_name %>
</div>
<div class="item">
<div class="label">Title</div>
Chief Executive Officer
</div>
<div class="item">
<div class="label">Country</div>
<%= company_representative.display_country %>
</div>
<div class="item">
<div class="label">Address</div>
<%= "#{company_representative.street_address}, #{company_representative.city}, #{company_representative.state}" %>
</div>
<div class="item">
<div class="label">Email</div>
<%= company_representative.display_email %>
</div>
<div class="item">
<div class="label">Date signed</div>
<%= company_representative.created_at.strftime("%B %d, %Y") %>
</div>
</div>
<div class="column">
<b>CONTRACTOR</b>
<div class="item" style="border-bottom: none;">
<div class="label">Signature</div>
<div class="signature">
<%= signatory&.legal_name %>
</div>
</div>
<div class="item">
<div class="label">Name</div>
<%= signatory&.legal_name %>
</div>
<div class="item">
<div class="label">Legal Entity</div>
<%= signatory&.billing_entity_name %>
</div>
<div class="item">
<div class="label">Country</div>
<%= signatory&.display_country %>
</div>
<div class="item">
<div class="label">Address</div>
<% if signatory.present? %>
<%= "#{signatory.street_address}, #{signatory.city}, #{signatory.state}" %>
<% end %>
</div>
<div class="item">
<div class="label">Email</div>
<%= signatory&.display_email %>
</div>
<div class="item">
<div class="label">Date signed</div>
<%= signature&.signed_at&.strftime("%B %d, %Y") %>
</div>
</div>
</div>
6 changes: 5 additions & 1 deletion backend/config/routes/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@
resources :dividend_computations, only: [:index, :create, :show]
resources :dividend_rounds, only: [:create]
resources :templates, only: [:index, :show, :update]
resources :documents, only: [:create]
resources :documents, only: [:create] do
member do
post :signed
end
end
resources :share_classes, only: [:index]
resources :cap_tables, only: [] do
collection do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddPrimaryAdminIdToCompanies < ActiveRecord::Migration[8.0]
def change
add_reference :companies, :primary_admin, null: true, index: true
end
end
4 changes: 3 additions & 1 deletion backend/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.0].define(version: 2025_10_02_213957) do
ActiveRecord::Schema[8.0].define(version: 2025_12_25_151433) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"

Expand Down Expand Up @@ -105,8 +105,10 @@
t.jsonb "json_data", default: {"flags" => []}, null: false
t.boolean "equity_enabled", default: false, null: false
t.string "invite_link"
t.bigint "primary_admin_id"
Copy link
Member

Choose a reason for hiding this comment

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

We need to add this to frontend/db/schema.ts as well?

t.index ["external_id"], name: "index_companies_on_external_id", unique: true
t.index ["invite_link"], name: "index_companies_on_invite_link", unique: true
t.index ["primary_admin_id"], name: "index_companies_on_primary_admin_id"
end

create_table "company_administrators", force: :cascade do |t|
Expand Down
13 changes: 13 additions & 0 deletions backend/spec/factories/document_signatures.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

FactoryBot.define do
factory :document_signature do
document
user
title { "Signer" }

trait :signed do
signed_at { Time.current }
end
end
end
14 changes: 13 additions & 1 deletion backend/spec/models/company_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

describe "associations" do
it { is_expected.to have_many(:company_administrators) }
it { is_expected.to have_one(:primary_admin).class_name("CompanyAdministrator") }
it { is_expected.to belong_to(:primary_admin).class_name("CompanyAdministrator").optional(true) }
it { is_expected.to have_many(:administrators).through(:company_administrators).source(:user) }
it { is_expected.to have_many(:company_lawyers) }
it { is_expected.to have_many(:lawyers).through(:company_lawyers).source(:user) }
Expand Down Expand Up @@ -470,6 +470,18 @@
end
end

describe "#primary_admin" do
it "returns the primary admin" do
company = create(:company)
admin = create(:company_administrator, company:)
expect(company.primary_admin).to eq admin
admin2 = create(:company_administrator, company:)
expect(company.primary_admin).to eq admin
company.update!(primary_admin: admin2)
expect(company.primary_admin).to eq admin2
end
end

describe "#active?" do
it "returns true if deactivated_at is not set, false otherwise" do
company = build(:company)
Expand Down
24 changes: 23 additions & 1 deletion backend/spec/sidekiq/create_document_pdf_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
include ActiveJob::TestHelper

let(:company) { create(:company) }
let!(:admin) { create(:company_administrator, company:) }
let(:pdf_service) { instance_double(CreatePdf, perform: "pdf content") }

before do
Expand All @@ -19,13 +20,34 @@

sanitized_text = ActionController::Base.helpers.sanitize(document.text)
expect(sanitized_text).to eq("<h1>Test</h1>alert('x')<img src=\"x\">")
expect(CreatePdf).to receive(:new).with(body_html: sanitized_text).and_return(pdf_service)
expect(CreatePdf).to receive(:new) do |args|
expect(args[:body_html]).to include(sanitized_text)
expect(args[:body_html]).to include(admin.user.legal_name)
end.and_return(pdf_service)

expect { described_class.new.perform(document.id, document.text) }
.to change { document.reload.attachments.attached? }.from(false).to(true)

attachment = document.reload.attachments.first
expect(attachment.filename.to_s).to eq("#{document.name}.pdf")
expect(attachment.content_type).to eq("application/pdf")

user = create(:user)
signature = create(:document_signature, document:, user:, title: "Signer")
expect(CreatePdf).to receive(:new) do |args|
expect(args[:body_html]).to include(sanitized_text)
expect(args[:body_html]).to include(admin.user.legal_name)
expect(args[:body_html]).to include(user.legal_name)
end.and_return(pdf_service)
described_class.new.perform(document.id, document.text)

signature.update!(signed_at: Date.new(2025, 12, 20))
expect(CreatePdf).to receive(:new) do |args|
expect(args[:body_html]).to include(sanitized_text)
expect(args[:body_html]).to include(admin.user.legal_name)
expect(args[:body_html]).to include(user.legal_name)
expect(args[:body_html]).to include("December 20, 2025")
end.and_return(pdf_service)
described_class.new.perform(document.id, document.text)
end
end
12 changes: 11 additions & 1 deletion e2e/tests/company/documents/documents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ test.describe("Documents", () => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding();
await documentsFactory.create({ companyId: company.id, text: "Test document text" });
const { user: recipient } = await usersFactory.create({ legalName: "Recipient 1" });
await companyContractorsFactory.create({ companyId: company.id, userId: recipient.id });
await companyContractorsFactory.create({ companyId: company.id, userId: recipient.id }, { withoutContract: true });
Copy link
Member Author

Choose a reason for hiding this comment

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

Makes the below cleaner as otherwise the user already has a contract.

await login(page, adminUser, "/documents");
await logout(page);
await expect(page.getByRole("heading", { name: "Documents" })).toBeVisible();
Expand All @@ -110,6 +110,16 @@ test.describe("Documents", () => {
await expect(page.getByText("Some other text")).toBeVisible();
await page.getByRole("button", { name: "Add your signature" }).click();
await page.getByRole("button", { name: "Agree & Submit" }).click();

await expect(page.getByText("No results.")).toBeVisible();
await page.locator("main").getByRole("button", { name: "Filter" }).click();
await page.getByRole("menuitem", { name: "Status" }).click();
await page.getByRole("menuitemcheckbox", { name: "All" }).click();
await expect(page.getByRole("menuitem")).toHaveCount(0);
await expect(page.locator("tbody tr")).toHaveCount(1);
await expect(page.getByText("Signed")).toBeVisible();
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByRole("menuitem", { name: "Download" })).toBeVisible();
});

test("shows the correct names for documents", async ({ page }) => {
Expand Down
1 change: 1 addition & 0 deletions frontend/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,7 @@ export const companies = pgTable(
conversionSharePriceUsd: numeric("conversion_share_price_usd"),
jsonData: jsonb("json_data").notNull().$type<{ flags: string[] }>().default({ flags: [] }),
inviteLink: varchar("invite_link"),
primaryAdminId: bigint("primary_admin_id", { mode: "bigint" }),
},
(table) => [
index("index_companies_on_external_id").using("btree", table.externalId.asc().nullsLast().op("text_ops")),
Expand Down
16 changes: 14 additions & 2 deletions frontend/trpc/routes/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { companyProcedure, createRouter } from "@/trpc";
import { simpleUser } from "@/trpc/routes/users";
import { assertDefined } from "@/utils/assert";
import { signed_company_document_url } from "@/utils/routes";

const visibleDocuments = (companyId: bigint, userId: bigint | SQLWrapper | undefined) =>
and(
Expand Down Expand Up @@ -57,7 +58,7 @@ export const documentsRouter = createRouter({
)
.leftJoin(activeStorageBlobs, eq(activeStorageAttachments.blobId, activeStorageBlobs.id))
.where(where)
.orderBy(desc(documents.id));
.orderBy(desc(documents.id), desc(activeStorageAttachments.id));
Copy link
Member Author

Choose a reason for hiding this comment

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

To match document.live_attachment.


const signatories = await db.query.documentSignatures.findMany({
columns: { documentId: true, title: true, signedAt: true },
Expand Down Expand Up @@ -114,7 +115,7 @@ export const documentsRouter = createRouter({
.limit(1);
if (!document) throw new TRPCError({ code: "NOT_FOUND" });

return await db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
await tx
.update(documentSignatures)
.set({ signedAt: new Date() })
Expand Down Expand Up @@ -142,5 +143,16 @@ export const documentsRouter = createRouter({

return { documentId: input.id, complete: allSigned };
});

// Generate PDF outside the transaction so signedAt is committed
if (result.complete) {
const response = await fetch(signed_company_document_url(ctx.company.externalId, document.documents.id), {
method: "POST",
headers: ctx.headers,
});
if (!response.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: await response.text() });
}

return result;
}),
});
Loading
Loading