Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
/**
|
|
* invoices.service billing-entity integration tests.
|
|
*
|
|
* Covers:
|
|
* - createInvoice with billingEntity=client snapshots clientName from client.fullName
|
|
* - createInvoice with billingEntity=company snapshots clientName from company.name
|
|
* - createInvoice throws ValidationError when client does not exist
|
|
* - createInvoice throws ValidationError when company is in another tenant (cross-tenant)
|
|
* - createInvoice uses entity primary email + address when none provided
|
|
* - createInvoice allows caller to override billingEmail and billingAddress
|
|
*
|
|
* Uses dynamic imports (PR 8 pattern) so env is loaded before service modules
|
|
* touch `db`.
|
|
*/
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
|
|
describe('invoices.service — billing entity', () => {
|
|
let createInvoice: typeof import('@/lib/services/invoices').createInvoice;
|
|
|
|
let makePort: typeof import('../helpers/factories').makePort;
|
|
let makeClient: typeof import('../helpers/factories').makeClient;
|
|
let makeCompany: typeof import('../helpers/factories').makeCompany;
|
|
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
|
|
|
|
let db: typeof import('@/lib/db').db;
|
|
let clientContacts: typeof import('@/lib/db/schema/clients').clientContacts;
|
|
let clientAddresses: typeof import('@/lib/db/schema/clients').clientAddresses;
|
|
let companyAddresses: typeof import('@/lib/db/schema/companies').companyAddresses;
|
|
|
|
let ValidationError: typeof import('@/lib/errors').ValidationError;
|
|
|
|
beforeAll(async () => {
|
|
const svc = await import('@/lib/services/invoices');
|
|
createInvoice = svc.createInvoice;
|
|
|
|
const factories = await import('../helpers/factories');
|
|
makePort = factories.makePort;
|
|
makeClient = factories.makeClient;
|
|
makeCompany = factories.makeCompany;
|
|
makeAuditMeta = factories.makeAuditMeta;
|
|
|
|
const dbMod = await import('@/lib/db');
|
|
db = dbMod.db;
|
|
|
|
const clientsSchema = await import('@/lib/db/schema/clients');
|
|
clientContacts = clientsSchema.clientContacts;
|
|
clientAddresses = clientsSchema.clientAddresses;
|
|
|
|
const companiesSchema = await import('@/lib/db/schema/companies');
|
|
companyAddresses = companiesSchema.companyAddresses;
|
|
|
|
const errors = await import('@/lib/errors');
|
|
ValidationError = errors.ValidationError;
|
|
});
|
|
|
|
it('creates an invoice with billingEntity=client and snapshots clientName from client.fullName', async () => {
|
|
const port = await makePort();
|
|
const client = await makeClient({
|
|
portId: port.id,
|
|
overrides: { fullName: 'Alice Admiral' },
|
|
});
|
|
|
|
const invoice = await createInvoice(
|
|
port.id,
|
|
{
|
|
billingEntity: { type: 'client', id: client.id },
|
|
dueDate: '2026-12-31',
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 500 }],
|
|
},
|
|
makeAuditMeta({ portId: port.id }),
|
|
);
|
|
|
|
expect(invoice.id).toBeTruthy();
|
|
expect(invoice.billingEntityType).toBe('client');
|
|
expect(invoice.billingEntityId).toBe(client.id);
|
|
expect(invoice.clientName).toBe('Alice Admiral');
|
|
expect(invoice.portId).toBe(port.id);
|
|
});
|
|
|
|
it('creates an invoice with billingEntity=company and snapshots clientName from company.name', async () => {
|
|
const port = await makePort();
|
|
const company = await makeCompany({
|
|
portId: port.id,
|
|
overrides: { name: 'Poseidon Holdings Ltd' },
|
|
});
|
|
|
|
const invoice = await createInvoice(
|
|
port.id,
|
|
{
|
|
billingEntity: { type: 'company', id: company.id },
|
|
dueDate: '2026-12-31',
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [{ description: 'Marina services', quantity: 2, unitPrice: 1250 }],
|
|
},
|
|
makeAuditMeta({ portId: port.id }),
|
|
);
|
|
|
|
expect(invoice.billingEntityType).toBe('company');
|
|
expect(invoice.billingEntityId).toBe(company.id);
|
|
expect(invoice.clientName).toBe('Poseidon Holdings Ltd');
|
|
});
|
|
|
|
it('throws ValidationError when billing entity (client) does not exist', async () => {
|
|
const port = await makePort();
|
|
|
|
await expect(
|
|
createInvoice(
|
|
port.id,
|
|
{
|
|
billingEntity: { type: 'client', id: 'nonexistent-client-id' },
|
|
dueDate: '2026-12-31',
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
|
},
|
|
makeAuditMeta({ portId: port.id }),
|
|
),
|
|
).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('throws ValidationError when billing entity (company) is in a different port', async () => {
|
|
const portA = await makePort();
|
|
const portB = await makePort();
|
|
const companyInB = await makeCompany({ portId: portB.id });
|
|
|
|
await expect(
|
|
createInvoice(
|
|
portA.id,
|
|
{
|
|
billingEntity: { type: 'company', id: companyInB.id },
|
|
dueDate: '2026-12-31',
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
|
},
|
|
makeAuditMeta({ portId: portA.id }),
|
|
),
|
|
).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('uses entity primary email + address when none provided in the request', async () => {
|
|
const port = await makePort();
|
|
const client = await makeClient({
|
|
portId: port.id,
|
|
overrides: { fullName: 'Bob Bosun' },
|
|
});
|
|
|
|
// Add a primary email contact and a primary address
|
|
await db.insert(clientContacts).values({
|
|
clientId: client.id,
|
|
channel: 'email',
|
|
value: 'bob@example.com',
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(clientAddresses).values({
|
|
clientId: client.id,
|
|
portId: port.id,
|
|
label: 'Primary',
|
|
streetAddress: '1 Pier Road',
|
|
city: 'Harbor City',
|
|
subdivisionIso: 'US-CA',
|
|
postalCode: '90000',
|
|
countryIso: 'US',
|
|
isPrimary: true,
|
|
});
|
|
|
|
const invoice = await createInvoice(
|
|
port.id,
|
|
{
|
|
billingEntity: { type: 'client', id: client.id },
|
|
dueDate: '2026-12-31',
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
|
},
|
|
makeAuditMeta({ portId: port.id }),
|
|
);
|
|
|
|
expect(invoice.billingEmail).toBe('bob@example.com');
|
|
// Address is rendered using ISO->name lookup (US-CA -> California, US -> United States).
|
|
expect(invoice.billingAddress).toBe(
|
|
'1 Pier Road, Harbor City, California, 90000, United States',
|
|
);
|
|
});
|
|
|
|
it('allows caller to override billingEmail and billingAddress', async () => {
|
|
const port = await makePort();
|
|
const company = await makeCompany({
|
|
portId: port.id,
|
|
overrides: { name: 'Nautical Corp', billingEmail: 'billing@nautical.example' },
|
|
});
|
|
|
|
await db.insert(companyAddresses).values({
|
|
companyId: company.id,
|
|
portId: port.id,
|
|
label: 'Primary',
|
|
streetAddress: '2 Ocean Blvd',
|
|
city: 'Portville',
|
|
subdivisionIso: 'US-FL',
|
|
postalCode: '33101',
|
|
countryIso: 'US',
|
|
isPrimary: true,
|
|
});
|
|
|
|
const invoice = await createInvoice(
|
|
port.id,
|
|
{
|
|
billingEntity: { type: 'company', id: company.id },
|
|
billingEmail: 'override@example.com',
|
|
billingAddress: 'Custom address line',
|
|
dueDate: '2026-12-31',
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
|
},
|
|
makeAuditMeta({ portId: port.id }),
|
|
);
|
|
|
|
expect(invoice.billingEmail).toBe('override@example.com');
|
|
expect(invoice.billingAddress).toBe('Custom address line');
|
|
// clientName snapshot is still entity-derived (not overridable on create)
|
|
expect(invoice.clientName).toBe('Nautical Corp');
|
|
});
|
|
});
|