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
12 changes: 9 additions & 3 deletions apps/api/src/document-templates/document-templates.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export class DocumentTemplatesService implements OnApplicationBootstrap {
* Resolve a template for production rendering (Trip Orders, queue jobs).
*
* 3-tier resolution order (most specific first):
* 1. User-level override: slug + userId + status='published'
* 1. User-level override: slug + agencyId + userId + status='published'
* 2. Agency-level override: slug + agencyId + userId IS NULL + status='published'
* 3. System default: slug + agencyId IS NULL + userId IS NULL (any status)
*
Expand All @@ -314,7 +314,10 @@ export class DocumentTemplatesService implements OnApplicationBootstrap {
async resolvePublishedTemplate(slug: string, agencyId: string, userId?: string) {
const { documentTemplates } = this.db.schema

// Tier 1 — user-level published override
// Tier 1 — user-level published override. MUST be agency-scoped: a
// user template row always carries its agencyId (forkTemplate sets both),
// so requiring agencyId here fails CLOSED — a cross-agency or stale advisor
// template can never override another agency's legal (TICO §38) invoice.
if (userId) {
const [userRow] = await this.db.client
.select()
Expand All @@ -323,6 +326,7 @@ export class DocumentTemplatesService implements OnApplicationBootstrap {
and(
eq(documentTemplates.slug, slug),
eq(documentTemplates.isActive, true),
eq(documentTemplates.agencyId, agencyId),
eq(documentTemplates.userId, userId),
eq(documentTemplates.status, 'published'),
),
Expand Down Expand Up @@ -408,7 +412,9 @@ export class DocumentTemplatesService implements OnApplicationBootstrap {
contextParams: ContextParams,
customVariables?: Record<string, unknown>,
): Promise<{ subject: string; html: string; text: string } | null> {
const override = await this.resolvePublishedTemplate(slug, contextParams.agencyId)
// Pass the acting advisor (agentId) so a per-advisor published override wins
// the 3-tier cascade (user → agency → system). Absent → agency→system (unchanged).
const override = await this.resolvePublishedTemplate(slug, contextParams.agencyId, contextParams.agentId)
const tpl =
override?.emailHtml && override.status === 'published'
? override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { DocumentTemplatesService } from './document-templates.service'
import { DatabaseService } from '../db/database.service'

/**
* Advisor self-branding P1 — the dormant per-advisor template cascade.
*
* resolvePublishedTemplate('trip-order', agencyId, userId) already implemented the
* 3-tier cascade (user → agency → system); P1 activates it by passing the acting
* advisor's userId from the invoice render path. These tests prove the tier
* precedence, so a per-advisor published override applies when (and only when)
* the userId is supplied.
*/
describe('DocumentTemplatesService.resolvePublishedTemplate — 3-tier cascade (P1)', () => {
let service: DocumentTemplatesService
let limit: jest.Mock

const makeService = () => {
limit = jest.fn().mockResolvedValue([])
const client = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit,
}
const db = {
client,
schema: {
documentTemplates: {
slug: 'slug',
isActive: 'is_active',
userId: 'user_id',
agencyId: 'agency_id',
status: 'status',
},
},
} as unknown as DatabaseService
return new DocumentTemplatesService(db, {} as never, {} as never)
}

beforeEach(() => {
service = makeService()
})

it('userId supplied + a published USER override exists → returns the user-tier row (Tier 1)', async () => {
const userRow = { id: 'u-tpl', slug: 'trip-order', userId: 'advisor-1' }
limit.mockResolvedValueOnce([userRow]) // Tier 1 hit

const result = await service.resolvePublishedTemplate('trip-order', 'agency-1', 'advisor-1')

expect(result).toBe(userRow)
expect(limit).toHaveBeenCalledTimes(1) // short-circuits — never queries agency/system
})

it('userId supplied but NO user override → falls through to the agency tier (Tier 2)', async () => {
const agencyRow = { id: 'a-tpl', slug: 'trip-order', agencyId: 'agency-1' }
limit
.mockResolvedValueOnce([]) // Tier 1 miss
.mockResolvedValueOnce([agencyRow]) // Tier 2 hit

const result = await service.resolvePublishedTemplate('trip-order', 'agency-1', 'advisor-1')

expect(result).toBe(agencyRow)
expect(limit).toHaveBeenCalledTimes(2)
})

it('NO userId → skips the user tier entirely, resolves the agency tier', async () => {
const agencyRow = { id: 'a-tpl', slug: 'trip-order', agencyId: 'agency-1' }
limit.mockResolvedValueOnce([agencyRow]) // first query is the agency tier

const result = await service.resolvePublishedTemplate('trip-order', 'agency-1')

expect(result).toBe(agencyRow)
expect(limit).toHaveBeenCalledTimes(1) // user tier not queried
})

it('FAILS CLOSED: a user template for a DIFFERENT agency does NOT override (agency-scoped Tier 1)', async () => {
// Tier 1 is now scoped by agencyId, so a cross-agency (or stale) advisor
// template is excluded by the DB filter → Tier 1 returns nothing and the
// current agency's template is used. Without the agency scope, a foreign
// advisor template could hijack another agency's legal (TICO §38) invoice.
const agencyRow = { id: 'a-tpl', slug: 'trip-order', agencyId: 'agency-1' }
limit
.mockResolvedValueOnce([]) // Tier 1 — foreign-agency user row excluded by the agencyId filter
.mockResolvedValueOnce([agencyRow]) // Tier 2 — current agency template

const result = await service.resolvePublishedTemplate('trip-order', 'agency-1', 'advisor-from-other-agency')

expect(result).toBe(agencyRow)
expect(result).not.toMatchObject({ agencyId: 'other-agency' })
})

it('the Tier 1 user query is agency-scoped (includes agencyId in its WHERE)', async () => {
// Guard the fix structurally: Tier 1 must filter on documentTemplates.agencyId.
// We capture the drizzle conditions handed to `where` and assert the agency
// column participates (its equality is present in the Tier 1 predicate set).
const whereSpy = jest.fn().mockReturnThis()
;(service as any).db.client.where = whereSpy
;(service as any).db.client.limit = jest.fn().mockResolvedValue([])

await service.resolvePublishedTemplate('trip-order', 'agency-1', 'advisor-1')

// First `where` call is Tier 1. Its condition tree must reference the agency column.
const tier1Condition = JSON.stringify(whereSpy.mock.calls[0]?.[0] ?? {})
expect(tier1Condition).toContain('agency_id')
})

it('userId supplied, no user/agency override → falls back to the system default (Tier 3)', async () => {
const systemRow = { id: 's-tpl', slug: 'trip-order', agencyId: null, userId: null }
limit
.mockResolvedValueOnce([]) // Tier 1 miss
.mockResolvedValueOnce([]) // Tier 2 miss
.mockResolvedValueOnce([systemRow]) // Tier 3 hit

const result = await service.resolvePublishedTemplate('trip-order', 'agency-1', 'advisor-1')

expect(result).toBe(systemRow)
expect(limit).toHaveBeenCalledTimes(3)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const GROUP_TRIP_ORDER_PDF_HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body>
<div class="header">
<div class="logo">{{#if agency.logo}}<img src="{{agency.logo}}" alt="Logo" style="max-height:60px">{{/if}}</div>
<div class="logo">{{#if brand.logoUrl}}<img src="{{brand.logoUrl}}" alt="Logo" style="max-height:60px">{{/if}}</div>
<div style="text-align:right">
<div class="title">Group Trip Order</div>
<div class="meta">{{generated_date}}</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { HandlebarsRendererService } from '../handlebars-renderer.service'
import { TRIP_ORDER_SYSTEM_TEMPLATE } from './trip-order.template'
import { GROUP_TRIP_ORDER_SYSTEM_TEMPLATE } from './group-trip-order.template'

/**
* Advisor self-branding P1 — end-to-end render proof.
*
* The LETTERHEAD renders the advisor brand (headline + logo); the FOOTER and
* TICO §38 fine-print ALWAYS render the agency registrant (Phoenix + TICO#),
* regardless of tier. An unbranded advisor's invoice is identical to today.
*/
describe('trip-order template — advisor branding render (P1)', () => {
const renderer = new HandlebarsRendererService()
renderer.onModuleInit()

const render = (context: Record<string, unknown>) =>
renderer.render(TRIP_ORDER_SYSTEM_TEMPLATE.pdfHtml, context)

const AGENCY = {
name: 'Phoenix Voyages',
logo: 'https://cdn.example.com/phoenix-logo.png',
address: '123 Main St, Toronto ON',
phone: '+1-416-555-0100',
email: 'hello@phoenixvoyages.ca',
}

// Mirrors what TripOrderService.buildTemplateContext produces from a resolved brand.
const contextFor = (brand: {
headline: string
logoUrl: string | null
brandColor?: string | null
tier: 'none' | 'personal' | 'dba'
}) => ({
trip: { name: 'Ocean Allure', totalCost: 3500, currency: 'CAD', startDate: '2026-07-01' },
contact: { full_name: 'Jane Traveller' },
agency: AGENCY, // registrant — footer/disclosures read this
brand, // letterhead reads this
businessConfig: {
company_name: AGENCY.name,
tico_registration: '50028032',
primary_color: '#c59746',
},
payment: { amountPaid: 0, balanceDue: 3500 },
passengers: [],
bookings: [],
activities_subtotal: 3500,
insurance_subtotal: 0,
service_fees_subtotal: 0,
})

const letterheadH1 = (html: string) =>
/<h1 class="company-name">([^<]*)<\/h1>/.exec(html)?.[1]?.trim()

it('(a) UNBRANDED (tier none) → letterhead = agency name; footer = Phoenix + TICO (identical to today)', () => {
const html = render(contextFor({ headline: 'Phoenix Voyages', logoUrl: AGENCY.logo, tier: 'none' }))

// Letterhead is the agency name — no advisor brand string.
expect(letterheadH1(html)).toBe('Phoenix Voyages')
expect(html).not.toMatch(/ by Phoenix Voyages/)
// Letterhead logo is the agency logo.
expect(html).toContain(`src="${AGENCY.logo}"`)
// Footer registrant + TICO.
expect(html).toContain('<strong>Phoenix Voyages</strong>')
expect(html).toContain('TICO: 50028032')
})

it('(b) PERSONAL brand → letterhead "X by Phoenix Voyages" + advisor logo; footer STILL Phoenix + TICO', () => {
const html = render(
contextFor({
headline: 'Suzie Travels by Phoenix Voyages',
logoUrl: 'https://cdn.example.com/suzie-logo.png',
tier: 'personal',
}),
)

// Letterhead shows the branded headline + advisor logo.
expect(letterheadH1(html)).toBe('Suzie Travels by Phoenix Voyages')
expect(html).toContain('src="https://cdn.example.com/suzie-logo.png"')
// Footer registrant is STILL the agency + TICO (non-overridable).
expect(html).toContain('<strong>Phoenix Voyages</strong>')
expect(html).toContain('TICO: 50028032')
})

it('(c) DBA brand (active+registered+verified) → letterhead = bare DBA + advisor logo; footer STILL Phoenix + TICO#50028032', () => {
const html = render(
contextFor({
headline: 'Lola Luxury Retreats',
logoUrl: 'https://cdn.example.com/lola-logo.png',
tier: 'dba',
}),
)

// Letterhead is the bare DBA name — NO agency suffix.
expect(letterheadH1(html)).toBe('Lola Luxury Retreats')
expect(html).not.toContain('Lola Luxury Retreats by Phoenix Voyages')
expect(html).toContain('src="https://cdn.example.com/lola-logo.png"')
// Footer registrant STILL the agency (Phoenix carries the legal footer).
expect(html).toContain('<strong>Phoenix Voyages</strong>')
expect(html).toContain('TICO: 50028032')
})

it('registrant footer is the agency across ALL tiers (non-overridable)', () => {
for (const tier of ['none', 'personal', 'dba'] as const) {
const html = render(
contextFor({ headline: tier === 'none' ? 'Phoenix Voyages' : 'Branded Co', logoUrl: null, tier }),
)
expect(html).toContain('<strong>Phoenix Voyages</strong>')
expect(html).toContain('50028032')
}
})
})

describe('group-trip-order template — advisor branding render (P1)', () => {
const renderer = new HandlebarsRendererService()
renderer.onModuleInit()

const render = (context: Record<string, unknown>) =>
renderer.render(GROUP_TRIP_ORDER_SYSTEM_TEMPLATE.pdfHtml, context)

const groupContext = (brand: { logoUrl: string | null }) => ({
group_name: 'Costa Rica Retreat',
currency: 'CAD',
group_line_items: [],
group_grand_total_formatted: '0.00',
group_trip_count: 3,
business: { company_name: 'Phoenix Voyages', tico_registration: '50028032' },
agency: { name: 'Phoenix Voyages', logo: 'https://cdn.example.com/phoenix-logo.png' },
brand,
generated_date: '2026-07-05',
})

it('branded group → letterhead logo = advisor; footer compliance = Phoenix + TICO#50028032', () => {
const html = render(groupContext({ logoUrl: 'https://cdn.example.com/lola-logo.png' }))
expect(html).toContain('src="https://cdn.example.com/lola-logo.png"')
expect(html).toContain('Phoenix Voyages')
expect(html).toContain('TICO Registration: 50028032')
})

it('unbranded group → letterhead logo = agency (falls back); footer = Phoenix + TICO', () => {
const html = render(groupContext({ logoUrl: 'https://cdn.example.com/phoenix-logo.png' }))
expect(html).toContain('src="https://cdn.example.com/phoenix-logo.png"')
expect(html).toContain('TICO Registration: 50028032')
})
})

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ describe('Group Bookings Phase 2d — finalize emits invoiced (#1282 absorbed)',
const eventEmitter = { emit } as any

// Constructor order: db, financialSummaryService, exchangeRatesService,
// emailService, templatesService, handlebars, puppeteerPdf, eventEmitter.
// emailService, templatesService, brandingService, handlebars, puppeteerPdf,
// eventEmitter.
const brandingStub = { resolveBrand: async () => ({ headline: 'Phoenix Voyages', logoUrl: null, brandColor: null, tier: 'none' }) } as any
service = new TripOrderService(
dbService,
{} as any,
{} as any,
{} as any,
{} as any,
brandingStub,
{} as any,
{} as any,
eventEmitter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,16 @@ describe('Trip Order render — cross-billed group core (Integration)', () => {
throw new Error('live convertCurrency must not be called for cross-billed-in payments')
},
} as any
// P1 — getBusinessConfiguration resolves an (agency-only) brand; stub it.
const brandingStub = { resolveBrand: async () => ({ headline: 'Phoenix Voyages', logoUrl: null, brandColor: null, tier: 'none' }) } as any
service = new TripOrderService(
dbService,
{} as any, // financialSummaryService (not used by these 3 methods)
exchangeRates,
{} as any, {} as any, {} as any, {} as any, {} as any,
{} as any, // emailService
{} as any, // templatesService
brandingStub, // brandingService
{} as any, {} as any, {} as any, // handlebars, puppeteerPdf, eventEmitter
)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ describe('Group checklist data (Integration, GM-6)', () => {
const dbService = { db, client: db, schema } as any
const stub = {} as any
// buildGroupChecklistData touches this.db + this.financialSummaryService (2nd ctor arg).
service = new TripOrderService(dbService, financialStub as any, stub, stub, stub, stub, stub, stub)
// P1 — getBusinessConfiguration resolves an (agency-only) brand; stub it.
const brandingStub = { resolveBrand: async () => ({ headline: 'Phoenix Voyages', logoUrl: null, brandColor: null, tier: 'none' }) } as any
service = new TripOrderService(dbService, financialStub as any, stub, stub, stub, brandingStub, stub, stub, stub)
})

afterAll(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ describe('Group manifest data (Integration, Phase 3b)', () => {
const dbService = { db, client: db, schema } as any
const stub = {} as any
// buildGroupManifestData only touches this.db; the rest can be stubs.
service = new TripOrderService(dbService, stub, stub, stub, stub, stub, stub, stub)
// P1 — getBusinessConfiguration resolves an (agency-only) brand; stub it.
const brandingStub = { resolveBrand: async () => ({ headline: 'Phoenix Voyages', logoUrl: null, brandColor: null, tier: 'none' }) } as any
service = new TripOrderService(dbService, stub, stub, stub, stub, brandingStub, stub, stub, stub)
})

afterAll(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ describe('Group rooming-list data (Integration, Phase 4c)', () => {
const dbService = { db, client: db, schema } as any
const stub = {} as any
// buildGroupRoomingListData only touches this.db; the rest can be stubs.
service = new TripOrderService(dbService, stub, stub, stub, stub, stub, stub, stub)
// P1 — getBusinessConfiguration resolves an (agency-only) brand; stub it.
const brandingStub = { resolveBrand: async () => ({ headline: 'Phoenix Voyages', logoUrl: null, brandColor: null, tier: 'none' }) } as any
service = new TripOrderService(dbService, stub, stub, stub, stub, brandingStub, stub, stub, stub)
})

afterAll(async () => {
Expand Down
Loading
Loading