From a229a8935bf70ff5b0bd577a991b5c227d0ba23a Mon Sep 17 00:00:00 2001 From: Al Date: Sun, 5 Jul 2026 16:38:00 -0400 Subject: [PATCH 1/2] feat(advisor-branding P1): wire trip-order invoices to resolveBrand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advisor self-branding P1 — the invoice LETTERHEAD renders the acting advisor's brand (headline + logo); the FOOTER / TICO §38 fine-print ALWAYS renders the agency registrant (Phoenix + TICO#), non-overridable, regardless of tier. Ships LIVE but inert until an advisor has an active brand row (default tier='none' → agency headline = today's output). No migration; money boundary untouched. Wiring - getBusinessConfiguration(agencyId, advisorId?) resolves the brand via resolveBrand and bakes brand_headline/brand_logo_url/brand_color/brand_tier + advisor_user_id into BusinessConfiguration (frozen into the snapshot at finalize, so a reissue re-resolves the same brand + per-advisor template). Registrant fields (company_name/tico/address/phone/email) are LEFT UNTOUCHED. - Both context builders expose a `brand` key (letterhead) alongside the unchanged `agency` key (registrant); templates read brand.headline/logoUrl in the letterhead only. embedLogoAsBase64 now embeds the brand logo. - Live PDF + email + group-trip-order pass the advisor (trip.ownerId / bookingTrip.ownerId); manifest/rooming/checklist stay agency-only. Per-advisor template cascade (dormant → activated) - renderTripOrderPdf / renderTripOrderEmail / renderGroupPdf and DocumentTemplatesService.renderEmail now pass the acting advisor as the resolvePublishedTemplate userId, so a per-advisor published override wins the 3-tier cascade (user → agency → system). §38 validator - validateTICOCompliance now asserts the registrant (agency) legal name is present REGARDLESS of any brand headline (a DBA/personal invoice must still carry the agency legal name + TICO#). Existing checks unchanged. Tests (foreground) - trip-order.branding.render.spec.ts: unbranded → identical to today; personal → "X by Phoenix Voyages" + advisor logo, footer still Phoenix+TICO; DBA → bare DBA name + advisor logo, footer still Phoenix+TICO#50028032; registrant pinned across all tiers; group template branded/unbranded. - trip-order-tico-compliance.spec.ts: §38(6) registrant present for unbranded/personal/dba; flags a missing registrant name. - resolve-published-template.cascade.spec.ts: 3-tier precedence with userId. - Updated 6 group specs for the new brandingService constructor arg. Co-Authored-By: Claude Opus 4.8 --- .../document-templates.service.ts | 4 +- ...resolve-published-template.cascade.spec.ts | 88 +++++++++++ .../group-trip-order.template.ts | 2 +- .../trip-order.branding.render.spec.ts | 144 ++++++++++++++++++ .../system-templates/trip-order.template.ts | 2 +- .../group-booking-finalize-invoiced.spec.ts | 5 +- ...oup-booking-trip-order.integration.spec.ts | 7 +- .../group-checklist.integration.spec.ts | 4 +- .../group-manifest.integration.spec.ts | 4 +- .../group-rooming-list.integration.spec.ts | 4 +- .../trip-order-tico-compliance.spec.ts | 48 ++++++ apps/api/src/financials/financials.module.ts | 3 +- apps/api/src/financials/pdf/types.ts | 11 ++ apps/api/src/financials/tico-compliance.ts | 14 ++ apps/api/src/financials/trip-order.service.ts | 110 +++++++++++-- ...group-dual-trip-orders.integration.spec.ts | 4 +- 16 files changed, 430 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/document-templates/resolve-published-template.cascade.spec.ts create mode 100644 apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts diff --git a/apps/api/src/document-templates/document-templates.service.ts b/apps/api/src/document-templates/document-templates.service.ts index ca50c7f12..4147b0a94 100644 --- a/apps/api/src/document-templates/document-templates.service.ts +++ b/apps/api/src/document-templates/document-templates.service.ts @@ -408,7 +408,9 @@ export class DocumentTemplatesService implements OnApplicationBootstrap { contextParams: ContextParams, customVariables?: Record, ): 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 diff --git a/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts b/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts new file mode 100644 index 000000000..c74c35681 --- /dev/null +++ b/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts @@ -0,0 +1,88 @@ +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('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) + }) +}) diff --git a/apps/api/src/document-templates/system-templates/group-trip-order.template.ts b/apps/api/src/document-templates/system-templates/group-trip-order.template.ts index 470084406..58a769ecf 100644 --- a/apps/api/src/document-templates/system-templates/group-trip-order.template.ts +++ b/apps/api/src/document-templates/system-templates/group-trip-order.template.ts @@ -16,7 +16,7 @@ const GROUP_TRIP_ORDER_PDF_HTML = `
- +
Group Trip Order
{{generated_date}}
diff --git a/apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts b/apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts new file mode 100644 index 000000000..7903e5c3d --- /dev/null +++ b/apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts @@ -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) => + 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>/.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('Phoenix Voyages') + 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('Phoenix Voyages') + 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('Phoenix Voyages') + 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('Phoenix Voyages') + expect(html).toContain('50028032') + } + }) +}) + +describe('group-trip-order template — advisor branding render (P1)', () => { + const renderer = new HandlebarsRendererService() + renderer.onModuleInit() + + const render = (context: Record) => + 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') + }) +}) diff --git a/apps/api/src/document-templates/system-templates/trip-order.template.ts b/apps/api/src/document-templates/system-templates/trip-order.template.ts index ee723fae9..be1f0e33f 100644 --- a/apps/api/src/document-templates/system-templates/trip-order.template.ts +++ b/apps/api/src/document-templates/system-templates/trip-order.template.ts @@ -163,6 +163,6 @@ interruption, delay, or medical emergencies. {{fallback agency.name "Phoenix Voyages"}} | {{agency.phone}} | {{agency.email}} {{#if businessConfig.tico_registration}}TICO #{{businessConfig.tico_registration}}{{/if}} Please retain this document for your records.`, - pdfHtml: "\n\n\n \n \n \n\n\n
\n\n \n
\n
\n
\n {{#if agency.logo}}\"{{agency.name}}\"{{/if}}\n
\n

{{fallback agency.name \"Phoenix Voyages\"}}

\n

{{fallback businessConfig.company_tagline \"Discover, Soar, Repeat\"}}

\n {{#if agency.address}}

{{agency.address}}

{{/if}}\n {{#if agency.phone}}

{{agency.phone}}

{{/if}}\n {{#if agency.email}}

{{agency.email}}

{{/if}}\n
\n
\n
\n

TRIP ORDER

\n
\n Order #: {{fallback trip.referenceNumber trip.reference}}\n
\n

Order Date: {{formatDate trip.startDate \"MMM d, yyyy\"}}

\n
\n
\n
\n\n \n
\n
\n
CUSTOMER INFORMATION
\n

{{contact.full_name}}

\n {{#if contact.email}}

{{contact.email}}

{{/if}}\n {{#if contact.phone}}

{{contact.phone}}

{{/if}}\n {{#if contact.addressLine1}}

{{contact.addressLine1}}

{{/if}}\n {{#if contact.city}}

{{contact.city}}{{#if contact.province}}, {{contact.province}}{{/if}} {{contact.postalCode}}

{{/if}}\n {{#if contact.country}}

{{contact.country}}

{{/if}}\n
\n
\n
SERVICE DETAILS
\n

{{trip.name}}

\n

Departure: {{formatDate trip.startDate \"MMM d, yyyy\"}}

\n {{#if trip.endDate}}

Return: {{formatDate trip.endDate \"MMM d, yyyy\"}}

{{/if}}\n {{#if trip.destination}}

Destination: {{trip.destination}}

{{/if}}\n
\n
\n
YOUR CONSULTANT
\n {{#if agent}}\n

{{agent.full_name}}

\n {{#if agent.email}}

{{agent.email}}

{{/if}}\n {{#if agent.phone}}

{{agent.phone}}

{{/if}}\n {{else}}\n

{{fallback agency.name \"Phoenix Voyages\"}}

\n {{/if}}\n
\n
\n\n \n
\n

FINANCIAL SUMMARY

\n
\n
\n Trip Order Amount\n {{formatCurrency trip.totalCost trip.currency}}\n
\n
\n Payments Received\n {{formatCurrency payment.amountPaid trip.currency}}\n
\n {{#if payment.payOnSite}}
\n Due at Check-In (paid to supplier)\n {{formatCurrency payment.payOnSite trip.currency}}\n
{{/if}}\n {{#if payment.chargeThrough}}
\n Program Cost (collected & remitted)\n {{formatCurrency payment.chargeThrough trip.currency}}\n
{{/if}}\n
\n {{#if payment.payOnSite}}Balance Due to Agency{{else}}Balance Due{{/if}}\n {{formatCurrency payment.balanceDue trip.currency}}\n
\n
\n {{#if payment.payOnSite}}

† Total Cost includes {{formatCurrency payment.payOnSite trip.currency}} payable by the traveller directly to the supplier at check-in. This amount is not collected by the agency and is excluded from the Balance Due to Agency.

{{/if}}\n
\n\n\n \n {{#if payment.paymentsList}}\n

Payments Received

\n \n \n \n \n \n \n \n \n \n \n {{#each payment.paymentsList}}\n \n \n \n \n \n \n {{/each}}\n \n \n \n \n \n \n \n
DateMethodStatusAmount
{{formatDate this.date \"MMM d, yyyy\"}}{{this.payment_method_type}}{{this.status}}{{formatCurrency this.amount @root.trip.currency}}
Total Paid{{formatCurrency payment.amountPaid trip.currency}}
\n {{/if}}\n\n \n {{#if bookings}}\n

Cost Breakdown

\n \n \n \n \n \n \n \n \n \n {{#each bookings}}\n \n \n \n \n \n {{/each}}\n \n \n {{#if taxes_total}}\n \n \n \n \n \n {{/if}}\n \n \n \n \n \n
ServiceTaxes & FeesAmount
\n {{this.title}}{{#if this.flight_segments}}{{#each this.flight_segments}}
{{this}}{{/each}}{{/if}}\n {{#if this.vendor_confirmation}}
Ref: {{this.vendor_confirmation}}{{/if}}\n
{{#if this.paid_by_group}}—{{else}}{{#if this._isIncluded}}—{{else}}{{#if this.taxes_and_fees}}{{formatCurrency this.taxes_and_fees this.currency}}{{else}}—{{/if}}{{/if}}{{/if}}{{#if this.paid_by_group}}Paid by group{{else}}{{formatCurrency this.amount this.currency}}{{/if}}
Total Taxes & Fees (included){{formatCurrency taxes_total trip.currency}}
Activities Subtotal{{formatCurrency activities_subtotal trip.currency}}
\n {{/if}}\n\n \n {{#if insurance_packages}}\n

Travel Insurance

\n \n \n \n {{#each insurance_packages}}\n \n {{/each}}\n \n \n
InsuranceTravellersAmount
{{this.provider}} — {{this.name}}{{#if this.policy_label}}
Coverage: {{this.policy_label}}{{/if}}{{#if this.has_coverage_amount}}
Coverage amount: {{formatCurrency this.coverage_amount this.coverage_currency}}{{/if}}{{#if this.has_deductible}}
Deductible: {{formatCurrency this.deductible this.coverage_currency}}{{/if}}{{#if this.coverage_start_date}}
Valid: {{formatDate this.coverage_start_date \"MMM d, yyyy\"}}{{#if this.coverage_end_date}} – {{formatDate this.coverage_end_date \"MMM d, yyyy\"}}{{/if}}{{/if}}{{#if this.coverage_summary}}
{{this.coverage_summary}}{{/if}}{{#if this.terms_url}}
View full policy terms & conditions{{/if}}{{#if this.cancellation_policy}}
Cancellation: {{this.cancellation_policy}}{{/if}}{{#if this.terms_and_conditions}}
{{this.terms_and_conditions}}{{/if}}
{{this.traveler_count}}{{formatCurrency this.amount this.currency}}
Insurance Subtotal{{formatCurrency insurance_subtotal trip.currency}}
\n {{/if}}\n\n \n {{#if service_fees}}\n

Service Fees

\n \n \n \n {{#each service_fees}}\n \n {{/each}}\n \n \n
Service FeeAmount
{{this.description}}{{formatCurrency this.amount this.currency}}
Service Fees Subtotal{{formatCurrency service_fees_subtotal trip.currency}}
\n {{/if}}\n\n \n \n \n
TOTAL COST{{formatCurrency trip.totalCost trip.currency}}
\n\n \n {{#if charge_through_items}}\n

Pass-Through (Collected & Remitted)

\n \n \n \n {{#each charge_through_items}}\n \n {{/each}}\n \n \n
Program CostAmount
{{this.title}}{{#if this.supplier}}
Remitted to: {{this.supplier}}{{/if}}
{{formatCurrency this.amount this.currency}}
Pass-Through Subtotal{{formatCurrency charge_through_subtotal trip.currency}}
\n

These program funds are collected by the agency and remitted onward to the program provider. They are NOT part of your Trip Order balance — you owe nothing additional.

\n {{/if}}\n\n \n {{#if passengers}}\n

Passengers

\n
\n {{#each passengers}}\n
\n

{{index_plus_one @index}}. {{this.full_name}}

\n {{#if this.type}}

{{this.type}}

{{/if}}\n {{#if this.dateOfBirth}}

DOB: {{formatDate this.dateOfBirth \"MMM d, yyyy\"}}

{{/if}}\n
\n {{/each}}\n
\n {{/if}}\n\n \n {{#if bookings}}\n

Booking Details

\n {{#each bookings}}\n
\n
\n
\n

{{this.title}}

\n

{{this.booking_type}}

{{#if this.flight_segments}}{{#each this.flight_segments}}

{{this}}

{{/each}}{{/if}}\n
\n {{#if this.vendor_confirmation}}\n
\n

Confirmation #

\n

{{this.vendor_confirmation}}

\n
\n {{/if}}\n
\n {{#if this.start_date}}\n

\n Start: {{formatDate this.start_date \"MMM d, yyyy\"}}{{#if this.start_time}} at {{this.start_time}}{{/if}}\n {{#if this.end_date}} • End: {{formatDate this.end_date \"MMM d, yyyy\"}}{{#if this.end_time}} at {{this.end_time}}{{/if}}{{/if}}\n

\n {{/if}}\n {{#if this.supplier}}

Supplier: {{this.supplier}}

{{/if}}\n {{#if this.pay_on_site}}

Payable at check-in — paid directly to the supplier (not collected by the agency).

{{/if}}\n {{#if this.paid_by_group}}

Included in the group booking — paid by the group (no charge to you).

{{/if}}\n {{#if this.terms_and_conditions}}\n
\n

Terms & Conditions

\n

{{this.terms_and_conditions}}

\n
\n {{/if}}\n {{#if this.cancellation_policy}}\n
\n

Cancellation Policy

\n

{{this.cancellation_policy}}

\n
\n {{/if}}\n
\n {{/each}}\n {{/if}}\n\n \n

Important Disclosures

\n
\n

This trip order is subject to the terms and conditions of {{fallback agency.name \"Phoenix Voyages\"}}. All prices are in {{fallback trip.currency \"CAD\"}} unless otherwise stated. Changes or cancellations may be subject to fees as outlined in your booking agreement.

\n

It is strongly recommended that all travellers purchase comprehensive travel insurance prior to departure. {{fallback agency.name \"Phoenix Voyages\"}} is not liable for losses due to trip cancellation, interruption, delay, or medical emergencies.

\n
\n\n \n

Documentation Requirements

\n
\n

Passport Requirements: All travellers must have a valid passport with at least 6 months validity beyond the return date. Please verify entry requirements for all destinations on your itinerary.

\n

Travel Insurance: Comprehensive travel insurance including medical, trip cancellation, and baggage coverage is strongly recommended for all travellers.

\n

Reservations or bookings for services which are not directly supplied by this agency (such as cruises, air carriage, hotel accommodations, ground transportation, meals, tours, etc.). This agency, therefore, shall not be responsible for breach of contract or any intentional or careless actions or omissions on the part of such suppliers, which result in any loss, damage, delay, or injury to you or your travel companions or group members. Unless the term \"guaranteed\" is specifically stated in writing on your ticket, invoice or reservation itinerary, we do not guarantee any such supplier's rate, bookings or reservation. Agents shall not be responsible for any injuries, damages, or losses caused to any traveler in connection with terrorist activities, social or labor unrest, mechanical or construction difficulties, diseases, local laws, climate conditions, abnormal conditions or developments, or any other actions, omissions or conditions outside the agent's control. By embarking upon his / her travel, the traveler(s) voluntarily assume(s) all risks with such travel, whether expected or unexpected. Traveler is hereby warned of such risks, and is advised to obtain appropriate insurance coverage against them. Your retention of tickets, reservations, or bookings after issuance shall constitute a consent to the above, and an agreement on your part to convey the contents hereto to your travel companions or group members. Note: Phoenix Voyages reserves the right to charge a cancellation fee of $100 per stateroom on all cruise travel or $100 for air or other travel arrangements. All bookings are subject to the applicable Terms and Conditions of the individual travel provider (air line, cruise line, hotel, etc.) including any applicable cancellation penalties.

\n

Price increases may occur beyond our control, but will be limited to 7% increase, other than PST and GST. If the increase is greater than 7% the customer has the right to cancel the order and receive a full refund, or accept the price increase at their choice. No price increases are permitted after the customer is paid in full.

\n
\n\n \n
\n
\n
\n {{#if businessConfig.tico_registration}}

TICO: {{businessConfig.tico_registration}}

{{/if}}\n {{#if businessConfig.hst_number}}

HST: {{businessConfig.hst_number}}

{{/if}}\n
\n
\n

{{fallback agency.name \"Phoenix Voyages\"}}

\n

{{agency.phone}} | {{agency.email}}

\n
\n
\n

{{formatDate \"now\" \"MMM d, yyyy\"}}

\n
\n
\n

Please retain this document for your records

\n
\n\n
\n\n", + pdfHtml: "\n\n\n \n \n \n\n\n
\n\n \n
\n
\n
\n {{#if brand.logoUrl}}\"{{fallback{{/if}}\n
\n

{{fallback brand.headline \"Phoenix Voyages\"}}

\n

{{fallback businessConfig.company_tagline \"Discover, Soar, Repeat\"}}

\n {{#if agency.address}}

{{agency.address}}

{{/if}}\n {{#if agency.phone}}

{{agency.phone}}

{{/if}}\n {{#if agency.email}}

{{agency.email}}

{{/if}}\n
\n
\n
\n

TRIP ORDER

\n
\n Order #: {{fallback trip.referenceNumber trip.reference}}\n
\n

Order Date: {{formatDate trip.startDate \"MMM d, yyyy\"}}

\n
\n
\n
\n\n \n
\n
\n
CUSTOMER INFORMATION
\n

{{contact.full_name}}

\n {{#if contact.email}}

{{contact.email}}

{{/if}}\n {{#if contact.phone}}

{{contact.phone}}

{{/if}}\n {{#if contact.addressLine1}}

{{contact.addressLine1}}

{{/if}}\n {{#if contact.city}}

{{contact.city}}{{#if contact.province}}, {{contact.province}}{{/if}} {{contact.postalCode}}

{{/if}}\n {{#if contact.country}}

{{contact.country}}

{{/if}}\n
\n
\n
SERVICE DETAILS
\n

{{trip.name}}

\n

Departure: {{formatDate trip.startDate \"MMM d, yyyy\"}}

\n {{#if trip.endDate}}

Return: {{formatDate trip.endDate \"MMM d, yyyy\"}}

{{/if}}\n {{#if trip.destination}}

Destination: {{trip.destination}}

{{/if}}\n
\n
\n
YOUR CONSULTANT
\n {{#if agent}}\n

{{agent.full_name}}

\n {{#if agent.email}}

{{agent.email}}

{{/if}}\n {{#if agent.phone}}

{{agent.phone}}

{{/if}}\n {{else}}\n

{{fallback agency.name \"Phoenix Voyages\"}}

\n {{/if}}\n
\n
\n\n \n
\n

FINANCIAL SUMMARY

\n
\n
\n Trip Order Amount\n {{formatCurrency trip.totalCost trip.currency}}\n
\n
\n Payments Received\n {{formatCurrency payment.amountPaid trip.currency}}\n
\n {{#if payment.payOnSite}}
\n Due at Check-In (paid to supplier)\n {{formatCurrency payment.payOnSite trip.currency}}\n
{{/if}}\n {{#if payment.chargeThrough}}
\n Program Cost (collected & remitted)\n {{formatCurrency payment.chargeThrough trip.currency}}\n
{{/if}}\n
\n {{#if payment.payOnSite}}Balance Due to Agency{{else}}Balance Due{{/if}}\n {{formatCurrency payment.balanceDue trip.currency}}\n
\n
\n {{#if payment.payOnSite}}

† Total Cost includes {{formatCurrency payment.payOnSite trip.currency}} payable by the traveller directly to the supplier at check-in. This amount is not collected by the agency and is excluded from the Balance Due to Agency.

{{/if}}\n
\n\n\n \n {{#if payment.paymentsList}}\n

Payments Received

\n \n \n \n \n \n \n \n \n \n \n {{#each payment.paymentsList}}\n \n \n \n \n \n \n {{/each}}\n \n \n \n \n \n \n \n
DateMethodStatusAmount
{{formatDate this.date \"MMM d, yyyy\"}}{{this.payment_method_type}}{{this.status}}{{formatCurrency this.amount @root.trip.currency}}
Total Paid{{formatCurrency payment.amountPaid trip.currency}}
\n {{/if}}\n\n \n {{#if bookings}}\n

Cost Breakdown

\n \n \n \n \n \n \n \n \n \n {{#each bookings}}\n \n \n \n \n \n {{/each}}\n \n \n {{#if taxes_total}}\n \n \n \n \n \n {{/if}}\n \n \n \n \n \n
ServiceTaxes & FeesAmount
\n {{this.title}}{{#if this.flight_segments}}{{#each this.flight_segments}}
{{this}}{{/each}}{{/if}}\n {{#if this.vendor_confirmation}}
Ref: {{this.vendor_confirmation}}{{/if}}\n
{{#if this.paid_by_group}}—{{else}}{{#if this._isIncluded}}—{{else}}{{#if this.taxes_and_fees}}{{formatCurrency this.taxes_and_fees this.currency}}{{else}}—{{/if}}{{/if}}{{/if}}{{#if this.paid_by_group}}Paid by group{{else}}{{formatCurrency this.amount this.currency}}{{/if}}
Total Taxes & Fees (included){{formatCurrency taxes_total trip.currency}}
Activities Subtotal{{formatCurrency activities_subtotal trip.currency}}
\n {{/if}}\n\n \n {{#if insurance_packages}}\n

Travel Insurance

\n \n \n \n {{#each insurance_packages}}\n \n {{/each}}\n \n \n
InsuranceTravellersAmount
{{this.provider}} — {{this.name}}{{#if this.policy_label}}
Coverage: {{this.policy_label}}{{/if}}{{#if this.has_coverage_amount}}
Coverage amount: {{formatCurrency this.coverage_amount this.coverage_currency}}{{/if}}{{#if this.has_deductible}}
Deductible: {{formatCurrency this.deductible this.coverage_currency}}{{/if}}{{#if this.coverage_start_date}}
Valid: {{formatDate this.coverage_start_date \"MMM d, yyyy\"}}{{#if this.coverage_end_date}} – {{formatDate this.coverage_end_date \"MMM d, yyyy\"}}{{/if}}{{/if}}{{#if this.coverage_summary}}
{{this.coverage_summary}}{{/if}}{{#if this.terms_url}}
View full policy terms & conditions{{/if}}{{#if this.cancellation_policy}}
Cancellation: {{this.cancellation_policy}}{{/if}}{{#if this.terms_and_conditions}}
{{this.terms_and_conditions}}{{/if}}
{{this.traveler_count}}{{formatCurrency this.amount this.currency}}
Insurance Subtotal{{formatCurrency insurance_subtotal trip.currency}}
\n {{/if}}\n\n \n {{#if service_fees}}\n

Service Fees

\n \n \n \n {{#each service_fees}}\n \n {{/each}}\n \n \n
Service FeeAmount
{{this.description}}{{formatCurrency this.amount this.currency}}
Service Fees Subtotal{{formatCurrency service_fees_subtotal trip.currency}}
\n {{/if}}\n\n \n \n \n
TOTAL COST{{formatCurrency trip.totalCost trip.currency}}
\n\n \n {{#if charge_through_items}}\n

Pass-Through (Collected & Remitted)

\n \n \n \n {{#each charge_through_items}}\n \n {{/each}}\n \n \n
Program CostAmount
{{this.title}}{{#if this.supplier}}
Remitted to: {{this.supplier}}{{/if}}
{{formatCurrency this.amount this.currency}}
Pass-Through Subtotal{{formatCurrency charge_through_subtotal trip.currency}}
\n

These program funds are collected by the agency and remitted onward to the program provider. They are NOT part of your Trip Order balance — you owe nothing additional.

\n {{/if}}\n\n \n {{#if passengers}}\n

Passengers

\n
\n {{#each passengers}}\n
\n

{{index_plus_one @index}}. {{this.full_name}}

\n {{#if this.type}}

{{this.type}}

{{/if}}\n {{#if this.dateOfBirth}}

DOB: {{formatDate this.dateOfBirth \"MMM d, yyyy\"}}

{{/if}}\n
\n {{/each}}\n
\n {{/if}}\n\n \n {{#if bookings}}\n

Booking Details

\n {{#each bookings}}\n
\n
\n
\n

{{this.title}}

\n

{{this.booking_type}}

{{#if this.flight_segments}}{{#each this.flight_segments}}

{{this}}

{{/each}}{{/if}}\n
\n {{#if this.vendor_confirmation}}\n
\n

Confirmation #

\n

{{this.vendor_confirmation}}

\n
\n {{/if}}\n
\n {{#if this.start_date}}\n

\n Start: {{formatDate this.start_date \"MMM d, yyyy\"}}{{#if this.start_time}} at {{this.start_time}}{{/if}}\n {{#if this.end_date}} • End: {{formatDate this.end_date \"MMM d, yyyy\"}}{{#if this.end_time}} at {{this.end_time}}{{/if}}{{/if}}\n

\n {{/if}}\n {{#if this.supplier}}

Supplier: {{this.supplier}}

{{/if}}\n {{#if this.pay_on_site}}

Payable at check-in — paid directly to the supplier (not collected by the agency).

{{/if}}\n {{#if this.paid_by_group}}

Included in the group booking — paid by the group (no charge to you).

{{/if}}\n {{#if this.terms_and_conditions}}\n
\n

Terms & Conditions

\n

{{this.terms_and_conditions}}

\n
\n {{/if}}\n {{#if this.cancellation_policy}}\n
\n

Cancellation Policy

\n

{{this.cancellation_policy}}

\n
\n {{/if}}\n
\n {{/each}}\n {{/if}}\n\n \n

Important Disclosures

\n
\n

This trip order is subject to the terms and conditions of {{fallback agency.name \"Phoenix Voyages\"}}. All prices are in {{fallback trip.currency \"CAD\"}} unless otherwise stated. Changes or cancellations may be subject to fees as outlined in your booking agreement.

\n

It is strongly recommended that all travellers purchase comprehensive travel insurance prior to departure. {{fallback agency.name \"Phoenix Voyages\"}} is not liable for losses due to trip cancellation, interruption, delay, or medical emergencies.

\n
\n\n \n

Documentation Requirements

\n
\n

Passport Requirements: All travellers must have a valid passport with at least 6 months validity beyond the return date. Please verify entry requirements for all destinations on your itinerary.

\n

Travel Insurance: Comprehensive travel insurance including medical, trip cancellation, and baggage coverage is strongly recommended for all travellers.

\n

Reservations or bookings for services which are not directly supplied by this agency (such as cruises, air carriage, hotel accommodations, ground transportation, meals, tours, etc.). This agency, therefore, shall not be responsible for breach of contract or any intentional or careless actions or omissions on the part of such suppliers, which result in any loss, damage, delay, or injury to you or your travel companions or group members. Unless the term \"guaranteed\" is specifically stated in writing on your ticket, invoice or reservation itinerary, we do not guarantee any such supplier's rate, bookings or reservation. Agents shall not be responsible for any injuries, damages, or losses caused to any traveler in connection with terrorist activities, social or labor unrest, mechanical or construction difficulties, diseases, local laws, climate conditions, abnormal conditions or developments, or any other actions, omissions or conditions outside the agent's control. By embarking upon his / her travel, the traveler(s) voluntarily assume(s) all risks with such travel, whether expected or unexpected. Traveler is hereby warned of such risks, and is advised to obtain appropriate insurance coverage against them. Your retention of tickets, reservations, or bookings after issuance shall constitute a consent to the above, and an agreement on your part to convey the contents hereto to your travel companions or group members. Note: Phoenix Voyages reserves the right to charge a cancellation fee of $100 per stateroom on all cruise travel or $100 for air or other travel arrangements. All bookings are subject to the applicable Terms and Conditions of the individual travel provider (air line, cruise line, hotel, etc.) including any applicable cancellation penalties.

\n

Price increases may occur beyond our control, but will be limited to 7% increase, other than PST and GST. If the increase is greater than 7% the customer has the right to cancel the order and receive a full refund, or accept the price increase at their choice. No price increases are permitted after the customer is paid in full.

\n
\n\n \n
\n
\n
\n {{#if businessConfig.tico_registration}}

TICO: {{businessConfig.tico_registration}}

{{/if}}\n {{#if businessConfig.hst_number}}

HST: {{businessConfig.hst_number}}

{{/if}}\n
\n
\n

{{fallback agency.name \"Phoenix Voyages\"}}

\n

{{agency.phone}} | {{agency.email}}

\n
\n
\n

{{formatDate \"now\" \"MMM d, yyyy\"}}

\n
\n
\n

Please retain this document for your records

\n
\n\n
\n\n", pdfCss: "/* Phoenix Voyages Trip Order \u2014 Alpha V2 Design */\n@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Lato:wght@400;700&display=swap');\n\nbody {\n font-family: 'Lato', 'Helvetica Neue', Arial, sans-serif;\n font-size: 10pt;\n line-height: 1.6;\n color: #111827;\n margin: 0;\n padding: 0;\n}\n.trip-order {\n max-width: 800px;\n margin: 0 auto;\n padding: 40px;\n background: #ffffff;\n}\n\n/* \u2500\u2500 Header \u2500\u2500 */\n.header {\n border-bottom: 2px solid #e5e7eb;\n padding-bottom: 20px;\n margin-bottom: 20px;\n}\n.header-row {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n}\n.logo-section {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n}\n.logo {\n width: 60px;\n height: 60px;\n object-fit: contain;\n}\n.company-info { margin-top: 4px; }\n.company-name {\n font-family: 'Cinzel', serif;\n font-weight: 700;\n font-size: 20pt;\n color: #c59746;\n margin: 0 0 2px 0;\n text-transform: uppercase;\n}\n.tagline {\n font-family: 'Cinzel', serif;\n font-size: 11pt;\n color: #e89e4a;\n margin: 0 0 6px 0;\n}\n.company-address {\n font-size: 9pt;\n color: #374151;\n line-height: 1.3;\n margin: 0 0 2px 0;\n}\n.order-box { text-align: right; }\n.order-title {\n font-family: 'Cinzel', serif;\n font-weight: 700;\n font-size: 16pt;\n color: #1a1a1a;\n margin: 0 0 4px 0;\n}\n.order-number-box {\n background: #f3f4f6;\n border-radius: 6px;\n padding: 8px 12px;\n margin: 6px 0;\n display: inline-block;\n}\n.order-number {\n font-size: 14pt;\n font-weight: 700;\n color: #c59746;\n}\n.order-date {\n font-size: 9pt;\n color: #4b5563;\n margin: 6px 0 0 0;\n}\n\n/* \u2500\u2500 Info Grid \u2500\u2500 */\n.info-grid {\n display: flex;\n gap: 12px;\n margin-bottom: 20px;\n}\n.info-box {\n flex: 1;\n background: #f9fafb;\n padding: 10px;\n border-radius: 6px;\n}\n.info-label {\n font-weight: 700;\n font-size: 9pt;\n color: #111827;\n margin: 0 0 6px 0;\n text-transform: uppercase;\n}\n.info-value {\n font-size: 10pt;\n font-weight: 500;\n color: #111827;\n margin: 0 0 2px 0;\n}\n.info-value-small {\n font-size: 9pt;\n color: #4b5563;\n margin: 0 0 1px 0;\n}\n\n/* \u2500\u2500 Financial Summary \u2500\u2500 */\n.financial-summary {\n background: #f9fafb;\n border: 2px solid #d1d5db;\n border-radius: 8px;\n padding: 20px;\n margin-bottom: 20px;\n}\n.financial-title {\n font-family: 'Cinzel', serif;\n font-weight: 700;\n font-size: 14pt;\n color: #111827;\n text-align: center;\n margin: 0 0 12px 0;\n text-transform: uppercase;\n}\n.financial-grid {\n display: flex;\n justify-content: space-around;\n}\n.financial-item {\n flex: 1;\n text-align: center;\n padding: 0 8px;\n}\n.financial-item.bordered {\n border-left: 1px solid #d1d5db;\n border-right: 1px solid #d1d5db;\n}\n.financial-label {\n display: block;\n font-size: 9pt;\n color: #4b5563;\n font-weight: 500;\n margin-bottom: 4px;\n}\n.financial-amount {\n display: block;\n font-size: 18pt;\n font-weight: 700;\n}\n.financial-amount.gold { color: #c59746; }\n.financial-amount.green { color: #22c55e; }\n.financial-amount.red { color: #ef4444; }\n\n/* \u2500\u2500 Section Headers \u2500\u2500 */\n.section-title {\n font-family: 'Cinzel', serif;\n font-weight: 700;\n font-size: 13pt;\n color: #111827;\n border-bottom: 1px solid #e5e7eb;\n padding-bottom: 6px;\n margin: 16px 0 12px 0;\n}\n\n/* \u2500\u2500 Data Tables \u2500\u2500 */\n.data-table {\n width: 100%;\n border-collapse: collapse;\n margin-bottom: 16px;\n}\n.data-table thead tr {\n background: #f9fafb;\n border-bottom: 1px solid #e5e7eb;\n}\n.data-table th {\n font-weight: 700;\n font-size: 9pt;\n color: #374151;\n padding: 6px 8px;\n}\n.data-table td {\n font-size: 9pt;\n color: #111827;\n padding: 8px;\n border-bottom: 1px solid #f3f4f6;\n}\n.cell-left { text-align: left; }\n.cell-right { text-align: right; }\n.bold { font-weight: 700; }\n.large { font-size: 11pt; }\n.table-footer td {\n background: #f9fafb;\n border-top: 2px solid #9ca3af;\n padding: 8px;\n font-size: 10pt;\n}\n\n/* \u2500\u2500 Passengers \u2500\u2500 */\n.passenger-grid {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 16px;\n}\n.passenger-card {\n flex: 1;\n min-width: 200px;\n background: #f9fafb;\n padding: 8px 12px;\n border-radius: 6px;\n}\n.passenger-name {\n font-size: 10pt;\n font-weight: 600;\n color: #111827;\n margin: 0 0 2px 0;\n}\n.passenger-type {\n font-size: 8pt;\n color: #c59746;\n font-weight: 600;\n text-transform: capitalize;\n margin: 0 0 2px 0;\n}\n.passenger-dob {\n font-size: 8pt;\n color: #4b5563;\n margin: 0;\n}\n\n/* \u2500\u2500 Booking Cards \u2500\u2500 */\n.booking-card {\n background: #f9fafb;\n border-radius: 6px;\n padding: 10px 12px;\n margin-bottom: 8px;\n}\n.booking-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n}\n.booking-title {\n font-size: 10pt;\n font-weight: 600;\n color: #111827;\n margin: 0 0 2px 0;\n}\n.booking-type {\n font-size: 8pt;\n color: #4b5563;\n text-transform: capitalize;\n margin: 0;\n}\n.booking-confirmation {\n font-size: 10pt;\n font-weight: 700;\n color: #c59746;\n margin: 0;\n}\n.booking-dates {\n font-size: 8pt;\n color: #4b5563;\n margin: 4px 0 0 0;\n}\n\n/* \u2500\u2500 Disclosure \u2500\u2500 */\n.disclosure-box {\n background: #f9fafb;\n padding: 14px;\n border-radius: 4px;\n margin-bottom: 16px;\n}\n.disclosure-box p {\n font-size: 8pt;\n color: #374151;\n line-height: 1.5;\n margin: 0 0 6px 0;\n}\n.disclosure-box p:last-child { margin-bottom: 0; }\n\n/* \u2500\u2500 Footer \u2500\u2500 */\n.footer {\n border-top: 1px solid #e5e7eb;\n margin-top: 24px;\n padding-top: 12px;\n font-size: 8pt;\n color: #4b5563;\n}\n.footer-row {\n display: flex;\n justify-content: space-between;\n width: 100%;\n}\n.footer p { margin: 1px 0; }\n.retain-notice {\n text-align: center;\n margin-top: 6px;\n font-size: 8pt;\n color: #6b7280;\n}\n\n/* \u2500\u2500 Page Break Control \u2500\u2500 */\n .section-title { page-break-after: avoid; }\n .booking-card { page-break-inside: avoid; }\n .passenger-card { page-break-inside: avoid; }\n .passenger-grid { page-break-inside: avoid; }\n .info-box { page-break-inside: avoid; }\n .info-grid { page-break-inside: avoid; }\n .financial-summary { page-break-inside: avoid; }\n .financial-grid { page-break-inside: avoid; }\n .disclosure-box { page-break-inside: avoid; }\n .data-table { page-break-inside: avoid; }\n .footer { page-break-inside: avoid; }\n .header { page-break-inside: avoid; }\n .table-footer { page-break-inside: avoid; }\n\n @media print {\n body { margin: 0; }\n .trip-order { padding: 0; }\n}", } diff --git a/apps/api/src/financials/__tests__/group-booking-finalize-invoiced.spec.ts b/apps/api/src/financials/__tests__/group-booking-finalize-invoiced.spec.ts index 0600dca2e..576d6301d 100644 --- a/apps/api/src/financials/__tests__/group-booking-finalize-invoiced.spec.ts +++ b/apps/api/src/financials/__tests__/group-booking-finalize-invoiced.spec.ts @@ -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, diff --git a/apps/api/src/financials/__tests__/group-booking-trip-order.integration.spec.ts b/apps/api/src/financials/__tests__/group-booking-trip-order.integration.spec.ts index 17cb14dfb..753e740f3 100644 --- a/apps/api/src/financials/__tests__/group-booking-trip-order.integration.spec.ts +++ b/apps/api/src/financials/__tests__/group-booking-trip-order.integration.spec.ts @@ -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 ) }) diff --git a/apps/api/src/financials/__tests__/group-checklist.integration.spec.ts b/apps/api/src/financials/__tests__/group-checklist.integration.spec.ts index 00b5adb31..ff847f5fb 100644 --- a/apps/api/src/financials/__tests__/group-checklist.integration.spec.ts +++ b/apps/api/src/financials/__tests__/group-checklist.integration.spec.ts @@ -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 () => { diff --git a/apps/api/src/financials/__tests__/group-manifest.integration.spec.ts b/apps/api/src/financials/__tests__/group-manifest.integration.spec.ts index 4b58e9e89..8d7119e1b 100644 --- a/apps/api/src/financials/__tests__/group-manifest.integration.spec.ts +++ b/apps/api/src/financials/__tests__/group-manifest.integration.spec.ts @@ -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 () => { diff --git a/apps/api/src/financials/__tests__/group-rooming-list.integration.spec.ts b/apps/api/src/financials/__tests__/group-rooming-list.integration.spec.ts index 3bf9e6f15..d8cc42a2c 100644 --- a/apps/api/src/financials/__tests__/group-rooming-list.integration.spec.ts +++ b/apps/api/src/financials/__tests__/group-rooming-list.integration.spec.ts @@ -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 () => { diff --git a/apps/api/src/financials/__tests__/trip-order-tico-compliance.spec.ts b/apps/api/src/financials/__tests__/trip-order-tico-compliance.spec.ts index 09a512f0b..1c07cf53b 100644 --- a/apps/api/src/financials/__tests__/trip-order-tico-compliance.spec.ts +++ b/apps/api/src/financials/__tests__/trip-order-tico-compliance.spec.ts @@ -409,4 +409,52 @@ describe('validateTICOCompliance — B4 §38 expansion', () => { expect(v.filter((x) => /non-refundable/.test(x))).toEqual([]) }) }) + + // ============================================================================ + // §38(6) — registrant (agency) legal name present REGARDLESS of advisor brand + // (advisor self-branding P1) + // ============================================================================ + + describe('§38(6) registrant legal name (branded invoices)', () => { + const registrantMsg = /Registrant \(agency\) legal name is required/ + + it('passes for an UNBRANDED invoice (registrant present, no brand fields)', () => { + const v = messagesOf(makeValidShape()) + expect(v.filter((x) => registrantMsg.test(x))).toEqual([]) + }) + + it('passes for a PERSONAL-brand invoice — registrant company_name still present', () => { + const shape = makeValidShape() + // Letterhead is branded but the registrant (company_name / agency_info.name) + // is still the agency. A DBA/personal brand never suppresses §38(6). + shape.businessConfig = { + ...shape.businessConfig, + brand_headline: "Suzie's Travels by Phoenix Voyages", + brand_tier: 'personal', + } + const v = messagesOf(shape) + expect(v.filter((x) => registrantMsg.test(x))).toEqual([]) + }) + + it('passes for a DBA-brand invoice — registrant legal name + TICO still present', () => { + const shape = makeValidShape() + shape.businessConfig = { + ...shape.businessConfig, + brand_headline: 'Lola Luxury Retreats', + brand_tier: 'dba', + } + const v = messagesOf(shape) + expect(v.filter((x) => registrantMsg.test(x))).toEqual([]) + // TICO registration still asserted. + expect(v.filter((x) => /TICO registration number is required/.test(x))).toEqual([]) + }) + + it('FLAGS an invoice whose registrant legal name is missing (empty agency + company_name)', () => { + const shape = makeValidShape() + shape.orderData.order_header.agency_info = { name: '' } as never + shape.businessConfig = { ...shape.businessConfig, company_name: '' } + const v = messagesOf(shape) + expect(v.filter((x) => registrantMsg.test(x)).length).toBe(1) + }) + }) }) diff --git a/apps/api/src/financials/financials.module.ts b/apps/api/src/financials/financials.module.ts index 23ea7762e..9b1b5bd03 100644 --- a/apps/api/src/financials/financials.module.ts +++ b/apps/api/src/financials/financials.module.ts @@ -16,6 +16,7 @@ import { EmailModule } from '../email/email.module' import { DocumentTemplatesModule } from '../document-templates/document-templates.module' import { DocumentRenderModule } from '../document-render/document-render.module' import { CommissionModule } from './commission/commission.module' +import { BrandingModule } from '../branding/branding.module' // Shared access services (added directly — NOT via TripsModule to avoid circular dep) import { TripAccessService } from '../trips/trip-access.service' @@ -42,7 +43,7 @@ import { StripeConnectController } from './stripe-connect.controller' import { StripeInvoiceController } from './stripe-invoice.controller' @Module({ - imports: [DatabaseModule, forwardRef(() => EmailModule), DocumentTemplatesModule, DocumentRenderModule, CommissionModule], + imports: [DatabaseModule, forwardRef(() => EmailModule), DocumentTemplatesModule, DocumentRenderModule, CommissionModule, BrandingModule], controllers: [ ExchangeRatesController, TravellerSplitsController, diff --git a/apps/api/src/financials/pdf/types.ts b/apps/api/src/financials/pdf/types.ts index f530d56cb..c6cbc1b24 100644 --- a/apps/api/src/financials/pdf/types.ts +++ b/apps/api/src/financials/pdf/types.ts @@ -318,6 +318,17 @@ export interface BusinessConfiguration { custom_compliance_disclosures?: string[] primary_color?: string secondary_color?: string + // Advisor self-branding P1 (resolveBrand). The LETTERHEAD reads these; the + // FOOTER/registrant keeps using company_name + tico_registration + full_address + // (always the agency, non-overridable). Absent on pre-P1 snapshots → the + // context builder falls back to company_name/logo_url so output is unchanged. + brand_headline?: string + brand_logo_url?: string | null + brand_color?: string | null + brand_tier?: 'none' | 'personal' | 'dba' + // Acting advisor, frozen into the snapshot so a reissue re-resolves the same + // per-advisor template tier. Feeds resolvePublishedTemplate's userId. + advisor_user_id?: string | null } /** diff --git a/apps/api/src/financials/tico-compliance.ts b/apps/api/src/financials/tico-compliance.ts index 6941e0cae..03e4c0a29 100644 --- a/apps/api/src/financials/tico-compliance.ts +++ b/apps/api/src/financials/tico-compliance.ts @@ -111,6 +111,20 @@ export function validateTICOCompliance( ) } + // §38(6) — the REGISTRANT (agency) legal name must appear on every invoice, + // REGARDLESS of any advisor brand headline. The brand headline lives in a + // SEPARATE field (brand_headline); company_name is always the agency + // registrant and backs the pinned footer + frozen agency_info. A branded / + // DBA invoice that dropped the registrant name would violate §38. + const registrantName = ( + orderData?.order_header?.agency_info?.name || + businessConfig.company_name || + '' + ).trim() + if (!registrantName) { + add('Registrant (agency) legal name is required on every invoice (Reg. 26/05 §38(6))') + } + // §38 — Insurance disclosure (offered/declined). Customer must see it. const hasInsuranceDisclosure = Boolean(businessConfig.document_insurance_requirements?.trim()) || diff --git a/apps/api/src/financials/trip-order.service.ts b/apps/api/src/financials/trip-order.service.ts index 70d729790..ccb2a3066 100644 --- a/apps/api/src/financials/trip-order.service.ts +++ b/apps/api/src/financials/trip-order.service.ts @@ -25,6 +25,7 @@ import { FinancialSummaryService } from './financial-summary.service' import { ExchangeRatesService } from './exchange-rates.service' import { EmailService } from '../email/email.service' import { DocumentTemplatesService } from '../document-templates/document-templates.service' +import { BrandingService } from '../branding/branding.service' import { HandlebarsRendererService } from '../document-templates/handlebars-renderer.service' import { PuppeteerPdfService } from '../document-render/puppeteer-pdf.service' import { validateTICOCompliance as validateTICOCompliancePure } from './tico-compliance' @@ -123,6 +124,7 @@ export class TripOrderService { @Inject(forwardRef(() => EmailService)) private readonly emailService: EmailService, private readonly templatesService: DocumentTemplatesService, + private readonly brandingService: BrandingService, private readonly handlebars: HandlebarsRendererService, private readonly puppeteerPdf: PuppeteerPdfService, private readonly eventEmitter: EventEmitter2, @@ -206,7 +208,10 @@ export class TripOrderService { if (!trip) { throw new NotFoundException(`Trip ${tripId} not found`) } - const businessConfig = await this.getBusinessConfiguration(agencyId) + // Advisor self-branding P1 — resolve the brand for the acting advisor (trip + // owner). tier='none' (no active brand) yields the agency headline, so an + // unbranded advisor's invoice is byte-identical to today. + const businessConfig = await this.getBusinessConfiguration(agencyId, trip.ownerId ?? undefined) const financialSummary = await this.financialSummaryService.getTripFinancialSummary(tripId) const passengers = await this.getTripPassengers(tripId) const primaryContact = await this.getPrimaryContact(tripId) @@ -288,7 +293,10 @@ export class TripOrderService { throw new NotFoundException(`Trip ${tripId} not found`) } - const businessConfig = await this.getBusinessConfiguration(agencyId) + // Advisor self-branding P1 — resolve the brand for the acting advisor (trip + // owner). tier='none' (no active brand) yields the agency headline, so an + // unbranded advisor's invoice is byte-identical to today. + const businessConfig = await this.getBusinessConfiguration(agencyId, trip.ownerId ?? undefined) const financialSummary = await this.financialSummaryService.getTripFinancialSummary(tripId) const passengers = await this.getTripPassengers(tripId) const primaryContact = await this.getPrimaryContact(tripId) @@ -814,8 +822,10 @@ export class TripOrderService { agencyId: string, context: Record, ): Promise { - // Resolve the trip-order template (agency override or system default) - const template = await this.templatesService.resolvePublishedTemplate('trip-order', agencyId) + // Resolve the trip-order template. Pass the acting advisor so a per-advisor + // published override wins the 3-tier cascade (user → agency → system). + const advisorId = context.__advisorId as string | undefined + const template = await this.templatesService.resolvePublishedTemplate('trip-order', agencyId, advisorId) if (!template || !template.pdfHtml) { throw new NotFoundException('Trip order PDF template not found') } @@ -842,7 +852,8 @@ export class TripOrderService { agencyId: string, context: Record, ): Promise<{ subject: string; html: string; text: string }> { - const override = await this.templatesService.resolvePublishedTemplate('trip-order', agencyId) + const advisorId = context.__advisorId as string | undefined + const override = await this.templatesService.resolvePublishedTemplate('trip-order', agencyId, advisorId) const emailTpl = override?.emailHtml && override.status === 'published' ? override @@ -871,7 +882,8 @@ export class TripOrderService { context: Record, type: 'group-trip-order' | 'group-manifest' | 'group-rooming-list' | 'group-checklist', ): Promise { - const template = await this.templatesService.resolvePublishedTemplate(type, agencyId) + const advisorId = context.__advisorId as string | undefined + const template = await this.templatesService.resolvePublishedTemplate(type, agencyId, advisorId) // resolvePublishedTemplate's system-row fallback returns the row at any // status; assert published explicitly since group trip orders are a legal // artifact (Ontario Reg 26/05). @@ -888,8 +900,12 @@ export class TripOrderService { * so Puppeteer doesn't depend on external network access during PDF rendering. */ private async embedLogoAsBase64(context: Record): Promise { + // The letterhead logo now renders from context.brand.logoUrl (advisor brand, + // falling back to the agency). Embed THAT so Puppeteer never needs the + // network. Fall back to the legacy agency.logo for any context without brand. + const brand = context.brand as Record | undefined const agency = context.agency as Record | undefined - const logoUrl = agency?.logo as string | undefined + const logoUrl = (brand?.logoUrl as string | undefined) ?? (agency?.logo as string | undefined) if (!logoUrl) return try { @@ -901,7 +917,8 @@ export class TripOrderService { const buffer = Buffer.from(await response.arrayBuffer()) const contentType = response.headers.get('content-type') || 'image/png' const base64 = `data:${contentType};base64,${buffer.toString('base64')}` - agency!.logo = base64 + if (brand) brand.logoUrl = base64 + if (agency) agency.logo = base64 } catch (err) { this.logger.warn(`Failed to embed logo as base64: ${err}`) // Leave original URL as fallback — Puppeteer may still fetch it @@ -997,6 +1014,8 @@ export class TripOrderService { phone: agent.phone, } : null, + // Registrant (agency) — LETTERHEAD contact + the pinned footer read this. + // It is ALWAYS the agency (never the advisor brand). agency: { name: businessConfig.company_name, logo: businessConfig.logo_url, @@ -1004,6 +1023,14 @@ export class TripOrderService { phone: businessConfig.phone || businessConfig.toll_free, email: businessConfig.email, }, + // Advisor brand — the LETTERHEAD headline/logo/color only. Falls back to + // the agency when unbranded, so tier='none' renders identically to today. + brand: { + headline: businessConfig.brand_headline || businessConfig.company_name, + logoUrl: businessConfig.brand_logo_url ?? businessConfig.logo_url ?? null, + brandColor: businessConfig.brand_color ?? businessConfig.primary_color ?? '#c59746', + tier: businessConfig.brand_tier ?? 'none', + }, businessConfig: { company_name: businessConfig.company_name, company_tagline: businessConfig.company_tagline || 'Discover, Soar, Repeat', @@ -1012,6 +1039,8 @@ export class TripOrderService { logo_url: businessConfig.logo_url, primary_color: businessConfig.primary_color || '#c59746', }, + // Acting advisor → resolvePublishedTemplate userId (per-advisor cascade). + __advisorId: trip.ownerId ?? businessConfig.advisor_user_id ?? undefined, payment: { amountPaid: paymentSummary.processed_payments, balanceDue: paymentSummary.balance_due, @@ -1167,6 +1196,9 @@ export class TripOrderService { phone: header.agent_info.phone, } : null, + // Registrant (agency) — from the FROZEN header.agency_info (falling back to + // businessConfig). LETTERHEAD contact + pinned footer read this; always the + // agency, never the advisor brand. agency: { name: header.agency_info?.name || businessConfig.company_name, logo: businessConfig.logo_url, @@ -1174,6 +1206,15 @@ export class TripOrderService { phone: header.agency_info?.phone || businessConfig.phone, email: header.agency_info?.email || businessConfig.email, }, + // Advisor brand — LETTERHEAD only. Read from the FROZEN businessConfig + // (baked at finalize). Pre-P1 snapshots lack brand_* → falls back to the + // agency company_name/logo, so a reissued legacy order is unchanged. + brand: { + headline: businessConfig.brand_headline || businessConfig.company_name, + logoUrl: businessConfig.brand_logo_url ?? businessConfig.logo_url ?? null, + brandColor: businessConfig.brand_color ?? businessConfig.primary_color ?? '#c59746', + tier: businessConfig.brand_tier ?? 'none', + }, businessConfig: { company_name: businessConfig.company_name, company_tagline: businessConfig.company_tagline || 'Discover, Soar, Repeat', @@ -1182,6 +1223,8 @@ export class TripOrderService { logo_url: businessConfig.logo_url, primary_color: businessConfig.primary_color || '#c59746', }, + // Frozen acting advisor → resolvePublishedTemplate userId (per-advisor cascade). + __advisorId: businessConfig.advisor_user_id ?? undefined, payment: { amountPaid: paymentSummary?.processed_payments ?? 0, balanceDue: paymentSummary?.balance_due ?? costBreak.final_total, @@ -1307,9 +1350,12 @@ export class TripOrderService { return trip ?? null } - private async getBusinessConfiguration(agencyId: string): Promise { + private async getBusinessConfiguration( + agencyId: string, + advisorId?: string, + ): Promise { if (!agencyId) { - return this.getDefaultBusinessConfig() + return this.applyBrand(this.getDefaultBusinessConfig(), agencyId, advisorId) } const [settings] = await this.db.client @@ -1353,7 +1399,7 @@ export class TripOrderService { ? rawCustom.filter((s): s is string => typeof s === 'string') : [] - return { + const baseConfig: BusinessConfiguration = { ...defaultConfig, company_name: agency?.name || defaultConfig.company_name, full_address: settings?.companyAddress || defaultConfig.full_address, @@ -1370,6 +1416,33 @@ export class TripOrderService { settings?.includeDefaultTicoDisclosures ?? defaultConfig.include_default_tico_disclosures, custom_compliance_disclosures: customDisclosures, } + + return this.applyBrand(baseConfig, agencyId, advisorId) + } + + /** + * Advisor self-branding P1 — overlay the resolved brand onto a business + * config. The LETTERHEAD reads brand_* (headline/logo/color); the FOOTER and + * §38 registrant fields (company_name / tico_registration / full_address / + * phone / email) are LEFT UNTOUCHED — they stay the agency (non-overridable). + * + * resolveBrand fails open to the agency-only brand, so on any error this is a + * no-op overlay (brand_headline === company_name) and output is unchanged. + */ + private async applyBrand( + config: BusinessConfiguration, + agencyId: string, + advisorId?: string, + ): Promise { + const brand = await this.brandingService.resolveBrand(agencyId, advisorId) + return { + ...config, + brand_headline: brand.headline, + brand_logo_url: brand.logoUrl ?? config.logo_url ?? null, + brand_color: brand.brandColor ?? config.primary_color ?? null, + brand_tier: brand.tier, + advisor_user_id: advisorId ?? null, + } } private getDefaultBusinessConfig(): BusinessConfiguration { @@ -2060,8 +2133,8 @@ export class TripOrderService { const { lineItems, grandTotalCents } = await this.buildGroupOrderData(bookingTripId, currency) - // Get business config - const businessConfig = await this.getBusinessConfiguration(agencyId) + // Get business config — branded for the group booking trip's advisor (owner). + const businessConfig = await this.getBusinessConfiguration(agencyId, bookingTrip.ownerId ?? undefined) // Get primary contact from the booking trip (the group bill-to) const primaryContact = bookingTrip.primaryContactId @@ -2111,8 +2184,17 @@ export class TripOrderService { group_grand_total_formatted: new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 2 }).format(grandTotalCents / 100), group_trip_count: groupTrips.length, business: businessConfig, - // Logo source for embedLogoAsBase64 + {{agency.logo}} in the template. + // Registrant (agency) — the footer compliance line reads business.* (always agency). agency: { name: businessConfig.company_name, logo: businessConfig.logo_url }, + // Advisor brand — the group letterhead logo reads brand.logoUrl (falls back + // to the agency logo when unbranded, so output is unchanged). + brand: { + headline: businessConfig.brand_headline || businessConfig.company_name, + logoUrl: businessConfig.brand_logo_url ?? businessConfig.logo_url ?? null, + brandColor: businessConfig.brand_color ?? businessConfig.primary_color ?? '#c59746', + tier: businessConfig.brand_tier ?? 'none', + }, + __advisorId: bookingTrip.ownerId ?? businessConfig.advisor_user_id ?? undefined, generated_date: new Date().toISOString().split('T')[0], } diff --git a/apps/api/src/trips/__tests__/group-dual-trip-orders.integration.spec.ts b/apps/api/src/trips/__tests__/group-dual-trip-orders.integration.spec.ts index d88437af5..27403b192 100644 --- a/apps/api/src/trips/__tests__/group-dual-trip-orders.integration.spec.ts +++ b/apps/api/src/trips/__tests__/group-dual-trip-orders.integration.spec.ts @@ -62,7 +62,9 @@ describe('Group Dual Trip Orders (Integration, Phase 1c)', () => { }), } as any // getTripBookings + findOne only touch this.db; the rest can be stubs. - tripOrderService = 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 + tripOrderService = new TripOrderService(dbService, stub, stub, stub, stub, brandingStub, stub, stub, stub) // getGroupSummary needs the REAL financial summary service (last ctor arg); // findOne/getGroupSummary don't touch the other collaborators. const financialSummaryService = new FinancialSummaryService(dbService, fakeExchange, {} as any) From 8b842810da670dc8ba96f562ffef4b54d8553421 Mon Sep 17 00:00:00 2001 From: Al Date: Sun, 5 Jul 2026 16:44:36 -0400 Subject: [PATCH 2/2] fix(advisor-branding P1): agency-scope the Tier-1 template cascade (Codex r1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex gpt-5.5 xhigh r1 BLOCK — the newly activated per-advisor template cascade was not agency-scoped, so passing the advisor userId into a TICO §38 invoice render could let a cross-agency or stale user template override another agency's legal document. resolvePublishedTemplate Tier 1 (user-level) matched slug + userId + status only. Added eq(documentTemplates.agencyId, agencyId) so it FAILS CLOSED — a user template row always carries its agencyId (forkTemplate sets both), so this excludes any foreign-agency advisor template. Protects both the trip-order render path and the renderEmail agentId path. +2 regression specs: cross-agency user template is ignored (falls through to the agency template); structural guard that Tier 1's WHERE references agency_id. Co-Authored-By: Claude Opus 4.8 --- .../document-templates.service.ts | 8 +++-- ...resolve-published-template.cascade.spec.ts | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/api/src/document-templates/document-templates.service.ts b/apps/api/src/document-templates/document-templates.service.ts index 4147b0a94..6ec980ba5 100644 --- a/apps/api/src/document-templates/document-templates.service.ts +++ b/apps/api/src/document-templates/document-templates.service.ts @@ -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) * @@ -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() @@ -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'), ), diff --git a/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts b/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts index c74c35681..4d71fbbdf 100644 --- a/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts +++ b/apps/api/src/document-templates/resolve-published-template.cascade.spec.ts @@ -73,6 +73,37 @@ describe('DocumentTemplatesService.resolvePublishedTemplate — 3-tier cascade ( 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