make a contrat template

This commit is contained in:
Marshal
2026-07-09 21:13:07 +00:00
parent e4429592d3
commit 3443b79644
26 changed files with 3243 additions and 41 deletions

View File

@@ -0,0 +1,63 @@
import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
bullets: string[];
}
/** A dynamic article ready for the Handlebars template. */
export interface RenderedArticle {
number: number;
title: string;
/** Set (instead of clauses) when the body is a single plain paragraph. */
paragraph?: string;
clauses: RenderedClause[];
}
/**
* Parse a template article body into clauses. Format: one clause per line;
* lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single clause without bullets renders as a plain
* paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
clauses.push({ text: bullet, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
} else {
clauses.push({ text: line, bullets: [] });
}
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };
}
/**
* Interpolate Handlebars placeholders ({{client.companyName}}, {{contractDate}},
* …) inside admin-authored template text against the contract view model.
* Malformed placeholders must never break document generation — fall back to
* the raw text.
*/
export function interpolateTemplateText(text: string, context: unknown): string {
if (!text || !text.includes('{{')) return text ?? '';
try {
return Handlebars.compile(text)(context);
} catch {
return text;
}
}

View File

@@ -8,6 +8,7 @@ import {
ContractSignerRole,
} from '../modules/contracts/entities/contract-signature.entity';
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
import { ContractViewModel } from './contract-view-model.builder';
@@ -74,6 +75,7 @@ export class ContractDocumentViewModelBuilder {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly contractTemplates: ContractTemplatesService,
) {}
async build(
@@ -86,7 +88,32 @@ export class ContractDocumentViewModelBuilder {
const templateKey =
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
const template = getTemplateMeta(templateKey);
let template = getTemplateMeta(templateKey);
// Prefer the admin-editable DB template matching the contract's
// direction/freight pair; fall back to the code-defined generic layout
// when none is active.
const dynamicSource = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
);
const dynamicTemplate = dynamicSource
? {
code: dynamicSource.code,
name: dynamicSource.name,
documentTitle: dynamicSource.documentTitle,
whereasClauses: dynamicSource.whereasClauses ?? [],
articles: dynamicSource.articles ?? [],
}
: undefined;
if (dynamicTemplate) {
template = {
...template,
title: dynamicTemplate.name,
templateFile: 'edr-dynamic.hbs',
};
}
const pricing = this.buildPricing(contract);
const signatures = await this.loadSignatures(contractId);
@@ -139,6 +166,7 @@ export class ContractDocumentViewModelBuilder {
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
dynamicTemplate,
};
return { contract, view };

View File

@@ -0,0 +1,151 @@
import { parseArticleBody, interpolateTemplateText } from './contract-article.util';
import { ContractRendererService } from './contract-renderer.service';
import { getTemplateMeta } from './contract-template.registry';
import type { ContractViewModel } from './contract-view-model.builder';
describe('parseArticleBody', () => {
it('numbers each non-empty line as a clause', () => {
const parsed = parseArticleBody('First clause.\nSecond clause.\n\nThird clause.');
expect(parsed.paragraph).toBeUndefined();
expect(parsed.clauses.map((c) => c.text)).toEqual([
'First clause.',
'Second clause.',
'Third clause.',
]);
});
it('nests "- " lines as bullets under the previous clause', () => {
const parsed = parseArticleBody('Rates are:\n- USD 10 per ton\n- USD 20 per wagon\nPayment in advance.');
expect(parsed.clauses).toHaveLength(2);
expect(parsed.clauses[0].bullets).toEqual(['USD 10 per ton', 'USD 20 per wagon']);
expect(parsed.clauses[1].text).toBe('Payment in advance.');
});
it('renders a single bare line as a paragraph', () => {
const parsed = parseArticleBody('This Agreement becomes effective when signed.');
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
expect(parsed.clauses).toEqual([]);
});
});
describe('interpolateTemplateText', () => {
it('fills placeholders from the view model', () => {
expect(
interpolateTemplateText('Valid until August 31, {{contractYear}}.', {
contractYear: 2026,
}),
).toBe('Valid until August 31, 2026.');
});
it('falls back to raw text on malformed placeholders', () => {
expect(interpolateTemplateText('Broken {{#if}} tag', {})).toBe('Broken {{#if}} tag');
});
});
describe('dynamic template rendering (edr-dynamic.hbs)', () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
function dynamicView(): ContractViewModel {
const meta = getTemplateMeta('IMP_BULK_USD_FORWARDING');
return {
bookingId: 'test-id',
reference: 'EDR/CT/2026/0042',
status: 'CONTRACT_READY',
templateKey: 'IMP_BULK_USD_FORWARDING',
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
contractDate: '1 January 2026',
contractYear: 2026,
client: {
companyName: 'Abyssinia Trading PLC',
companyAddress: 'Bole Sub-city, Addis Ababa',
companyLocation: 'Ethiopia',
phone: '+251900000000',
email: 'test@example.com',
tinNumber: '1234567890',
vatNumber: 'VAT-001',
fanNumber: 'FAN-001',
businessLicense: 'BL-001',
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: {
originLabel: 'Nagad',
destinationLabel: 'Galaan Multipurpose Port',
tradeDirection: 'IMPORT',
freightType: 'BULK',
serviceType: 'Rail + clearance',
scheduledDate: '—',
contractType: 'GENERAL',
cargoDescription: 'Steel billets',
totalWeightVgm: '—',
equipmentReturn: '—',
hazardousLabel: 'No',
firstMilePickupAddress: '—',
lastMileDeliveryAddress: '—',
},
pricing: {
displayMode: 'UNIT_RATES',
unitRates: [
{ label: 'Rail transport', unitPrice: 59.4, unit: 'ton', currency: 'USD' },
],
currency: 'USD',
equipmentReturn: '—',
originLabel: 'Nagad',
destinationLabel: 'Galaan Multipurpose Port',
} as unknown as ContractViewModel['pricing'],
signatures: [],
canSignCustomer: false,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
dynamicTemplate: {
code: 'IMPORT_BULK',
name: 'Bulk Import Contract',
documentTitle: 'Bulk Cargo Transportation and Customs Clearance Services',
whereasClauses: ['The Client has agreed to engage the Service Provider.'],
articles: [
{
id: 'objective',
title: 'Objective of the Services',
body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance',
order: 1,
},
{
id: 'duration',
title: 'Duration',
body: 'Valid until August 31, {{contractYear}}.',
order: 2,
},
],
},
};
}
it('renders numbered dynamic articles with bullets and interpolation', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Bulk Cargo Transportation and Customs Clearance Services');
expect(html).toContain('Article 1');
expect(html).toContain('Objective of the Services');
expect(html).toContain('Rail transport to GMP');
expect(html).toContain('Valid until August 31, 2026.');
expect(html).toContain('Abyssinia Trading PLC');
expect(html).toContain('Annex A — Commercial Schedule');
// Greenish theme marker from styles.hbs
expect(html).toContain('#1b9e7a');
});
it('keeps the generic layout when no dynamic template is attached', () => {
const view = dynamicView();
delete view.dynamicTemplate;
view.template = getTemplateMeta('IMP_BULK_USD_FORWARDING');
const html = renderer.render(view);
expect(html).toContain('Article 5: Contract Price');
});
});

