fix(sales): wire missing berth-rule triggers + portal company-billed invoices

- G-C4: deposit_received in invoices.ts
- G-C4 + G-I2: interest_archived + notifyNextInLine in archiveInterest
- G-C4: interest_completed in setInterestOutcome
- G-C4: berth_unlinked in removeInterestBerth
- G-I5: portal invoices include billingEntityType='company' when client is the director

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:53:10 +02:00
parent 1b00c8a7a2
commit c0e5af8b92
5 changed files with 149 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq, count, inArray, isNull, desc, sql } from 'drizzle-orm';
import { and, eq, count, inArray, isNull, desc, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
@@ -248,22 +248,58 @@ export async function getClientInvoices(
.filter((c) => c.channel === 'email')
.map((c) => c.value.toLowerCase());
if (emailContacts.length === 0) return [];
// G-I5: the most common B2B pattern is "individual client buys through their
// company" — those invoices ship with billingEntityType='company' and the
// portal user (client) is just a director of that company. Filtering on
// billingEmail alone hides these invoices. Resolve director memberships
// through company_memberships (role='director', active = endDate IS NULL)
// and OR them into the predicate.
const directorMemberships = await db
.select({ companyId: companyMemberships.companyId })
.from(companyMemberships)
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
.where(
and(
eq(companyMemberships.clientId, clientId),
eq(companyMemberships.role, 'director'),
isNull(companyMemberships.endDate),
eq(companies.portId, portId),
),
);
const directorCompanyIds = directorMemberships.map((m) => m.companyId);
// Fetch only the invoices matching any of the client's email addresses.
// Without the inArray push-down here every portal invoice page-load
// full-scanned the invoices table and filtered in JS — by 12mo it would
// have been the worst portal endpoint in the platform. Defensive limit
// 100 caps the upper bound for clients with abnormally many invoices.
// If the portal user has neither billing emails on file nor any active
// director memberships, there's nothing this query could return.
if (emailContacts.length === 0 && directorCompanyIds.length === 0) return [];
// Build the OR predicate: (billingEmail ∈ client emails) OR
// (billingEntityType='company' AND billingEntityId ∈ director company ids).
const emailPredicate =
emailContacts.length > 0
? inArray(sql`lower(${invoices.billingEmail})`, emailContacts)
: undefined;
const companyPredicate =
directorCompanyIds.length > 0
? and(
eq(invoices.billingEntityType, 'company'),
inArray(invoices.billingEntityId, directorCompanyIds),
)
: undefined;
const matchPredicate =
emailPredicate && companyPredicate
? or(emailPredicate, companyPredicate)
: (emailPredicate ?? companyPredicate);
// Fetch only the invoices matching any of the client's email addresses or
// company memberships. Without the predicate push-down here every portal
// invoice page-load full-scanned the invoices table and filtered in JS —
// by 12mo it would have been the worst portal endpoint in the platform.
// Defensive limit 100 caps the upper bound for clients with abnormally many
// invoices.
const clientInvoices = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.portId, portId),
inArray(sql`lower(${invoices.billingEmail})`, emailContacts),
),
)
.where(and(eq(invoices.portId, portId), matchPredicate))
.orderBy(invoices.createdAt)
.limit(100);