@mgsoftwarebv/mcp-server-bridge 3.5.6 → 3.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -106677,7 +106677,7 @@ var TOOLS = [
106677
106677
  },
106678
106678
  {
106679
106679
  name: "get-invoices",
106680
- description: "List invoices with optional filtering by customer, status, or a search on invoice number / customer name. Use this to check whether a (draft) invoice already exists before linking a deliverables document to it with `invoiceId` on create-document or link-document-to-invoice.",
106680
+ description: "List invoices with optional filtering by customer, status, or a search on invoice number / customer name. Use `get-invoice-by-id` for full detail including line items, product snapshots and linked documents. Use this listing to find a (draft) invoice before linking a deliverables document with `invoiceId` on create-document or link-document-to-invoice.",
106681
106681
  inputSchema: {
106682
106682
  type: "object",
106683
106683
  properties: {
@@ -106722,6 +106722,136 @@ var TOOLS = [
106722
106722
  required: ["documentId", "invoiceId"]
106723
106723
  }
106724
106724
  },
106725
+ {
106726
+ name: "get-invoice-by-id",
106727
+ description: "Get a single invoice by UUID or invoice number (e.g. 260060). Returns status, customer, dates, currency, totals, all line items (compact description + extended product/variant/clause context) and linked documents. Use before editing draft line descriptions with update-invoice-lines.",
106728
+ inputSchema: {
106729
+ type: "object",
106730
+ properties: {
106731
+ teamId: teamIdProp,
106732
+ invoiceId: {
106733
+ type: "string",
106734
+ description: "Invoice UUID or invoice number"
106735
+ }
106736
+ },
106737
+ required: ["invoiceId"]
106738
+ }
106739
+ },
106740
+ {
106741
+ name: "update-invoice",
106742
+ description: "Update a DRAFT invoice. Only invoices still in status `draft` can be changed \u2014 sent/paid/unpaid/overdue invoices are immutable here. Editable fields: title (template.title), note, internalNote, dueDate, issueDate. Provide `lineItems` to REPLACE all items (totals recomputed; productId items are re-snapshotted with clause + pricing variant).",
106743
+ inputSchema: {
106744
+ type: "object",
106745
+ properties: {
106746
+ teamId: teamIdProp,
106747
+ invoiceId: { type: "string", description: "Invoice UUID or number" },
106748
+ title: { type: "string", description: "Overrides template.title" },
106749
+ note: {
106750
+ type: ["string", "null"],
106751
+ description: "Customer-facing note; null clears it."
106752
+ },
106753
+ internalNote: { type: ["string", "null"] },
106754
+ dueDate: { type: ["string", "null"], description: "ISO date" },
106755
+ issueDate: { type: ["string", "null"], description: "ISO date" },
106756
+ lineItems: {
106757
+ type: "array",
106758
+ description: "Replaces ALL line items. Each: { name?, quantity?, unit?, price?, productId?, pricingOptionId?, prorate?, startDate?, periodEndDate? }.",
106759
+ items: {
106760
+ type: "object",
106761
+ properties: {
106762
+ name: { type: "string", description: "Compact line description" },
106763
+ quantity: { type: "number" },
106764
+ unit: { type: "string" },
106765
+ price: { type: "number", description: "Unit price excl. VAT" },
106766
+ productId: { type: "string" },
106767
+ pricingOptionId: { type: "string" },
106768
+ prorate: {
106769
+ type: "boolean",
106770
+ description: "Pro-rate yearly variant from startDate"
106771
+ },
106772
+ startDate: { type: "string", description: "ISO date for pro-rata" },
106773
+ periodEndDate: { type: "string" }
106774
+ }
106775
+ }
106776
+ }
106777
+ },
106778
+ required: ["invoiceId"]
106779
+ }
106780
+ },
106781
+ {
106782
+ name: "update-invoice-lines",
106783
+ description: "Update specific line items on a DRAFT invoice without replacing the whole list. Address lines by zero-based `index`. Only provided fields change; clause/pricingOption/productId snapshots are preserved. Ideal for compacting factuurregel descriptions. Recomputes invoice totals.",
106784
+ inputSchema: {
106785
+ type: "object",
106786
+ properties: {
106787
+ teamId: teamIdProp,
106788
+ invoiceId: { type: "string", description: "Invoice UUID or number" },
106789
+ lineItems: {
106790
+ type: "array",
106791
+ description: "Patches to apply. Each: { index, description?, quantity?, unit?, price? }.",
106792
+ items: {
106793
+ type: "object",
106794
+ properties: {
106795
+ index: {
106796
+ type: "number",
106797
+ description: "Zero-based line index"
106798
+ },
106799
+ description: {
106800
+ type: "string",
106801
+ description: "Compact factuurregel text (plain string)"
106802
+ },
106803
+ quantity: { type: "number" },
106804
+ unit: { type: "string" },
106805
+ price: { type: "number" }
106806
+ },
106807
+ required: ["index"]
106808
+ }
106809
+ }
106810
+ },
106811
+ required: ["invoiceId", "lineItems"]
106812
+ }
106813
+ },
106814
+ {
106815
+ name: "add-product-to-invoice",
106816
+ description: "Add a catalog product as a new line item on a DRAFT invoice. Snapshots clause + chosen pricing variant onto the line. Only works on `draft` invoices. Supports optional pro-rata billing for yearly variants (prorate + startDate). Recomputes totals.",
106817
+ inputSchema: {
106818
+ type: "object",
106819
+ properties: {
106820
+ teamId: teamIdProp,
106821
+ invoiceId: { type: "string", description: "Invoice UUID or number" },
106822
+ productId: {
106823
+ type: "string",
106824
+ description: "Catalog product ID (see get-products)"
106825
+ },
106826
+ quantity: { type: "number", default: 1 },
106827
+ customDescription: {
106828
+ type: "string",
106829
+ description: "Overrides the compact line description."
106830
+ },
106831
+ customPrice: {
106832
+ type: "number",
106833
+ description: "Overrides the unit price (excl. VAT)."
106834
+ },
106835
+ pricingOptionId: {
106836
+ type: "string",
106837
+ description: "Pricing variant id (monthly/yearly); defaults to product default."
106838
+ },
106839
+ prorate: {
106840
+ type: "boolean",
106841
+ description: "When true and a yearly variant is selected, price is pro-rated from startDate."
106842
+ },
106843
+ startDate: {
106844
+ type: "string",
106845
+ description: "ISO start date for pro-rata (required when prorate is true)."
106846
+ },
106847
+ periodEndDate: {
106848
+ type: "string",
106849
+ description: "Optional end of billing period for pro-rata (defaults to end of start year)."
106850
+ }
106851
+ },
106852
+ required: ["invoiceId", "productId"]
106853
+ }
106854
+ },
106725
106855
  {
106726
106856
  name: "get-products",
106727
106857
  description: "List catalog products used on invoices AND quotes (the shared `invoice_products` catalog). Each entry includes its ID (UUID), name, unit price, currency, unit, active/archived flag, configurable flag, usage stats, and structured package metadata (category, billingType, tier, optional add-on flag and includedItems). Editing or archiving a catalog product never changes existing invoices/quotes \u2014 those keep an immutable line-item snapshot; catalog changes only affect documents created afterwards.",
@@ -113547,151 +113677,937 @@ async function handleLogHours(input) {
113547
113677
  return { content: [{ type: "text", text: responseText }] };
113548
113678
  }
113549
113679
 
113550
- // src/tools/invoices.ts
113551
- var INVOICE_STATUSES = [
113552
- "draft",
113553
- "overdue",
113554
- "paid",
113555
- "partially_paid",
113556
- "unpaid",
113557
- "canceled",
113558
- "scheduled",
113559
- "refunded"
113560
- ];
113561
- async function handleGetInvoices(input) {
113562
- const { customerId, status, q: q3, pageSize = 20 } = input;
113563
- if (status && !INVOICE_STATUSES.includes(status)) {
113564
- return {
113565
- content: [
113566
- {
113567
- type: "text",
113568
- text: `Error: invalid status "${status}". Allowed: ${INVOICE_STATUSES.join(", ")}.`
113569
- }
113570
- ]
113571
- };
113572
- }
113573
- const scope = await resolveTeamScope(input.teamId);
113574
- if (!scope.ok) return scope.response;
113575
- if (scope.teamIds.length === 0) {
113576
- return { content: [{ type: "text", text: "No accessible teams found." }] };
113577
- }
113578
- const filters = [inArray(schema_exports.invoices.teamId, scope.teamIds)];
113579
- if (customerId) filters.push(eq(schema_exports.invoices.customerId, customerId));
113580
- if (status) filters.push(eq(schema_exports.invoices.status, status));
113581
- if (q3) {
113582
- filters.push(
113583
- or(
113584
- ilike(schema_exports.invoices.invoiceNumber, `%${q3}%`),
113585
- ilike(schema_exports.invoices.customerName, `%${q3}%`)
113586
- )
113587
- );
113588
- }
113589
- const rows = await db.select({
113590
- id: schema_exports.invoices.id,
113591
- invoiceNumber: schema_exports.invoices.invoiceNumber,
113592
- status: schema_exports.invoices.status,
113593
- teamId: schema_exports.invoices.teamId,
113594
- customerId: schema_exports.invoices.customerId,
113595
- customerName: schema_exports.invoices.customerName,
113596
- amount: schema_exports.invoices.amount,
113597
- currency: schema_exports.invoices.currency,
113598
- issueDate: schema_exports.invoices.issueDate,
113599
- dueDate: schema_exports.invoices.dueDate,
113600
- createdAt: schema_exports.invoices.createdAt
113601
- }).from(schema_exports.invoices).where(and(...filters)).orderBy(desc(schema_exports.invoices.createdAt)).limit(Math.min(pageSize, 100));
113602
- if (rows.length === 0) {
113603
- return { content: [{ type: "text", text: "No invoices found." }] };
113680
+ // ../invoice/src/utils/included-items.ts
113681
+ function parseIncludedItems(value) {
113682
+ if (value == null) return null;
113683
+ if (!Array.isArray(value)) return null;
113684
+ const items = [];
113685
+ for (const entry of value) {
113686
+ if (typeof entry === "string") {
113687
+ const label = entry.trim();
113688
+ if (label) items.push({ label });
113689
+ continue;
113690
+ }
113691
+ if (entry && typeof entry === "object" && "label" in entry) {
113692
+ const raw = entry;
113693
+ const label = String(raw.label ?? "").trim();
113694
+ if (!label) continue;
113695
+ items.push({
113696
+ label,
113697
+ productId: raw.productId ?? null
113698
+ });
113699
+ }
113604
113700
  }
113605
- const list = rows.map(
113606
- (inv) => `**${inv.invoiceNumber ?? "(draft, no number)"}**
113607
- ID: ${inv.id}
113608
- Status: ${inv.status} | Amount: ${inv.amount ?? "?"} ${inv.currency ?? ""}
113609
- Customer: ${inv.customerName ?? inv.customerId ?? "(none)"}
113610
- Issue date: ${inv.issueDate ? new Date(inv.issueDate).toLocaleDateString() : "-"} | Due: ${inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : "-"}
113611
- `
113612
- ).join("\n");
113613
- return {
113614
- content: [
113615
- {
113616
- type: "text",
113617
- text: `Found ${rows.length} invoices:
113701
+ return items.length > 0 ? items : null;
113702
+ }
113703
+ function serializeIncludedItems(items) {
113704
+ if (items == null) return null;
113705
+ const cleaned = items.map((item) => ({
113706
+ label: item.label.trim(),
113707
+ ...item.productId ? { productId: item.productId } : {}
113708
+ })).filter((item) => item.label.length > 0);
113709
+ return cleaned.length > 0 ? cleaned : null;
113710
+ }
113711
+ function includedItemLabels(items) {
113712
+ const parsed = items ? serializeIncludedItems(items) : null;
113713
+ if (!parsed?.length) return null;
113714
+ return parsed.map((item) => item.label);
113715
+ }
113618
113716
 
113619
- ${list}
113620
- Use \`link-document-to-invoice\` (or \`invoiceId\` on create-document) to attach a deliverables document to one of these invoices.`
113717
+ // ../invoice/src/utils/product-options.ts
113718
+ function findOption(group3, optionId) {
113719
+ if (!optionId) return void 0;
113720
+ return group3.options?.find((option) => option.id === optionId);
113721
+ }
113722
+ function buildDefaultConfiguration(product) {
113723
+ const groups = product.options ?? [];
113724
+ return {
113725
+ selections: groups.map((group3) => {
113726
+ if (group3.type === "quantity") {
113727
+ return {
113728
+ groupId: group3.id,
113729
+ type: "quantity",
113730
+ quantity: group3.defaultQuantity ?? group3.min ?? 0
113731
+ };
113621
113732
  }
113622
- ]
113733
+ if (group3.type === "single_choice") {
113734
+ const defaultOption = group3.options?.find((option) => option.selectedByDefault) ?? group3.options?.[0];
113735
+ return {
113736
+ groupId: group3.id,
113737
+ type: "single_choice",
113738
+ optionId: defaultOption?.id ?? null
113739
+ };
113740
+ }
113741
+ return {
113742
+ groupId: group3.id,
113743
+ type: "addon",
113744
+ optionIds: group3.options?.filter((option) => option.selectedByDefault).map((option) => option.id) ?? []
113745
+ };
113746
+ })
113623
113747
  };
113624
113748
  }
113625
- async function handleLinkDocumentToInvoice(input) {
113626
- const { documentId, invoiceId } = input;
113627
- if (!documentId) {
113628
- return {
113629
- content: [{ type: "text", text: "Error: `documentId` is required." }]
113630
- };
113631
- }
113632
- const scope = await resolveTeamScope(input.teamId);
113633
- if (!scope.ok) return scope.response;
113634
- if (scope.teamIds.length === 0) {
113635
- return { content: [{ type: "text", text: "No accessible teams found." }] };
113749
+ function computeConfiguredPrice(product, configuration) {
113750
+ const base = product.price ?? 0;
113751
+ const groups = product.options ?? [];
113752
+ if (!configuration || groups.length === 0) {
113753
+ return base;
113636
113754
  }
113637
- const [doc] = await db.select({
113638
- id: schema_exports.documents.id,
113639
- title: schema_exports.documents.title,
113640
- teamId: schema_exports.documents.teamId,
113641
- invoiceId: schema_exports.documents.invoiceId
113642
- }).from(schema_exports.documents).where(
113643
- and(
113644
- eq(schema_exports.documents.id, documentId),
113645
- inArray(schema_exports.documents.teamId, scope.teamIds),
113646
- isNull(schema_exports.documents.deletedAt)
113647
- )
113648
- ).limit(1);
113649
- if (!doc) {
113650
- return {
113651
- content: [
113652
- {
113653
- type: "text",
113654
- text: `Document ${documentId} not found or you don't have access to it.`
113655
- }
113656
- ]
113657
- };
113755
+ let extra = 0;
113756
+ for (const group3 of groups) {
113757
+ const selection = configuration.selections.find(
113758
+ (item) => item.groupId === group3.id
113759
+ );
113760
+ if (!selection) continue;
113761
+ if (group3.type === "quantity" && selection.type === "quantity") {
113762
+ const clamped = clampQuantity(selection.quantity, group3);
113763
+ extra += clamped * (group3.pricePerUnit ?? 0);
113764
+ continue;
113765
+ }
113766
+ if (group3.type === "single_choice" && selection.type === "single_choice") {
113767
+ const option = findOption(group3, selection.optionId);
113768
+ extra += option?.price ?? 0;
113769
+ continue;
113770
+ }
113771
+ if (group3.type === "addon" && selection.type === "addon") {
113772
+ for (const optionId of selection.optionIds) {
113773
+ const option = findOption(group3, optionId);
113774
+ extra += option?.price ?? 0;
113775
+ }
113776
+ }
113658
113777
  }
113659
- if (!invoiceId) {
113660
- await db.update(schema_exports.documents).set({ invoiceId: null, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.documents.id, doc.id));
113661
- return {
113662
- content: [
113663
- {
113664
- type: "text",
113665
- text: `\u2705 Document "${doc.title}" (${doc.id}) is unlinked from its invoice.`
113666
- }
113667
- ]
113668
- };
113778
+ return base + extra;
113779
+ }
113780
+ function clampQuantity(quantity, group3) {
113781
+ const value = Number.isFinite(quantity) ? quantity : 0;
113782
+ const min = group3.min ?? 0;
113783
+ const max = group3.max;
113784
+ const lower = Math.max(value, min);
113785
+ return max != null ? Math.min(lower, max) : lower;
113786
+ }
113787
+
113788
+ // ../invoice/src/utils/product-clause.ts
113789
+ function trimToNull(value) {
113790
+ if (typeof value !== "string") return null;
113791
+ const trimmed = value.trim();
113792
+ return trimmed.length > 0 ? trimmed : null;
113793
+ }
113794
+ function parseStringList(value) {
113795
+ if (!Array.isArray(value)) return [];
113796
+ const out = [];
113797
+ for (const entry of value) {
113798
+ const label = trimToNull(entry);
113799
+ if (label) out.push(label);
113800
+ }
113801
+ return out;
113802
+ }
113803
+ function parseLimits(value) {
113804
+ if (!value || typeof value !== "object") return null;
113805
+ const raw = value;
113806
+ const hoursRaw = raw.includedHoursPerMonth;
113807
+ let includedHoursPerMonth = null;
113808
+ if (typeof hoursRaw === "number" && Number.isFinite(hoursRaw)) {
113809
+ includedHoursPerMonth = hoursRaw;
113810
+ } else if (typeof hoursRaw === "string" && hoursRaw.trim() !== "") {
113811
+ const parsed = Number(hoursRaw);
113812
+ if (Number.isFinite(parsed)) includedHoursPerMonth = parsed;
113813
+ }
113814
+ const rollover = typeof raw.rollover === "boolean" ? raw.rollover : null;
113815
+ const limits = {
113816
+ includedHoursPerMonth,
113817
+ rollover,
113818
+ minimumBillingUnitExtraWork: trimToNull(raw.minimumBillingUnitExtraWork),
113819
+ responseTime: trimToNull(raw.responseTime),
113820
+ supportLevel: trimToNull(raw.supportLevel)
113821
+ };
113822
+ return isLimitsEmpty(limits) ? null : limits;
113823
+ }
113824
+ function isLimitsEmpty(limits) {
113825
+ if (!limits) return true;
113826
+ return limits.includedHoursPerMonth == null && limits.rollover == null && !limits.minimumBillingUnitExtraWork && !limits.responseTime && !limits.supportLevel;
113827
+ }
113828
+ function parseProductClause(value) {
113829
+ if (value == null) return null;
113830
+ if (typeof value !== "object" || Array.isArray(value)) return null;
113831
+ const raw = value;
113832
+ const clause = {
113833
+ commercialDescription: trimToNull(raw.commercialDescription),
113834
+ includedScope: parseStringList(raw.includedScope),
113835
+ excludedScope: parseStringList(raw.excludedScope),
113836
+ limits: parseLimits(raw.limits),
113837
+ customerValue: trimToNull(raw.customerValue),
113838
+ extraWorkConditions: trimToNull(raw.extraWorkConditions)
113839
+ };
113840
+ return isClauseEmpty(clause) ? null : clause;
113841
+ }
113842
+ function isClauseEmpty(clause) {
113843
+ if (!clause) return true;
113844
+ return !clause.commercialDescription && (clause.includedScope?.length ?? 0) === 0 && (clause.excludedScope?.length ?? 0) === 0 && isLimitsEmpty(clause.limits) && !clause.customerValue && !clause.extraWorkConditions;
113845
+ }
113846
+ function serializeProductClause(clause) {
113847
+ if (clause == null) return null;
113848
+ const parsed = parseProductClause(clause);
113849
+ if (!parsed) return null;
113850
+ const out = {};
113851
+ if (parsed.commercialDescription) {
113852
+ out.commercialDescription = parsed.commercialDescription;
113853
+ }
113854
+ if (parsed.includedScope && parsed.includedScope.length > 0) {
113855
+ out.includedScope = parsed.includedScope;
113856
+ }
113857
+ if (parsed.excludedScope && parsed.excludedScope.length > 0) {
113858
+ out.excludedScope = parsed.excludedScope;
113859
+ }
113860
+ if (!isLimitsEmpty(parsed.limits)) {
113861
+ const limits = parsed.limits;
113862
+ const cleanedLimits = {};
113863
+ if (limits.includedHoursPerMonth != null) {
113864
+ cleanedLimits.includedHoursPerMonth = limits.includedHoursPerMonth;
113865
+ }
113866
+ if (limits.rollover != null) cleanedLimits.rollover = limits.rollover;
113867
+ if (limits.minimumBillingUnitExtraWork) {
113868
+ cleanedLimits.minimumBillingUnitExtraWork = limits.minimumBillingUnitExtraWork;
113869
+ }
113870
+ if (limits.responseTime) cleanedLimits.responseTime = limits.responseTime;
113871
+ if (limits.supportLevel) cleanedLimits.supportLevel = limits.supportLevel;
113872
+ out.limits = cleanedLimits;
113873
+ }
113874
+ if (parsed.customerValue) out.customerValue = parsed.customerValue;
113875
+ if (parsed.extraWorkConditions) {
113876
+ out.extraWorkConditions = parsed.extraWorkConditions;
113877
+ }
113878
+ return Object.keys(out).length > 0 ? out : null;
113879
+ }
113880
+ function clauseLimitLines(limits) {
113881
+ if (isLimitsEmpty(limits) || !limits) return [];
113882
+ const lines = [];
113883
+ if (limits.includedHoursPerMonth != null) {
113884
+ lines.push(`Inbegrepen uren per maand: ${limits.includedHoursPerMonth}`);
113885
+ }
113886
+ if (limits.rollover != null) {
113887
+ lines.push(
113888
+ limits.rollover ? "Niet-gebruikte uren schuiven door naar de volgende maand" : "Niet-gebruikte uren vervallen aan het einde van de maand"
113889
+ );
113890
+ }
113891
+ if (limits.minimumBillingUnitExtraWork) {
113892
+ lines.push(
113893
+ `Minimale facturatie-eenheid meerwerk: ${limits.minimumBillingUnitExtraWork}`
113894
+ );
113895
+ }
113896
+ if (limits.supportLevel) lines.push(`Supportniveau: ${limits.supportLevel}`);
113897
+ if (limits.responseTime) lines.push(`Responstijd: ${limits.responseTime}`);
113898
+ return lines;
113899
+ }
113900
+ function clauseSummaryLines(clause) {
113901
+ const parsed = parseProductClause(clause);
113902
+ if (!parsed) return [];
113903
+ const lines = [];
113904
+ if (parsed.commercialDescription) {
113905
+ lines.push(parsed.commercialDescription);
113906
+ }
113907
+ if (parsed.includedScope?.length) {
113908
+ lines.push(`Inbegrepen: ${parsed.includedScope.join("; ")}`);
113909
+ }
113910
+ if (parsed.excludedScope?.length) {
113911
+ lines.push(`Niet inbegrepen: ${parsed.excludedScope.join("; ")}`);
113912
+ }
113913
+ lines.push(...clauseLimitLines(parsed.limits));
113914
+ if (parsed.customerValue) {
113915
+ lines.push(`Klantwaarde: ${parsed.customerValue}`);
113916
+ }
113917
+ if (parsed.extraWorkConditions) {
113918
+ lines.push(`Meerwerk: ${parsed.extraWorkConditions}`);
113919
+ }
113920
+ return lines;
113921
+ }
113922
+
113923
+ // ../invoice/src/utils/pricing-options.ts
113924
+ function defaultMonthsCovered(interval2) {
113925
+ return interval2 === "year" ? 12 : 1;
113926
+ }
113927
+ function formatRecurringNote(option) {
113928
+ const period = option.billingInterval === "year" ? "per jaar" : "per maand";
113929
+ const bits = [`${option.label} (${period})`];
113930
+ if (option.discountDescription) bits.push(option.discountDescription);
113931
+ const contract = option.contract;
113932
+ const terms = [];
113933
+ if (contract?.minimumTermMonths) {
113934
+ terms.push(`min. looptijd ${contract.minimumTermMonths} mnd`);
113935
+ }
113936
+ if (contract?.noticePeriodDays) {
113937
+ terms.push(`opzegtermijn ${contract.noticePeriodDays} dagen`);
113938
+ }
113939
+ if (contract?.renewalPolicy === "auto_renew") {
113940
+ terms.push("stilzwijgend verlengd");
113941
+ }
113942
+ if (terms.length) bits.push(terms.join(", "));
113943
+ return bits.join(" \xB7 ");
113944
+ }
113945
+ function resolveDefaultPricingOption(options, defaultId) {
113946
+ if (!options || options.length === 0) return null;
113947
+ if (defaultId) {
113948
+ const found = options.find((o3) => o3.id === defaultId);
113949
+ if (found) return found;
113950
+ }
113951
+ return options[0] ?? null;
113952
+ }
113953
+ function effectivePricingOptions(product) {
113954
+ if (product.pricingOptions && product.pricingOptions.length > 0) {
113955
+ return product.pricingOptions;
113956
+ }
113957
+ const interval2 = product.billingType === "yearly" ? "year" : product.billingType === "monthly" ? "month" : null;
113958
+ if (interval2 && product.price != null) {
113959
+ return [
113960
+ {
113961
+ id: interval2,
113962
+ label: interval2 === "year" ? "Jaarlijks" : "Maandelijks",
113963
+ billingInterval: interval2,
113964
+ price: product.price,
113965
+ monthsCovered: defaultMonthsCovered(interval2)
113966
+ }
113967
+ ];
113968
+ }
113969
+ return [];
113970
+ }
113971
+ function endOfUtcYear(iso) {
113972
+ const date10 = new Date(iso);
113973
+ if (Number.isNaN(date10.getTime())) return iso;
113974
+ return new Date(
113975
+ Date.UTC(date10.getUTCFullYear(), 11, 31, 23, 59, 59, 999)
113976
+ ).toISOString();
113977
+ }
113978
+ function countInclusiveMonths(startIso, endIso) {
113979
+ const start = new Date(startIso);
113980
+ const end = new Date(endIso);
113981
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0;
113982
+ if (end.getTime() < start.getTime()) return 0;
113983
+ return (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + (end.getUTCMonth() - start.getUTCMonth()) + 1;
113984
+ }
113985
+ function computeProRata(input) {
113986
+ if (!input.startDate) return null;
113987
+ const start = new Date(input.startDate);
113988
+ if (Number.isNaN(start.getTime())) return null;
113989
+ const monthsInPeriod = input.monthsInPeriod != null && input.monthsInPeriod > 0 ? Math.round(input.monthsInPeriod) : 12;
113990
+ const periodStart = input.startDate;
113991
+ const periodEnd = input.periodEndDate ?? endOfUtcYear(input.startDate);
113992
+ const end = new Date(periodEnd);
113993
+ if (Number.isNaN(end.getTime())) return null;
113994
+ let months2 = countInclusiveMonths(periodStart, periodEnd);
113995
+ months2 = Math.min(Math.max(months2, 1), monthsInPeriod);
113996
+ const fraction = months2 / monthsInPeriod;
113997
+ const amount = Math.round(input.pricePerPeriod * fraction * 100) / 100;
113998
+ return { months: months2, fraction, amount, periodStart, periodEnd };
113999
+ }
114000
+
114001
+ // src/tools/invoice-line-util.ts
114002
+ function round2(n3) {
114003
+ return Math.round(n3 * 100) / 100;
114004
+ }
114005
+ function lineFinancials(quantity, price, defaults) {
114006
+ const lineTotal = quantity * price;
114007
+ return {
114008
+ vat: defaults.includeVat ? round2(lineTotal * (defaults.vatRate / 100)) : void 0,
114009
+ tax: defaults.includeTax ? round2(lineTotal * (defaults.taxRate / 100)) : void 0
114010
+ };
114011
+ }
114012
+ function computeInvoiceTotals(items, defaults, discount = 0) {
114013
+ const subtotal = items.reduce(
114014
+ (sum, i6) => sum + (i6.quantity || 0) * (i6.price || 0),
114015
+ 0
114016
+ );
114017
+ const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
114018
+ const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
114019
+ const safeDiscount = defaults.includeDiscount ? discount ?? 0 : 0;
114020
+ const amount = subtotal + vat + tax - safeDiscount;
114021
+ return {
114022
+ subtotal: round2(subtotal),
114023
+ vat: round2(vat),
114024
+ tax: round2(tax),
114025
+ amount: round2(amount)
114026
+ };
114027
+ }
114028
+ function templateDefaultsFromInvoice(template, currency) {
114029
+ const t8 = template ?? {};
114030
+ return {
114031
+ currency: t8.currency || currency || "EUR",
114032
+ vatRate: t8.vatRate ?? 21,
114033
+ taxRate: t8.taxRate ?? 0,
114034
+ includeVat: t8.includeVat ?? true,
114035
+ includeTax: t8.includeTax ?? false,
114036
+ includeDiscount: t8.includeDiscount ?? false,
114037
+ includeDecimals: t8.includeDecimals ?? true,
114038
+ includeUnits: t8.includeUnits ?? true,
114039
+ raw: t8
114040
+ };
114041
+ }
114042
+ function plainTextFromLineItemName(name21) {
114043
+ if (typeof name21 === "string") return name21.trim();
114044
+ if (!name21 || typeof name21 !== "object") return "";
114045
+ const parts = [];
114046
+ const walk = (node) => {
114047
+ if (!node || typeof node !== "object") return;
114048
+ const n3 = node;
114049
+ if (typeof n3.text === "string") parts.push(n3.text);
114050
+ if (Array.isArray(n3.content)) n3.content.forEach(walk);
114051
+ };
114052
+ walk(name21);
114053
+ return parts.join(" ").replace(/\s+/g, " ").trim();
114054
+ }
114055
+ function resolvePricingOption(product, pricingOptionId) {
114056
+ const options = effectivePricingOptions(product);
114057
+ if (options.length === 0) return null;
114058
+ if (pricingOptionId) {
114059
+ const found = options.find((o3) => o3.id === pricingOptionId);
114060
+ if (found) return found;
114061
+ }
114062
+ return resolveDefaultPricingOption(options, product.defaultPricingOptionId);
114063
+ }
114064
+ function compactLineDescription(product, override) {
114065
+ if (override?.trim()) return override.trim();
114066
+ const clause = parseProductClause(product.clause);
114067
+ if (clause?.commercialDescription) return clause.commercialDescription;
114068
+ return product.name;
114069
+ }
114070
+ function buildInvoiceLineFromProduct(product, opts, defaults) {
114071
+ const quantity = opts.quantity ?? 1;
114072
+ const pricingOption = resolvePricingOption(product, opts.pricingOptionId);
114073
+ const clause = serializeProductClause(product.clause);
114074
+ let configuration;
114075
+ let price = opts.customPrice ?? product.price ?? 0;
114076
+ if (product.isConfigurable && product.options) {
114077
+ const groups = product.options;
114078
+ if (groups?.length) {
114079
+ const defaultConfig = buildDefaultConfiguration({
114080
+ ...product,
114081
+ options: groups
114082
+ });
114083
+ configuration = defaultConfig;
114084
+ price = computeConfiguredPrice(
114085
+ product,
114086
+ defaultConfig
114087
+ );
114088
+ }
114089
+ }
114090
+ if (pricingOption && !(product.isConfigurable && product.options)) {
114091
+ price = pricingOption.price;
114092
+ }
114093
+ if (opts.prorate && pricingOption?.billingInterval === "year" && opts.startDate) {
114094
+ const proRata = computeProRata({
114095
+ pricePerPeriod: price,
114096
+ startDate: opts.startDate,
114097
+ periodEndDate: opts.periodEndDate,
114098
+ monthsInPeriod: pricingOption.monthsCovered ?? 12
114099
+ });
114100
+ if (proRata) price = proRata.amount;
114101
+ }
114102
+ if (opts.customPrice != null) price = opts.customPrice;
114103
+ const { vat, tax } = lineFinancials(quantity, price, defaults);
114104
+ return {
114105
+ name: compactLineDescription(product, opts.customDescription),
114106
+ quantity,
114107
+ unit: product.unit || void 0,
114108
+ price,
114109
+ vat,
114110
+ tax,
114111
+ productId: product.id,
114112
+ configuration,
114113
+ clause,
114114
+ pricingOption: pricingOption ?? null
114115
+ };
114116
+ }
114117
+ function formatLineItemDetail(line2, index2) {
114118
+ const description = plainTextFromLineItemName(line2.name) || "(no description)";
114119
+ const lineTotal = round2((line2.quantity ?? 0) * (line2.price ?? 0));
114120
+ const parts = [
114121
+ `[${index2}] **${description}**`,
114122
+ ` qty=${line2.quantity ?? 0}${line2.unit ? ` ${line2.unit}` : ""} \xD7 ${line2.price ?? 0} = ${lineTotal}`
114123
+ ];
114124
+ if (line2.productId) parts.push(` productId: ${line2.productId}`);
114125
+ if (line2.pricingOption) {
114126
+ parts.push(` variant: ${formatRecurringNote(line2.pricingOption)}`);
114127
+ }
114128
+ const clauseLines = clauseSummaryLines(parseProductClause(line2.clause));
114129
+ if (clauseLines.length > 0) {
114130
+ parts.push(" scope:");
114131
+ clauseLines.forEach((l4) => parts.push(` - ${l4}`));
114132
+ }
114133
+ return parts.join("\n");
114134
+ }
114135
+
114136
+ // src/tools/invoices.ts
114137
+ var INVOICE_STATUSES = [
114138
+ "draft",
114139
+ "overdue",
114140
+ "paid",
114141
+ "partially_paid",
114142
+ "unpaid",
114143
+ "canceled",
114144
+ "scheduled",
114145
+ "refunded"
114146
+ ];
114147
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
114148
+ function textResponse2(text3) {
114149
+ return { content: [{ type: "text", text: text3 }] };
114150
+ }
114151
+ function tiptapNote(text3) {
114152
+ return {
114153
+ type: "doc",
114154
+ content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
114155
+ };
114156
+ }
114157
+ var INVOICE_DETAIL_COLUMNS = {
114158
+ id: schema_exports.invoices.id,
114159
+ teamId: schema_exports.invoices.teamId,
114160
+ invoiceNumber: schema_exports.invoices.invoiceNumber,
114161
+ status: schema_exports.invoices.status,
114162
+ customerId: schema_exports.invoices.customerId,
114163
+ customerName: schema_exports.invoices.customerName,
114164
+ amount: schema_exports.invoices.amount,
114165
+ subtotal: schema_exports.invoices.subtotal,
114166
+ vat: schema_exports.invoices.vat,
114167
+ tax: schema_exports.invoices.tax,
114168
+ discount: schema_exports.invoices.discount,
114169
+ currency: schema_exports.invoices.currency,
114170
+ issueDate: schema_exports.invoices.issueDate,
114171
+ dueDate: schema_exports.invoices.dueDate,
114172
+ createdAt: schema_exports.invoices.createdAt,
114173
+ updatedAt: schema_exports.invoices.updatedAt,
114174
+ note: schema_exports.invoices.note,
114175
+ internalNote: schema_exports.invoices.internalNote,
114176
+ lineItems: schema_exports.invoices.lineItems,
114177
+ template: schema_exports.invoices.template,
114178
+ noteDetails: schema_exports.invoices.noteDetails
114179
+ };
114180
+ var PRODUCT_COLUMNS = {
114181
+ id: schema_exports.invoiceProducts.id,
114182
+ name: schema_exports.invoiceProducts.name,
114183
+ description: schema_exports.invoiceProducts.description,
114184
+ price: schema_exports.invoiceProducts.price,
114185
+ currency: schema_exports.invoiceProducts.currency,
114186
+ unit: schema_exports.invoiceProducts.unit,
114187
+ isConfigurable: schema_exports.invoiceProducts.isConfigurable,
114188
+ options: schema_exports.invoiceProducts.options,
114189
+ billingType: schema_exports.invoiceProducts.billingType,
114190
+ category: schema_exports.invoiceProducts.category,
114191
+ includedItems: schema_exports.invoiceProducts.includedItems,
114192
+ optional: schema_exports.invoiceProducts.optional,
114193
+ tier: schema_exports.invoiceProducts.tier,
114194
+ clause: schema_exports.invoiceProducts.clause,
114195
+ serviceCadence: schema_exports.invoiceProducts.serviceCadence,
114196
+ pricingOptions: schema_exports.invoiceProducts.pricingOptions,
114197
+ defaultPricingOptionId: schema_exports.invoiceProducts.defaultPricingOptionId
114198
+ };
114199
+ function parseStoredLineItems(value) {
114200
+ return Array.isArray(value) ? value : [];
114201
+ }
114202
+ async function loadInvoiceByIdentifier(identifier, teamIds) {
114203
+ if (teamIds.length === 0) return null;
114204
+ const byId = UUID_RE.test(identifier);
114205
+ const filters = [inArray(schema_exports.invoices.teamId, teamIds)];
114206
+ filters.push(
114207
+ byId ? eq(schema_exports.invoices.id, identifier) : eq(schema_exports.invoices.invoiceNumber, identifier)
114208
+ );
114209
+ const [row] = await db.select(INVOICE_DETAIL_COLUMNS).from(schema_exports.invoices).where(and(...filters)).limit(1);
114210
+ return row ?? null;
114211
+ }
114212
+ async function loadInvoiceInTeam(identifier, teamId) {
114213
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114214
+ return loadInvoiceByIdentifier(identifier, accessibleTeamIds);
114215
+ }
114216
+ async function loadProductsInTeam(productIds, teamId) {
114217
+ if (productIds.length === 0) return /* @__PURE__ */ new Map();
114218
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114219
+ const rows = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
114220
+ and(
114221
+ inArray(schema_exports.invoiceProducts.id, productIds),
114222
+ inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
114223
+ )
114224
+ );
114225
+ return new Map(rows.map((r6) => [r6.id, r6]));
114226
+ }
114227
+ async function resolveInvoiceLineItems(inputs, defaults, teamId) {
114228
+ const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
114229
+ const products = await loadProductsInTeam([...new Set(productIds)], teamId);
114230
+ const items = [];
114231
+ for (const input of inputs) {
114232
+ if (input.productId) {
114233
+ const product = products.get(input.productId);
114234
+ if (!product) {
114235
+ return {
114236
+ items: [],
114237
+ error: `Product ${input.productId} not found or not owned by this team.`
114238
+ };
114239
+ }
114240
+ items.push(
114241
+ buildInvoiceLineFromProduct(
114242
+ product,
114243
+ {
114244
+ quantity: input.quantity,
114245
+ customDescription: input.name ?? input.description,
114246
+ customPrice: input.price,
114247
+ pricingOptionId: input.pricingOptionId,
114248
+ prorate: input.prorate,
114249
+ startDate: input.startDate,
114250
+ periodEndDate: input.periodEndDate
114251
+ },
114252
+ defaults
114253
+ )
114254
+ );
114255
+ continue;
114256
+ }
114257
+ const quantity = input.quantity ?? 1;
114258
+ const price = input.price ?? 0;
114259
+ const name21 = input.name?.trim() || input.description?.trim() || "(no description)";
114260
+ const { vat, tax } = lineFinancials(quantity, price, defaults);
114261
+ items.push({ name: name21, quantity, unit: input.unit, price, vat, tax });
114262
+ }
114263
+ return { items };
114264
+ }
114265
+ function notDraftResponse(invoice) {
114266
+ return textResponse2(
114267
+ `Invoice ${invoice.invoiceNumber ?? invoice.id} has status "${invoice.status}", not "draft". These tools only modify draft invoices \u2014 sent/paid/unpaid/overdue invoices are immutable here.`
114268
+ );
114269
+ }
114270
+ function formatInvoiceSummary(invoice) {
114271
+ const items = parseStoredLineItems(invoice.lineItems);
114272
+ return `**${invoice.invoiceNumber ?? "(draft, no number)"}** (${invoice.status})
114273
+ ID: ${invoice.id}
114274
+ Customer: ${invoice.customerName ?? invoice.customerId ?? "(none)"}
114275
+ Total: ${invoice.amount ?? "?"} ${invoice.currency ?? ""} (subtotal ${invoice.subtotal ?? "?"}, VAT ${invoice.vat ?? 0})
114276
+ Line items: ${items.length}
114277
+ Issue: ${invoice.issueDate ? new Date(invoice.issueDate).toLocaleDateString() : "-"} | Due: ${invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : "-"}
114278
+ `;
114279
+ }
114280
+ async function handleGetInvoices(input) {
114281
+ const { customerId, status, q: q3, pageSize = 20 } = input;
114282
+ if (status && !INVOICE_STATUSES.includes(status)) {
114283
+ return textResponse2(
114284
+ `Error: invalid status "${status}". Allowed: ${INVOICE_STATUSES.join(", ")}.`
114285
+ );
114286
+ }
114287
+ const scope = await resolveTeamScope(input.teamId);
114288
+ if (!scope.ok) return scope.response;
114289
+ if (scope.teamIds.length === 0) {
114290
+ return textResponse2("No accessible teams found.");
114291
+ }
114292
+ const filters = [inArray(schema_exports.invoices.teamId, scope.teamIds)];
114293
+ if (customerId) filters.push(eq(schema_exports.invoices.customerId, customerId));
114294
+ if (status) filters.push(eq(schema_exports.invoices.status, status));
114295
+ if (q3) {
114296
+ filters.push(
114297
+ or(
114298
+ ilike(schema_exports.invoices.invoiceNumber, `%${q3}%`),
114299
+ ilike(schema_exports.invoices.customerName, `%${q3}%`)
114300
+ )
114301
+ );
114302
+ }
114303
+ const rows = await db.select({
114304
+ id: schema_exports.invoices.id,
114305
+ invoiceNumber: schema_exports.invoices.invoiceNumber,
114306
+ status: schema_exports.invoices.status,
114307
+ teamId: schema_exports.invoices.teamId,
114308
+ customerId: schema_exports.invoices.customerId,
114309
+ customerName: schema_exports.invoices.customerName,
114310
+ amount: schema_exports.invoices.amount,
114311
+ currency: schema_exports.invoices.currency,
114312
+ issueDate: schema_exports.invoices.issueDate,
114313
+ dueDate: schema_exports.invoices.dueDate,
114314
+ createdAt: schema_exports.invoices.createdAt
114315
+ }).from(schema_exports.invoices).where(and(...filters)).orderBy(desc(schema_exports.invoices.createdAt)).limit(Math.min(pageSize, 100));
114316
+ if (rows.length === 0) {
114317
+ return textResponse2("No invoices found.");
114318
+ }
114319
+ const list = rows.map(
114320
+ (inv) => `**${inv.invoiceNumber ?? "(draft, no number)"}**
114321
+ ID: ${inv.id}
114322
+ Status: ${inv.status} | Amount: ${inv.amount ?? "?"} ${inv.currency ?? ""}
114323
+ Customer: ${inv.customerName ?? inv.customerId ?? "(none)"}
114324
+ Issue date: ${inv.issueDate ? new Date(inv.issueDate).toLocaleDateString() : "-"} | Due: ${inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : "-"}
114325
+ `
114326
+ ).join("\n");
114327
+ return textResponse2(
114328
+ `Found ${rows.length} invoices:
114329
+
114330
+ ${list}
114331
+ Use \`get-invoice-by-id\` for line items and linked documents. Use \`link-document-to-invoice\` (or \`invoiceId\` on create-document) to attach deliverables.`
114332
+ );
114333
+ }
114334
+ async function handleGetInvoiceById(input) {
114335
+ const { invoiceId } = input;
114336
+ if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
114337
+ const scope = await resolveTeamScope(input.teamId);
114338
+ if (!scope.ok) return scope.response;
114339
+ if (scope.teamIds.length === 0) {
114340
+ return textResponse2("No accessible teams found.");
114341
+ }
114342
+ const invoice = await loadInvoiceByIdentifier(invoiceId, scope.teamIds);
114343
+ if (!invoice) {
114344
+ return textResponse2(
114345
+ `Invoice ${invoiceId} not found or you don't have access to it.`
114346
+ );
114347
+ }
114348
+ const lineItems = parseStoredLineItems(invoice.lineItems);
114349
+ const linkedDocs = await db.select({
114350
+ id: schema_exports.documents.id,
114351
+ title: schema_exports.documents.title,
114352
+ type: schema_exports.documents.type
114353
+ }).from(schema_exports.documents).where(
114354
+ and(
114355
+ eq(schema_exports.documents.invoiceId, invoice.id),
114356
+ isNull(schema_exports.documents.deletedAt)
114357
+ )
114358
+ );
114359
+ const linesText = lineItems.length > 0 ? lineItems.map((line2, i6) => formatLineItemDetail(line2, i6)).join("\n\n") : "(no line items)";
114360
+ const docsText = linkedDocs.length > 0 ? linkedDocs.map((d6) => `- ${d6.title} (${d6.type ?? "document"}) \u2014 ${d6.id}`).join("\n") : "(none)";
114361
+ return textResponse2(
114362
+ `**Invoice ${invoice.invoiceNumber ?? invoice.id}**
114363
+
114364
+ ID: ${invoice.id}
114365
+ Status: ${invoice.status}
114366
+ Customer: ${invoice.customerName ?? invoice.customerId ?? "(none)"}
114367
+ Currency: ${invoice.currency ?? "EUR"}
114368
+ Issue date: ${invoice.issueDate ? new Date(invoice.issueDate).toLocaleDateString() : "-"}
114369
+ Due date: ${invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : "-"}
114370
+ Subtotal: ${invoice.subtotal ?? "?"} | VAT: ${invoice.vat ?? 0} | Tax: ${invoice.tax ?? 0} | Total: ${invoice.amount ?? "?"}
114371
+
114372
+ **Line items (${lineItems.length})**
114373
+ ${linesText}
114374
+
114375
+ **Linked documents (${linkedDocs.length})**
114376
+ ${docsText}
114377
+
114378
+ ` + (invoice.status === "draft" ? "This invoice is a draft \u2014 use `update-invoice-lines` to compact descriptions or `add-product-to-invoice` to add catalog products." : "This invoice is not a draft \u2014 line items are read-only via MCP.")
114379
+ );
114380
+ }
114381
+ async function handleUpdateInvoice(input) {
114382
+ const { invoiceId } = input;
114383
+ if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
114384
+ const resolved = await resolveTeamId(input.teamId);
114385
+ if (!resolved.ok) return resolved.response;
114386
+ const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
114387
+ if (!invoice) {
114388
+ return textResponse2(
114389
+ `Invoice ${invoiceId} not found or not owned by this team.`
114390
+ );
114391
+ }
114392
+ if (invoice.status !== "draft") return notDraftResponse(invoice);
114393
+ const defaults = templateDefaultsFromInvoice(
114394
+ invoice.template,
114395
+ invoice.currency
114396
+ );
114397
+ const updates = {};
114398
+ if (input.title !== void 0) {
114399
+ updates.template = { ...defaults.raw, title: input.title };
114400
+ }
114401
+ if (input.note !== void 0) {
114402
+ updates.noteDetails = input.note ? tiptapNote(input.note) : null;
114403
+ }
114404
+ if (input.internalNote !== void 0) {
114405
+ updates.internalNote = input.internalNote;
114406
+ }
114407
+ if (input.dueDate !== void 0) updates.dueDate = input.dueDate;
114408
+ if (input.issueDate !== void 0) updates.issueDate = input.issueDate;
114409
+ if (input.lineItems !== void 0) {
114410
+ const { items, error: error49 } = await resolveInvoiceLineItems(
114411
+ input.lineItems,
114412
+ defaults,
114413
+ invoice.teamId
114414
+ );
114415
+ if (error49) return textResponse2(`Error: ${error49}`);
114416
+ const totals = computeInvoiceTotals(
114417
+ items,
114418
+ defaults,
114419
+ invoice.discount ?? 0
114420
+ );
114421
+ updates.lineItems = items;
114422
+ updates.subtotal = totals.subtotal;
114423
+ updates.vat = totals.vat;
114424
+ updates.tax = totals.tax;
114425
+ updates.amount = totals.amount;
114426
+ }
114427
+ if (Object.keys(updates).length === 0) {
114428
+ return textResponse2(
114429
+ "No fields to update. Provide at least one of: title, note, internalNote, dueDate, issueDate, lineItems."
114430
+ );
114431
+ }
114432
+ updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
114433
+ const [updated] = await db.update(schema_exports.invoices).set(updates).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
114434
+ if (!updated) return textResponse2(`Failed to update invoice ${invoiceId}.`);
114435
+ return textResponse2(`\u2705 **Draft invoice updated**
114436
+
114437
+ ${formatInvoiceSummary(updated)}`);
114438
+ }
114439
+ async function handleUpdateInvoiceLines(input) {
114440
+ const { invoiceId, lineItems: patches } = input;
114441
+ if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
114442
+ if (!patches || patches.length === 0) {
114443
+ return textResponse2("Error: `lineItems` must be a non-empty array.");
114444
+ }
114445
+ const resolved = await resolveTeamId(input.teamId);
114446
+ if (!resolved.ok) return resolved.response;
114447
+ const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
114448
+ if (!invoice) {
114449
+ return textResponse2(
114450
+ `Invoice ${invoiceId} not found or not owned by this team.`
114451
+ );
114452
+ }
114453
+ if (invoice.status !== "draft") return notDraftResponse(invoice);
114454
+ const defaults = templateDefaultsFromInvoice(
114455
+ invoice.template,
114456
+ invoice.currency
114457
+ );
114458
+ const items = [...parseStoredLineItems(invoice.lineItems)];
114459
+ let updatedCount = 0;
114460
+ for (const patch of patches) {
114461
+ const index2 = patch.index;
114462
+ if (index2 < 0 || index2 >= items.length) {
114463
+ return textResponse2(
114464
+ `Error: line index ${index2} is out of range (invoice has ${items.length} line(s)).`
114465
+ );
114466
+ }
114467
+ const line2 = { ...items[index2] };
114468
+ if (patch.description !== void 0) line2.name = patch.description;
114469
+ if (patch.quantity !== void 0) line2.quantity = patch.quantity;
114470
+ if (patch.unit !== void 0) line2.unit = patch.unit ?? void 0;
114471
+ if (patch.price !== void 0) line2.price = patch.price;
114472
+ const qty = line2.quantity ?? 1;
114473
+ const price = line2.price ?? 0;
114474
+ const { vat, tax } = lineFinancials(qty, price, defaults);
114475
+ line2.vat = vat;
114476
+ line2.tax = tax;
114477
+ items[index2] = line2;
114478
+ updatedCount++;
114479
+ }
114480
+ const totals = computeInvoiceTotals(items, defaults, invoice.discount ?? 0);
114481
+ const [updated] = await db.update(schema_exports.invoices).set({
114482
+ lineItems: items,
114483
+ subtotal: totals.subtotal,
114484
+ vat: totals.vat,
114485
+ tax: totals.tax,
114486
+ amount: totals.amount,
114487
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
114488
+ }).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
114489
+ if (!updated) {
114490
+ return textResponse2(`Failed to update invoice lines for ${invoiceId}.`);
114491
+ }
114492
+ const changedLines = patches.map((p3) => {
114493
+ const line2 = items[p3.index];
114494
+ return `[${p3.index}] ${plainTextFromLineItemName(line2.name)}`;
114495
+ }).join("\n");
114496
+ return textResponse2(
114497
+ `\u2705 **Updated ${updatedCount} line item(s) on draft invoice ${updated.invoiceNumber ?? updated.id}**
114498
+
114499
+ ${changedLines}
114500
+
114501
+ New total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})`
114502
+ );
114503
+ }
114504
+ async function handleAddProductToInvoice(input) {
114505
+ const { invoiceId, productId } = input;
114506
+ if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
114507
+ if (!productId) return textResponse2("Error: `productId` is required.");
114508
+ const resolved = await resolveTeamId(input.teamId);
114509
+ if (!resolved.ok) return resolved.response;
114510
+ const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
114511
+ if (!invoice) {
114512
+ return textResponse2(
114513
+ `Invoice ${invoiceId} not found or not owned by this team.`
114514
+ );
114515
+ }
114516
+ if (invoice.status !== "draft") return notDraftResponse(invoice);
114517
+ const products = await loadProductsInTeam([productId], invoice.teamId);
114518
+ const product = products.get(productId);
114519
+ if (!product) {
114520
+ return textResponse2(
114521
+ `Product ${productId} not found or not owned by this team.`
114522
+ );
114523
+ }
114524
+ const defaults = templateDefaultsFromInvoice(
114525
+ invoice.template,
114526
+ invoice.currency
114527
+ );
114528
+ const newItem = buildInvoiceLineFromProduct(
114529
+ product,
114530
+ {
114531
+ quantity: input.quantity,
114532
+ customDescription: input.customDescription,
114533
+ customPrice: input.customPrice,
114534
+ pricingOptionId: input.pricingOptionId,
114535
+ prorate: input.prorate,
114536
+ startDate: input.startDate,
114537
+ periodEndDate: input.periodEndDate
114538
+ },
114539
+ defaults
114540
+ );
114541
+ const items = [...parseStoredLineItems(invoice.lineItems), newItem];
114542
+ const totals = computeInvoiceTotals(items, defaults, invoice.discount ?? 0);
114543
+ const [updated] = await db.update(schema_exports.invoices).set({
114544
+ lineItems: items,
114545
+ subtotal: totals.subtotal,
114546
+ vat: totals.vat,
114547
+ tax: totals.tax,
114548
+ amount: totals.amount,
114549
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
114550
+ }).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
114551
+ if (!updated) {
114552
+ return textResponse2(`Failed to add product to invoice ${invoiceId}.`);
114553
+ }
114554
+ return textResponse2(
114555
+ `\u2705 **Product added to draft invoice ${updated.invoiceNumber ?? updated.id}**
114556
+
114557
+ ` + formatLineItemDetail(newItem, items.length - 1) + `
114558
+
114559
+ New invoice total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})
114560
+ Clause and pricing variant are snapshotted on the line \u2014 later catalog edits won't change this invoice.`
114561
+ );
114562
+ }
114563
+ async function handleLinkDocumentToInvoice(input) {
114564
+ const { documentId, invoiceId } = input;
114565
+ if (!documentId) {
114566
+ return textResponse2("Error: `documentId` is required.");
114567
+ }
114568
+ const scope = await resolveTeamScope(input.teamId);
114569
+ if (!scope.ok) return scope.response;
114570
+ if (scope.teamIds.length === 0) {
114571
+ return textResponse2("No accessible teams found.");
114572
+ }
114573
+ const [doc] = await db.select({
114574
+ id: schema_exports.documents.id,
114575
+ title: schema_exports.documents.title,
114576
+ teamId: schema_exports.documents.teamId,
114577
+ invoiceId: schema_exports.documents.invoiceId
114578
+ }).from(schema_exports.documents).where(
114579
+ and(
114580
+ eq(schema_exports.documents.id, documentId),
114581
+ inArray(schema_exports.documents.teamId, scope.teamIds),
114582
+ isNull(schema_exports.documents.deletedAt)
114583
+ )
114584
+ ).limit(1);
114585
+ if (!doc) {
114586
+ return textResponse2(
114587
+ `Document ${documentId} not found or you don't have access to it.`
114588
+ );
114589
+ }
114590
+ if (!invoiceId) {
114591
+ await db.update(schema_exports.documents).set({ invoiceId: null, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.documents.id, doc.id));
114592
+ return textResponse2(
114593
+ `\u2705 Document "${doc.title}" (${doc.id}) is unlinked from its invoice.`
114594
+ );
113669
114595
  }
113670
114596
  const invoice = await findAccessibleInvoice(invoiceId, [doc.teamId]);
113671
114597
  if (!invoice) {
113672
- return {
113673
- content: [
113674
- {
113675
- type: "text",
113676
- text: `Error: invoice ${invoiceId} not found in team ${doc.teamId}. Use get-invoices to find a valid invoice id.`
113677
- }
113678
- ]
113679
- };
114598
+ return textResponse2(
114599
+ `Error: invoice ${invoiceId} not found in team ${doc.teamId}. Use get-invoices to find a valid invoice id.`
114600
+ );
113680
114601
  }
113681
114602
  await db.update(schema_exports.documents).set({ invoiceId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.documents.id, doc.id));
113682
- return {
113683
- content: [
113684
- {
113685
- type: "text",
113686
- text: `\u2705 **Document linked to invoice!**
114603
+ return textResponse2(
114604
+ `\u2705 **Document linked to invoice!**
113687
114605
 
113688
114606
  Document: ${doc.title} (${doc.id})
113689
114607
  Invoice: ${invoice.invoiceNumber ?? invoice.id} (${invoice.status})
113690
114608
 
113691
114609
  The document can now be selected as a PDF attachment when sending this invoice from the dashboard.`
113692
- }
113693
- ]
113694
- };
114610
+ );
113695
114611
  }
113696
114612
 
113697
114613
  // src/tools/project-cleanup-util.ts
@@ -113817,7 +114733,7 @@ ${description ? `Description: ${description}
113817
114733
  ]
113818
114734
  };
113819
114735
  }
113820
- function textResponse2(text3) {
114736
+ function textResponse3(text3) {
113821
114737
  return { content: [{ type: "text", text: text3 }] };
113822
114738
  }
113823
114739
  function memberLabel(m4) {
@@ -113831,7 +114747,7 @@ async function requireTeamOwner2(teamId, userId) {
113831
114747
  eq(schema_exports.usersOnTeam.teamId, teamId)
113832
114748
  )
113833
114749
  ).limit(1);
113834
- return membership?.role === "owner" ? null : textResponse2(OWNER_REQUIRED);
114750
+ return membership?.role === "owner" ? null : textResponse3(OWNER_REQUIRED);
113835
114751
  }
113836
114752
  async function setProjectMemberAccess(params) {
113837
114753
  const { projectId, teamId, memberIds, createdBy } = params;
@@ -113935,7 +114851,7 @@ async function resolveTeamMember(teamId, opts) {
113935
114851
  if (!match) {
113936
114852
  return {
113937
114853
  ok: false,
113938
- response: textResponse2(
114854
+ response: textResponse3(
113939
114855
  `User ${opts.userId} is not a member of this team. Call get-project-members to see the team roster.`
113940
114856
  )
113941
114857
  };
@@ -113948,7 +114864,7 @@ async function resolveTeamMember(teamId, opts) {
113948
114864
  if (matches.length === 0) {
113949
114865
  return {
113950
114866
  ok: false,
113951
- response: textResponse2(
114867
+ response: textResponse3(
113952
114868
  `No team member found with email "${opts.email}". Call get-project-members to see the team roster.`
113953
114869
  )
113954
114870
  };
@@ -113956,7 +114872,7 @@ async function resolveTeamMember(teamId, opts) {
113956
114872
  if (matches.length > 1) {
113957
114873
  return {
113958
114874
  ok: false,
113959
- response: textResponse2(
114875
+ response: textResponse3(
113960
114876
  `Multiple team members match email "${opts.email}". Pass an explicit userId instead.`
113961
114877
  )
113962
114878
  };
@@ -113965,7 +114881,7 @@ async function resolveTeamMember(teamId, opts) {
113965
114881
  }
113966
114882
  return {
113967
114883
  ok: false,
113968
- response: textResponse2(
114884
+ response: textResponse3(
113969
114885
  "Provide either a userId or an email to identify the member."
113970
114886
  )
113971
114887
  };
@@ -114014,7 +114930,7 @@ async function handleUpdateProject(input) {
114014
114930
  if (!resolved.ok) return resolved.response;
114015
114931
  const existing = await loadProjectInTeam(id, resolved.teamId);
114016
114932
  if (!existing) {
114017
- return textResponse2(
114933
+ return textResponse3(
114018
114934
  `Project ${id} not found, or it is not owned by this team.`
114019
114935
  );
114020
114936
  }
@@ -114029,7 +114945,7 @@ async function handleUpdateProject(input) {
114029
114945
  )
114030
114946
  ).limit(1);
114031
114947
  if (dupe) {
114032
- return textResponse2(
114948
+ return textResponse3(
114033
114949
  `A project named "${input.name}" already exists in this team. Choose a different name.`
114034
114950
  );
114035
114951
  }
@@ -114094,7 +115010,7 @@ async function handleUpdateProject(input) {
114094
115010
  customerName: schema_exports.customers.name
114095
115011
  }).from(schema_exports.projects).leftJoin(schema_exports.customers, eq(schema_exports.projects.customerId, schema_exports.customers.id)).where(eq(schema_exports.projects.id, id)).limit(1);
114096
115012
  if (!updated) {
114097
- return textResponse2(`Failed to update project ${id}.`);
115013
+ return textResponse3(`Failed to update project ${id}.`);
114098
115014
  }
114099
115015
  const lines = [
114100
115016
  "\u2705 **Project Updated**",
@@ -114112,7 +115028,7 @@ async function handleUpdateProject(input) {
114112
115028
  if (willRename) {
114113
115029
  lines.push("", "Note: tickets for this project were renumbered.");
114114
115030
  }
114115
- return textResponse2(lines.join("\n"));
115031
+ return textResponse3(lines.join("\n"));
114116
115032
  }
114117
115033
  async function handleGetProjectMembers(input) {
114118
115034
  const { projectId } = input;
@@ -114120,7 +115036,7 @@ async function handleGetProjectMembers(input) {
114120
115036
  if (!resolved.ok) return resolved.response;
114121
115037
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114122
115038
  if (!project) {
114123
- return textResponse2(
115039
+ return textResponse3(
114124
115040
  `Project ${projectId} not found, or it is not owned by this team.`
114125
115041
  );
114126
115042
  }
@@ -114149,7 +115065,7 @@ async function handleGetProjectMembers(input) {
114149
115065
  return `- ${memberLabel(m4)} (userId: ${m4.userId}, role: ${m4.role ?? "member"}) \u2014 ${access}`;
114150
115066
  }).join("\n");
114151
115067
  const note = state2.projectMemberIds.size === 0 ? "No members are explicitly assigned to this project, so every owner and every unrestricted member can see it." : `${state2.projectMemberIds.size} member(s) are explicitly assigned to this project.`;
114152
- return textResponse2(
115068
+ return textResponse3(
114153
115069
  `**Project members for "${project.name}"** (ID: ${project.id})
114154
115070
 
114155
115071
  ${note}
@@ -114170,7 +115086,7 @@ async function handleSetProjectMembers(input) {
114170
115086
  if (ownerError) return ownerError;
114171
115087
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114172
115088
  if (!project) {
114173
- return textResponse2(
115089
+ return textResponse3(
114174
115090
  `Project ${projectId} not found, or it is not owned by this team.`
114175
115091
  );
114176
115092
  }
@@ -114208,7 +115124,7 @@ async function handleSetProjectMembers(input) {
114208
115124
 
114209
115125
  \u26A0\uFE0F ${names} previously had no restrictions (could see all projects). They are now restricted to only the projects explicitly assigned to them.`;
114210
115126
  }
114211
- return textResponse2(
115127
+ return textResponse3(
114212
115128
  `\u2705 **Project members updated**
114213
115129
 
114214
115130
  Members with explicit access to this project:
@@ -114224,7 +115140,7 @@ async function handleAddProjectMember(input) {
114224
115140
  if (ownerError) return ownerError;
114225
115141
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114226
115142
  if (!project) {
114227
- return textResponse2(
115143
+ return textResponse3(
114228
115144
  `Project ${projectId} not found, or it is not owned by this team.`
114229
115145
  );
114230
115146
  }
@@ -114235,7 +115151,7 @@ async function handleAddProjectMember(input) {
114235
115151
  if (!member2.ok) return member2.response;
114236
115152
  const state2 = await getProjectAccessState(resolved.teamId, projectId);
114237
115153
  if (state2.projectMemberIds.has(member2.member.userId)) {
114238
- return textResponse2(
115154
+ return textResponse3(
114239
115155
  `${memberLabel(member2.member)} already has explicit access to this project.`
114240
115156
  );
114241
115157
  }
@@ -114250,7 +115166,7 @@ async function handleAddProjectMember(input) {
114250
115166
  if (wasUnrestricted) {
114251
115167
  text3 += "\n\n\u26A0\uFE0F This member previously had no access restrictions (they could see all projects). They are now restricted to ONLY the projects explicitly assigned to them. Grant any other projects they still need with add-project-member, or remove all their assignments to restore full visibility.";
114252
115168
  }
114253
- return textResponse2(text3);
115169
+ return textResponse3(text3);
114254
115170
  }
114255
115171
  async function handleRemoveProjectMember(input) {
114256
115172
  const ctx = getAuthContext();
@@ -114261,7 +115177,7 @@ async function handleRemoveProjectMember(input) {
114261
115177
  if (ownerError) return ownerError;
114262
115178
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114263
115179
  if (!project) {
114264
- return textResponse2(
115180
+ return textResponse3(
114265
115181
  `Project ${projectId} not found, or it is not owned by this team.`
114266
115182
  );
114267
115183
  }
@@ -114272,297 +115188,125 @@ async function handleRemoveProjectMember(input) {
114272
115188
  if (!member2.ok) return member2.response;
114273
115189
  const state2 = await getProjectAccessState(resolved.teamId, projectId);
114274
115190
  if (!state2.projectMemberIds.has(member2.member.userId)) {
114275
- return textResponse2(
115191
+ return textResponse3(
114276
115192
  `${memberLabel(member2.member)} has no explicit assignment to this project; nothing to remove.`
114277
- );
114278
- }
114279
- await setProjectMemberAccess({
114280
- projectId,
114281
- teamId: resolved.teamId,
114282
- memberIds: [...state2.projectMemberIds].filter(
114283
- (uid2) => uid2 !== member2.member.userId
114284
- ),
114285
- createdBy: ctx.userId
114286
- });
114287
- let text3 = `\u2705 Removed ${memberLabel(member2.member)} (userId: ${member2.member.userId}) from the project.`;
114288
- if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
114289
- text3 += "\n\nThis was the member's last project assignment, so their access restrictions were cleared \u2014 they can see all projects in the team again (default behavior).";
114290
- }
114291
- return textResponse2(text3);
114292
- }
114293
- async function loadProjectForCleanup(projectId, teamId) {
114294
- const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114295
- const [row] = await db.select({
114296
- id: schema_exports.projects.id,
114297
- name: schema_exports.projects.name,
114298
- teamId: schema_exports.projects.teamId,
114299
- settings: schema_exports.projects.settings
114300
- }).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
114301
- if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
114302
- return null;
114303
- }
114304
- return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
114305
- }
114306
- async function countProjectDependencies(projectId) {
114307
- const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
114308
- const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
114309
- countRows(schema_exports.tickets),
114310
- countRows(schema_exports.timesheetEvents),
114311
- countRows(schema_exports.timesheetTemplates),
114312
- countRows(schema_exports.trips),
114313
- countRows(schema_exports.tripTemplates)
114314
- ]);
114315
- return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
114316
- }
114317
- async function handleArchiveProject(input) {
114318
- const { projectId, reason } = input;
114319
- if (!projectId) return textResponse2("Error: `projectId` is required.");
114320
- const resolved = await resolveTeamId(input.teamId);
114321
- if (!resolved.ok) return resolved.response;
114322
- const project = await loadProjectForCleanup(projectId, resolved.teamId);
114323
- if (!project) {
114324
- return textResponse2(
114325
- `Project ${projectId} not found, or it is not owned by this team.`
114326
- );
114327
- }
114328
- const state2 = getProjectArchiveState(project.settings);
114329
- if (state2.archived) {
114330
- return textResponse2(
114331
- `Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
114332
- );
114333
- }
114334
- const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
114335
- const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
114336
- await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
114337
- return textResponse2(
114338
- `\u2705 **Project archived**
114339
-
114340
- Project: ${project.name}
114341
- ID: ${project.id}
114342
- Action: archived (soft, reversible)
114343
- Status: archived
114344
- Timestamp: ${archivedAt}
114345
- ${reason ? `Reason: ${reason}
114346
- ` : ""}
114347
- Archived projects are hidden from get-projects by default (pass status: 'archived' or 'all' to see them). No tickets, hours, or other data were touched.
114348
-
114349
- Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
114350
- );
114351
- }
114352
- async function handleDeleteProject(input) {
114353
- const ctx = getAuthContext();
114354
- const { projectId, confirmEmptyOnly } = input;
114355
- if (!projectId) return textResponse2("Error: `projectId` is required.");
114356
- const resolved = await resolveTeamId(input.teamId);
114357
- if (!resolved.ok) return resolved.response;
114358
- const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
114359
- if (ownerError) return ownerError;
114360
- const project = await loadProjectForCleanup(projectId, resolved.teamId);
114361
- if (!project) {
114362
- return textResponse2(
114363
- `Project ${projectId} not found, or it is not owned by this team.`
114364
- );
114365
- }
114366
- const deps = await countProjectDependencies(project.id);
114367
- const summary = formatProjectDependencies(deps);
114368
- if (!isProjectEmpty(deps)) {
114369
- return textResponse2(
114370
- `\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
114371
-
114372
- Dependencies: ${summary}.
114373
-
114374
- A hard delete would orphan these records, so it is not allowed. Use archive-project instead to safely retire this project (reversible, keeps all data).`
114375
- );
114376
- }
114377
- if (confirmEmptyOnly !== true) {
114378
- return textResponse2(
114379
- `Project "${project.name}" (${project.id}) has no dependencies and can be safely deleted. This is a permanent hard delete. Re-run delete-project with confirmEmptyOnly: true to proceed (or use archive-project to keep the record).`
114380
- );
114381
- }
114382
- await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
114383
- return textResponse2(
114384
- `\u2705 **Project deleted**
114385
-
114386
- Project: ${project.name}
114387
- ID: ${project.id}
114388
- Action: hard delete (empty project)
114389
- Status: deleted
114390
- Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
114391
-
114392
- The project had no tickets, hours, trips, or templates. Any project-scoped config (member access, tags, slack/github links, team rates) was removed with it.`
114393
- );
114394
- }
114395
-
114396
- // ../invoice/src/utils/included-items.ts
114397
- function parseIncludedItems(value) {
114398
- if (value == null) return null;
114399
- if (!Array.isArray(value)) return null;
114400
- const items = [];
114401
- for (const entry of value) {
114402
- if (typeof entry === "string") {
114403
- const label = entry.trim();
114404
- if (label) items.push({ label });
114405
- continue;
114406
- }
114407
- if (entry && typeof entry === "object" && "label" in entry) {
114408
- const raw = entry;
114409
- const label = String(raw.label ?? "").trim();
114410
- if (!label) continue;
114411
- items.push({
114412
- label,
114413
- productId: raw.productId ?? null
114414
- });
114415
- }
114416
- }
114417
- return items.length > 0 ? items : null;
114418
- }
114419
- function serializeIncludedItems(items) {
114420
- if (items == null) return null;
114421
- const cleaned = items.map((item) => ({
114422
- label: item.label.trim(),
114423
- ...item.productId ? { productId: item.productId } : {}
114424
- })).filter((item) => item.label.length > 0);
114425
- return cleaned.length > 0 ? cleaned : null;
114426
- }
114427
- function includedItemLabels(items) {
114428
- const parsed = items ? serializeIncludedItems(items) : null;
114429
- if (!parsed?.length) return null;
114430
- return parsed.map((item) => item.label);
114431
- }
114432
-
114433
- // ../invoice/src/utils/product-clause.ts
114434
- function trimToNull(value) {
114435
- if (typeof value !== "string") return null;
114436
- const trimmed = value.trim();
114437
- return trimmed.length > 0 ? trimmed : null;
114438
- }
114439
- function parseStringList(value) {
114440
- if (!Array.isArray(value)) return [];
114441
- const out = [];
114442
- for (const entry of value) {
114443
- const label = trimToNull(entry);
114444
- if (label) out.push(label);
114445
- }
114446
- return out;
114447
- }
114448
- function parseLimits(value) {
114449
- if (!value || typeof value !== "object") return null;
114450
- const raw = value;
114451
- const hoursRaw = raw.includedHoursPerMonth;
114452
- let includedHoursPerMonth = null;
114453
- if (typeof hoursRaw === "number" && Number.isFinite(hoursRaw)) {
114454
- includedHoursPerMonth = hoursRaw;
114455
- } else if (typeof hoursRaw === "string" && hoursRaw.trim() !== "") {
114456
- const parsed = Number(hoursRaw);
114457
- if (Number.isFinite(parsed)) includedHoursPerMonth = parsed;
114458
- }
114459
- const rollover = typeof raw.rollover === "boolean" ? raw.rollover : null;
114460
- const limits = {
114461
- includedHoursPerMonth,
114462
- rollover,
114463
- minimumBillingUnitExtraWork: trimToNull(raw.minimumBillingUnitExtraWork),
114464
- responseTime: trimToNull(raw.responseTime),
114465
- supportLevel: trimToNull(raw.supportLevel)
114466
- };
114467
- return isLimitsEmpty(limits) ? null : limits;
114468
- }
114469
- function isLimitsEmpty(limits) {
114470
- if (!limits) return true;
114471
- return limits.includedHoursPerMonth == null && limits.rollover == null && !limits.minimumBillingUnitExtraWork && !limits.responseTime && !limits.supportLevel;
114472
- }
114473
- function parseProductClause(value) {
114474
- if (value == null) return null;
114475
- if (typeof value !== "object" || Array.isArray(value)) return null;
114476
- const raw = value;
114477
- const clause = {
114478
- commercialDescription: trimToNull(raw.commercialDescription),
114479
- includedScope: parseStringList(raw.includedScope),
114480
- excludedScope: parseStringList(raw.excludedScope),
114481
- limits: parseLimits(raw.limits),
114482
- customerValue: trimToNull(raw.customerValue),
114483
- extraWorkConditions: trimToNull(raw.extraWorkConditions)
114484
- };
114485
- return isClauseEmpty(clause) ? null : clause;
114486
- }
114487
- function isClauseEmpty(clause) {
114488
- if (!clause) return true;
114489
- return !clause.commercialDescription && (clause.includedScope?.length ?? 0) === 0 && (clause.excludedScope?.length ?? 0) === 0 && isLimitsEmpty(clause.limits) && !clause.customerValue && !clause.extraWorkConditions;
114490
- }
114491
- function serializeProductClause(clause) {
114492
- if (clause == null) return null;
114493
- const parsed = parseProductClause(clause);
114494
- if (!parsed) return null;
114495
- const out = {};
114496
- if (parsed.commercialDescription) {
114497
- out.commercialDescription = parsed.commercialDescription;
114498
- }
114499
- if (parsed.includedScope && parsed.includedScope.length > 0) {
114500
- out.includedScope = parsed.includedScope;
114501
- }
114502
- if (parsed.excludedScope && parsed.excludedScope.length > 0) {
114503
- out.excludedScope = parsed.excludedScope;
114504
- }
114505
- if (!isLimitsEmpty(parsed.limits)) {
114506
- const limits = parsed.limits;
114507
- const cleanedLimits = {};
114508
- if (limits.includedHoursPerMonth != null) {
114509
- cleanedLimits.includedHoursPerMonth = limits.includedHoursPerMonth;
114510
- }
114511
- if (limits.rollover != null) cleanedLimits.rollover = limits.rollover;
114512
- if (limits.minimumBillingUnitExtraWork) {
114513
- cleanedLimits.minimumBillingUnitExtraWork = limits.minimumBillingUnitExtraWork;
114514
- }
114515
- if (limits.responseTime) cleanedLimits.responseTime = limits.responseTime;
114516
- if (limits.supportLevel) cleanedLimits.supportLevel = limits.supportLevel;
114517
- out.limits = cleanedLimits;
115193
+ );
114518
115194
  }
114519
- if (parsed.customerValue) out.customerValue = parsed.customerValue;
114520
- if (parsed.extraWorkConditions) {
114521
- out.extraWorkConditions = parsed.extraWorkConditions;
115195
+ await setProjectMemberAccess({
115196
+ projectId,
115197
+ teamId: resolved.teamId,
115198
+ memberIds: [...state2.projectMemberIds].filter(
115199
+ (uid2) => uid2 !== member2.member.userId
115200
+ ),
115201
+ createdBy: ctx.userId
115202
+ });
115203
+ let text3 = `\u2705 Removed ${memberLabel(member2.member)} (userId: ${member2.member.userId}) from the project.`;
115204
+ if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
115205
+ text3 += "\n\nThis was the member's last project assignment, so their access restrictions were cleared \u2014 they can see all projects in the team again (default behavior).";
114522
115206
  }
114523
- return Object.keys(out).length > 0 ? out : null;
115207
+ return textResponse3(text3);
114524
115208
  }
114525
- function clauseLimitLines(limits) {
114526
- if (isLimitsEmpty(limits) || !limits) return [];
114527
- const lines = [];
114528
- if (limits.includedHoursPerMonth != null) {
114529
- lines.push(`Inbegrepen uren per maand: ${limits.includedHoursPerMonth}`);
115209
+ async function loadProjectForCleanup(projectId, teamId) {
115210
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
115211
+ const [row] = await db.select({
115212
+ id: schema_exports.projects.id,
115213
+ name: schema_exports.projects.name,
115214
+ teamId: schema_exports.projects.teamId,
115215
+ settings: schema_exports.projects.settings
115216
+ }).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
115217
+ if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
115218
+ return null;
114530
115219
  }
114531
- if (limits.rollover != null) {
114532
- lines.push(
114533
- limits.rollover ? "Niet-gebruikte uren schuiven door naar de volgende maand" : "Niet-gebruikte uren vervallen aan het einde van de maand"
115220
+ return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
115221
+ }
115222
+ async function countProjectDependencies(projectId) {
115223
+ const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
115224
+ const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
115225
+ countRows(schema_exports.tickets),
115226
+ countRows(schema_exports.timesheetEvents),
115227
+ countRows(schema_exports.timesheetTemplates),
115228
+ countRows(schema_exports.trips),
115229
+ countRows(schema_exports.tripTemplates)
115230
+ ]);
115231
+ return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
115232
+ }
115233
+ async function handleArchiveProject(input) {
115234
+ const { projectId, reason } = input;
115235
+ if (!projectId) return textResponse3("Error: `projectId` is required.");
115236
+ const resolved = await resolveTeamId(input.teamId);
115237
+ if (!resolved.ok) return resolved.response;
115238
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
115239
+ if (!project) {
115240
+ return textResponse3(
115241
+ `Project ${projectId} not found, or it is not owned by this team.`
114534
115242
  );
114535
115243
  }
114536
- if (limits.minimumBillingUnitExtraWork) {
114537
- lines.push(
114538
- `Minimale facturatie-eenheid meerwerk: ${limits.minimumBillingUnitExtraWork}`
115244
+ const state2 = getProjectArchiveState(project.settings);
115245
+ if (state2.archived) {
115246
+ return textResponse3(
115247
+ `Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
114539
115248
  );
114540
115249
  }
114541
- if (limits.supportLevel) lines.push(`Supportniveau: ${limits.supportLevel}`);
114542
- if (limits.responseTime) lines.push(`Responstijd: ${limits.responseTime}`);
114543
- return lines;
115250
+ const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
115251
+ const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
115252
+ await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
115253
+ return textResponse3(
115254
+ `\u2705 **Project archived**
115255
+
115256
+ Project: ${project.name}
115257
+ ID: ${project.id}
115258
+ Action: archived (soft, reversible)
115259
+ Status: archived
115260
+ Timestamp: ${archivedAt}
115261
+ ${reason ? `Reason: ${reason}
115262
+ ` : ""}
115263
+ Archived projects are hidden from get-projects by default (pass status: 'archived' or 'all' to see them). No tickets, hours, or other data were touched.
115264
+
115265
+ Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
115266
+ );
114544
115267
  }
114545
- function clauseSummaryLines(clause) {
114546
- const parsed = parseProductClause(clause);
114547
- if (!parsed) return [];
114548
- const lines = [];
114549
- if (parsed.commercialDescription) {
114550
- lines.push(parsed.commercialDescription);
114551
- }
114552
- if (parsed.includedScope?.length) {
114553
- lines.push(`Inbegrepen: ${parsed.includedScope.join("; ")}`);
114554
- }
114555
- if (parsed.excludedScope?.length) {
114556
- lines.push(`Niet inbegrepen: ${parsed.excludedScope.join("; ")}`);
115268
+ async function handleDeleteProject(input) {
115269
+ const ctx = getAuthContext();
115270
+ const { projectId, confirmEmptyOnly } = input;
115271
+ if (!projectId) return textResponse3("Error: `projectId` is required.");
115272
+ const resolved = await resolveTeamId(input.teamId);
115273
+ if (!resolved.ok) return resolved.response;
115274
+ const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
115275
+ if (ownerError) return ownerError;
115276
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
115277
+ if (!project) {
115278
+ return textResponse3(
115279
+ `Project ${projectId} not found, or it is not owned by this team.`
115280
+ );
114557
115281
  }
114558
- lines.push(...clauseLimitLines(parsed.limits));
114559
- if (parsed.customerValue) {
114560
- lines.push(`Klantwaarde: ${parsed.customerValue}`);
115282
+ const deps = await countProjectDependencies(project.id);
115283
+ const summary = formatProjectDependencies(deps);
115284
+ if (!isProjectEmpty(deps)) {
115285
+ return textResponse3(
115286
+ `\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
115287
+
115288
+ Dependencies: ${summary}.
115289
+
115290
+ A hard delete would orphan these records, so it is not allowed. Use archive-project instead to safely retire this project (reversible, keeps all data).`
115291
+ );
114561
115292
  }
114562
- if (parsed.extraWorkConditions) {
114563
- lines.push(`Meerwerk: ${parsed.extraWorkConditions}`);
115293
+ if (confirmEmptyOnly !== true) {
115294
+ return textResponse3(
115295
+ `Project "${project.name}" (${project.id}) has no dependencies and can be safely deleted. This is a permanent hard delete. Re-run delete-project with confirmEmptyOnly: true to proceed (or use archive-project to keep the record).`
115296
+ );
114564
115297
  }
114565
- return lines;
115298
+ await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
115299
+ return textResponse3(
115300
+ `\u2705 **Project deleted**
115301
+
115302
+ Project: ${project.name}
115303
+ ID: ${project.id}
115304
+ Action: hard delete (empty project)
115305
+ Status: deleted
115306
+ Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
115307
+
115308
+ The project had no tickets, hours, trips, or templates. Any project-scoped config (member access, tags, slack/github links, team rates) was removed with it.`
115309
+ );
114566
115310
  }
114567
115311
 
114568
115312
  // src/tools/products.ts
@@ -114583,7 +115327,7 @@ var TIERS = [
114583
115327
  "silver",
114584
115328
  "gold"
114585
115329
  ];
114586
- var PRODUCT_COLUMNS = {
115330
+ var PRODUCT_COLUMNS2 = {
114587
115331
  id: schema_exports.invoiceProducts.id,
114588
115332
  teamId: schema_exports.invoiceProducts.teamId,
114589
115333
  name: schema_exports.invoiceProducts.name,
@@ -114605,7 +115349,7 @@ var PRODUCT_COLUMNS = {
114605
115349
  createdAt: schema_exports.invoiceProducts.createdAt,
114606
115350
  updatedAt: schema_exports.invoiceProducts.updatedAt
114607
115351
  };
114608
- function textResponse3(text3) {
115352
+ function textResponse4(text3) {
114609
115353
  return { content: [{ type: "text", text: text3 }] };
114610
115354
  }
114611
115355
  function formatPrice(p3) {
@@ -114651,14 +115395,14 @@ async function handleGetProducts(input) {
114651
115395
  const { q: q3, currency, pageSize = 20 } = input;
114652
115396
  const status = input.status ?? "active";
114653
115397
  if (!PRODUCT_STATUSES.includes(status)) {
114654
- return textResponse3(
115398
+ return textResponse4(
114655
115399
  `Error: invalid status "${status}". Allowed: ${PRODUCT_STATUSES.join(", ")}.`
114656
115400
  );
114657
115401
  }
114658
115402
  const scope = await resolveTeamScope(input.teamId);
114659
115403
  if (!scope.ok) return scope.response;
114660
115404
  if (scope.teamIds.length === 0) {
114661
- return textResponse3("No accessible teams found.");
115405
+ return textResponse4("No accessible teams found.");
114662
115406
  }
114663
115407
  const filters = [inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)];
114664
115408
  if (status === "active") {
@@ -114675,17 +115419,17 @@ async function handleGetProducts(input) {
114675
115419
  )
114676
115420
  );
114677
115421
  }
114678
- const rows = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(and(...filters)).orderBy(
115422
+ const rows = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(and(...filters)).orderBy(
114679
115423
  desc(schema_exports.invoiceProducts.usageCount),
114680
115424
  desc(schema_exports.invoiceProducts.lastUsedAt),
114681
115425
  asc(schema_exports.invoiceProducts.name)
114682
115426
  ).limit(Math.min(pageSize, 100));
114683
115427
  if (rows.length === 0) {
114684
- return textResponse3(
115428
+ return textResponse4(
114685
115429
  `No products found${status !== "all" ? ` (status: ${status})` : ""}.`
114686
115430
  );
114687
115431
  }
114688
- return textResponse3(
115432
+ return textResponse4(
114689
115433
  `Found ${rows.length} product(s):
114690
115434
 
114691
115435
  ${rows.map(formatProduct).join("\n")}`
@@ -114693,28 +115437,28 @@ ${rows.map(formatProduct).join("\n")}`
114693
115437
  }
114694
115438
  async function handleGetProductById(input) {
114695
115439
  const { productId } = input;
114696
- if (!productId) return textResponse3("Error: `productId` is required.");
115440
+ if (!productId) return textResponse4("Error: `productId` is required.");
114697
115441
  const scope = await resolveTeamScope(input.teamId);
114698
115442
  if (!scope.ok) return scope.response;
114699
115443
  if (scope.teamIds.length === 0) {
114700
- return textResponse3("No accessible teams found.");
115444
+ return textResponse4("No accessible teams found.");
114701
115445
  }
114702
- const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
115446
+ const [row] = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(
114703
115447
  and(
114704
115448
  eq(schema_exports.invoiceProducts.id, productId),
114705
115449
  inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)
114706
115450
  )
114707
115451
  ).limit(1);
114708
115452
  if (!row) {
114709
- return textResponse3(
115453
+ return textResponse4(
114710
115454
  `Product ${productId} not found or you don't have access to it.`
114711
115455
  );
114712
115456
  }
114713
- return textResponse3(formatProduct(row));
115457
+ return textResponse4(formatProduct(row));
114714
115458
  }
114715
115459
  async function loadProductInTeam(productId, teamId) {
114716
115460
  const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114717
- const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
115461
+ const [row] = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(
114718
115462
  and(
114719
115463
  eq(schema_exports.invoiceProducts.id, productId),
114720
115464
  inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
@@ -114725,10 +115469,10 @@ async function loadProductInTeam(productId, teamId) {
114725
115469
  async function handleCreateProduct(input) {
114726
115470
  const { name: name21, description, price, currency, unit } = input;
114727
115471
  if (!name21 || name21.trim().length === 0) {
114728
- return textResponse3("Error: `name` is required.");
115472
+ return textResponse4("Error: `name` is required.");
114729
115473
  }
114730
115474
  const enumError = validateEnum("billingType", input.billingType, BILLING_TYPES) ?? validateEnum("category", input.category, CATEGORIES) ?? validateEnum("tier", input.tier, TIERS);
114731
- if (enumError) return textResponse3(enumError);
115475
+ if (enumError) return textResponse4(enumError);
114732
115476
  const resolved = await resolveTeamId(input.teamId);
114733
115477
  if (!resolved.ok) return resolved.response;
114734
115478
  const [created] = await db.insert(schema_exports.invoiceProducts).values({
@@ -114747,9 +115491,9 @@ async function handleCreateProduct(input) {
114747
115491
  clause: serializeProductClause(input.clause) ?? null,
114748
115492
  isActive: true,
114749
115493
  lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
114750
- }).returning(PRODUCT_COLUMNS);
114751
- if (!created) return textResponse3("Failed to create product.");
114752
- return textResponse3(
115494
+ }).returning(PRODUCT_COLUMNS2);
115495
+ if (!created) return textResponse4("Failed to create product.");
115496
+ return textResponse4(
114753
115497
  `\u2705 **Product created**
114754
115498
 
114755
115499
  ${formatProduct(created)}`
@@ -114757,21 +115501,21 @@ ${formatProduct(created)}`
114757
115501
  }
114758
115502
  async function handleUpdateProduct(input) {
114759
115503
  const { productId } = input;
114760
- if (!productId) return textResponse3("Error: `productId` is required.");
115504
+ if (!productId) return textResponse4("Error: `productId` is required.");
114761
115505
  const resolved = await resolveTeamId(input.teamId);
114762
115506
  if (!resolved.ok) return resolved.response;
114763
115507
  const existing = await loadProductInTeam(productId, resolved.teamId);
114764
115508
  if (!existing) {
114765
- return textResponse3(
115509
+ return textResponse4(
114766
115510
  `Product ${productId} not found, or it is not owned by this team.`
114767
115511
  );
114768
115512
  }
114769
115513
  const enumError = validateEnum("billingType", input.billingType, BILLING_TYPES) ?? validateEnum("category", input.category, CATEGORIES) ?? validateEnum("tier", input.tier, TIERS);
114770
- if (enumError) return textResponse3(enumError);
115514
+ if (enumError) return textResponse4(enumError);
114771
115515
  const updates = {};
114772
115516
  if (input.name !== void 0) {
114773
115517
  if (!input.name || input.name.trim().length === 0) {
114774
- return textResponse3("Error: `name` cannot be empty.");
115518
+ return textResponse4("Error: `name` cannot be empty.");
114775
115519
  }
114776
115520
  updates.name = input.name.trim();
114777
115521
  }
@@ -114792,14 +115536,14 @@ async function handleUpdateProduct(input) {
114792
115536
  updates.clause = serializeProductClause(input.clause);
114793
115537
  }
114794
115538
  if (Object.keys(updates).length === 0) {
114795
- return textResponse3(
115539
+ return textResponse4(
114796
115540
  "No fields to update. Provide at least one of: name, description, price, currency, unit, isActive, billingType, category, includedItems, optional, tier, sortOrder, clause."
114797
115541
  );
114798
115542
  }
114799
115543
  updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
114800
- const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
114801
- if (!updated) return textResponse3(`Failed to update product ${productId}.`);
114802
- return textResponse3(
115544
+ const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS2);
115545
+ if (!updated) return textResponse4(`Failed to update product ${productId}.`);
115546
+ return textResponse4(
114803
115547
  `\u2705 **Product updated**
114804
115548
 
114805
115549
  ${formatProduct(updated)}
@@ -114808,23 +115552,23 @@ Note: this only affects future invoices/quotes. Existing documents keep their li
114808
115552
  }
114809
115553
  async function handleArchiveProduct(input) {
114810
115554
  const { productId, reason } = input;
114811
- if (!productId) return textResponse3("Error: `productId` is required.");
115555
+ if (!productId) return textResponse4("Error: `productId` is required.");
114812
115556
  const resolved = await resolveTeamId(input.teamId);
114813
115557
  if (!resolved.ok) return resolved.response;
114814
115558
  const existing = await loadProductInTeam(productId, resolved.teamId);
114815
115559
  if (!existing) {
114816
- return textResponse3(
115560
+ return textResponse4(
114817
115561
  `Product ${productId} not found, or it is not owned by this team.`
114818
115562
  );
114819
115563
  }
114820
115564
  if (!existing.isActive) {
114821
- return textResponse3(
115565
+ return textResponse4(
114822
115566
  `Product "${existing.name}" (${existing.id}) is already archived.`
114823
115567
  );
114824
115568
  }
114825
- const [archived] = await db.update(schema_exports.invoiceProducts).set({ isActive: false, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
114826
- if (!archived) return textResponse3(`Failed to archive product ${productId}.`);
114827
- return textResponse3(
115569
+ const [archived] = await db.update(schema_exports.invoiceProducts).set({ isActive: false, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS2);
115570
+ if (!archived) return textResponse4(`Failed to archive product ${productId}.`);
115571
+ return textResponse4(
114828
115572
  `\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
114829
115573
 
114830
115574
  ${formatProduct(archived)}${reason ? `Reason: ${reason}
@@ -114833,14 +115577,14 @@ ${formatProduct(archived)}${reason ? `Reason: ${reason}
114833
115577
  }
114834
115578
 
114835
115579
  // src/tools/quote-line-util.ts
114836
- function round2(n3) {
115580
+ function round22(n3) {
114837
115581
  return Math.round(n3 * 100) / 100;
114838
115582
  }
114839
- function lineFinancials(quantity, price, defaults) {
115583
+ function lineFinancials2(quantity, price, defaults) {
114840
115584
  const lineTotal = quantity * price;
114841
115585
  return {
114842
- vat: defaults.includeVat ? round2(lineTotal * (defaults.vatRate / 100)) : void 0,
114843
- tax: defaults.includeTax ? round2(lineTotal * (defaults.taxRate / 100)) : void 0
115586
+ vat: defaults.includeVat ? round22(lineTotal * (defaults.vatRate / 100)) : void 0,
115587
+ tax: defaults.includeTax ? round22(lineTotal * (defaults.taxRate / 100)) : void 0
114844
115588
  };
114845
115589
  }
114846
115590
  function computeTotals(items, defaults) {
@@ -114851,10 +115595,10 @@ function computeTotals(items, defaults) {
114851
115595
  const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
114852
115596
  const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
114853
115597
  return {
114854
- subtotal: round2(subtotal),
114855
- vat: round2(vat),
114856
- tax: round2(tax),
114857
- amount: round2(subtotal + vat + tax)
115598
+ subtotal: round22(subtotal),
115599
+ vat: round22(vat),
115600
+ tax: round22(tax),
115601
+ amount: round22(subtotal + vat + tax)
114858
115602
  };
114859
115603
  }
114860
115604
  function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
@@ -114884,7 +115628,7 @@ function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ ne
114884
115628
  function lineItemFromProduct(product, opts, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
114885
115629
  const quantity = opts.quantity ?? 1;
114886
115630
  const price = opts.customPrice ?? product.price ?? 0;
114887
- const { vat, tax } = lineFinancials(quantity, price, defaults);
115631
+ const { vat, tax } = lineFinancials2(quantity, price, defaults);
114888
115632
  return {
114889
115633
  name: opts.customDescription || product.name,
114890
115634
  quantity,
@@ -114924,7 +115668,7 @@ var QUOTE_STATUSES = [
114924
115668
  "expired"
114925
115669
  ];
114926
115670
  var SAFE_DRAFT_STATUSES = /* @__PURE__ */ new Set(["draft"]);
114927
- function textResponse4(text3) {
115671
+ function textResponse5(text3) {
114928
115672
  return { content: [{ type: "text", text: text3 }] };
114929
115673
  }
114930
115674
  async function loadTemplateDefaults(teamId) {
@@ -114990,7 +115734,7 @@ async function nextQuotationNumber(teamId) {
114990
115734
  if (!value) throw new Error("Failed to fetch next quotation number");
114991
115735
  return value;
114992
115736
  }
114993
- async function loadProductsInTeam(productIds, teamId) {
115737
+ async function loadProductsInTeam2(productIds, teamId) {
114994
115738
  if (productIds.length === 0) return /* @__PURE__ */ new Map();
114995
115739
  const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114996
115740
  const rows = await db.select({
@@ -115018,7 +115762,7 @@ async function loadProductsInTeam(productIds, teamId) {
115018
115762
  }
115019
115763
  async function resolveLineItems(inputs, defaults, teamId) {
115020
115764
  const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
115021
- const products = await loadProductsInTeam([...new Set(productIds)], teamId);
115765
+ const products = await loadProductsInTeam2([...new Set(productIds)], teamId);
115022
115766
  const items = [];
115023
115767
  for (const input of inputs) {
115024
115768
  if (input.productId) {
@@ -115044,7 +115788,7 @@ async function resolveLineItems(inputs, defaults, teamId) {
115044
115788
  }
115045
115789
  const quantity = input.quantity ?? 1;
115046
115790
  const price = input.price ?? 0;
115047
- const { vat, tax } = lineFinancials(quantity, price, defaults);
115791
+ const { vat, tax } = lineFinancials2(quantity, price, defaults);
115048
115792
  items.push({
115049
115793
  name: input.name?.trim() || "(no description)",
115050
115794
  quantity,
@@ -115083,7 +115827,7 @@ ${q3.validUntil ? `Valid until: ${new Date(q3.validUntil).toLocaleDateString()}
115083
115827
  ` : ""}Created: ${new Date(q3.createdAt).toLocaleDateString()}
115084
115828
  `;
115085
115829
  }
115086
- function tiptapNote(text3) {
115830
+ function tiptapNote2(text3) {
115087
115831
  return {
115088
115832
  type: "doc",
115089
115833
  content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
@@ -115092,14 +115836,14 @@ function tiptapNote(text3) {
115092
115836
  async function handleGetQuotes(input) {
115093
115837
  const { customerId, status, q: q3, pageSize = 20 } = input;
115094
115838
  if (status && !QUOTE_STATUSES.includes(status)) {
115095
- return textResponse4(
115839
+ return textResponse5(
115096
115840
  `Error: invalid status "${status}". Allowed: ${QUOTE_STATUSES.join(", ")}.`
115097
115841
  );
115098
115842
  }
115099
115843
  const scope = await resolveTeamScope(input.teamId);
115100
115844
  if (!scope.ok) return scope.response;
115101
115845
  if (scope.teamIds.length === 0) {
115102
- return textResponse4("No accessible teams found.");
115846
+ return textResponse5("No accessible teams found.");
115103
115847
  }
115104
115848
  const filters = [inArray(schema_exports.quotations.teamId, scope.teamIds)];
115105
115849
  if (customerId) filters.push(eq(schema_exports.quotations.customerId, customerId));
@@ -115114,10 +115858,10 @@ async function handleGetQuotes(input) {
115114
115858
  }
115115
115859
  const rows = await db.select(QUOTE_COLUMNS).from(schema_exports.quotations).where(and(...filters)).orderBy(desc(schema_exports.quotations.createdAt)).limit(Math.min(pageSize, 100));
115116
115860
  if (rows.length === 0) {
115117
- return textResponse4("No quotes found.");
115861
+ return textResponse5("No quotes found.");
115118
115862
  }
115119
115863
  const note = input.projectId ? "\nNote: `projectId` was ignored \u2014 quotations are not linked to projects." : "";
115120
- return textResponse4(
115864
+ return textResponse5(
115121
115865
  `Found ${rows.length} quote(s):
115122
115866
 
115123
115867
  ${rows.map(formatQuote).join("\n")}${note}`
@@ -115125,10 +115869,10 @@ ${rows.map(formatQuote).join("\n")}${note}`
115125
115869
  }
115126
115870
  async function handleCreateQuote(input) {
115127
115871
  const { customerId } = input;
115128
- if (!customerId) return textResponse4("Error: `customerId` is required.");
115872
+ if (!customerId) return textResponse5("Error: `customerId` is required.");
115129
115873
  const status = input.status ?? "draft";
115130
115874
  if (!SAFE_DRAFT_STATUSES.has(status)) {
115131
- return textResponse4(
115875
+ return textResponse5(
115132
115876
  `Error: this tool only creates draft quotes. Requested status "${status}" is not allowed. Sending/accepting a quote is a manual dashboard action.`
115133
115877
  );
115134
115878
  }
@@ -115151,7 +115895,7 @@ async function handleCreateQuote(input) {
115151
115895
  )
115152
115896
  ).limit(1);
115153
115897
  if (!customer) {
115154
- return textResponse4(
115898
+ return textResponse5(
115155
115899
  `Customer ${customerId} not found or not owned by this team.`
115156
115900
  );
115157
115901
  }
@@ -115161,7 +115905,7 @@ async function handleCreateQuote(input) {
115161
115905
  defaults,
115162
115906
  teamId
115163
115907
  );
115164
- if (error49) return textResponse4(`Error: ${error49}`);
115908
+ if (error49) return textResponse5(`Error: ${error49}`);
115165
115909
  const totals = computeTotals(items, defaults);
115166
115910
  const quotationNumber = await nextQuotationNumber(teamId);
115167
115911
  const template = buildQuoteTemplate(defaults, input.title);
@@ -115212,7 +115956,7 @@ async function handleCreateQuote(input) {
115212
115956
  customerDetails,
115213
115957
  fromDetails: defaults.fromDetails ?? null,
115214
115958
  paymentDetails: defaults.paymentDetails ?? null,
115215
- noteDetails: input.description ? tiptapNote(input.description) : null,
115959
+ noteDetails: input.description ? tiptapNote2(input.description) : null,
115216
115960
  issueDate: (/* @__PURE__ */ new Date()).toISOString(),
115217
115961
  validUntil: input.validUntil ?? null,
115218
115962
  lineItems: items,
@@ -115221,8 +115965,8 @@ async function handleCreateQuote(input) {
115221
115965
  tax: totals.tax,
115222
115966
  amount: totals.amount
115223
115967
  }).returning(QUOTE_COLUMNS);
115224
- if (!created) return textResponse4("Failed to create quote.");
115225
- return textResponse4(
115968
+ if (!created) return textResponse5("Failed to create quote.");
115969
+ return textResponse5(
115226
115970
  `\u2705 **Draft quote created**
115227
115971
 
115228
115972
  ${formatQuote(created)}
@@ -115239,16 +115983,16 @@ async function loadQuoteInTeam(id, teamId) {
115239
115983
  ).limit(1);
115240
115984
  return row ?? null;
115241
115985
  }
115242
- function notDraftResponse(quote) {
115243
- return textResponse4(
115986
+ function notDraftResponse2(quote) {
115987
+ return textResponse5(
115244
115988
  `Quote ${quote.quotationNumber ?? quote.id} has status "${quote.status}", not "draft". These tools only modify draft quotes \u2014 sent/accepted/rejected/expired quotes are immutable here so their product snapshots stay reproducible.`
115245
115989
  );
115246
115990
  }
115247
115991
  async function handleUpdateQuote(input) {
115248
115992
  const { id } = input;
115249
- if (!id) return textResponse4("Error: `id` is required.");
115993
+ if (!id) return textResponse5("Error: `id` is required.");
115250
115994
  if (input.status !== void 0 && !SAFE_DRAFT_STATUSES.has(input.status)) {
115251
- return textResponse4(
115995
+ return textResponse5(
115252
115996
  `Error: status can only stay within {${[...SAFE_DRAFT_STATUSES].join(", ")}}. "${input.status}" (send/accept/reject/expire) must be done manually from the dashboard.`
115253
115997
  );
115254
115998
  }
@@ -115256,16 +116000,16 @@ async function handleUpdateQuote(input) {
115256
116000
  if (!resolved.ok) return resolved.response;
115257
116001
  const quote = await loadQuoteInTeam(id, resolved.teamId);
115258
116002
  if (!quote) {
115259
- return textResponse4(`Quote ${id} not found or not owned by this team.`);
116003
+ return textResponse5(`Quote ${id} not found or not owned by this team.`);
115260
116004
  }
115261
- if (quote.status !== "draft") return notDraftResponse(quote);
116005
+ if (quote.status !== "draft") return notDraftResponse2(quote);
115262
116006
  const defaults = templateDefaultsFromStored(quote.template, quote.currency);
115263
116007
  const updates = {};
115264
116008
  if (input.title !== void 0) {
115265
116009
  updates.template = buildQuoteTemplate(defaults, input.title);
115266
116010
  }
115267
116011
  if (input.description !== void 0) {
115268
- updates.noteDetails = input.description ? tiptapNote(input.description) : null;
116012
+ updates.noteDetails = input.description ? tiptapNote2(input.description) : null;
115269
116013
  }
115270
116014
  if (input.validUntil !== void 0) {
115271
116015
  updates.validUntil = input.validUntil;
@@ -115276,7 +116020,7 @@ async function handleUpdateQuote(input) {
115276
116020
  defaults,
115277
116021
  quote.teamId
115278
116022
  );
115279
- if (error49) return textResponse4(`Error: ${error49}`);
116023
+ if (error49) return textResponse5(`Error: ${error49}`);
115280
116024
  const totals = computeTotals(items, defaults);
115281
116025
  updates.lineItems = items;
115282
116026
  updates.subtotal = totals.subtotal;
@@ -115285,32 +116029,32 @@ async function handleUpdateQuote(input) {
115285
116029
  updates.amount = totals.amount;
115286
116030
  }
115287
116031
  if (Object.keys(updates).length === 0) {
115288
- return textResponse4(
116032
+ return textResponse5(
115289
116033
  "No fields to update. Provide at least one of: title, description, validUntil, lineItems."
115290
116034
  );
115291
116035
  }
115292
116036
  updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
115293
116037
  const [updated] = await db.update(schema_exports.quotations).set(updates).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
115294
- if (!updated) return textResponse4(`Failed to update quote ${id}.`);
115295
- return textResponse4(`\u2705 **Draft quote updated**
116038
+ if (!updated) return textResponse5(`Failed to update quote ${id}.`);
116039
+ return textResponse5(`\u2705 **Draft quote updated**
115296
116040
 
115297
116041
  ${formatQuote(updated)}`);
115298
116042
  }
115299
116043
  async function handleAddProductToQuote(input) {
115300
116044
  const { quoteId, productId } = input;
115301
- if (!quoteId) return textResponse4("Error: `quoteId` is required.");
115302
- if (!productId) return textResponse4("Error: `productId` is required.");
116045
+ if (!quoteId) return textResponse5("Error: `quoteId` is required.");
116046
+ if (!productId) return textResponse5("Error: `productId` is required.");
115303
116047
  const resolved = await resolveTeamId(input.teamId);
115304
116048
  if (!resolved.ok) return resolved.response;
115305
116049
  const quote = await loadQuoteInTeam(quoteId, resolved.teamId);
115306
116050
  if (!quote) {
115307
- return textResponse4(`Quote ${quoteId} not found or not owned by this team.`);
116051
+ return textResponse5(`Quote ${quoteId} not found or not owned by this team.`);
115308
116052
  }
115309
- if (quote.status !== "draft") return notDraftResponse(quote);
115310
- const products = await loadProductsInTeam([productId], quote.teamId);
116053
+ if (quote.status !== "draft") return notDraftResponse2(quote);
116054
+ const products = await loadProductsInTeam2([productId], quote.teamId);
115311
116055
  const product = products.get(productId);
115312
116056
  if (!product) {
115313
- return textResponse4(
116057
+ return textResponse5(
115314
116058
  `Product ${productId} not found or not owned by this team.`
115315
116059
  );
115316
116060
  }
@@ -115336,7 +116080,7 @@ async function handleAddProductToQuote(input) {
115336
116080
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
115337
116081
  }).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
115338
116082
  if (!updated) {
115339
- return textResponse4(`Failed to add product to quote ${quoteId}.`);
116083
+ return textResponse5(`Failed to add product to quote ${quoteId}.`);
115340
116084
  }
115341
116085
  await db.update(schema_exports.invoiceProducts).set({
115342
116086
  usageCount: sql`${schema_exports.invoiceProducts.usageCount} + 1`,
@@ -115352,7 +116096,7 @@ async function handleAddProductToQuote(input) {
115352
116096
  if (meta5.includedItems && meta5.includedItems.length > 0) {
115353
116097
  metaParts.push(`included=[${meta5.includedItems.join(", ")}]`);
115354
116098
  }
115355
- return textResponse4(
116099
+ return textResponse5(
115356
116100
  `\u2705 **Product added to draft quote ${updated.quotationNumber ?? updated.id}**
115357
116101
 
115358
116102
  Line item: ${newItem.name} \xD7 ${newItem.quantity}${newItem.unit ? ` ${newItem.unit}` : ""} @ ${newItem.price} ${snap.currency}
@@ -121087,7 +121831,7 @@ function formatDeleteAttachmentRefusal(reason, context2) {
121087
121831
  }
121088
121832
 
121089
121833
  // src/tools/ticket-attachments.ts
121090
- function textResponse5(text3) {
121834
+ function textResponse6(text3) {
121091
121835
  return { content: [{ type: "text", text: text3 }] };
121092
121836
  }
121093
121837
  async function findAttachment(attachmentId) {
@@ -121160,7 +121904,7 @@ ${url3}`
121160
121904
  async function handleUploadTicketAttachment(input) {
121161
121905
  const ctx = getAuthContext() ?? authContext;
121162
121906
  if (!ctx) {
121163
- return textResponse5("Error: Not authenticated.");
121907
+ return textResponse6("Error: Not authenticated.");
121164
121908
  }
121165
121909
  const access = await loadAccessibleTicket(input.teamId, input.ticketId);
121166
121910
  if (!access.ok) return access.response;
@@ -121176,12 +121920,12 @@ async function handleUploadTicketAttachment(input) {
121176
121920
  userId: ctx.userId
121177
121921
  });
121178
121922
  if (!resolved.ok) {
121179
- return textResponse5(resolved.message);
121923
+ return textResponse6(resolved.message);
121180
121924
  }
121181
121925
  const { buffer: buffer2, fileName, mimeType, stagingStorageKey } = resolved;
121182
121926
  const validationError = validateAttachmentBuffer(buffer2, mimeType);
121183
121927
  if (validationError) {
121184
- return textResponse5(validationError.message);
121928
+ return textResponse6(validationError.message);
121185
121929
  }
121186
121930
  const storageKey = `${ticket.teamId}/tickets/${ticket.id}/${Date.now()}_${fileName}`;
121187
121931
  try {
@@ -121192,7 +121936,7 @@ async function handleUploadTicketAttachment(input) {
121192
121936
  options: { contentType: mimeType, upsert: true }
121193
121937
  });
121194
121938
  } catch (error49) {
121195
- return textResponse5(
121939
+ return textResponse6(
121196
121940
  `Upload failed: ${error49 instanceof Error ? error49.message : String(error49)}`
121197
121941
  );
121198
121942
  }
@@ -121221,7 +121965,7 @@ async function handleUploadTicketAttachment(input) {
121221
121965
  url3 = signed.url;
121222
121966
  } catch {
121223
121967
  }
121224
- return textResponse5(
121968
+ return textResponse6(
121225
121969
  `\u{1F4CE} **Attached to ${ticket.ticketNumber}**
121226
121970
  File: ${fileName}
121227
121971
  Type: ${mimeType}
@@ -121235,18 +121979,18 @@ ${url3}` : "")
121235
121979
  async function handleDeleteTicketAttachment(input) {
121236
121980
  const ctx = getAuthContext() ?? authContext;
121237
121981
  if (!ctx) {
121238
- return textResponse5("Error: Not authenticated.");
121982
+ return textResponse6("Error: Not authenticated.");
121239
121983
  }
121240
121984
  const inputError = validateDeleteAttachmentInput(input.attachmentId);
121241
121985
  if (inputError) {
121242
- return textResponse5(formatDeleteAttachmentRefusal(inputError, { ticketNumber: input.ticketId }));
121986
+ return textResponse6(formatDeleteAttachmentRefusal(inputError, { ticketNumber: input.ticketId }));
121243
121987
  }
121244
121988
  const access = await loadAccessibleTicket(input.teamId, input.ticketId);
121245
121989
  if (!access.ok) return access.response;
121246
121990
  const ticket = access.ticket;
121247
121991
  const attachment = await findAttachment(input.attachmentId);
121248
121992
  if (!attachment) {
121249
- return textResponse5(
121993
+ return textResponse6(
121250
121994
  formatDeleteAttachmentRefusal("attachment_not_found", {
121251
121995
  attachmentId: input.attachmentId,
121252
121996
  ticketNumber: ticket.ticketNumber
@@ -121254,7 +121998,7 @@ async function handleDeleteTicketAttachment(input) {
121254
121998
  );
121255
121999
  }
121256
122000
  if (!validateAttachmentBelongsToTicket(attachment.ticketId, ticket.id)) {
121257
- return textResponse5(
122001
+ return textResponse6(
121258
122002
  formatDeleteAttachmentRefusal("wrong_ticket", {
121259
122003
  attachmentId: input.attachmentId,
121260
122004
  ticketNumber: ticket.ticketNumber,
@@ -121266,7 +122010,7 @@ async function handleDeleteTicketAttachment(input) {
121266
122010
  const table = attachment.source === "ticket" ? schema_exports.ticketAttachments : schema_exports.ticketCommentAttachments;
121267
122011
  const [deletedRow] = await db.delete(table).where(eq(table.id, input.attachmentId)).returning({ id: table.id });
121268
122012
  if (!deletedRow) {
121269
- return textResponse5(
122013
+ return textResponse6(
121270
122014
  `Failed to delete attachment ${input.attachmentId}. It may have been removed already.`
121271
122015
  );
121272
122016
  }
@@ -121296,7 +122040,7 @@ async function handleDeleteTicketAttachment(input) {
121296
122040
  fileName: attachment.fileName,
121297
122041
  source: attachment.source
121298
122042
  });
121299
- return textResponse5(JSON.stringify(result, null, 2));
122043
+ return textResponse6(JSON.stringify(result, null, 2));
121300
122044
  }
121301
122045
 
121302
122046
  // src/tools/tiptap-text.ts
@@ -121713,7 +122457,7 @@ function formatTagUsage(usage) {
121713
122457
  }
121714
122458
 
121715
122459
  // src/tools/tag-management.ts
121716
- function textResponse6(text3) {
122460
+ function textResponse7(text3) {
121717
122461
  return { content: [{ type: "text", text: text3 }] };
121718
122462
  }
121719
122463
  var TAG_COLUMNS = {
@@ -121754,24 +122498,24 @@ function scopeFilter(projectId) {
121754
122498
  return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
121755
122499
  }
121756
122500
  async function handleUpdateTag(input) {
121757
- if (!input.tagId) return textResponse6("Error: `tagId` is required.");
122501
+ if (!input.tagId) return textResponse7("Error: `tagId` is required.");
121758
122502
  const resolved = await resolveTeamId(input.teamId);
121759
122503
  if (!resolved.ok) return resolved.response;
121760
122504
  const existing = await loadTagInTeam(input.tagId, resolved.teamId);
121761
122505
  if (!existing) {
121762
- return textResponse6(
122506
+ return textResponse7(
121763
122507
  `Tag ${input.tagId} not found, or it is not owned by this team.`
121764
122508
  );
121765
122509
  }
121766
122510
  const renaming = input.name !== void 0;
121767
122511
  const rescoping = input.projectId !== void 0;
121768
122512
  if (!renaming && !rescoping) {
121769
- return textResponse6(
122513
+ return textResponse7(
121770
122514
  "No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
121771
122515
  );
121772
122516
  }
121773
122517
  if (renaming && !isValidTagName(input.name)) {
121774
- return textResponse6("Error: `name` cannot be empty.");
122518
+ return textResponse7("Error: `name` cannot be empty.");
121775
122519
  }
121776
122520
  const nextName = renaming ? input.name.trim() : existing.name;
121777
122521
  const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
@@ -121784,13 +122528,13 @@ async function handleUpdateTag(input) {
121784
122528
  )
121785
122529
  ).limit(1);
121786
122530
  if (collision) {
121787
- return textResponse6(
122531
+ return textResponse7(
121788
122532
  `\u274C Cannot update: another tag already uses the name "${collision.name}" (id: ${collision.id}) in this scope. Use merge-tags to combine them instead of renaming.`
121789
122533
  );
121790
122534
  }
121791
122535
  const [updated] = await db.update(schema_exports.tags).set({ name: nextName, projectId: nextProjectId }).where(eq(schema_exports.tags.id, existing.id)).returning(TAG_COLUMNS);
121792
- if (!updated) return textResponse6(`Failed to update tag ${input.tagId}.`);
121793
- return textResponse6(
122536
+ if (!updated) return textResponse7(`Failed to update tag ${input.tagId}.`);
122537
+ return textResponse7(
121794
122538
  `\u2705 **Tag updated**
121795
122539
 
121796
122540
  ${describeTag(updated)}
@@ -121799,34 +122543,34 @@ Existing ticket/customer/project/transaction tag relations are preserved.`
121799
122543
  );
121800
122544
  }
121801
122545
  async function handleDeleteTag(input) {
121802
- if (!input.tagId) return textResponse6("Error: `tagId` is required.");
122546
+ if (!input.tagId) return textResponse7("Error: `tagId` is required.");
121803
122547
  const mode = input.mode ?? "delete_if_unused";
121804
122548
  const resolved = await resolveTeamId(input.teamId);
121805
122549
  if (!resolved.ok) return resolved.response;
121806
122550
  const existing = await loadTagInTeam(input.tagId, resolved.teamId);
121807
122551
  if (!existing) {
121808
- return textResponse6(
122552
+ return textResponse7(
121809
122553
  `Tag ${input.tagId} not found, or it is not owned by this team.`
121810
122554
  );
121811
122555
  }
121812
122556
  const usage = await getTagUsage(existing.id);
121813
122557
  const total = totalTagUsage(usage);
121814
122558
  if (mode === "archive") {
121815
- return textResponse6(
122559
+ return textResponse7(
121816
122560
  `\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
121817
122561
 
121818
122562
  Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
121819
122563
  );
121820
122564
  }
121821
122565
  if (total > 0) {
121822
- return textResponse6(
122566
+ return textResponse7(
121823
122567
  `\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
121824
122568
 
121825
122569
  Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
121826
122570
  );
121827
122571
  }
121828
122572
  await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
121829
- return textResponse6(
122573
+ return textResponse7(
121830
122574
  `\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
121831
122575
  );
121832
122576
  }
@@ -121836,7 +122580,7 @@ async function resolveMergeTarget(teamId, input) {
121836
122580
  if (!tag) {
121837
122581
  return {
121838
122582
  ok: false,
121839
- response: textResponse6(
122583
+ response: textResponse7(
121840
122584
  `Target tag ${input.targetTagId} not found, or it is not owned by this team.`
121841
122585
  )
121842
122586
  };
@@ -121846,7 +122590,7 @@ async function resolveMergeTarget(teamId, input) {
121846
122590
  if (!isValidTagName(input.targetName)) {
121847
122591
  return {
121848
122592
  ok: false,
121849
- response: textResponse6(
122593
+ response: textResponse7(
121850
122594
  "Error: provide either `targetTagId` or a non-empty `targetName`."
121851
122595
  )
121852
122596
  };
@@ -121864,14 +122608,14 @@ async function resolveMergeTarget(teamId, input) {
121864
122608
  }
121865
122609
  const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
121866
122610
  if (!created) {
121867
- return { ok: false, response: textResponse6("Failed to create target tag.") };
122611
+ return { ok: false, response: textResponse7("Failed to create target tag.") };
121868
122612
  }
121869
122613
  return { ok: true, tag: created, created: true };
121870
122614
  }
121871
122615
  async function handleMergeTags(input) {
121872
122616
  const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
121873
122617
  if (rawSourceIds.length === 0) {
121874
- return textResponse6("Error: `sourceTagIds` must contain at least one tag id.");
122618
+ return textResponse7("Error: `sourceTagIds` must contain at least one tag id.");
121875
122619
  }
121876
122620
  const resolved = await resolveTeamId(input.teamId);
121877
122621
  if (!resolved.ok) return resolved.response;
@@ -121885,7 +122629,7 @@ async function handleMergeTags(input) {
121885
122629
  const foundIds = new Set(sourceTags.map((t8) => t8.id));
121886
122630
  const missing = rawSourceIds.filter((id) => !foundIds.has(id));
121887
122631
  if (missing.length > 0) {
121888
- return textResponse6(
122632
+ return textResponse7(
121889
122633
  `Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
121890
122634
  );
121891
122635
  }
@@ -121893,7 +122637,7 @@ async function handleMergeTags(input) {
121893
122637
  if (!target.ok) return target.response;
121894
122638
  const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
121895
122639
  if (sourcesToMerge.length === 0) {
121896
- return textResponse6(
122640
+ return textResponse7(
121897
122641
  "Error: nothing to merge \u2014 the only source tag is the same as the target tag."
121898
122642
  );
121899
122643
  }
@@ -121990,7 +122734,7 @@ async function handleMergeTags(input) {
121990
122734
  const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
121991
122735
  const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
121992
122736
  const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
121993
- return textResponse6(
122737
+ return textResponse7(
121994
122738
  `\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
121995
122739
 
121996
122740
  Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
@@ -122357,15 +123101,15 @@ var TRIP_LOCKED_FIELDS = [
122357
123101
  "invoiceId",
122358
123102
  "isInvoiced"
122359
123103
  ];
122360
- function round22(value) {
123104
+ function round23(value) {
122361
123105
  return Math.round(value * 100) / 100;
122362
123106
  }
122363
123107
  function deriveTripAmount(input) {
122364
123108
  if (input.amount != null) return input.amount;
122365
123109
  if (input.rate == null) return null;
122366
- if (input.billingType === "per_trip") return round22(input.rate);
123110
+ if (input.billingType === "per_trip") return round23(input.rate);
122367
123111
  if (input.billingType === "per_km" && input.distance != null) {
122368
- return round22(input.distance * input.rate);
123112
+ return round23(input.distance * input.rate);
122369
123113
  }
122370
123114
  return null;
122371
123115
  }
@@ -122376,7 +123120,7 @@ function attemptedLockedFields(update) {
122376
123120
  // src/tools/trips.ts
122377
123121
  var TRIP_TYPES = ["private", "business"];
122378
123122
  var BILLING_TYPES2 = TRIP_BILLING_TYPES;
122379
- function textResponse7(text3) {
123123
+ function textResponse8(text3) {
122380
123124
  return { content: [{ type: "text", text: text3 }] };
122381
123125
  }
122382
123126
  function jsonResponse(payload) {
@@ -122432,19 +123176,19 @@ var TRIP_RELATIONS = {
122432
123176
  };
122433
123177
  async function handleGetTrips(input) {
122434
123178
  if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
122435
- return textResponse7(
123179
+ return textResponse8(
122436
123180
  `Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
122437
123181
  );
122438
123182
  }
122439
123183
  if (input.billingType && !BILLING_TYPES2.includes(input.billingType)) {
122440
- return textResponse7(
123184
+ return textResponse8(
122441
123185
  `Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
122442
123186
  );
122443
123187
  }
122444
123188
  const scope = await resolveTeamScope(input.teamId);
122445
123189
  if (!scope.ok) return scope.response;
122446
123190
  if (scope.teamIds.length === 0) {
122447
- return textResponse7("No accessible teams found.");
123191
+ return textResponse8("No accessible teams found.");
122448
123192
  }
122449
123193
  const filters = [inArray(schema_exports.trips.teamId, scope.teamIds)];
122450
123194
  if (input.dateFrom) filters.push(gte(schema_exports.trips.date, input.dateFrom));
@@ -122489,10 +123233,10 @@ async function handleGetTrips(input) {
122489
123233
  return jsonResponse({
122490
123234
  count: rows.length,
122491
123235
  totals: {
122492
- businessKm: round22(totals.businessKm),
122493
- privateKm: round22(totals.privateKm),
122494
- totalKm: round22(totals.totalKm),
122495
- totalAmount: round22(totals.totalAmount)
123236
+ businessKm: round23(totals.businessKm),
123237
+ privateKm: round23(totals.privateKm),
123238
+ totalKm: round23(totals.totalKm),
123239
+ totalAmount: round23(totals.totalAmount)
122496
123240
  },
122497
123241
  trips: rows.map(formatTrip)
122498
123242
  });
@@ -122546,20 +123290,20 @@ async function validateInvoice(invoiceId, teamId) {
122546
123290
  }
122547
123291
  async function handleCreateTrip(input) {
122548
123292
  const ctx = getAuthContext();
122549
- if (!input.date) return textResponse7("Error: `date` (YYYY-MM-DD) is required.");
123293
+ if (!input.date) return textResponse8("Error: `date` (YYYY-MM-DD) is required.");
122550
123294
  if (!input.startLocation || !input.endLocation) {
122551
- return textResponse7(
123295
+ return textResponse8(
122552
123296
  "Error: `startLocation` and `endLocation` are required."
122553
123297
  );
122554
123298
  }
122555
123299
  if (!input.tripType || !TRIP_TYPES.includes(input.tripType)) {
122556
- return textResponse7(
123300
+ return textResponse8(
122557
123301
  `Error: \`tripType\` is required and must be one of: ${TRIP_TYPES.join(", ")}.`
122558
123302
  );
122559
123303
  }
122560
123304
  const billingType = input.billingType ?? "not_billable";
122561
123305
  if (!BILLING_TYPES2.includes(billingType)) {
122562
- return textResponse7(
123306
+ return textResponse8(
122563
123307
  `Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
122564
123308
  );
122565
123309
  }
@@ -122571,7 +123315,7 @@ async function handleCreateTrip(input) {
122571
123315
  customerId: input.customerId,
122572
123316
  vehicleId: input.vehicleId
122573
123317
  });
122574
- if (linkError) return textResponse7(`Error: ${linkError}`);
123318
+ if (linkError) return textResponse8(`Error: ${linkError}`);
122575
123319
  if (!input.allowDuplicate) {
122576
123320
  const dupFilters = [
122577
123321
  eq(schema_exports.trips.teamId, teamId),
@@ -122588,7 +123332,7 @@ async function handleCreateTrip(input) {
122588
123332
  }
122589
123333
  const [dup] = await db.select({ id: schema_exports.trips.id, distance: schema_exports.trips.distance }).from(schema_exports.trips).where(and(...dupFilters)).limit(1);
122590
123334
  if (dup) {
122591
- return textResponse7(
123335
+ return textResponse8(
122592
123336
  `\u26A0\uFE0F A matching trip already exists for ${input.date} (${input.startLocation} \u2192 ${input.endLocation}): trip ${dup.id}${dup.distance != null ? ` (${toNumber2(dup.distance)} km)` : ""}. Not creating a duplicate. Use update-trip to adjust it, or re-call create-trip with allowDuplicate: true to record a second trip anyway.`
122593
123337
  );
122594
123338
  }
@@ -122618,7 +123362,7 @@ async function handleCreateTrip(input) {
122618
123362
  vehicleId: input.vehicleId ?? null,
122619
123363
  snapshotId: input.snapshotId ?? null
122620
123364
  }).returning({ id: schema_exports.trips.id });
122621
- if (!created) return textResponse7("Failed to create trip.");
123365
+ if (!created) return textResponse8("Failed to create trip.");
122622
123366
  const trip = await loadTripInTeams(created.id, [teamId]);
122623
123367
  return {
122624
123368
  content: [
@@ -122633,14 +123377,14 @@ ${JSON.stringify(formatTrip(trip), null, 2)}`
122633
123377
  }
122634
123378
  async function handleUpdateTrip(input) {
122635
123379
  const ctx = getAuthContext();
122636
- if (!input.id) return textResponse7("Error: `id` is required.");
123380
+ if (!input.id) return textResponse8("Error: `id` is required.");
122637
123381
  if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
122638
- return textResponse7(
123382
+ return textResponse8(
122639
123383
  `Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
122640
123384
  );
122641
123385
  }
122642
123386
  if (input.billingType && !BILLING_TYPES2.includes(input.billingType)) {
122643
- return textResponse7(
123387
+ return textResponse8(
122644
123388
  `Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
122645
123389
  );
122646
123390
  }
@@ -122649,7 +123393,7 @@ async function handleUpdateTrip(input) {
122649
123393
  const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
122650
123394
  const existing = await loadTripInTeams(input.id, accessibleTeamIds);
122651
123395
  if (!existing) {
122652
- return textResponse7(
123396
+ return textResponse8(
122653
123397
  `Trip ${input.id} not found or you don't have access to it. Call get-trips to find a valid id.`
122654
123398
  );
122655
123399
  }
@@ -122660,7 +123404,7 @@ async function handleUpdateTrip(input) {
122660
123404
  input
122661
123405
  );
122662
123406
  if (attempted.length > 0) {
122663
- return textResponse7(
123407
+ return textResponse8(
122664
123408
  `Error: trip ${input.id} is invoiced${existing.invoiceId ? ` (invoice ${existing.invoiceId})` : ""}. Financial/distance fields are locked: ${attempted.join(", ")}. Re-call with allowInvoicedOverride: true to change them anyway, or only update project/customer/notes/vehicle links.`
122665
123409
  );
122666
123410
  }
@@ -122670,10 +123414,10 @@ async function handleUpdateTrip(input) {
122670
123414
  customerId: input.customerId ?? void 0,
122671
123415
  vehicleId: input.vehicleId ?? void 0
122672
123416
  });
122673
- if (linkError) return textResponse7(`Error: ${linkError}`);
123417
+ if (linkError) return textResponse8(`Error: ${linkError}`);
122674
123418
  if (input.invoiceId) {
122675
123419
  const invoiceError = await validateInvoice(input.invoiceId, teamId);
122676
- if (invoiceError) return textResponse7(`Error: ${invoiceError}`);
123420
+ if (invoiceError) return textResponse8(`Error: ${invoiceError}`);
122677
123421
  }
122678
123422
  const updates = {};
122679
123423
  if (input.date !== void 0) updates.date = input.date;
@@ -122721,7 +123465,7 @@ async function handleUpdateTrip(input) {
122721
123465
  if (derived != null) updates.amount = derived;
122722
123466
  }
122723
123467
  if (Object.keys(updates).length === 0) {
122724
- return textResponse7(
123468
+ return textResponse8(
122725
123469
  "No fields to update. Provide at least one editable field."
122726
123470
  );
122727
123471
  }
@@ -122748,7 +123492,7 @@ async function handleGetVehicles(input) {
122748
123492
  const scope = await resolveTeamScope(input.teamId);
122749
123493
  if (!scope.ok) return scope.response;
122750
123494
  if (scope.teamIds.length === 0) {
122751
- return textResponse7("No accessible teams found.");
123495
+ return textResponse8("No accessible teams found.");
122752
123496
  }
122753
123497
  const filters = [inArray(schema_exports.vehicles.teamId, scope.teamIds)];
122754
123498
  if (input.q) filters.push(ilike(schema_exports.vehicles.name, `%${input.q}%`));
@@ -122774,7 +123518,7 @@ async function handleGetTripTemplates(input) {
122774
123518
  const scope = await resolveTeamScope(input.teamId);
122775
123519
  if (!scope.ok) return scope.response;
122776
123520
  if (scope.teamIds.length === 0) {
122777
- return textResponse7("No accessible teams found.");
123521
+ return textResponse8("No accessible teams found.");
122778
123522
  }
122779
123523
  const filters = [inArray(schema_exports.tripTemplates.teamId, scope.teamIds)];
122780
123524
  const userId = input.userId ?? ctx.userId;
@@ -122810,14 +123554,14 @@ async function handleGetTripTemplates(input) {
122810
123554
  async function handleGetFrequentTripsForProject(input) {
122811
123555
  const ctx = getAuthContext();
122812
123556
  if (!input.projectId) {
122813
- return textResponse7("Error: `projectId` is required.");
123557
+ return textResponse8("Error: `projectId` is required.");
122814
123558
  }
122815
123559
  const resolved = await resolveTeamId(input.teamId);
122816
123560
  if (!resolved.ok) return resolved.response;
122817
123561
  const teamId = resolved.teamId;
122818
123562
  const projectIds = await getAccessibleProjectIds(ctx.userId, teamId);
122819
123563
  if (!projectIds.includes(input.projectId)) {
122820
- return textResponse7(
123564
+ return textResponse8(
122821
123565
  `Project not found or no access: ${input.projectId}. Call get-projects first.`
122822
123566
  );
122823
123567
  }
@@ -122852,7 +123596,7 @@ async function handleGetFrequentTripsForProject(input) {
122852
123596
  endLocation: g6.endLocation,
122853
123597
  tripType: g6.tripType,
122854
123598
  count: g6.count,
122855
- avgDistance: g6.avgDistance != null ? round22(toNumber2(g6.avgDistance)) : null,
123599
+ avgDistance: g6.avgDistance != null ? round23(toNumber2(g6.avgDistance)) : null,
122856
123600
  lastUsedDate: g6.lastUsedDate
122857
123601
  }))
122858
123602
  });
@@ -123442,6 +124186,22 @@ function createMcpServer() {
123442
124186
  );
123443
124187
  case "get-invoices":
123444
124188
  return await handleGetInvoices(asToolArgs(toolArgs));
124189
+ case "get-invoice-by-id":
124190
+ return await handleGetInvoiceById(
124191
+ asToolArgs(toolArgs)
124192
+ );
124193
+ case "update-invoice":
124194
+ return await handleUpdateInvoice(
124195
+ asToolArgs(toolArgs)
124196
+ );
124197
+ case "update-invoice-lines":
124198
+ return await handleUpdateInvoiceLines(
124199
+ asToolArgs(toolArgs)
124200
+ );
124201
+ case "add-product-to-invoice":
124202
+ return await handleAddProductToInvoice(
124203
+ asToolArgs(toolArgs)
124204
+ );
123445
124205
  case "link-document-to-invoice":
123446
124206
  return await handleLinkDocumentToInvoice(
123447
124207
  asToolArgs(toolArgs)