View File

@@ -3,6 +3,11 @@ import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
import {
interpolateTemplateText,
parseArticleBody,
RenderedArticle,
} from './contract-article.util';
import { ContractViewModel } from './contract-view-model.builder';
@Injectable()
@@ -31,9 +36,40 @@ export class ContractRendererService implements OnModuleInit {
return template({
...view,
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
...this.buildDynamicSections(view),
});
}
/**
* Turn the DB-backed dynamic template (when present) into render-ready data:
* interpolate placeholders against the view model, then parse each article
* body into numbered clauses with nested bullets.
*/
private buildDynamicSections(view: ContractViewModel): {
dynamicDocumentTitle?: string;
dynamicWhereas?: string[];
dynamicArticles?: RenderedArticle[];
} {
const dyn = view.dynamicTemplate;
if (!dyn || dyn.articles.length === 0) return {};
const articles = [...dyn.articles]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((article, index) => ({
number: index + 1,
title: interpolateTemplateText(article.title, view),
...parseArticleBody(interpolateTemplateText(article.body, view)),
}));
return {
dynamicDocumentTitle: interpolateTemplateText(dyn.documentTitle, view),
dynamicWhereas: dyn.whereasClauses.map((clause) =>
interpolateTemplateText(clause, view),
),
dynamicArticles: articles,
};
}
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
const cached = this.compiled.get(fileName);
if (cached) return cached;

View File

@@ -17,6 +17,21 @@ export interface ContractSignatureView {
signatureImageUrl?: string | null;
}
/**
* DB-backed contract template (freight.contract_templates) attached to the
* view model when an active template matches the contract's direction/freight
* pair. The renderer turns its articles into numbered clauses and switches to
* the dedicated edr-dynamic.hbs layout; absent, the legacy generic layout with
* code-defined clause packs is used.
*/
export interface ContractDynamicTemplateView {
code: string;
name: string;
documentTitle: string;
whereasClauses: string[];
articles: Array<{ id: string; title: string; body: string; order: number }>;
}
export interface ContractViewModel {
bookingId: string;
reference: string;
@@ -65,6 +80,7 @@ export interface ContractViewModel {
hasContractDocument: boolean;
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
dynamicTemplate?: ContractDynamicTemplateView;
}
@Injectable()

View File

@@ -0,0 +1,23 @@
{{#each dynamicArticles}}
<section class="article">
<h2 class="article-heading"><span class="article-no">Article {{number}}</span><span class="article-name">{{title}}</span></h2>
{{#if paragraph}}
<p class="article-paragraph">{{paragraph}}</p>
{{else}}
<ol class="clauses">
{{#each clauses}}
<li>
{{text}}
{{#if bullets.length}}
<ul class="clause-bullets">
{{#each bullets}}
<li>{{this}}</li>
{{/each}}
</ul>
{{/if}}
</li>
{{/each}}
</ol>
{{/if}}
</section>
{{/each}}

View File

@@ -4,11 +4,11 @@
body {
margin: 0;
background: #f5f7fb;
color: #111827;
background: #f3f8f5;
color: #16241d;
font-family: "Times New Roman", Times, serif;
font-size: 10.5pt;
line-height: 1.48;
line-height: 1.5;
}
.contract {
@@ -21,25 +21,28 @@
h1, h2, h3, p { margin-top: 0; }
h1 {
color: #0f2742;
font-size: 18pt;
line-height: 1.25;
color: #0a3d2e;
font-size: 17pt;
letter-spacing: 0.02em;
line-height: 1.3;
margin-bottom: 10px;
text-align: center;
text-transform: uppercase;
}
h2 {
border-bottom: 1.5px solid #1e3a5f;
color: #1e3a5f;
font-size: 12pt;
letter-spacing: 0.03em;
margin: 18px 0 10px;
border-bottom: 1.5px solid #1b9e7a;
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 11.5pt;
letter-spacing: 0.04em;
margin: 20px 0 10px;
padding-bottom: 5px;
text-transform: uppercase;
}
h3 {
color: #0f2742;
font-size: 10.8pt;
color: #0a3d2e;
font-family: Arial, sans-serif;
font-size: 10.5pt;
margin: 12px 0 6px;
}
p { margin-bottom: 8px; }
@@ -52,16 +55,17 @@
page-break-inside: avoid;
}
/* ── Brand header ─────────────────────────────────────────────────────── */
.brand-row {
align-items: center;
border-bottom: 3px solid #1e3a5f;
border-bottom: 3px double #1b9e7a;
display: flex;
gap: 14px;
padding-bottom: 14px;
}
.logo-mark {
align-items: center;
background: #1e3a5f;
background: linear-gradient(135deg, #0e5b45 0%, #1b9e7a 100%);
border-radius: 8px;
color: #fff;
display: flex;
@@ -74,7 +78,7 @@
width: 72px;
}
.kicker {
color: #1e3a5f;
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
@@ -83,36 +87,74 @@
text-transform: uppercase;
}
.muted {
color: #6b7280;
color: #5c6f66;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0;
}
.muted-note {
color: #5c6f66;
font-size: 9.5pt;
}
/* ── Cover page ───────────────────────────────────────────────────────── */
.cover {
display: flex;
flex-direction: column;
min-height: 255mm;
position: relative;
}
.cover-title {
margin: 54mm 0 34mm;
margin: 34mm 0 22mm;
text-align: center;
}
.cover-rule {
background: #1b9e7a;
height: 2px;
margin: 14px auto;
width: 46mm;
}
.document-label {
color: #6b7280;
color: #1b9e7a;
font-family: Arial, sans-serif;
font-size: 10pt;
font-size: 11pt;
font-weight: 700;
letter-spacing: 0.12em;
margin-bottom: 10px;
letter-spacing: 0.18em;
margin-bottom: 6px;
text-transform: uppercase;
}
.cover-for,
.cover-between {
color: #5c6f66;
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-style: italic;
margin: 10px 0 6px;
}
.cover-party {
color: #0a3d2e;
font-family: Arial, sans-serif;
font-size: 12pt;
font-weight: 700;
margin: 4px 0;
}
.summary-line {
color: #374151;
color: #38493f;
font-family: Arial, sans-serif;
font-size: 9.5pt;
margin-top: 12px;
}
.cover-year {
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 13pt;
font-weight: 700;
letter-spacing: 0.1em;
margin-top: auto;
text-align: right;
}
/* ── Tables ───────────────────────────────────────────────────────────── */
table {
border-collapse: collapse;
width: 100%;
@@ -129,7 +171,7 @@
.details-table td,
.schedule th,
.schedule td {
border: 1px solid #cbd5e1;
border: 1px solid #c9e4d9;
padding: 7px 8px;
text-align: left;
vertical-align: top;
@@ -137,35 +179,46 @@
.meta-grid th,
.details-table th,
.schedule th {
background: #eef4fb;
color: #1e3a5f;
background: #e9f6f0;
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 8.5pt;
text-transform: uppercase;
}
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
.schedule tbody tr:nth-child(even) td { background: #f5faf8; }
.total-row td {
background: #e8f0f8 !important;
color: #0f2742;
background: #ddf2e9 !important;
color: #0a3d2e;
font-weight: 700;
}
/* ── Parties ──────────────────────────────────────────────────────────── */
.lead {
color: #374151;
color: #38493f;
font-size: 10.5pt;
}
.between-label {
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
letter-spacing: 0.08em;
margin: 10px 0 4px;
text-transform: uppercase;
}
.party-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
margin-top: 12px;
}
.party-card {
border: 1px solid #cbd5e1;
border: 1px solid #c9e4d9;
border-radius: 8px;
padding: 12px;
}
.party-card h3 {
background: #1e3a5f;
background: #0e5b45;
border-radius: 5px;
color: #fff;
font-family: Arial, sans-serif;
@@ -175,7 +228,7 @@
text-transform: uppercase;
}
.party-name {
color: #0f2742;
color: #0a3d2e;
font-weight: 700;
margin-bottom: 8px;
}
@@ -185,7 +238,7 @@
margin: 0;
}
dt {
color: #475569;
color: #47594f;
font-family: Arial, sans-serif;
font-size: 8.5pt;
font-weight: 700;
@@ -196,6 +249,81 @@
padding: 2px 0;
}
/* ── Recitals ─────────────────────────────────────────────────────────── */
.whereas-label {
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 9pt;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.now-therefore {
color: #0a3d2e;
font-weight: 700;
margin-top: 10px;
}
/* ── Dynamic articles ─────────────────────────────────────────────────── */
.article-heading {
align-items: baseline;
display: flex;
gap: 10px;
}
.article-no {
color: #1b9e7a;
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
}
.article-name { color: #0e5b45; }
.article-paragraph { margin: 4px 0 0; }
ol.clauses {
counter-reset: clause;
list-style: none;
margin: 6px 0 0;
padding-left: 0;
}
ol.clauses > li {
counter-increment: clause;
margin-bottom: 6px;
padding-left: 24px;
position: relative;
text-align: justify;
}
ol.clauses > li::before {
color: #0e5b45;
content: counter(clause) ".";
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
left: 0;
position: absolute;
top: 1px;
}
ul.clause-bullets {
margin: 5px 0 2px;
padding-left: 16px;
}
ul.clause-bullets > li {
list-style: none;
margin-bottom: 3px;
padding-left: 12px;
position: relative;
}
ul.clause-bullets > li::before {
color: #1b9e7a;
content: "▪";
font-size: 8pt;
left: 0;
position: absolute;
top: 1px;
}
/* ── Signatures ───────────────────────────────────────────────────────── */
.signatures {
display: grid;
gap: 18px;
@@ -204,13 +332,13 @@
page-break-inside: avoid;
}
.sig-block {
border: 1.5px solid #1e3a5f;
border: 1.5px solid #1b9e7a;
border-radius: 8px;
min-height: 96mm;
padding: 12px;
}
.sig-title {
color: #1e3a5f;
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 9pt;
font-weight: 700;
@@ -219,7 +347,7 @@
}
.sig-image-box {
align-items: center;
border: 1px dashed #94a3b8;
border: 1px dashed #7fbfa9;
display: flex;
height: 28mm;
justify-content: center;
@@ -231,21 +359,40 @@
max-width: 70mm;
}
.sig-placeholder {
color: #94a3b8;
color: #7fbfa9;
font-family: Arial, sans-serif;
font-size: 8.5pt;
}
.sig-line {
border-top: 1px solid #111827;
border-top: 1px solid #16241d;
margin-top: 16px;
padding-top: 5px;
}
.sig-meta {
color: #475569;
color: #47594f;
font-size: 9pt;
margin: 4px 0;
}
/* ── Witnesses ────────────────────────────────────────────────────────── */
.witnesses { margin-top: 20px; }
.witness-table {
font-size: 9.5pt;
margin-top: 6px;
}
.witness-table th,
.witness-table td {
border-bottom: 1px solid #c9e4d9;
padding: 9px 8px;
text-align: left;
}
.witness-table th {
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 8.5pt;
text-transform: uppercase;
}
@media print {
body { background: #fff; }
.contract {

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{dynamicDocumentTitle}}{{reference}}</title>
{{> styles}}
</head>
<body>
<main class="contract">
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Services</p>
</div>
</div>
<div class="cover-title">
<p class="document-label">Contract Agreement</p>
<div class="cover-rule"></div>
<p class="cover-for">for</p>
<h1>{{dynamicDocumentTitle}}</h1>
<p class="cover-between">between</p>
<p class="cover-party">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="cover-between">and</p>
<p class="cover-party">{{client.companyName}}</p>
<div class="cover-rule"></div>
</div>
<table class="meta-grid">
<tr>
<th>Contract Ref No.</th>
<td>{{reference}}</td>
<th>Contract Date</th>
<td>{{contractDate}}</td>
</tr>
<tr>
<th>Trade Direction</th>
<td>{{schedule.tradeDirection}}</td>
<th>Freight Type</th>
<td>{{schedule.freightType}}</td>
</tr>
</table>
<p class="cover-year">{{contractYear}}</p>
</section>
{{!-- ──────────────────────────── Preamble ──────────────────────────── --}}
<section class="page-section">
<h2>Parties to the Agreement</h2>
<p class="lead">
This Contract Agreement is made on <strong>{{contractDate}}</strong>.
</p>
<p class="between-label">Between</p>
<p>
<strong>Ethio-Djibouti Standard Gauge Railway Share Company (EDR)</strong>, a share company
incorporated under the laws of the Federal Democratic Republic of Ethiopia (FDRE), having its
principal place of business at {{provider.address}} (hereinafter referred to as the
<strong>"Service Provider"</strong>);
</p>
<p class="between-label">And</p>
<p>
<strong>{{client.companyName}}</strong>, an organization incorporated under the laws of the
Federal Democratic Republic of Ethiopia (FDRE), having its principal place of business at
{{client.companyAddress}} (hereinafter referred to as the <strong>"Client"</strong>).
</p>
<div class="party-grid">
<div class="party-card">
<h3>Service Provider</h3>
<p class="party-name">{{provider.name}}</p>
<dl>
<dt>Address</dt><dd>{{provider.address}}</dd>
<dt>Phone</dt><dd>{{provider.phone}}</dd>
<dt>Email</dt><dd>{{provider.email}}</dd>
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
</dl>
</div>
<div class="party-card">
<h3>Client</h3>
<p class="party-name">{{client.companyName}}</p>
<dl>
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
<dt>Phone</dt><dd>{{client.phone}}</dd>
<dt>Email</dt><dd>{{client.email}}</dd>
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
</dl>
</div>
</div>
</section>
{{#if dynamicWhereas.length}}
<section class="page-section">
<h2>Recitals</h2>
{{#each dynamicWhereas}}
<p><span class="whereas-label">Whereas</span> {{this}}</p>
{{/each}}
<p class="now-therefore">Now, therefore, the parties agree as follows:</p>
</section>
{{/if}}
{{!-- ──────────────────────── Dynamic articles ──────────────────────── --}}
{{> dynamic_articles}}
{{!-- ─────────────────── Commercial schedule (annex) ─────────────────── --}}
<section class="page-section annex">
<h2>Annex A — Commercial Schedule</h2>
<table class="details-table">
<tbody>
<tr>
<th>Route</th>
<td>{{schedule.originLabel}}{{schedule.destinationLabel}}</td>
<th>Service type</th>
<td>{{schedule.serviceType}}</td>
</tr>
<tr>
<th>Cargo</th>
<td>{{schedule.cargoDescription}}</td>
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>
<th>Payment currency</th>
<td>{{paymentArticle}}</td>
</tr>
</tbody>
</table>
{{#if pricing.unitRates.length}}
<h3>Agreed Unit Rates</h3>
<p class="muted-note">
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</section>
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}
<section class="page-section">
<h2>Execution</h2>
<p>
In witness whereof, the parties hereto have caused this contract to be signed in their respective
names as of the day and year first above written. The signatories confirm that they are fully
authorized to sign and execute this Contract Agreement.
</p>
{{> signatures_block}}
<div class="witnesses">
<p class="sig-title">Witnesses</p>
<table class="witness-table">
<thead>
<tr><th></th><th>Name</th><th>Signature</th><th>Date</th></tr>
</thead>
<tbody>
<tr><td>1.</td><td></td><td></td><td></td></tr>
<tr><td>2.</td><td></td><td></td><td></td></tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>