@mgsoftwarebv/mcp-server-bridge 3.5.6 → 3.5.8

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
@@ -103433,10 +103433,28 @@ var customerRecurringServices = pgTable(
103433
103433
  // Frozen RecurringContractTerms (notice period, minimum term, renewal
103434
103434
  // policy, cancellation effect, billing after cancellation, free notes).
103435
103435
  contractTerms: jsonb("contract_terms").$type(),
103436
+ // Frozen ProductClause — customer-specific scope/limits snapshot.
103437
+ clause: jsonb().$type(),
103438
+ // Structured discount applied on top of catalog price.
103439
+ discountType: text("discount_type"),
103440
+ discountValue: numericCasted("discount_value", { precision: 10, scale: 2 }),
103441
+ // Compact invoice line text (overrides name on draft invoices when set).
103442
+ lineDescription: text("line_description"),
103443
+ // Umbrella contract label for grouped billable lines on invoices.
103444
+ umbrellaLabel: text("umbrella_label"),
103445
+ // Pro-rata first invoice for yearly variants starting mid-period.
103446
+ proRataFirstInvoice: boolean3("pro_rata_first_invoice").default(false).notNull(),
103447
+ // End of billing/contract period for pro-rata (defaults to end of start year).
103448
+ contractPeriodEnd: timestamp("contract_period_end", {
103449
+ withTimezone: true,
103450
+ mode: "string"
103451
+ }),
103436
103452
  // --- Lifecycle ---
103437
103453
  // "active" | "paused" | "cancelled" | "ended"
103438
103454
  status: text().$type().default("active").notNull(),
103439
103455
  startDate: timestamp("start_date", { withTimezone: true, mode: "string" }),
103456
+ // Planned end of the agreement (before cancellation/ended).
103457
+ endDate: timestamp("end_date", { withTimezone: true, mode: "string" }),
103440
103458
  // Next renewal boundary (end of current covered term).
103441
103459
  renewalDate: timestamp("renewal_date", {
103442
103460
  withTimezone: true,
@@ -106677,7 +106695,7 @@ var TOOLS = [
106677
106695
  },
106678
106696
  {
106679
106697
  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.",
106698
+ 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
106699
  inputSchema: {
106682
106700
  type: "object",
106683
106701
  properties: {
@@ -106722,6 +106740,136 @@ var TOOLS = [
106722
106740
  required: ["documentId", "invoiceId"]
106723
106741
  }
106724
106742
  },
106743
+ {
106744
+ name: "get-invoice-by-id",
106745
+ 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.",
106746
+ inputSchema: {
106747
+ type: "object",
106748
+ properties: {
106749
+ teamId: teamIdProp,
106750
+ invoiceId: {
106751
+ type: "string",
106752
+ description: "Invoice UUID or invoice number"
106753
+ }
106754
+ },
106755
+ required: ["invoiceId"]
106756
+ }
106757
+ },
106758
+ {
106759
+ name: "update-invoice",
106760
+ 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).",
106761
+ inputSchema: {
106762
+ type: "object",
106763
+ properties: {
106764
+ teamId: teamIdProp,
106765
+ invoiceId: { type: "string", description: "Invoice UUID or number" },
106766
+ title: { type: "string", description: "Overrides template.title" },
106767
+ note: {
106768
+ type: ["string", "null"],
106769
+ description: "Customer-facing note; null clears it."
106770
+ },
106771
+ internalNote: { type: ["string", "null"] },
106772
+ dueDate: { type: ["string", "null"], description: "ISO date" },
106773
+ issueDate: { type: ["string", "null"], description: "ISO date" },
106774
+ lineItems: {
106775
+ type: "array",
106776
+ description: "Replaces ALL line items. Each: { name?, quantity?, unit?, price?, productId?, pricingOptionId?, prorate?, startDate?, periodEndDate? }.",
106777
+ items: {
106778
+ type: "object",
106779
+ properties: {
106780
+ name: { type: "string", description: "Compact line description" },
106781
+ quantity: { type: "number" },
106782
+ unit: { type: "string" },
106783
+ price: { type: "number", description: "Unit price excl. VAT" },
106784
+ productId: { type: "string" },
106785
+ pricingOptionId: { type: "string" },
106786
+ prorate: {
106787
+ type: "boolean",
106788
+ description: "Pro-rate yearly variant from startDate"
106789
+ },
106790
+ startDate: { type: "string", description: "ISO date for pro-rata" },
106791
+ periodEndDate: { type: "string" }
106792
+ }
106793
+ }
106794
+ }
106795
+ },
106796
+ required: ["invoiceId"]
106797
+ }
106798
+ },
106799
+ {
106800
+ name: "update-invoice-lines",
106801
+ 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.",
106802
+ inputSchema: {
106803
+ type: "object",
106804
+ properties: {
106805
+ teamId: teamIdProp,
106806
+ invoiceId: { type: "string", description: "Invoice UUID or number" },
106807
+ lineItems: {
106808
+ type: "array",
106809
+ description: "Patches to apply. Each: { index, description?, quantity?, unit?, price? }.",
106810
+ items: {
106811
+ type: "object",
106812
+ properties: {
106813
+ index: {
106814
+ type: "number",
106815
+ description: "Zero-based line index"
106816
+ },
106817
+ description: {
106818
+ type: "string",
106819
+ description: "Compact factuurregel text (plain string)"
106820
+ },
106821
+ quantity: { type: "number" },
106822
+ unit: { type: "string" },
106823
+ price: { type: "number" }
106824
+ },
106825
+ required: ["index"]
106826
+ }
106827
+ }
106828
+ },
106829
+ required: ["invoiceId", "lineItems"]
106830
+ }
106831
+ },
106832
+ {
106833
+ name: "add-product-to-invoice",
106834
+ 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.",
106835
+ inputSchema: {
106836
+ type: "object",
106837
+ properties: {
106838
+ teamId: teamIdProp,
106839
+ invoiceId: { type: "string", description: "Invoice UUID or number" },
106840
+ productId: {
106841
+ type: "string",
106842
+ description: "Catalog product ID (see get-products)"
106843
+ },
106844
+ quantity: { type: "number", default: 1 },
106845
+ customDescription: {
106846
+ type: "string",
106847
+ description: "Overrides the compact line description."
106848
+ },
106849
+ customPrice: {
106850
+ type: "number",
106851
+ description: "Overrides the unit price (excl. VAT)."
106852
+ },
106853
+ pricingOptionId: {
106854
+ type: "string",
106855
+ description: "Pricing variant id (monthly/yearly); defaults to product default."
106856
+ },
106857
+ prorate: {
106858
+ type: "boolean",
106859
+ description: "When true and a yearly variant is selected, price is pro-rated from startDate."
106860
+ },
106861
+ startDate: {
106862
+ type: "string",
106863
+ description: "ISO start date for pro-rata (required when prorate is true)."
106864
+ },
106865
+ periodEndDate: {
106866
+ type: "string",
106867
+ description: "Optional end of billing period for pro-rata (defaults to end of start year)."
106868
+ }
106869
+ },
106870
+ required: ["invoiceId", "productId"]
106871
+ }
106872
+ },
106725
106873
  {
106726
106874
  name: "get-products",
106727
106875
  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.",
@@ -107253,6 +107401,90 @@ var TOOLS = [
107253
107401
  },
107254
107402
  required: ["projectId", "directoryPath"]
107255
107403
  }
107404
+ },
107405
+ {
107406
+ name: "get-customer-agreements",
107407
+ description: "List per-customer product agreements (customer_recurring_services): custom price, discount, clause snapshot, umbrella label, billing cadence, start/end dates.",
107408
+ inputSchema: {
107409
+ type: "object",
107410
+ properties: {
107411
+ teamId: teamIdProp,
107412
+ customerId: { type: "string", description: "Filter by customer UUID" },
107413
+ status: {
107414
+ type: "string",
107415
+ enum: ["active", "paused", "cancelled", "ended"],
107416
+ description: "Filter by agreement status"
107417
+ }
107418
+ }
107419
+ }
107420
+ },
107421
+ {
107422
+ name: "create-customer-agreement",
107423
+ description: "Create a customer-specific product agreement between catalog and billing. Snapshots price, discount, clause and invoice line text without mutating the catalog product.",
107424
+ inputSchema: {
107425
+ type: "object",
107426
+ properties: {
107427
+ teamId: teamIdProp,
107428
+ customerId: { type: "string", description: "Customer UUID" },
107429
+ productId: { type: "string", description: "Optional catalog product UUID" },
107430
+ name: { type: "string", description: "Agreement name" },
107431
+ description: { type: "string" },
107432
+ price: { type: "number" },
107433
+ billingInterval: { type: "string", enum: ["month", "year"] },
107434
+ discountType: { type: "string", enum: ["percentage", "amount"] },
107435
+ discountValue: { type: "number" },
107436
+ discountDescription: { type: "string" },
107437
+ lineDescription: {
107438
+ type: "string",
107439
+ description: "Compact invoice line text override"
107440
+ },
107441
+ umbrellaLabel: {
107442
+ type: "string",
107443
+ description: "Umbrella contract label for grouped invoice lines"
107444
+ },
107445
+ clause: productClauseProp,
107446
+ startDate: { type: "string", description: "ISO start date" },
107447
+ endDate: { type: "string", description: "ISO planned end date" },
107448
+ contractPeriodEnd: {
107449
+ type: "string",
107450
+ description: "ISO period end for pro-rata calculations"
107451
+ },
107452
+ proRataFirstInvoice: { type: "boolean" }
107453
+ },
107454
+ required: ["customerId", "name"]
107455
+ }
107456
+ },
107457
+ {
107458
+ name: "update-customer-agreement",
107459
+ description: "Update an existing customer product agreement by id.",
107460
+ inputSchema: {
107461
+ type: "object",
107462
+ properties: {
107463
+ teamId: teamIdProp,
107464
+ id: { type: "string", description: "Agreement UUID" },
107465
+ customerId: { type: "string" },
107466
+ productId: { type: "string" },
107467
+ name: { type: "string" },
107468
+ description: { type: "string" },
107469
+ price: { type: "number" },
107470
+ billingInterval: { type: "string", enum: ["month", "year"] },
107471
+ discountType: { type: "string", enum: ["percentage", "amount"] },
107472
+ discountValue: { type: "number" },
107473
+ discountDescription: { type: "string" },
107474
+ lineDescription: { type: "string" },
107475
+ umbrellaLabel: { type: "string" },
107476
+ clause: productClauseProp,
107477
+ startDate: { type: "string" },
107478
+ endDate: { type: "string" },
107479
+ contractPeriodEnd: { type: "string" },
107480
+ proRataFirstInvoice: { type: "boolean" },
107481
+ status: {
107482
+ type: "string",
107483
+ enum: ["active", "paused", "cancelled", "ended"]
107484
+ }
107485
+ },
107486
+ required: ["id"]
107487
+ }
107256
107488
  }
107257
107489
  ];
107258
107490
  var RESOURCES = [
@@ -108347,130 +108579,374 @@ The customer had no projects, tickets, invoices, quotations, documents, time ent
108347
108579
  );
108348
108580
  }
108349
108581
 
108350
- // ../document/src/humanizer/rules.ts
108351
- var REPLACEMENTS = [
108352
- // --- Dutch clichés ---
108353
- { pattern: /\bbovendien\b/gi, replacement: "daarnaast", rule: "nl-cliche" },
108354
- { pattern: /\btevens\b/gi, replacement: "ook", rule: "nl-cliche" },
108355
- { pattern: /\bkortom\b/gi, replacement: "samengevat", rule: "nl-cliche" },
108356
- { pattern: /\bnaadloze\b/gi, replacement: "soepele", rule: "nl-cliche" },
108357
- { pattern: /\bnaadloos\b/gi, replacement: "soepel", rule: "nl-cliche" },
108358
- { pattern: /\brobuuste\b/gi, replacement: "solide", rule: "nl-cliche" },
108359
- { pattern: /\brobuust\b/gi, replacement: "solide", rule: "nl-cliche" },
108360
- { pattern: /\bcruciale\b/gi, replacement: "belangrijke", rule: "nl-cliche" },
108361
- { pattern: /\bcruciaal\b/gi, replacement: "belangrijk", rule: "nl-cliche" },
108362
- {
108363
- pattern: /\bessentiële\b/gi,
108364
- replacement: "belangrijke",
108365
- rule: "nl-cliche"
108366
- },
108367
- { pattern: /\bessentieel\b/gi, replacement: "belangrijk", rule: "nl-cliche" },
108368
- {
108369
- pattern: /\been breed scala aan\b/gi,
108370
- replacement: "veel",
108371
- rule: "nl-cliche"
108372
- },
108373
- {
108374
- pattern: /\boptimaal benutten\b/gi,
108375
- replacement: "goed benutten",
108376
- rule: "nl-cliche"
108377
- },
108378
- {
108379
- pattern: /\bin de wereld van vandaag\b/gi,
108380
- replacement: "vandaag de dag",
108381
- rule: "nl-cliche"
108382
- },
108383
- {
108384
- pattern: /\bin het huidige digitale tijdperk\b/gi,
108385
- replacement: "tegenwoordig",
108386
- rule: "nl-cliche"
108387
- },
108388
- {
108389
- pattern: /\bstate-of-the-art\b/gi,
108390
- replacement: "moderne",
108391
- rule: "nl-cliche"
108392
- },
108393
- // --- Double superlatives / intensifiers ---
108394
- { pattern: /\bzeer unieke?\b/gi, replacement: "unieke", rule: "superlative" },
108395
- { pattern: /\bheel erg\b/gi, replacement: "erg", rule: "superlative" },
108396
- {
108397
- pattern: /\babsoluut essentieel\b/gi,
108398
- replacement: "belangrijk",
108399
- rule: "superlative"
108400
- },
108401
- // --- English clichés ---
108402
- { pattern: /\bdelve into\b/gi, replacement: "look at", rule: "en-cliche" },
108403
- { pattern: /\bdelves into\b/gi, replacement: "looks at", rule: "en-cliche" },
108404
- {
108405
- pattern: /\bdelving into\b/gi,
108406
- replacement: "looking at",
108407
- rule: "en-cliche"
108408
- },
108409
- { pattern: /\bleveraging\b/gi, replacement: "using", rule: "en-cliche" },
108410
- { pattern: /\bleverages\b/gi, replacement: "uses", rule: "en-cliche" },
108411
- { pattern: /\bleverage\b/gi, replacement: "use", rule: "en-cliche" },
108412
- { pattern: /\bseamlessly\b/gi, replacement: "smoothly", rule: "en-cliche" },
108413
- { pattern: /\bseamless\b/gi, replacement: "smooth", rule: "en-cliche" },
108414
- { pattern: /\bfurthermore\b/gi, replacement: "also", rule: "en-cliche" },
108415
- { pattern: /\bmoreover\b/gi, replacement: "also", rule: "en-cliche" },
108416
- { pattern: /\butilizes\b/gi, replacement: "uses", rule: "en-cliche" },
108417
- { pattern: /\butilizing\b/gi, replacement: "using", rule: "en-cliche" },
108418
- { pattern: /\butilize\b/gi, replacement: "use", rule: "en-cliche" },
108419
- { pattern: /\bcutting-edge\b/gi, replacement: "modern", rule: "en-cliche" },
108420
- { pattern: /\bgame-changer\b/gi, replacement: "big step", rule: "en-cliche" },
108421
- { pattern: /\bembark on\b/gi, replacement: "start", rule: "en-cliche" },
108422
- {
108423
- pattern: /\ba wide range of\b/gi,
108424
- replacement: "many",
108425
- rule: "en-cliche"
108426
- },
108427
- {
108428
- pattern: /\bin today's (fast-paced )?world\b/gi,
108429
- replacement: "today",
108430
- rule: "en-cliche"
108431
- },
108432
- {
108433
- pattern: /\bin this digital age\b/gi,
108434
- replacement: "today",
108435
- rule: "en-cliche"
108436
- }
108437
- ];
108438
- var FILLER_PHRASES = [
108439
- {
108440
- pattern: /(^|(?<=[.!?]\s))het is belangrijk om (te vermelden|op te merken|te benadrukken) dat\s+/gi,
108441
- rule: "nl-filler"
108442
- },
108443
- {
108444
- pattern: /(^|(?<=[.!?]\s))het is goed om te weten dat\s+/gi,
108445
- rule: "nl-filler"
108446
- },
108447
- {
108448
- pattern: /(^|(?<=[.!?]\s))zoals eerder (vermeld|genoemd|aangegeven),?\s+/gi,
108449
- rule: "nl-filler"
108450
- },
108451
- {
108452
- pattern: /(^|(?<=[.!?]\s))it('|’)s worth noting that\s+/gi,
108453
- rule: "en-filler"
108454
- },
108455
- {
108456
- pattern: /(^|(?<=[.!?]\s))it is (important|worth mentioning) (to note )?that\s+/gi,
108457
- rule: "en-filler"
108458
- },
108459
- {
108460
- pattern: /(^|(?<=[.!?]\s))as (previously )?mentioned( before| earlier)?,?\s+/gi,
108461
- rule: "en-filler"
108462
- },
108463
- { pattern: /(^|(?<=[.!?]\s))needless to say,?\s+/gi, rule: "en-filler" }
108464
- ];
108465
- function matchCase(replacement, original) {
108466
- if (!original.length || !replacement.length) return replacement;
108467
- const first = original[0];
108468
- if (first === first.toUpperCase() && first !== first.toLowerCase()) {
108469
- return replacement[0].toUpperCase() + replacement.slice(1);
108470
- }
108471
- return replacement;
108582
+ // ../invoice/src/utils/product-clause.ts
108583
+ function trimToNull(value) {
108584
+ if (typeof value !== "string") return null;
108585
+ const trimmed = value.trim();
108586
+ return trimmed.length > 0 ? trimmed : null;
108472
108587
  }
108473
- function capitalizeSentenceStarts(text3) {
108588
+ function parseStringList(value) {
108589
+ if (!Array.isArray(value)) return [];
108590
+ const out = [];
108591
+ for (const entry of value) {
108592
+ const label = trimToNull(entry);
108593
+ if (label) out.push(label);
108594
+ }
108595
+ return out;
108596
+ }
108597
+ function parseLimits(value) {
108598
+ if (!value || typeof value !== "object") return null;
108599
+ const raw = value;
108600
+ const hoursRaw = raw.includedHoursPerMonth;
108601
+ let includedHoursPerMonth = null;
108602
+ if (typeof hoursRaw === "number" && Number.isFinite(hoursRaw)) {
108603
+ includedHoursPerMonth = hoursRaw;
108604
+ } else if (typeof hoursRaw === "string" && hoursRaw.trim() !== "") {
108605
+ const parsed = Number(hoursRaw);
108606
+ if (Number.isFinite(parsed)) includedHoursPerMonth = parsed;
108607
+ }
108608
+ const rollover = typeof raw.rollover === "boolean" ? raw.rollover : null;
108609
+ const limits = {
108610
+ includedHoursPerMonth,
108611
+ rollover,
108612
+ minimumBillingUnitExtraWork: trimToNull(raw.minimumBillingUnitExtraWork),
108613
+ responseTime: trimToNull(raw.responseTime),
108614
+ supportLevel: trimToNull(raw.supportLevel)
108615
+ };
108616
+ return isLimitsEmpty(limits) ? null : limits;
108617
+ }
108618
+ function isLimitsEmpty(limits) {
108619
+ if (!limits) return true;
108620
+ return limits.includedHoursPerMonth == null && limits.rollover == null && !limits.minimumBillingUnitExtraWork && !limits.responseTime && !limits.supportLevel;
108621
+ }
108622
+ function parseProductClause(value) {
108623
+ if (value == null) return null;
108624
+ if (typeof value !== "object" || Array.isArray(value)) return null;
108625
+ const raw = value;
108626
+ const clause = {
108627
+ commercialDescription: trimToNull(raw.commercialDescription),
108628
+ includedScope: parseStringList(raw.includedScope),
108629
+ excludedScope: parseStringList(raw.excludedScope),
108630
+ limits: parseLimits(raw.limits),
108631
+ customerValue: trimToNull(raw.customerValue),
108632
+ extraWorkConditions: trimToNull(raw.extraWorkConditions)
108633
+ };
108634
+ return isClauseEmpty(clause) ? null : clause;
108635
+ }
108636
+ function isClauseEmpty(clause) {
108637
+ if (!clause) return true;
108638
+ return !clause.commercialDescription && (clause.includedScope?.length ?? 0) === 0 && (clause.excludedScope?.length ?? 0) === 0 && isLimitsEmpty(clause.limits) && !clause.customerValue && !clause.extraWorkConditions;
108639
+ }
108640
+ function serializeProductClause(clause) {
108641
+ if (clause == null) return null;
108642
+ const parsed = parseProductClause(clause);
108643
+ if (!parsed) return null;
108644
+ const out = {};
108645
+ if (parsed.commercialDescription) {
108646
+ out.commercialDescription = parsed.commercialDescription;
108647
+ }
108648
+ if (parsed.includedScope && parsed.includedScope.length > 0) {
108649
+ out.includedScope = parsed.includedScope;
108650
+ }
108651
+ if (parsed.excludedScope && parsed.excludedScope.length > 0) {
108652
+ out.excludedScope = parsed.excludedScope;
108653
+ }
108654
+ if (!isLimitsEmpty(parsed.limits)) {
108655
+ const limits = parsed.limits;
108656
+ const cleanedLimits = {};
108657
+ if (limits.includedHoursPerMonth != null) {
108658
+ cleanedLimits.includedHoursPerMonth = limits.includedHoursPerMonth;
108659
+ }
108660
+ if (limits.rollover != null) cleanedLimits.rollover = limits.rollover;
108661
+ if (limits.minimumBillingUnitExtraWork) {
108662
+ cleanedLimits.minimumBillingUnitExtraWork = limits.minimumBillingUnitExtraWork;
108663
+ }
108664
+ if (limits.responseTime) cleanedLimits.responseTime = limits.responseTime;
108665
+ if (limits.supportLevel) cleanedLimits.supportLevel = limits.supportLevel;
108666
+ out.limits = cleanedLimits;
108667
+ }
108668
+ if (parsed.customerValue) out.customerValue = parsed.customerValue;
108669
+ if (parsed.extraWorkConditions) {
108670
+ out.extraWorkConditions = parsed.extraWorkConditions;
108671
+ }
108672
+ return Object.keys(out).length > 0 ? out : null;
108673
+ }
108674
+ function clauseLimitLines(limits) {
108675
+ if (isLimitsEmpty(limits) || !limits) return [];
108676
+ const lines = [];
108677
+ if (limits.includedHoursPerMonth != null) {
108678
+ lines.push(`Inbegrepen uren per maand: ${limits.includedHoursPerMonth}`);
108679
+ }
108680
+ if (limits.rollover != null) {
108681
+ lines.push(
108682
+ limits.rollover ? "Niet-gebruikte uren schuiven door naar de volgende maand" : "Niet-gebruikte uren vervallen aan het einde van de maand"
108683
+ );
108684
+ }
108685
+ if (limits.minimumBillingUnitExtraWork) {
108686
+ lines.push(
108687
+ `Minimale facturatie-eenheid meerwerk: ${limits.minimumBillingUnitExtraWork}`
108688
+ );
108689
+ }
108690
+ if (limits.supportLevel) lines.push(`Supportniveau: ${limits.supportLevel}`);
108691
+ if (limits.responseTime) lines.push(`Responstijd: ${limits.responseTime}`);
108692
+ return lines;
108693
+ }
108694
+ function clauseSummaryLines(clause) {
108695
+ const parsed = parseProductClause(clause);
108696
+ if (!parsed) return [];
108697
+ const lines = [];
108698
+ if (parsed.commercialDescription) {
108699
+ lines.push(parsed.commercialDescription);
108700
+ }
108701
+ if (parsed.includedScope?.length) {
108702
+ lines.push(`Inbegrepen: ${parsed.includedScope.join("; ")}`);
108703
+ }
108704
+ if (parsed.excludedScope?.length) {
108705
+ lines.push(`Niet inbegrepen: ${parsed.excludedScope.join("; ")}`);
108706
+ }
108707
+ lines.push(...clauseLimitLines(parsed.limits));
108708
+ if (parsed.customerValue) {
108709
+ lines.push(`Klantwaarde: ${parsed.customerValue}`);
108710
+ }
108711
+ if (parsed.extraWorkConditions) {
108712
+ lines.push(`Meerwerk: ${parsed.extraWorkConditions}`);
108713
+ }
108714
+ return lines;
108715
+ }
108716
+
108717
+ // src/tools/customer-agreements.ts
108718
+ function textResponse2(text3) {
108719
+ return { content: [{ type: "text", text: text3 }] };
108720
+ }
108721
+ function formatAgreement(row) {
108722
+ const parts = [
108723
+ `**${row.name}** (${row.status})`,
108724
+ `id: ${row.id}`,
108725
+ `customerId: ${row.customerId}`,
108726
+ row.productId ? `productId: ${row.productId}` : null,
108727
+ row.price != null ? `price: ${row.price} ${row.currency ?? "EUR"}` : null,
108728
+ row.billingInterval ? `billing: ${row.billingInterval}` : null,
108729
+ row.discountType && row.discountValue != null ? `discount: ${row.discountType} ${row.discountValue}` : null,
108730
+ row.lineDescription ? `invoice line: ${row.lineDescription}` : null,
108731
+ row.umbrellaLabel ? `umbrella: ${row.umbrellaLabel}` : null,
108732
+ row.startDate ? `start: ${row.startDate}` : null,
108733
+ row.endDate ? `end: ${row.endDate}` : null,
108734
+ row.proRataFirstInvoice ? "pro-rata first invoice: yes" : null
108735
+ ].filter(Boolean);
108736
+ return parts.join("\n");
108737
+ }
108738
+ async function handleGetCustomerAgreements(input) {
108739
+ const scope = await resolveTeamScope(input.teamId);
108740
+ if (!scope.ok) return scope.response;
108741
+ const conditions = [inArray(schema_exports.customerRecurringServices.teamId, scope.teamIds)];
108742
+ if (input.customerId) {
108743
+ conditions.push(
108744
+ eq(schema_exports.customerRecurringServices.customerId, input.customerId)
108745
+ );
108746
+ }
108747
+ if (input.status) {
108748
+ conditions.push(eq(schema_exports.customerRecurringServices.status, input.status));
108749
+ }
108750
+ const rows = await db.select().from(schema_exports.customerRecurringServices).where(and(...conditions)).orderBy(desc(schema_exports.customerRecurringServices.createdAt)).limit(50);
108751
+ if (rows.length === 0) {
108752
+ return textResponse2("No customer agreements found.");
108753
+ }
108754
+ return textResponse2(rows.map(formatAgreement).join("\n\n---\n\n"));
108755
+ }
108756
+ async function handleCreateCustomerAgreement(input) {
108757
+ const scope = await resolveTeamScope(input.teamId);
108758
+ if (!scope.ok) return scope.response;
108759
+ const teamId = scope.teamIds[0];
108760
+ const [row] = await db.insert(schema_exports.customerRecurringServices).values({
108761
+ teamId,
108762
+ customerId: input.customerId,
108763
+ productId: input.productId ?? null,
108764
+ name: input.name,
108765
+ description: input.description ?? null,
108766
+ price: input.price ?? null,
108767
+ billingInterval: input.billingInterval ?? null,
108768
+ discountType: input.discountType ?? null,
108769
+ discountValue: input.discountValue ?? null,
108770
+ discountDescription: input.discountDescription ?? null,
108771
+ lineDescription: input.lineDescription ?? null,
108772
+ umbrellaLabel: input.umbrellaLabel ?? null,
108773
+ clause: serializeProductClause(input.clause ?? null),
108774
+ startDate: input.startDate ?? null,
108775
+ endDate: input.endDate ?? null,
108776
+ contractPeriodEnd: input.contractPeriodEnd ?? null,
108777
+ proRataFirstInvoice: input.proRataFirstInvoice ?? false,
108778
+ status: "active"
108779
+ }).returning();
108780
+ return textResponse2(`Created customer agreement:
108781
+
108782
+ ${formatAgreement(row)}`);
108783
+ }
108784
+ async function handleUpdateCustomerAgreement(input) {
108785
+ const scope = await resolveTeamScope(input.teamId);
108786
+ if (!scope.ok) return scope.response;
108787
+ const teamId = scope.teamIds[0];
108788
+ const patch = {
108789
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
108790
+ };
108791
+ if (input.name !== void 0) patch.name = input.name;
108792
+ if (input.description !== void 0) patch.description = input.description;
108793
+ if (input.price !== void 0) patch.price = input.price;
108794
+ if (input.billingInterval !== void 0)
108795
+ patch.billingInterval = input.billingInterval;
108796
+ if (input.discountType !== void 0) patch.discountType = input.discountType;
108797
+ if (input.discountValue !== void 0) patch.discountValue = input.discountValue;
108798
+ if (input.discountDescription !== void 0)
108799
+ patch.discountDescription = input.discountDescription;
108800
+ if (input.lineDescription !== void 0)
108801
+ patch.lineDescription = input.lineDescription;
108802
+ if (input.umbrellaLabel !== void 0) patch.umbrellaLabel = input.umbrellaLabel;
108803
+ if (input.clause !== void 0)
108804
+ patch.clause = serializeProductClause(input.clause ?? null);
108805
+ if (input.startDate !== void 0) patch.startDate = input.startDate;
108806
+ if (input.endDate !== void 0) patch.endDate = input.endDate;
108807
+ if (input.contractPeriodEnd !== void 0)
108808
+ patch.contractPeriodEnd = input.contractPeriodEnd;
108809
+ if (input.proRataFirstInvoice !== void 0)
108810
+ patch.proRataFirstInvoice = input.proRataFirstInvoice;
108811
+ if (input.status !== void 0) patch.status = input.status;
108812
+ const [row] = await db.update(schema_exports.customerRecurringServices).set(patch).where(
108813
+ and(
108814
+ eq(schema_exports.customerRecurringServices.id, input.id),
108815
+ eq(schema_exports.customerRecurringServices.teamId, teamId)
108816
+ )
108817
+ ).returning();
108818
+ if (!row) {
108819
+ return textResponse2("Agreement not found.");
108820
+ }
108821
+ return textResponse2(`Updated customer agreement:
108822
+
108823
+ ${formatAgreement(row)}`);
108824
+ }
108825
+
108826
+ // ../document/src/humanizer/rules.ts
108827
+ var REPLACEMENTS = [
108828
+ // --- Dutch clichés ---
108829
+ { pattern: /\bbovendien\b/gi, replacement: "daarnaast", rule: "nl-cliche" },
108830
+ { pattern: /\btevens\b/gi, replacement: "ook", rule: "nl-cliche" },
108831
+ { pattern: /\bkortom\b/gi, replacement: "samengevat", rule: "nl-cliche" },
108832
+ { pattern: /\bnaadloze\b/gi, replacement: "soepele", rule: "nl-cliche" },
108833
+ { pattern: /\bnaadloos\b/gi, replacement: "soepel", rule: "nl-cliche" },
108834
+ { pattern: /\brobuuste\b/gi, replacement: "solide", rule: "nl-cliche" },
108835
+ { pattern: /\brobuust\b/gi, replacement: "solide", rule: "nl-cliche" },
108836
+ { pattern: /\bcruciale\b/gi, replacement: "belangrijke", rule: "nl-cliche" },
108837
+ { pattern: /\bcruciaal\b/gi, replacement: "belangrijk", rule: "nl-cliche" },
108838
+ {
108839
+ pattern: /\bessentiële\b/gi,
108840
+ replacement: "belangrijke",
108841
+ rule: "nl-cliche"
108842
+ },
108843
+ { pattern: /\bessentieel\b/gi, replacement: "belangrijk", rule: "nl-cliche" },
108844
+ {
108845
+ pattern: /\been breed scala aan\b/gi,
108846
+ replacement: "veel",
108847
+ rule: "nl-cliche"
108848
+ },
108849
+ {
108850
+ pattern: /\boptimaal benutten\b/gi,
108851
+ replacement: "goed benutten",
108852
+ rule: "nl-cliche"
108853
+ },
108854
+ {
108855
+ pattern: /\bin de wereld van vandaag\b/gi,
108856
+ replacement: "vandaag de dag",
108857
+ rule: "nl-cliche"
108858
+ },
108859
+ {
108860
+ pattern: /\bin het huidige digitale tijdperk\b/gi,
108861
+ replacement: "tegenwoordig",
108862
+ rule: "nl-cliche"
108863
+ },
108864
+ {
108865
+ pattern: /\bstate-of-the-art\b/gi,
108866
+ replacement: "moderne",
108867
+ rule: "nl-cliche"
108868
+ },
108869
+ // --- Double superlatives / intensifiers ---
108870
+ { pattern: /\bzeer unieke?\b/gi, replacement: "unieke", rule: "superlative" },
108871
+ { pattern: /\bheel erg\b/gi, replacement: "erg", rule: "superlative" },
108872
+ {
108873
+ pattern: /\babsoluut essentieel\b/gi,
108874
+ replacement: "belangrijk",
108875
+ rule: "superlative"
108876
+ },
108877
+ // --- English clichés ---
108878
+ { pattern: /\bdelve into\b/gi, replacement: "look at", rule: "en-cliche" },
108879
+ { pattern: /\bdelves into\b/gi, replacement: "looks at", rule: "en-cliche" },
108880
+ {
108881
+ pattern: /\bdelving into\b/gi,
108882
+ replacement: "looking at",
108883
+ rule: "en-cliche"
108884
+ },
108885
+ { pattern: /\bleveraging\b/gi, replacement: "using", rule: "en-cliche" },
108886
+ { pattern: /\bleverages\b/gi, replacement: "uses", rule: "en-cliche" },
108887
+ { pattern: /\bleverage\b/gi, replacement: "use", rule: "en-cliche" },
108888
+ { pattern: /\bseamlessly\b/gi, replacement: "smoothly", rule: "en-cliche" },
108889
+ { pattern: /\bseamless\b/gi, replacement: "smooth", rule: "en-cliche" },
108890
+ { pattern: /\bfurthermore\b/gi, replacement: "also", rule: "en-cliche" },
108891
+ { pattern: /\bmoreover\b/gi, replacement: "also", rule: "en-cliche" },
108892
+ { pattern: /\butilizes\b/gi, replacement: "uses", rule: "en-cliche" },
108893
+ { pattern: /\butilizing\b/gi, replacement: "using", rule: "en-cliche" },
108894
+ { pattern: /\butilize\b/gi, replacement: "use", rule: "en-cliche" },
108895
+ { pattern: /\bcutting-edge\b/gi, replacement: "modern", rule: "en-cliche" },
108896
+ { pattern: /\bgame-changer\b/gi, replacement: "big step", rule: "en-cliche" },
108897
+ { pattern: /\bembark on\b/gi, replacement: "start", rule: "en-cliche" },
108898
+ {
108899
+ pattern: /\ba wide range of\b/gi,
108900
+ replacement: "many",
108901
+ rule: "en-cliche"
108902
+ },
108903
+ {
108904
+ pattern: /\bin today's (fast-paced )?world\b/gi,
108905
+ replacement: "today",
108906
+ rule: "en-cliche"
108907
+ },
108908
+ {
108909
+ pattern: /\bin this digital age\b/gi,
108910
+ replacement: "today",
108911
+ rule: "en-cliche"
108912
+ }
108913
+ ];
108914
+ var FILLER_PHRASES = [
108915
+ {
108916
+ pattern: /(^|(?<=[.!?]\s))het is belangrijk om (te vermelden|op te merken|te benadrukken) dat\s+/gi,
108917
+ rule: "nl-filler"
108918
+ },
108919
+ {
108920
+ pattern: /(^|(?<=[.!?]\s))het is goed om te weten dat\s+/gi,
108921
+ rule: "nl-filler"
108922
+ },
108923
+ {
108924
+ pattern: /(^|(?<=[.!?]\s))zoals eerder (vermeld|genoemd|aangegeven),?\s+/gi,
108925
+ rule: "nl-filler"
108926
+ },
108927
+ {
108928
+ pattern: /(^|(?<=[.!?]\s))it('|’)s worth noting that\s+/gi,
108929
+ rule: "en-filler"
108930
+ },
108931
+ {
108932
+ pattern: /(^|(?<=[.!?]\s))it is (important|worth mentioning) (to note )?that\s+/gi,
108933
+ rule: "en-filler"
108934
+ },
108935
+ {
108936
+ pattern: /(^|(?<=[.!?]\s))as (previously )?mentioned( before| earlier)?,?\s+/gi,
108937
+ rule: "en-filler"
108938
+ },
108939
+ { pattern: /(^|(?<=[.!?]\s))needless to say,?\s+/gi, rule: "en-filler" }
108940
+ ];
108941
+ function matchCase(replacement, original) {
108942
+ if (!original.length || !replacement.length) return replacement;
108943
+ const first = original[0];
108944
+ if (first === first.toUpperCase() && first !== first.toLowerCase()) {
108945
+ return replacement[0].toUpperCase() + replacement.slice(1);
108946
+ }
108947
+ return replacement;
108948
+ }
108949
+ function capitalizeSentenceStarts(text3) {
108474
108950
  return text3.replace(/^([a-zà-ÿ])/u, (c6) => c6.toUpperCase()).replace(/([.!?]\s+)([a-zà-ÿ])/gu, (_m5, sep4, c6) => sep4 + c6.toUpperCase());
108475
108951
  }
108476
108952
  function humanizeText(input) {
@@ -113520,31 +113996,391 @@ async function handleLogHours(input) {
113520
113996
 
113521
113997
  `;
113522
113998
  }
113523
- responseText += `\u{1F4CB} **Entry Details:**
113524
- `;
113525
- responseText += ` \u2022 Project: ${project ? project.name : "(No project assigned)"}
113526
- `;
113527
- if (ticket)
113528
- responseText += ` \u2022 Ticket: ${ticket.title} (${ticket.status})
113529
- `;
113530
- responseText += ` \u2022 Description: ${workDescription}
113531
- `;
113532
- responseText += ` \u2022 ${wasUpdated ? "Added" : "Estimated"} Hours: ${estimatedHours}h (${Math.floor(estimatedHours)}h ${Math.round(estimatedHours % 1 * 60)}m)
113533
- `;
113534
- responseText += ` \u2022 Status: DRAFT (not billed yet)
113535
- `;
113536
- responseText += ` \u2022 Entry ID: ${agendaEntry.id}
113537
-
113538
- `;
113539
- if (chatContextSummary) {
113540
- responseText += `\u{1F4CA} **Work Context:**
113541
- `;
113542
- responseText += `${chatContextSummary.substring(0, 200)}${chatContextSummary.length > 200 ? "..." : ""}
113543
-
113544
- `;
113999
+ responseText += `\u{1F4CB} **Entry Details:**
114000
+ `;
114001
+ responseText += ` \u2022 Project: ${project ? project.name : "(No project assigned)"}
114002
+ `;
114003
+ if (ticket)
114004
+ responseText += ` \u2022 Ticket: ${ticket.title} (${ticket.status})
114005
+ `;
114006
+ responseText += ` \u2022 Description: ${workDescription}
114007
+ `;
114008
+ responseText += ` \u2022 ${wasUpdated ? "Added" : "Estimated"} Hours: ${estimatedHours}h (${Math.floor(estimatedHours)}h ${Math.round(estimatedHours % 1 * 60)}m)
114009
+ `;
114010
+ responseText += ` \u2022 Status: DRAFT (not billed yet)
114011
+ `;
114012
+ responseText += ` \u2022 Entry ID: ${agendaEntry.id}
114013
+
114014
+ `;
114015
+ if (chatContextSummary) {
114016
+ responseText += `\u{1F4CA} **Work Context:**
114017
+ `;
114018
+ responseText += `${chatContextSummary.substring(0, 200)}${chatContextSummary.length > 200 ? "..." : ""}
114019
+
114020
+ `;
114021
+ }
114022
+ responseText += `\u2705 Time entry ${wasUpdated ? "updated" : "created"} and ready for review in the agenda!`;
114023
+ return { content: [{ type: "text", text: responseText }] };
114024
+ }
114025
+
114026
+ // ../invoice/src/utils/included-items.ts
114027
+ function parseIncludedItems(value) {
114028
+ if (value == null) return null;
114029
+ if (!Array.isArray(value)) return null;
114030
+ const items = [];
114031
+ for (const entry of value) {
114032
+ if (typeof entry === "string") {
114033
+ const label = entry.trim();
114034
+ if (label) items.push({ label });
114035
+ continue;
114036
+ }
114037
+ if (entry && typeof entry === "object" && "label" in entry) {
114038
+ const raw = entry;
114039
+ const label = String(raw.label ?? "").trim();
114040
+ if (!label) continue;
114041
+ const billable = raw.billable === false ? false : raw.billable === true ? true : void 0;
114042
+ const rawPrice = raw.price;
114043
+ const price = typeof rawPrice === "number" && Number.isFinite(rawPrice) ? rawPrice : typeof rawPrice === "string" && rawPrice.trim() !== "" ? Number(rawPrice) : null;
114044
+ items.push({
114045
+ label,
114046
+ productId: typeof raw.productId === "string" ? raw.productId : null,
114047
+ ...billable !== void 0 ? { billable } : {},
114048
+ ...price != null && Number.isFinite(price) ? { price } : {},
114049
+ ...typeof raw.pricingOptionId === "string" ? { pricingOptionId: raw.pricingOptionId } : {}
114050
+ });
114051
+ }
114052
+ }
114053
+ return items.length > 0 ? items : null;
114054
+ }
114055
+ function serializeIncludedItems(items) {
114056
+ if (items == null) return null;
114057
+ const cleaned = items.map((item) => {
114058
+ const out = { label: item.label.trim() };
114059
+ if (item.productId) out.productId = item.productId;
114060
+ if (item.billable === false) out.billable = false;
114061
+ else if (item.billable === true) out.billable = true;
114062
+ if (item.price != null && Number.isFinite(item.price)) out.price = item.price;
114063
+ if (item.pricingOptionId) out.pricingOptionId = item.pricingOptionId;
114064
+ return out;
114065
+ }).filter((item) => item.label.length > 0);
114066
+ return cleaned.length > 0 ? cleaned : null;
114067
+ }
114068
+ function includedItemLabels(items) {
114069
+ const parsed = items ? serializeIncludedItems(items) : null;
114070
+ if (!parsed?.length) return null;
114071
+ return parsed.map((item) => item.label);
114072
+ }
114073
+
114074
+ // ../invoice/src/utils/finalize-line-items.ts
114075
+ function billableLineTotal(item) {
114076
+ if (item.billable === false) return 0;
114077
+ const price = item.price ?? 0;
114078
+ const quantity = item.quantity ?? 0;
114079
+ return price * quantity;
114080
+ }
114081
+
114082
+ // ../invoice/src/utils/product-options.ts
114083
+ function findOption(group3, optionId) {
114084
+ if (!optionId) return void 0;
114085
+ return group3.options?.find((option) => option.id === optionId);
114086
+ }
114087
+ function buildDefaultConfiguration(product) {
114088
+ const groups = product.options ?? [];
114089
+ return {
114090
+ selections: groups.map((group3) => {
114091
+ if (group3.type === "quantity") {
114092
+ return {
114093
+ groupId: group3.id,
114094
+ type: "quantity",
114095
+ quantity: group3.defaultQuantity ?? group3.min ?? 0
114096
+ };
114097
+ }
114098
+ if (group3.type === "single_choice") {
114099
+ const defaultOption = group3.options?.find((option) => option.selectedByDefault) ?? group3.options?.[0];
114100
+ return {
114101
+ groupId: group3.id,
114102
+ type: "single_choice",
114103
+ optionId: defaultOption?.id ?? null
114104
+ };
114105
+ }
114106
+ return {
114107
+ groupId: group3.id,
114108
+ type: "addon",
114109
+ optionIds: group3.options?.filter((option) => option.selectedByDefault).map((option) => option.id) ?? []
114110
+ };
114111
+ })
114112
+ };
114113
+ }
114114
+ function computeConfiguredPrice(product, configuration) {
114115
+ const base = product.price ?? 0;
114116
+ const groups = product.options ?? [];
114117
+ if (!configuration || groups.length === 0) {
114118
+ return base;
114119
+ }
114120
+ let extra = 0;
114121
+ for (const group3 of groups) {
114122
+ const selection = configuration.selections.find(
114123
+ (item) => item.groupId === group3.id
114124
+ );
114125
+ if (!selection) continue;
114126
+ if (group3.type === "quantity" && selection.type === "quantity") {
114127
+ const clamped = clampQuantity(selection.quantity, group3);
114128
+ extra += clamped * (group3.pricePerUnit ?? 0);
114129
+ continue;
114130
+ }
114131
+ if (group3.type === "single_choice" && selection.type === "single_choice") {
114132
+ const option = findOption(group3, selection.optionId);
114133
+ extra += option?.price ?? 0;
114134
+ continue;
114135
+ }
114136
+ if (group3.type === "addon" && selection.type === "addon") {
114137
+ for (const optionId of selection.optionIds) {
114138
+ const option = findOption(group3, optionId);
114139
+ extra += option?.price ?? 0;
114140
+ }
114141
+ }
114142
+ }
114143
+ return base + extra;
114144
+ }
114145
+ function clampQuantity(quantity, group3) {
114146
+ const value = Number.isFinite(quantity) ? quantity : 0;
114147
+ const min = group3.min ?? 0;
114148
+ const max = group3.max;
114149
+ const lower = Math.max(value, min);
114150
+ return max != null ? Math.min(lower, max) : lower;
114151
+ }
114152
+
114153
+ // ../invoice/src/utils/pricing-options.ts
114154
+ function defaultMonthsCovered(interval2) {
114155
+ return interval2 === "year" ? 12 : 1;
114156
+ }
114157
+ function formatRecurringNote(option) {
114158
+ const period = option.billingInterval === "year" ? "per jaar" : "per maand";
114159
+ const bits = [`${option.label} (${period})`];
114160
+ if (option.discountDescription) bits.push(option.discountDescription);
114161
+ const contract = option.contract;
114162
+ const terms = [];
114163
+ if (contract?.minimumTermMonths) {
114164
+ terms.push(`min. looptijd ${contract.minimumTermMonths} mnd`);
114165
+ }
114166
+ if (contract?.noticePeriodDays) {
114167
+ terms.push(`opzegtermijn ${contract.noticePeriodDays} dagen`);
114168
+ }
114169
+ if (contract?.renewalPolicy === "auto_renew") {
114170
+ terms.push("stilzwijgend verlengd");
114171
+ }
114172
+ if (terms.length) bits.push(terms.join(", "));
114173
+ return bits.join(" \xB7 ");
114174
+ }
114175
+ function resolveDefaultPricingOption(options, defaultId) {
114176
+ if (!options || options.length === 0) return null;
114177
+ if (defaultId) {
114178
+ const found = options.find((o3) => o3.id === defaultId);
114179
+ if (found) return found;
114180
+ }
114181
+ return options[0] ?? null;
114182
+ }
114183
+ function effectivePricingOptions(product) {
114184
+ if (product.pricingOptions && product.pricingOptions.length > 0) {
114185
+ return product.pricingOptions;
114186
+ }
114187
+ const interval2 = product.billingType === "yearly" ? "year" : product.billingType === "monthly" ? "month" : null;
114188
+ if (interval2 && product.price != null) {
114189
+ return [
114190
+ {
114191
+ id: interval2,
114192
+ label: interval2 === "year" ? "Jaarlijks" : "Maandelijks",
114193
+ billingInterval: interval2,
114194
+ price: product.price,
114195
+ monthsCovered: defaultMonthsCovered(interval2)
114196
+ }
114197
+ ];
114198
+ }
114199
+ return [];
114200
+ }
114201
+ function endOfUtcYear(iso) {
114202
+ const date10 = new Date(iso);
114203
+ if (Number.isNaN(date10.getTime())) return iso;
114204
+ return new Date(
114205
+ Date.UTC(date10.getUTCFullYear(), 11, 31, 23, 59, 59, 999)
114206
+ ).toISOString();
114207
+ }
114208
+ function countInclusiveMonths(startIso, endIso) {
114209
+ const start = new Date(startIso);
114210
+ const end = new Date(endIso);
114211
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0;
114212
+ if (end.getTime() < start.getTime()) return 0;
114213
+ return (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + (end.getUTCMonth() - start.getUTCMonth()) + 1;
114214
+ }
114215
+ function computeProRata(input) {
114216
+ if (!input.startDate) return null;
114217
+ const start = new Date(input.startDate);
114218
+ if (Number.isNaN(start.getTime())) return null;
114219
+ const monthsInPeriod = input.monthsInPeriod != null && input.monthsInPeriod > 0 ? Math.round(input.monthsInPeriod) : 12;
114220
+ const periodStart = input.startDate;
114221
+ const periodEnd = input.periodEndDate ?? endOfUtcYear(input.startDate);
114222
+ const end = new Date(periodEnd);
114223
+ if (Number.isNaN(end.getTime())) return null;
114224
+ let months2 = countInclusiveMonths(periodStart, periodEnd);
114225
+ months2 = Math.min(Math.max(months2, 1), monthsInPeriod);
114226
+ const fraction = months2 / monthsInPeriod;
114227
+ const amount = Math.round(input.pricePerPeriod * fraction * 100) / 100;
114228
+ return { months: months2, fraction, amount, periodStart, periodEnd };
114229
+ }
114230
+
114231
+ // src/tools/invoice-line-util.ts
114232
+ function round2(n3) {
114233
+ return Math.round(n3 * 100) / 100;
114234
+ }
114235
+ function lineFinancials(quantity, price, defaults) {
114236
+ const lineTotal = quantity * price;
114237
+ return {
114238
+ vat: defaults.includeVat ? round2(lineTotal * (defaults.vatRate / 100)) : void 0,
114239
+ tax: defaults.includeTax ? round2(lineTotal * (defaults.taxRate / 100)) : void 0
114240
+ };
114241
+ }
114242
+ function computeInvoiceTotals(items, defaults, discount = 0) {
114243
+ const subtotal = items.reduce(
114244
+ (sum, i6) => sum + billableLineTotal(i6),
114245
+ 0
114246
+ );
114247
+ const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
114248
+ const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
114249
+ const safeDiscount = defaults.includeDiscount ? discount ?? 0 : 0;
114250
+ const amount = subtotal + vat + tax - safeDiscount;
114251
+ return {
114252
+ subtotal: round2(subtotal),
114253
+ vat: round2(vat),
114254
+ tax: round2(tax),
114255
+ amount: round2(amount)
114256
+ };
114257
+ }
114258
+ function templateDefaultsFromInvoice(template, currency) {
114259
+ const t8 = template ?? {};
114260
+ return {
114261
+ currency: t8.currency || currency || "EUR",
114262
+ vatRate: t8.vatRate ?? 21,
114263
+ taxRate: t8.taxRate ?? 0,
114264
+ includeVat: t8.includeVat ?? true,
114265
+ includeTax: t8.includeTax ?? false,
114266
+ includeDiscount: t8.includeDiscount ?? false,
114267
+ includeDecimals: t8.includeDecimals ?? true,
114268
+ includeUnits: t8.includeUnits ?? true,
114269
+ raw: t8
114270
+ };
114271
+ }
114272
+ function plainTextFromLineItemName(name21) {
114273
+ if (typeof name21 === "string") return name21.trim();
114274
+ if (!name21 || typeof name21 !== "object") return "";
114275
+ const parts = [];
114276
+ const walk = (node) => {
114277
+ if (!node || typeof node !== "object") return;
114278
+ const n3 = node;
114279
+ if (typeof n3.text === "string") parts.push(n3.text);
114280
+ if (Array.isArray(n3.content)) n3.content.forEach(walk);
114281
+ };
114282
+ walk(name21);
114283
+ return parts.join(" ").replace(/\s+/g, " ").trim();
114284
+ }
114285
+ function resolvePricingOption(product, pricingOptionId) {
114286
+ const options = effectivePricingOptions(product);
114287
+ if (options.length === 0) return null;
114288
+ if (pricingOptionId) {
114289
+ const found = options.find((o3) => o3.id === pricingOptionId);
114290
+ if (found) return found;
114291
+ }
114292
+ return resolveDefaultPricingOption(options, product.defaultPricingOptionId);
114293
+ }
114294
+ function compactLineDescription(product, override) {
114295
+ if (override?.trim()) return override.trim();
114296
+ const clause = parseProductClause(product.clause);
114297
+ if (clause?.commercialDescription) return clause.commercialDescription;
114298
+ return product.name;
114299
+ }
114300
+ function buildInvoiceLineFromProduct(product, opts, defaults) {
114301
+ const quantity = opts.quantity ?? 1;
114302
+ const pricingOption = resolvePricingOption(product, opts.pricingOptionId);
114303
+ const clause = serializeProductClause(product.clause);
114304
+ let configuration;
114305
+ let price = opts.customPrice ?? product.price ?? 0;
114306
+ if (product.isConfigurable && product.options) {
114307
+ const groups = product.options;
114308
+ if (groups?.length) {
114309
+ const defaultConfig = buildDefaultConfiguration({
114310
+ ...product,
114311
+ options: groups
114312
+ });
114313
+ configuration = defaultConfig;
114314
+ price = computeConfiguredPrice(
114315
+ product,
114316
+ defaultConfig
114317
+ );
114318
+ }
114319
+ }
114320
+ if (pricingOption && !(product.isConfigurable && product.options)) {
114321
+ price = pricingOption.price;
114322
+ }
114323
+ if (opts.customPrice != null) price = opts.customPrice;
114324
+ let proRata = null;
114325
+ if (opts.prorate && pricingOption?.billingInterval === "year" && opts.startDate) {
114326
+ const result = computeProRata({
114327
+ pricePerPeriod: price,
114328
+ startDate: opts.startDate,
114329
+ periodEndDate: opts.periodEndDate,
114330
+ monthsInPeriod: pricingOption.monthsCovered ?? 12
114331
+ });
114332
+ if (result) {
114333
+ price = result.amount;
114334
+ proRata = {
114335
+ months: result.months,
114336
+ fraction: result.fraction,
114337
+ periodStart: result.periodStart,
114338
+ periodEnd: result.periodEnd
114339
+ };
114340
+ }
114341
+ }
114342
+ const { vat, tax } = lineFinancials(quantity, price, defaults);
114343
+ return {
114344
+ name: compactLineDescription(product, opts.customDescription),
114345
+ quantity,
114346
+ unit: product.unit || void 0,
114347
+ price,
114348
+ vat,
114349
+ tax,
114350
+ productId: product.id,
114351
+ configuration,
114352
+ clause,
114353
+ pricingOption: pricingOption ?? null,
114354
+ includedItems: serializeIncludedItems(parseIncludedItems(product.includedItems)),
114355
+ billable: opts.billable === false ? false : true,
114356
+ groupLabel: opts.groupLabel ?? null,
114357
+ proRata
114358
+ };
114359
+ }
114360
+ function formatLineItemDetail(line2, index2) {
114361
+ const description = plainTextFromLineItemName(line2.name) || "(no description)";
114362
+ const lineTotal = round2((line2.quantity ?? 0) * (line2.price ?? 0));
114363
+ const parts = [
114364
+ `[${index2}] **${description}**`,
114365
+ ` qty=${line2.quantity ?? 0}${line2.unit ? ` ${line2.unit}` : ""} \xD7 ${line2.price ?? 0} = ${lineTotal}`
114366
+ ];
114367
+ if (line2.productId) parts.push(` productId: ${line2.productId}`);
114368
+ if (line2.pricingOption) {
114369
+ parts.push(` variant: ${formatRecurringNote(line2.pricingOption)}`);
114370
+ }
114371
+ if (line2.proRata) {
114372
+ parts.push(
114373
+ ` pro-rata: ${line2.proRata.months} months (${Math.round(line2.proRata.fraction * 100)}%)`
114374
+ );
113545
114375
  }
113546
- responseText += `\u2705 Time entry ${wasUpdated ? "updated" : "created"} and ready for review in the agenda!`;
113547
- return { content: [{ type: "text", text: responseText }] };
114376
+ if (line2.groupLabel) parts.push(` group: ${line2.groupLabel}`);
114377
+ if (line2.billable === false) parts.push(" (scope only \u2014 not billable)");
114378
+ const clauseLines = clauseSummaryLines(parseProductClause(line2.clause));
114379
+ if (clauseLines.length > 0) {
114380
+ parts.push(" scope:");
114381
+ clauseLines.forEach((l4) => parts.push(` - ${l4}`));
114382
+ }
114383
+ return parts.join("\n");
113548
114384
  }
113549
114385
 
113550
114386
  // src/tools/invoices.ts
@@ -113558,22 +114394,150 @@ var INVOICE_STATUSES = [
113558
114394
  "scheduled",
113559
114395
  "refunded"
113560
114396
  ];
114397
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
114398
+ function textResponse3(text3) {
114399
+ return { content: [{ type: "text", text: text3 }] };
114400
+ }
114401
+ function tiptapNote(text3) {
114402
+ return {
114403
+ type: "doc",
114404
+ content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
114405
+ };
114406
+ }
114407
+ var INVOICE_DETAIL_COLUMNS = {
114408
+ id: schema_exports.invoices.id,
114409
+ teamId: schema_exports.invoices.teamId,
114410
+ invoiceNumber: schema_exports.invoices.invoiceNumber,
114411
+ status: schema_exports.invoices.status,
114412
+ customerId: schema_exports.invoices.customerId,
114413
+ customerName: schema_exports.invoices.customerName,
114414
+ amount: schema_exports.invoices.amount,
114415
+ subtotal: schema_exports.invoices.subtotal,
114416
+ vat: schema_exports.invoices.vat,
114417
+ tax: schema_exports.invoices.tax,
114418
+ discount: schema_exports.invoices.discount,
114419
+ currency: schema_exports.invoices.currency,
114420
+ issueDate: schema_exports.invoices.issueDate,
114421
+ dueDate: schema_exports.invoices.dueDate,
114422
+ createdAt: schema_exports.invoices.createdAt,
114423
+ updatedAt: schema_exports.invoices.updatedAt,
114424
+ note: schema_exports.invoices.note,
114425
+ internalNote: schema_exports.invoices.internalNote,
114426
+ lineItems: schema_exports.invoices.lineItems,
114427
+ template: schema_exports.invoices.template,
114428
+ noteDetails: schema_exports.invoices.noteDetails
114429
+ };
114430
+ var PRODUCT_COLUMNS = {
114431
+ id: schema_exports.invoiceProducts.id,
114432
+ name: schema_exports.invoiceProducts.name,
114433
+ description: schema_exports.invoiceProducts.description,
114434
+ price: schema_exports.invoiceProducts.price,
114435
+ currency: schema_exports.invoiceProducts.currency,
114436
+ unit: schema_exports.invoiceProducts.unit,
114437
+ isConfigurable: schema_exports.invoiceProducts.isConfigurable,
114438
+ options: schema_exports.invoiceProducts.options,
114439
+ billingType: schema_exports.invoiceProducts.billingType,
114440
+ category: schema_exports.invoiceProducts.category,
114441
+ includedItems: schema_exports.invoiceProducts.includedItems,
114442
+ optional: schema_exports.invoiceProducts.optional,
114443
+ tier: schema_exports.invoiceProducts.tier,
114444
+ clause: schema_exports.invoiceProducts.clause,
114445
+ serviceCadence: schema_exports.invoiceProducts.serviceCadence,
114446
+ pricingOptions: schema_exports.invoiceProducts.pricingOptions,
114447
+ defaultPricingOptionId: schema_exports.invoiceProducts.defaultPricingOptionId
114448
+ };
114449
+ function parseStoredLineItems(value) {
114450
+ return Array.isArray(value) ? value : [];
114451
+ }
114452
+ async function loadInvoiceByIdentifier(identifier, teamIds) {
114453
+ if (teamIds.length === 0) return null;
114454
+ const byId = UUID_RE.test(identifier);
114455
+ const filters = [inArray(schema_exports.invoices.teamId, teamIds)];
114456
+ filters.push(
114457
+ byId ? eq(schema_exports.invoices.id, identifier) : eq(schema_exports.invoices.invoiceNumber, identifier)
114458
+ );
114459
+ const [row] = await db.select(INVOICE_DETAIL_COLUMNS).from(schema_exports.invoices).where(and(...filters)).limit(1);
114460
+ return row ?? null;
114461
+ }
114462
+ async function loadInvoiceInTeam(identifier, teamId) {
114463
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114464
+ return loadInvoiceByIdentifier(identifier, accessibleTeamIds);
114465
+ }
114466
+ async function loadProductsInTeam(productIds, teamId) {
114467
+ if (productIds.length === 0) return /* @__PURE__ */ new Map();
114468
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114469
+ const rows = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
114470
+ and(
114471
+ inArray(schema_exports.invoiceProducts.id, productIds),
114472
+ inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
114473
+ )
114474
+ );
114475
+ return new Map(rows.map((r6) => [r6.id, r6]));
114476
+ }
114477
+ async function resolveInvoiceLineItems(inputs, defaults, teamId) {
114478
+ const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
114479
+ const products = await loadProductsInTeam([...new Set(productIds)], teamId);
114480
+ const items = [];
114481
+ for (const input of inputs) {
114482
+ if (input.productId) {
114483
+ const product = products.get(input.productId);
114484
+ if (!product) {
114485
+ return {
114486
+ items: [],
114487
+ error: `Product ${input.productId} not found or not owned by this team.`
114488
+ };
114489
+ }
114490
+ items.push(
114491
+ buildInvoiceLineFromProduct(
114492
+ product,
114493
+ {
114494
+ quantity: input.quantity,
114495
+ customDescription: input.name ?? input.description,
114496
+ customPrice: input.price,
114497
+ pricingOptionId: input.pricingOptionId,
114498
+ prorate: input.prorate,
114499
+ startDate: input.startDate,
114500
+ periodEndDate: input.periodEndDate
114501
+ },
114502
+ defaults
114503
+ )
114504
+ );
114505
+ continue;
114506
+ }
114507
+ const quantity = input.quantity ?? 1;
114508
+ const price = input.price ?? 0;
114509
+ const name21 = input.name?.trim() || input.description?.trim() || "(no description)";
114510
+ const { vat, tax } = lineFinancials(quantity, price, defaults);
114511
+ items.push({ name: name21, quantity, unit: input.unit, price, vat, tax });
114512
+ }
114513
+ return { items };
114514
+ }
114515
+ function notDraftResponse(invoice) {
114516
+ return textResponse3(
114517
+ `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.`
114518
+ );
114519
+ }
114520
+ function formatInvoiceSummary(invoice) {
114521
+ const items = parseStoredLineItems(invoice.lineItems);
114522
+ return `**${invoice.invoiceNumber ?? "(draft, no number)"}** (${invoice.status})
114523
+ ID: ${invoice.id}
114524
+ Customer: ${invoice.customerName ?? invoice.customerId ?? "(none)"}
114525
+ Total: ${invoice.amount ?? "?"} ${invoice.currency ?? ""} (subtotal ${invoice.subtotal ?? "?"}, VAT ${invoice.vat ?? 0})
114526
+ Line items: ${items.length}
114527
+ Issue: ${invoice.issueDate ? new Date(invoice.issueDate).toLocaleDateString() : "-"} | Due: ${invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : "-"}
114528
+ `;
114529
+ }
113561
114530
  async function handleGetInvoices(input) {
113562
114531
  const { customerId, status, q: q3, pageSize = 20 } = input;
113563
114532
  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
- };
114533
+ return textResponse3(
114534
+ `Error: invalid status "${status}". Allowed: ${INVOICE_STATUSES.join(", ")}.`
114535
+ );
113572
114536
  }
113573
114537
  const scope = await resolveTeamScope(input.teamId);
113574
114538
  if (!scope.ok) return scope.response;
113575
114539
  if (scope.teamIds.length === 0) {
113576
- return { content: [{ type: "text", text: "No accessible teams found." }] };
114540
+ return textResponse3("No accessible teams found.");
113577
114541
  }
113578
114542
  const filters = [inArray(schema_exports.invoices.teamId, scope.teamIds)];
113579
114543
  if (customerId) filters.push(eq(schema_exports.invoices.customerId, customerId));
@@ -113600,7 +114564,7 @@ async function handleGetInvoices(input) {
113600
114564
  createdAt: schema_exports.invoices.createdAt
113601
114565
  }).from(schema_exports.invoices).where(and(...filters)).orderBy(desc(schema_exports.invoices.createdAt)).limit(Math.min(pageSize, 100));
113602
114566
  if (rows.length === 0) {
113603
- return { content: [{ type: "text", text: "No invoices found." }] };
114567
+ return textResponse3("No invoices found.");
113604
114568
  }
113605
114569
  const list = rows.map(
113606
114570
  (inv) => `**${inv.invoiceNumber ?? "(draft, no number)"}**
@@ -113610,29 +114574,251 @@ Customer: ${inv.customerName ?? inv.customerId ?? "(none)"}
113610
114574
  Issue date: ${inv.issueDate ? new Date(inv.issueDate).toLocaleDateString() : "-"} | Due: ${inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : "-"}
113611
114575
  `
113612
114576
  ).join("\n");
113613
- return {
113614
- content: [
113615
- {
113616
- type: "text",
113617
- text: `Found ${rows.length} invoices:
114577
+ return textResponse3(
114578
+ `Found ${rows.length} invoices:
113618
114579
 
113619
114580
  ${list}
113620
- Use \`link-document-to-invoice\` (or \`invoiceId\` on create-document) to attach a deliverables document to one of these invoices.`
113621
- }
113622
- ]
113623
- };
114581
+ Use \`get-invoice-by-id\` for line items and linked documents. Use \`link-document-to-invoice\` (or \`invoiceId\` on create-document) to attach deliverables.`
114582
+ );
114583
+ }
114584
+ async function handleGetInvoiceById(input) {
114585
+ const { invoiceId } = input;
114586
+ if (!invoiceId) return textResponse3("Error: `invoiceId` is required.");
114587
+ const scope = await resolveTeamScope(input.teamId);
114588
+ if (!scope.ok) return scope.response;
114589
+ if (scope.teamIds.length === 0) {
114590
+ return textResponse3("No accessible teams found.");
114591
+ }
114592
+ const invoice = await loadInvoiceByIdentifier(invoiceId, scope.teamIds);
114593
+ if (!invoice) {
114594
+ return textResponse3(
114595
+ `Invoice ${invoiceId} not found or you don't have access to it.`
114596
+ );
114597
+ }
114598
+ const lineItems = parseStoredLineItems(invoice.lineItems);
114599
+ const linkedDocs = await db.select({
114600
+ id: schema_exports.documents.id,
114601
+ title: schema_exports.documents.title,
114602
+ type: schema_exports.documents.type
114603
+ }).from(schema_exports.documents).where(
114604
+ and(
114605
+ eq(schema_exports.documents.invoiceId, invoice.id),
114606
+ isNull(schema_exports.documents.deletedAt)
114607
+ )
114608
+ );
114609
+ const linesText = lineItems.length > 0 ? lineItems.map((line2, i6) => formatLineItemDetail(line2, i6)).join("\n\n") : "(no line items)";
114610
+ const docsText = linkedDocs.length > 0 ? linkedDocs.map((d6) => `- ${d6.title} (${d6.type ?? "document"}) \u2014 ${d6.id}`).join("\n") : "(none)";
114611
+ return textResponse3(
114612
+ `**Invoice ${invoice.invoiceNumber ?? invoice.id}**
114613
+
114614
+ ID: ${invoice.id}
114615
+ Status: ${invoice.status}
114616
+ Customer: ${invoice.customerName ?? invoice.customerId ?? "(none)"}
114617
+ Currency: ${invoice.currency ?? "EUR"}
114618
+ Issue date: ${invoice.issueDate ? new Date(invoice.issueDate).toLocaleDateString() : "-"}
114619
+ Due date: ${invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : "-"}
114620
+ Subtotal: ${invoice.subtotal ?? "?"} | VAT: ${invoice.vat ?? 0} | Tax: ${invoice.tax ?? 0} | Total: ${invoice.amount ?? "?"}
114621
+
114622
+ **Line items (${lineItems.length})**
114623
+ ${linesText}
114624
+
114625
+ **Linked documents (${linkedDocs.length})**
114626
+ ${docsText}
114627
+
114628
+ ` + (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.")
114629
+ );
114630
+ }
114631
+ async function handleUpdateInvoice(input) {
114632
+ const { invoiceId } = input;
114633
+ if (!invoiceId) return textResponse3("Error: `invoiceId` is required.");
114634
+ const resolved = await resolveTeamId(input.teamId);
114635
+ if (!resolved.ok) return resolved.response;
114636
+ const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
114637
+ if (!invoice) {
114638
+ return textResponse3(
114639
+ `Invoice ${invoiceId} not found or not owned by this team.`
114640
+ );
114641
+ }
114642
+ if (invoice.status !== "draft") return notDraftResponse(invoice);
114643
+ const defaults = templateDefaultsFromInvoice(
114644
+ invoice.template,
114645
+ invoice.currency
114646
+ );
114647
+ const updates = {};
114648
+ if (input.title !== void 0) {
114649
+ updates.template = { ...defaults.raw, title: input.title };
114650
+ }
114651
+ if (input.note !== void 0) {
114652
+ updates.noteDetails = input.note ? tiptapNote(input.note) : null;
114653
+ }
114654
+ if (input.internalNote !== void 0) {
114655
+ updates.internalNote = input.internalNote;
114656
+ }
114657
+ if (input.dueDate !== void 0) updates.dueDate = input.dueDate;
114658
+ if (input.issueDate !== void 0) updates.issueDate = input.issueDate;
114659
+ if (input.lineItems !== void 0) {
114660
+ const { items, error: error49 } = await resolveInvoiceLineItems(
114661
+ input.lineItems,
114662
+ defaults,
114663
+ invoice.teamId
114664
+ );
114665
+ if (error49) return textResponse3(`Error: ${error49}`);
114666
+ const totals = computeInvoiceTotals(
114667
+ items,
114668
+ defaults,
114669
+ invoice.discount ?? 0
114670
+ );
114671
+ updates.lineItems = items;
114672
+ updates.subtotal = totals.subtotal;
114673
+ updates.vat = totals.vat;
114674
+ updates.tax = totals.tax;
114675
+ updates.amount = totals.amount;
114676
+ }
114677
+ if (Object.keys(updates).length === 0) {
114678
+ return textResponse3(
114679
+ "No fields to update. Provide at least one of: title, note, internalNote, dueDate, issueDate, lineItems."
114680
+ );
114681
+ }
114682
+ updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
114683
+ const [updated] = await db.update(schema_exports.invoices).set(updates).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
114684
+ if (!updated) return textResponse3(`Failed to update invoice ${invoiceId}.`);
114685
+ return textResponse3(`\u2705 **Draft invoice updated**
114686
+
114687
+ ${formatInvoiceSummary(updated)}`);
114688
+ }
114689
+ async function handleUpdateInvoiceLines(input) {
114690
+ const { invoiceId, lineItems: patches } = input;
114691
+ if (!invoiceId) return textResponse3("Error: `invoiceId` is required.");
114692
+ if (!patches || patches.length === 0) {
114693
+ return textResponse3("Error: `lineItems` must be a non-empty array.");
114694
+ }
114695
+ const resolved = await resolveTeamId(input.teamId);
114696
+ if (!resolved.ok) return resolved.response;
114697
+ const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
114698
+ if (!invoice) {
114699
+ return textResponse3(
114700
+ `Invoice ${invoiceId} not found or not owned by this team.`
114701
+ );
114702
+ }
114703
+ if (invoice.status !== "draft") return notDraftResponse(invoice);
114704
+ const defaults = templateDefaultsFromInvoice(
114705
+ invoice.template,
114706
+ invoice.currency
114707
+ );
114708
+ const items = [...parseStoredLineItems(invoice.lineItems)];
114709
+ let updatedCount = 0;
114710
+ for (const patch of patches) {
114711
+ const index2 = patch.index;
114712
+ if (index2 < 0 || index2 >= items.length) {
114713
+ return textResponse3(
114714
+ `Error: line index ${index2} is out of range (invoice has ${items.length} line(s)).`
114715
+ );
114716
+ }
114717
+ const line2 = { ...items[index2] };
114718
+ if (patch.description !== void 0) line2.name = patch.description;
114719
+ if (patch.quantity !== void 0) line2.quantity = patch.quantity;
114720
+ if (patch.unit !== void 0) line2.unit = patch.unit ?? void 0;
114721
+ if (patch.price !== void 0) line2.price = patch.price;
114722
+ const qty = line2.quantity ?? 1;
114723
+ const price = line2.price ?? 0;
114724
+ const { vat, tax } = lineFinancials(qty, price, defaults);
114725
+ line2.vat = vat;
114726
+ line2.tax = tax;
114727
+ items[index2] = line2;
114728
+ updatedCount++;
114729
+ }
114730
+ const totals = computeInvoiceTotals(items, defaults, invoice.discount ?? 0);
114731
+ const [updated] = await db.update(schema_exports.invoices).set({
114732
+ lineItems: items,
114733
+ subtotal: totals.subtotal,
114734
+ vat: totals.vat,
114735
+ tax: totals.tax,
114736
+ amount: totals.amount,
114737
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
114738
+ }).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
114739
+ if (!updated) {
114740
+ return textResponse3(`Failed to update invoice lines for ${invoiceId}.`);
114741
+ }
114742
+ const changedLines = patches.map((p3) => {
114743
+ const line2 = items[p3.index];
114744
+ return `[${p3.index}] ${plainTextFromLineItemName(line2.name)}`;
114745
+ }).join("\n");
114746
+ return textResponse3(
114747
+ `\u2705 **Updated ${updatedCount} line item(s) on draft invoice ${updated.invoiceNumber ?? updated.id}**
114748
+
114749
+ ${changedLines}
114750
+
114751
+ New total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})`
114752
+ );
114753
+ }
114754
+ async function handleAddProductToInvoice(input) {
114755
+ const { invoiceId, productId } = input;
114756
+ if (!invoiceId) return textResponse3("Error: `invoiceId` is required.");
114757
+ if (!productId) return textResponse3("Error: `productId` is required.");
114758
+ const resolved = await resolveTeamId(input.teamId);
114759
+ if (!resolved.ok) return resolved.response;
114760
+ const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
114761
+ if (!invoice) {
114762
+ return textResponse3(
114763
+ `Invoice ${invoiceId} not found or not owned by this team.`
114764
+ );
114765
+ }
114766
+ if (invoice.status !== "draft") return notDraftResponse(invoice);
114767
+ const products = await loadProductsInTeam([productId], invoice.teamId);
114768
+ const product = products.get(productId);
114769
+ if (!product) {
114770
+ return textResponse3(
114771
+ `Product ${productId} not found or not owned by this team.`
114772
+ );
114773
+ }
114774
+ const defaults = templateDefaultsFromInvoice(
114775
+ invoice.template,
114776
+ invoice.currency
114777
+ );
114778
+ const newItem = buildInvoiceLineFromProduct(
114779
+ product,
114780
+ {
114781
+ quantity: input.quantity,
114782
+ customDescription: input.customDescription,
114783
+ customPrice: input.customPrice,
114784
+ pricingOptionId: input.pricingOptionId,
114785
+ prorate: input.prorate,
114786
+ startDate: input.startDate,
114787
+ periodEndDate: input.periodEndDate
114788
+ },
114789
+ defaults
114790
+ );
114791
+ const items = [...parseStoredLineItems(invoice.lineItems), newItem];
114792
+ const totals = computeInvoiceTotals(items, defaults, invoice.discount ?? 0);
114793
+ const [updated] = await db.update(schema_exports.invoices).set({
114794
+ lineItems: items,
114795
+ subtotal: totals.subtotal,
114796
+ vat: totals.vat,
114797
+ tax: totals.tax,
114798
+ amount: totals.amount,
114799
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
114800
+ }).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
114801
+ if (!updated) {
114802
+ return textResponse3(`Failed to add product to invoice ${invoiceId}.`);
114803
+ }
114804
+ return textResponse3(
114805
+ `\u2705 **Product added to draft invoice ${updated.invoiceNumber ?? updated.id}**
114806
+
114807
+ ` + formatLineItemDetail(newItem, items.length - 1) + `
114808
+
114809
+ New invoice total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})
114810
+ Clause and pricing variant are snapshotted on the line \u2014 later catalog edits won't change this invoice.`
114811
+ );
113624
114812
  }
113625
114813
  async function handleLinkDocumentToInvoice(input) {
113626
114814
  const { documentId, invoiceId } = input;
113627
114815
  if (!documentId) {
113628
- return {
113629
- content: [{ type: "text", text: "Error: `documentId` is required." }]
113630
- };
114816
+ return textResponse3("Error: `documentId` is required.");
113631
114817
  }
113632
114818
  const scope = await resolveTeamScope(input.teamId);
113633
114819
  if (!scope.ok) return scope.response;
113634
114820
  if (scope.teamIds.length === 0) {
113635
- return { content: [{ type: "text", text: "No accessible teams found." }] };
114821
+ return textResponse3("No accessible teams found.");
113636
114822
  }
113637
114823
  const [doc] = await db.select({
113638
114824
  id: schema_exports.documents.id,
@@ -113647,51 +114833,31 @@ async function handleLinkDocumentToInvoice(input) {
113647
114833
  )
113648
114834
  ).limit(1);
113649
114835
  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
- };
114836
+ return textResponse3(
114837
+ `Document ${documentId} not found or you don't have access to it.`
114838
+ );
113658
114839
  }
113659
114840
  if (!invoiceId) {
113660
114841
  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
- };
114842
+ return textResponse3(
114843
+ `\u2705 Document "${doc.title}" (${doc.id}) is unlinked from its invoice.`
114844
+ );
113669
114845
  }
113670
114846
  const invoice = await findAccessibleInvoice(invoiceId, [doc.teamId]);
113671
114847
  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
- };
114848
+ return textResponse3(
114849
+ `Error: invoice ${invoiceId} not found in team ${doc.teamId}. Use get-invoices to find a valid invoice id.`
114850
+ );
113680
114851
  }
113681
114852
  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!**
114853
+ return textResponse3(
114854
+ `\u2705 **Document linked to invoice!**
113687
114855
 
113688
114856
  Document: ${doc.title} (${doc.id})
113689
114857
  Invoice: ${invoice.invoiceNumber ?? invoice.id} (${invoice.status})
113690
114858
 
113691
114859
  The document can now be selected as a PDF attachment when sending this invoice from the dashboard.`
113692
- }
113693
- ]
113694
- };
114860
+ );
113695
114861
  }
113696
114862
 
113697
114863
  // src/tools/project-cleanup-util.ts
@@ -113817,7 +114983,7 @@ ${description ? `Description: ${description}
113817
114983
  ]
113818
114984
  };
113819
114985
  }
113820
- function textResponse2(text3) {
114986
+ function textResponse4(text3) {
113821
114987
  return { content: [{ type: "text", text: text3 }] };
113822
114988
  }
113823
114989
  function memberLabel(m4) {
@@ -113831,7 +114997,7 @@ async function requireTeamOwner2(teamId, userId) {
113831
114997
  eq(schema_exports.usersOnTeam.teamId, teamId)
113832
114998
  )
113833
114999
  ).limit(1);
113834
- return membership?.role === "owner" ? null : textResponse2(OWNER_REQUIRED);
115000
+ return membership?.role === "owner" ? null : textResponse4(OWNER_REQUIRED);
113835
115001
  }
113836
115002
  async function setProjectMemberAccess(params) {
113837
115003
  const { projectId, teamId, memberIds, createdBy } = params;
@@ -113935,7 +115101,7 @@ async function resolveTeamMember(teamId, opts) {
113935
115101
  if (!match) {
113936
115102
  return {
113937
115103
  ok: false,
113938
- response: textResponse2(
115104
+ response: textResponse4(
113939
115105
  `User ${opts.userId} is not a member of this team. Call get-project-members to see the team roster.`
113940
115106
  )
113941
115107
  };
@@ -113948,7 +115114,7 @@ async function resolveTeamMember(teamId, opts) {
113948
115114
  if (matches.length === 0) {
113949
115115
  return {
113950
115116
  ok: false,
113951
- response: textResponse2(
115117
+ response: textResponse4(
113952
115118
  `No team member found with email "${opts.email}". Call get-project-members to see the team roster.`
113953
115119
  )
113954
115120
  };
@@ -113956,7 +115122,7 @@ async function resolveTeamMember(teamId, opts) {
113956
115122
  if (matches.length > 1) {
113957
115123
  return {
113958
115124
  ok: false,
113959
- response: textResponse2(
115125
+ response: textResponse4(
113960
115126
  `Multiple team members match email "${opts.email}". Pass an explicit userId instead.`
113961
115127
  )
113962
115128
  };
@@ -113965,7 +115131,7 @@ async function resolveTeamMember(teamId, opts) {
113965
115131
  }
113966
115132
  return {
113967
115133
  ok: false,
113968
- response: textResponse2(
115134
+ response: textResponse4(
113969
115135
  "Provide either a userId or an email to identify the member."
113970
115136
  )
113971
115137
  };
@@ -114014,7 +115180,7 @@ async function handleUpdateProject(input) {
114014
115180
  if (!resolved.ok) return resolved.response;
114015
115181
  const existing = await loadProjectInTeam(id, resolved.teamId);
114016
115182
  if (!existing) {
114017
- return textResponse2(
115183
+ return textResponse4(
114018
115184
  `Project ${id} not found, or it is not owned by this team.`
114019
115185
  );
114020
115186
  }
@@ -114029,7 +115195,7 @@ async function handleUpdateProject(input) {
114029
115195
  )
114030
115196
  ).limit(1);
114031
115197
  if (dupe) {
114032
- return textResponse2(
115198
+ return textResponse4(
114033
115199
  `A project named "${input.name}" already exists in this team. Choose a different name.`
114034
115200
  );
114035
115201
  }
@@ -114094,7 +115260,7 @@ async function handleUpdateProject(input) {
114094
115260
  customerName: schema_exports.customers.name
114095
115261
  }).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
115262
  if (!updated) {
114097
- return textResponse2(`Failed to update project ${id}.`);
115263
+ return textResponse4(`Failed to update project ${id}.`);
114098
115264
  }
114099
115265
  const lines = [
114100
115266
  "\u2705 **Project Updated**",
@@ -114112,7 +115278,7 @@ async function handleUpdateProject(input) {
114112
115278
  if (willRename) {
114113
115279
  lines.push("", "Note: tickets for this project were renumbered.");
114114
115280
  }
114115
- return textResponse2(lines.join("\n"));
115281
+ return textResponse4(lines.join("\n"));
114116
115282
  }
114117
115283
  async function handleGetProjectMembers(input) {
114118
115284
  const { projectId } = input;
@@ -114120,7 +115286,7 @@ async function handleGetProjectMembers(input) {
114120
115286
  if (!resolved.ok) return resolved.response;
114121
115287
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114122
115288
  if (!project) {
114123
- return textResponse2(
115289
+ return textResponse4(
114124
115290
  `Project ${projectId} not found, or it is not owned by this team.`
114125
115291
  );
114126
115292
  }
@@ -114149,7 +115315,7 @@ async function handleGetProjectMembers(input) {
114149
115315
  return `- ${memberLabel(m4)} (userId: ${m4.userId}, role: ${m4.role ?? "member"}) \u2014 ${access}`;
114150
115316
  }).join("\n");
114151
115317
  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(
115318
+ return textResponse4(
114153
115319
  `**Project members for "${project.name}"** (ID: ${project.id})
114154
115320
 
114155
115321
  ${note}
@@ -114170,7 +115336,7 @@ async function handleSetProjectMembers(input) {
114170
115336
  if (ownerError) return ownerError;
114171
115337
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114172
115338
  if (!project) {
114173
- return textResponse2(
115339
+ return textResponse4(
114174
115340
  `Project ${projectId} not found, or it is not owned by this team.`
114175
115341
  );
114176
115342
  }
@@ -114208,7 +115374,7 @@ async function handleSetProjectMembers(input) {
114208
115374
 
114209
115375
  \u26A0\uFE0F ${names} previously had no restrictions (could see all projects). They are now restricted to only the projects explicitly assigned to them.`;
114210
115376
  }
114211
- return textResponse2(
115377
+ return textResponse4(
114212
115378
  `\u2705 **Project members updated**
114213
115379
 
114214
115380
  Members with explicit access to this project:
@@ -114224,7 +115390,7 @@ async function handleAddProjectMember(input) {
114224
115390
  if (ownerError) return ownerError;
114225
115391
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114226
115392
  if (!project) {
114227
- return textResponse2(
115393
+ return textResponse4(
114228
115394
  `Project ${projectId} not found, or it is not owned by this team.`
114229
115395
  );
114230
115396
  }
@@ -114235,7 +115401,7 @@ async function handleAddProjectMember(input) {
114235
115401
  if (!member2.ok) return member2.response;
114236
115402
  const state2 = await getProjectAccessState(resolved.teamId, projectId);
114237
115403
  if (state2.projectMemberIds.has(member2.member.userId)) {
114238
- return textResponse2(
115404
+ return textResponse4(
114239
115405
  `${memberLabel(member2.member)} already has explicit access to this project.`
114240
115406
  );
114241
115407
  }
@@ -114250,7 +115416,7 @@ async function handleAddProjectMember(input) {
114250
115416
  if (wasUnrestricted) {
114251
115417
  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
115418
  }
114253
- return textResponse2(text3);
115419
+ return textResponse4(text3);
114254
115420
  }
114255
115421
  async function handleRemoveProjectMember(input) {
114256
115422
  const ctx = getAuthContext();
@@ -114261,7 +115427,7 @@ async function handleRemoveProjectMember(input) {
114261
115427
  if (ownerError) return ownerError;
114262
115428
  const project = await loadProjectInTeam(projectId, resolved.teamId);
114263
115429
  if (!project) {
114264
- return textResponse2(
115430
+ return textResponse4(
114265
115431
  `Project ${projectId} not found, or it is not owned by this team.`
114266
115432
  );
114267
115433
  }
@@ -114271,298 +115437,126 @@ async function handleRemoveProjectMember(input) {
114271
115437
  });
114272
115438
  if (!member2.ok) return member2.response;
114273
115439
  const state2 = await getProjectAccessState(resolved.teamId, projectId);
114274
- if (!state2.projectMemberIds.has(member2.member.userId)) {
114275
- return textResponse2(
114276
- `${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;
115440
+ if (!state2.projectMemberIds.has(member2.member.userId)) {
115441
+ return textResponse4(
115442
+ `${memberLabel(member2.member)} has no explicit assignment to this project; nothing to remove.`
115443
+ );
114518
115444
  }
114519
- if (parsed.customerValue) out.customerValue = parsed.customerValue;
114520
- if (parsed.extraWorkConditions) {
114521
- out.extraWorkConditions = parsed.extraWorkConditions;
115445
+ await setProjectMemberAccess({
115446
+ projectId,
115447
+ teamId: resolved.teamId,
115448
+ memberIds: [...state2.projectMemberIds].filter(
115449
+ (uid2) => uid2 !== member2.member.userId
115450
+ ),
115451
+ createdBy: ctx.userId
115452
+ });
115453
+ let text3 = `\u2705 Removed ${memberLabel(member2.member)} (userId: ${member2.member.userId}) from the project.`;
115454
+ if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
115455
+ 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
115456
  }
114523
- return Object.keys(out).length > 0 ? out : null;
115457
+ return textResponse4(text3);
114524
115458
  }
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}`);
115459
+ async function loadProjectForCleanup(projectId, teamId) {
115460
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
115461
+ const [row] = await db.select({
115462
+ id: schema_exports.projects.id,
115463
+ name: schema_exports.projects.name,
115464
+ teamId: schema_exports.projects.teamId,
115465
+ settings: schema_exports.projects.settings
115466
+ }).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
115467
+ if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
115468
+ return null;
114530
115469
  }
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"
115470
+ return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
115471
+ }
115472
+ async function countProjectDependencies(projectId) {
115473
+ const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
115474
+ const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
115475
+ countRows(schema_exports.tickets),
115476
+ countRows(schema_exports.timesheetEvents),
115477
+ countRows(schema_exports.timesheetTemplates),
115478
+ countRows(schema_exports.trips),
115479
+ countRows(schema_exports.tripTemplates)
115480
+ ]);
115481
+ return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
115482
+ }
115483
+ async function handleArchiveProject(input) {
115484
+ const { projectId, reason } = input;
115485
+ if (!projectId) return textResponse4("Error: `projectId` is required.");
115486
+ const resolved = await resolveTeamId(input.teamId);
115487
+ if (!resolved.ok) return resolved.response;
115488
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
115489
+ if (!project) {
115490
+ return textResponse4(
115491
+ `Project ${projectId} not found, or it is not owned by this team.`
114534
115492
  );
114535
115493
  }
114536
- if (limits.minimumBillingUnitExtraWork) {
114537
- lines.push(
114538
- `Minimale facturatie-eenheid meerwerk: ${limits.minimumBillingUnitExtraWork}`
115494
+ const state2 = getProjectArchiveState(project.settings);
115495
+ if (state2.archived) {
115496
+ return textResponse4(
115497
+ `Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
114539
115498
  );
114540
115499
  }
114541
- if (limits.supportLevel) lines.push(`Supportniveau: ${limits.supportLevel}`);
114542
- if (limits.responseTime) lines.push(`Responstijd: ${limits.responseTime}`);
114543
- return lines;
115500
+ const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
115501
+ const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
115502
+ await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
115503
+ return textResponse4(
115504
+ `\u2705 **Project archived**
115505
+
115506
+ Project: ${project.name}
115507
+ ID: ${project.id}
115508
+ Action: archived (soft, reversible)
115509
+ Status: archived
115510
+ Timestamp: ${archivedAt}
115511
+ ${reason ? `Reason: ${reason}
115512
+ ` : ""}
115513
+ 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.
115514
+
115515
+ Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
115516
+ );
114544
115517
  }
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("; ")}`);
115518
+ async function handleDeleteProject(input) {
115519
+ const ctx = getAuthContext();
115520
+ const { projectId, confirmEmptyOnly } = input;
115521
+ if (!projectId) return textResponse4("Error: `projectId` is required.");
115522
+ const resolved = await resolveTeamId(input.teamId);
115523
+ if (!resolved.ok) return resolved.response;
115524
+ const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
115525
+ if (ownerError) return ownerError;
115526
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
115527
+ if (!project) {
115528
+ return textResponse4(
115529
+ `Project ${projectId} not found, or it is not owned by this team.`
115530
+ );
114557
115531
  }
114558
- lines.push(...clauseLimitLines(parsed.limits));
114559
- if (parsed.customerValue) {
114560
- lines.push(`Klantwaarde: ${parsed.customerValue}`);
115532
+ const deps = await countProjectDependencies(project.id);
115533
+ const summary = formatProjectDependencies(deps);
115534
+ if (!isProjectEmpty(deps)) {
115535
+ return textResponse4(
115536
+ `\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
115537
+
115538
+ Dependencies: ${summary}.
115539
+
115540
+ 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).`
115541
+ );
114561
115542
  }
114562
- if (parsed.extraWorkConditions) {
114563
- lines.push(`Meerwerk: ${parsed.extraWorkConditions}`);
115543
+ if (confirmEmptyOnly !== true) {
115544
+ return textResponse4(
115545
+ `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).`
115546
+ );
114564
115547
  }
114565
- return lines;
115548
+ await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
115549
+ return textResponse4(
115550
+ `\u2705 **Project deleted**
115551
+
115552
+ Project: ${project.name}
115553
+ ID: ${project.id}
115554
+ Action: hard delete (empty project)
115555
+ Status: deleted
115556
+ Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
115557
+
115558
+ 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.`
115559
+ );
114566
115560
  }
114567
115561
 
114568
115562
  // src/tools/products.ts
@@ -114583,7 +115577,7 @@ var TIERS = [
114583
115577
  "silver",
114584
115578
  "gold"
114585
115579
  ];
114586
- var PRODUCT_COLUMNS = {
115580
+ var PRODUCT_COLUMNS2 = {
114587
115581
  id: schema_exports.invoiceProducts.id,
114588
115582
  teamId: schema_exports.invoiceProducts.teamId,
114589
115583
  name: schema_exports.invoiceProducts.name,
@@ -114605,7 +115599,7 @@ var PRODUCT_COLUMNS = {
114605
115599
  createdAt: schema_exports.invoiceProducts.createdAt,
114606
115600
  updatedAt: schema_exports.invoiceProducts.updatedAt
114607
115601
  };
114608
- function textResponse3(text3) {
115602
+ function textResponse5(text3) {
114609
115603
  return { content: [{ type: "text", text: text3 }] };
114610
115604
  }
114611
115605
  function formatPrice(p3) {
@@ -114651,14 +115645,14 @@ async function handleGetProducts(input) {
114651
115645
  const { q: q3, currency, pageSize = 20 } = input;
114652
115646
  const status = input.status ?? "active";
114653
115647
  if (!PRODUCT_STATUSES.includes(status)) {
114654
- return textResponse3(
115648
+ return textResponse5(
114655
115649
  `Error: invalid status "${status}". Allowed: ${PRODUCT_STATUSES.join(", ")}.`
114656
115650
  );
114657
115651
  }
114658
115652
  const scope = await resolveTeamScope(input.teamId);
114659
115653
  if (!scope.ok) return scope.response;
114660
115654
  if (scope.teamIds.length === 0) {
114661
- return textResponse3("No accessible teams found.");
115655
+ return textResponse5("No accessible teams found.");
114662
115656
  }
114663
115657
  const filters = [inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)];
114664
115658
  if (status === "active") {
@@ -114675,17 +115669,17 @@ async function handleGetProducts(input) {
114675
115669
  )
114676
115670
  );
114677
115671
  }
114678
- const rows = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(and(...filters)).orderBy(
115672
+ const rows = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(and(...filters)).orderBy(
114679
115673
  desc(schema_exports.invoiceProducts.usageCount),
114680
115674
  desc(schema_exports.invoiceProducts.lastUsedAt),
114681
115675
  asc(schema_exports.invoiceProducts.name)
114682
115676
  ).limit(Math.min(pageSize, 100));
114683
115677
  if (rows.length === 0) {
114684
- return textResponse3(
115678
+ return textResponse5(
114685
115679
  `No products found${status !== "all" ? ` (status: ${status})` : ""}.`
114686
115680
  );
114687
115681
  }
114688
- return textResponse3(
115682
+ return textResponse5(
114689
115683
  `Found ${rows.length} product(s):
114690
115684
 
114691
115685
  ${rows.map(formatProduct).join("\n")}`
@@ -114693,28 +115687,28 @@ ${rows.map(formatProduct).join("\n")}`
114693
115687
  }
114694
115688
  async function handleGetProductById(input) {
114695
115689
  const { productId } = input;
114696
- if (!productId) return textResponse3("Error: `productId` is required.");
115690
+ if (!productId) return textResponse5("Error: `productId` is required.");
114697
115691
  const scope = await resolveTeamScope(input.teamId);
114698
115692
  if (!scope.ok) return scope.response;
114699
115693
  if (scope.teamIds.length === 0) {
114700
- return textResponse3("No accessible teams found.");
115694
+ return textResponse5("No accessible teams found.");
114701
115695
  }
114702
- const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
115696
+ const [row] = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(
114703
115697
  and(
114704
115698
  eq(schema_exports.invoiceProducts.id, productId),
114705
115699
  inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)
114706
115700
  )
114707
115701
  ).limit(1);
114708
115702
  if (!row) {
114709
- return textResponse3(
115703
+ return textResponse5(
114710
115704
  `Product ${productId} not found or you don't have access to it.`
114711
115705
  );
114712
115706
  }
114713
- return textResponse3(formatProduct(row));
115707
+ return textResponse5(formatProduct(row));
114714
115708
  }
114715
115709
  async function loadProductInTeam(productId, teamId) {
114716
115710
  const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114717
- const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
115711
+ const [row] = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(
114718
115712
  and(
114719
115713
  eq(schema_exports.invoiceProducts.id, productId),
114720
115714
  inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
@@ -114725,10 +115719,10 @@ async function loadProductInTeam(productId, teamId) {
114725
115719
  async function handleCreateProduct(input) {
114726
115720
  const { name: name21, description, price, currency, unit } = input;
114727
115721
  if (!name21 || name21.trim().length === 0) {
114728
- return textResponse3("Error: `name` is required.");
115722
+ return textResponse5("Error: `name` is required.");
114729
115723
  }
114730
115724
  const enumError = validateEnum("billingType", input.billingType, BILLING_TYPES) ?? validateEnum("category", input.category, CATEGORIES) ?? validateEnum("tier", input.tier, TIERS);
114731
- if (enumError) return textResponse3(enumError);
115725
+ if (enumError) return textResponse5(enumError);
114732
115726
  const resolved = await resolveTeamId(input.teamId);
114733
115727
  if (!resolved.ok) return resolved.response;
114734
115728
  const [created] = await db.insert(schema_exports.invoiceProducts).values({
@@ -114747,9 +115741,9 @@ async function handleCreateProduct(input) {
114747
115741
  clause: serializeProductClause(input.clause) ?? null,
114748
115742
  isActive: true,
114749
115743
  lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
114750
- }).returning(PRODUCT_COLUMNS);
114751
- if (!created) return textResponse3("Failed to create product.");
114752
- return textResponse3(
115744
+ }).returning(PRODUCT_COLUMNS2);
115745
+ if (!created) return textResponse5("Failed to create product.");
115746
+ return textResponse5(
114753
115747
  `\u2705 **Product created**
114754
115748
 
114755
115749
  ${formatProduct(created)}`
@@ -114757,21 +115751,21 @@ ${formatProduct(created)}`
114757
115751
  }
114758
115752
  async function handleUpdateProduct(input) {
114759
115753
  const { productId } = input;
114760
- if (!productId) return textResponse3("Error: `productId` is required.");
115754
+ if (!productId) return textResponse5("Error: `productId` is required.");
114761
115755
  const resolved = await resolveTeamId(input.teamId);
114762
115756
  if (!resolved.ok) return resolved.response;
114763
115757
  const existing = await loadProductInTeam(productId, resolved.teamId);
114764
115758
  if (!existing) {
114765
- return textResponse3(
115759
+ return textResponse5(
114766
115760
  `Product ${productId} not found, or it is not owned by this team.`
114767
115761
  );
114768
115762
  }
114769
115763
  const enumError = validateEnum("billingType", input.billingType, BILLING_TYPES) ?? validateEnum("category", input.category, CATEGORIES) ?? validateEnum("tier", input.tier, TIERS);
114770
- if (enumError) return textResponse3(enumError);
115764
+ if (enumError) return textResponse5(enumError);
114771
115765
  const updates = {};
114772
115766
  if (input.name !== void 0) {
114773
115767
  if (!input.name || input.name.trim().length === 0) {
114774
- return textResponse3("Error: `name` cannot be empty.");
115768
+ return textResponse5("Error: `name` cannot be empty.");
114775
115769
  }
114776
115770
  updates.name = input.name.trim();
114777
115771
  }
@@ -114792,14 +115786,14 @@ async function handleUpdateProduct(input) {
114792
115786
  updates.clause = serializeProductClause(input.clause);
114793
115787
  }
114794
115788
  if (Object.keys(updates).length === 0) {
114795
- return textResponse3(
115789
+ return textResponse5(
114796
115790
  "No fields to update. Provide at least one of: name, description, price, currency, unit, isActive, billingType, category, includedItems, optional, tier, sortOrder, clause."
114797
115791
  );
114798
115792
  }
114799
115793
  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(
115794
+ const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS2);
115795
+ if (!updated) return textResponse5(`Failed to update product ${productId}.`);
115796
+ return textResponse5(
114803
115797
  `\u2705 **Product updated**
114804
115798
 
114805
115799
  ${formatProduct(updated)}
@@ -114808,23 +115802,23 @@ Note: this only affects future invoices/quotes. Existing documents keep their li
114808
115802
  }
114809
115803
  async function handleArchiveProduct(input) {
114810
115804
  const { productId, reason } = input;
114811
- if (!productId) return textResponse3("Error: `productId` is required.");
115805
+ if (!productId) return textResponse5("Error: `productId` is required.");
114812
115806
  const resolved = await resolveTeamId(input.teamId);
114813
115807
  if (!resolved.ok) return resolved.response;
114814
115808
  const existing = await loadProductInTeam(productId, resolved.teamId);
114815
115809
  if (!existing) {
114816
- return textResponse3(
115810
+ return textResponse5(
114817
115811
  `Product ${productId} not found, or it is not owned by this team.`
114818
115812
  );
114819
115813
  }
114820
115814
  if (!existing.isActive) {
114821
- return textResponse3(
115815
+ return textResponse5(
114822
115816
  `Product "${existing.name}" (${existing.id}) is already archived.`
114823
115817
  );
114824
115818
  }
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(
115819
+ 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);
115820
+ if (!archived) return textResponse5(`Failed to archive product ${productId}.`);
115821
+ return textResponse5(
114828
115822
  `\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
114829
115823
 
114830
115824
  ${formatProduct(archived)}${reason ? `Reason: ${reason}
@@ -114833,28 +115827,25 @@ ${formatProduct(archived)}${reason ? `Reason: ${reason}
114833
115827
  }
114834
115828
 
114835
115829
  // src/tools/quote-line-util.ts
114836
- function round2(n3) {
115830
+ function round22(n3) {
114837
115831
  return Math.round(n3 * 100) / 100;
114838
115832
  }
114839
- function lineFinancials(quantity, price, defaults) {
115833
+ function lineFinancials2(quantity, price, defaults) {
114840
115834
  const lineTotal = quantity * price;
114841
115835
  return {
114842
- vat: defaults.includeVat ? round2(lineTotal * (defaults.vatRate / 100)) : void 0,
114843
- tax: defaults.includeTax ? round2(lineTotal * (defaults.taxRate / 100)) : void 0
115836
+ vat: defaults.includeVat ? round22(lineTotal * (defaults.vatRate / 100)) : void 0,
115837
+ tax: defaults.includeTax ? round22(lineTotal * (defaults.taxRate / 100)) : void 0
114844
115838
  };
114845
115839
  }
114846
115840
  function computeTotals(items, defaults) {
114847
- const subtotal = items.reduce(
114848
- (sum, i6) => sum + (i6.quantity || 0) * (i6.price || 0),
114849
- 0
114850
- );
115841
+ const subtotal = items.reduce((sum, i6) => sum + billableLineTotal(i6), 0);
114851
115842
  const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
114852
115843
  const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
114853
115844
  return {
114854
- subtotal: round2(subtotal),
114855
- vat: round2(vat),
114856
- tax: round2(tax),
114857
- amount: round2(subtotal + vat + tax)
115845
+ subtotal: round22(subtotal),
115846
+ vat: round22(vat),
115847
+ tax: round22(tax),
115848
+ amount: round22(subtotal + vat + tax)
114858
115849
  };
114859
115850
  }
114860
115851
  function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
@@ -114884,7 +115875,7 @@ function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ ne
114884
115875
  function lineItemFromProduct(product, opts, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
114885
115876
  const quantity = opts.quantity ?? 1;
114886
115877
  const price = opts.customPrice ?? product.price ?? 0;
114887
- const { vat, tax } = lineFinancials(quantity, price, defaults);
115878
+ const { vat, tax } = lineFinancials2(quantity, price, defaults);
114888
115879
  return {
114889
115880
  name: opts.customDescription || product.name,
114890
115881
  quantity,
@@ -114924,7 +115915,7 @@ var QUOTE_STATUSES = [
114924
115915
  "expired"
114925
115916
  ];
114926
115917
  var SAFE_DRAFT_STATUSES = /* @__PURE__ */ new Set(["draft"]);
114927
- function textResponse4(text3) {
115918
+ function textResponse6(text3) {
114928
115919
  return { content: [{ type: "text", text: text3 }] };
114929
115920
  }
114930
115921
  async function loadTemplateDefaults(teamId) {
@@ -114990,7 +115981,7 @@ async function nextQuotationNumber(teamId) {
114990
115981
  if (!value) throw new Error("Failed to fetch next quotation number");
114991
115982
  return value;
114992
115983
  }
114993
- async function loadProductsInTeam(productIds, teamId) {
115984
+ async function loadProductsInTeam2(productIds, teamId) {
114994
115985
  if (productIds.length === 0) return /* @__PURE__ */ new Map();
114995
115986
  const accessibleTeamIds = await getAccessibleTeamIds(teamId);
114996
115987
  const rows = await db.select({
@@ -115018,7 +116009,7 @@ async function loadProductsInTeam(productIds, teamId) {
115018
116009
  }
115019
116010
  async function resolveLineItems(inputs, defaults, teamId) {
115020
116011
  const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
115021
- const products = await loadProductsInTeam([...new Set(productIds)], teamId);
116012
+ const products = await loadProductsInTeam2([...new Set(productIds)], teamId);
115022
116013
  const items = [];
115023
116014
  for (const input of inputs) {
115024
116015
  if (input.productId) {
@@ -115044,7 +116035,7 @@ async function resolveLineItems(inputs, defaults, teamId) {
115044
116035
  }
115045
116036
  const quantity = input.quantity ?? 1;
115046
116037
  const price = input.price ?? 0;
115047
- const { vat, tax } = lineFinancials(quantity, price, defaults);
116038
+ const { vat, tax } = lineFinancials2(quantity, price, defaults);
115048
116039
  items.push({
115049
116040
  name: input.name?.trim() || "(no description)",
115050
116041
  quantity,
@@ -115083,7 +116074,7 @@ ${q3.validUntil ? `Valid until: ${new Date(q3.validUntil).toLocaleDateString()}
115083
116074
  ` : ""}Created: ${new Date(q3.createdAt).toLocaleDateString()}
115084
116075
  `;
115085
116076
  }
115086
- function tiptapNote(text3) {
116077
+ function tiptapNote2(text3) {
115087
116078
  return {
115088
116079
  type: "doc",
115089
116080
  content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
@@ -115092,14 +116083,14 @@ function tiptapNote(text3) {
115092
116083
  async function handleGetQuotes(input) {
115093
116084
  const { customerId, status, q: q3, pageSize = 20 } = input;
115094
116085
  if (status && !QUOTE_STATUSES.includes(status)) {
115095
- return textResponse4(
116086
+ return textResponse6(
115096
116087
  `Error: invalid status "${status}". Allowed: ${QUOTE_STATUSES.join(", ")}.`
115097
116088
  );
115098
116089
  }
115099
116090
  const scope = await resolveTeamScope(input.teamId);
115100
116091
  if (!scope.ok) return scope.response;
115101
116092
  if (scope.teamIds.length === 0) {
115102
- return textResponse4("No accessible teams found.");
116093
+ return textResponse6("No accessible teams found.");
115103
116094
  }
115104
116095
  const filters = [inArray(schema_exports.quotations.teamId, scope.teamIds)];
115105
116096
  if (customerId) filters.push(eq(schema_exports.quotations.customerId, customerId));
@@ -115114,10 +116105,10 @@ async function handleGetQuotes(input) {
115114
116105
  }
115115
116106
  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
116107
  if (rows.length === 0) {
115117
- return textResponse4("No quotes found.");
116108
+ return textResponse6("No quotes found.");
115118
116109
  }
115119
116110
  const note = input.projectId ? "\nNote: `projectId` was ignored \u2014 quotations are not linked to projects." : "";
115120
- return textResponse4(
116111
+ return textResponse6(
115121
116112
  `Found ${rows.length} quote(s):
115122
116113
 
115123
116114
  ${rows.map(formatQuote).join("\n")}${note}`
@@ -115125,10 +116116,10 @@ ${rows.map(formatQuote).join("\n")}${note}`
115125
116116
  }
115126
116117
  async function handleCreateQuote(input) {
115127
116118
  const { customerId } = input;
115128
- if (!customerId) return textResponse4("Error: `customerId` is required.");
116119
+ if (!customerId) return textResponse6("Error: `customerId` is required.");
115129
116120
  const status = input.status ?? "draft";
115130
116121
  if (!SAFE_DRAFT_STATUSES.has(status)) {
115131
- return textResponse4(
116122
+ return textResponse6(
115132
116123
  `Error: this tool only creates draft quotes. Requested status "${status}" is not allowed. Sending/accepting a quote is a manual dashboard action.`
115133
116124
  );
115134
116125
  }
@@ -115151,7 +116142,7 @@ async function handleCreateQuote(input) {
115151
116142
  )
115152
116143
  ).limit(1);
115153
116144
  if (!customer) {
115154
- return textResponse4(
116145
+ return textResponse6(
115155
116146
  `Customer ${customerId} not found or not owned by this team.`
115156
116147
  );
115157
116148
  }
@@ -115161,7 +116152,7 @@ async function handleCreateQuote(input) {
115161
116152
  defaults,
115162
116153
  teamId
115163
116154
  );
115164
- if (error49) return textResponse4(`Error: ${error49}`);
116155
+ if (error49) return textResponse6(`Error: ${error49}`);
115165
116156
  const totals = computeTotals(items, defaults);
115166
116157
  const quotationNumber = await nextQuotationNumber(teamId);
115167
116158
  const template = buildQuoteTemplate(defaults, input.title);
@@ -115212,7 +116203,7 @@ async function handleCreateQuote(input) {
115212
116203
  customerDetails,
115213
116204
  fromDetails: defaults.fromDetails ?? null,
115214
116205
  paymentDetails: defaults.paymentDetails ?? null,
115215
- noteDetails: input.description ? tiptapNote(input.description) : null,
116206
+ noteDetails: input.description ? tiptapNote2(input.description) : null,
115216
116207
  issueDate: (/* @__PURE__ */ new Date()).toISOString(),
115217
116208
  validUntil: input.validUntil ?? null,
115218
116209
  lineItems: items,
@@ -115221,8 +116212,8 @@ async function handleCreateQuote(input) {
115221
116212
  tax: totals.tax,
115222
116213
  amount: totals.amount
115223
116214
  }).returning(QUOTE_COLUMNS);
115224
- if (!created) return textResponse4("Failed to create quote.");
115225
- return textResponse4(
116215
+ if (!created) return textResponse6("Failed to create quote.");
116216
+ return textResponse6(
115226
116217
  `\u2705 **Draft quote created**
115227
116218
 
115228
116219
  ${formatQuote(created)}
@@ -115239,16 +116230,16 @@ async function loadQuoteInTeam(id, teamId) {
115239
116230
  ).limit(1);
115240
116231
  return row ?? null;
115241
116232
  }
115242
- function notDraftResponse(quote) {
115243
- return textResponse4(
116233
+ function notDraftResponse2(quote) {
116234
+ return textResponse6(
115244
116235
  `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
116236
  );
115246
116237
  }
115247
116238
  async function handleUpdateQuote(input) {
115248
116239
  const { id } = input;
115249
- if (!id) return textResponse4("Error: `id` is required.");
116240
+ if (!id) return textResponse6("Error: `id` is required.");
115250
116241
  if (input.status !== void 0 && !SAFE_DRAFT_STATUSES.has(input.status)) {
115251
- return textResponse4(
116242
+ return textResponse6(
115252
116243
  `Error: status can only stay within {${[...SAFE_DRAFT_STATUSES].join(", ")}}. "${input.status}" (send/accept/reject/expire) must be done manually from the dashboard.`
115253
116244
  );
115254
116245
  }
@@ -115256,16 +116247,16 @@ async function handleUpdateQuote(input) {
115256
116247
  if (!resolved.ok) return resolved.response;
115257
116248
  const quote = await loadQuoteInTeam(id, resolved.teamId);
115258
116249
  if (!quote) {
115259
- return textResponse4(`Quote ${id} not found or not owned by this team.`);
116250
+ return textResponse6(`Quote ${id} not found or not owned by this team.`);
115260
116251
  }
115261
- if (quote.status !== "draft") return notDraftResponse(quote);
116252
+ if (quote.status !== "draft") return notDraftResponse2(quote);
115262
116253
  const defaults = templateDefaultsFromStored(quote.template, quote.currency);
115263
116254
  const updates = {};
115264
116255
  if (input.title !== void 0) {
115265
116256
  updates.template = buildQuoteTemplate(defaults, input.title);
115266
116257
  }
115267
116258
  if (input.description !== void 0) {
115268
- updates.noteDetails = input.description ? tiptapNote(input.description) : null;
116259
+ updates.noteDetails = input.description ? tiptapNote2(input.description) : null;
115269
116260
  }
115270
116261
  if (input.validUntil !== void 0) {
115271
116262
  updates.validUntil = input.validUntil;
@@ -115276,7 +116267,7 @@ async function handleUpdateQuote(input) {
115276
116267
  defaults,
115277
116268
  quote.teamId
115278
116269
  );
115279
- if (error49) return textResponse4(`Error: ${error49}`);
116270
+ if (error49) return textResponse6(`Error: ${error49}`);
115280
116271
  const totals = computeTotals(items, defaults);
115281
116272
  updates.lineItems = items;
115282
116273
  updates.subtotal = totals.subtotal;
@@ -115285,32 +116276,32 @@ async function handleUpdateQuote(input) {
115285
116276
  updates.amount = totals.amount;
115286
116277
  }
115287
116278
  if (Object.keys(updates).length === 0) {
115288
- return textResponse4(
116279
+ return textResponse6(
115289
116280
  "No fields to update. Provide at least one of: title, description, validUntil, lineItems."
115290
116281
  );
115291
116282
  }
115292
116283
  updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
115293
116284
  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**
116285
+ if (!updated) return textResponse6(`Failed to update quote ${id}.`);
116286
+ return textResponse6(`\u2705 **Draft quote updated**
115296
116287
 
115297
116288
  ${formatQuote(updated)}`);
115298
116289
  }
115299
116290
  async function handleAddProductToQuote(input) {
115300
116291
  const { quoteId, productId } = input;
115301
- if (!quoteId) return textResponse4("Error: `quoteId` is required.");
115302
- if (!productId) return textResponse4("Error: `productId` is required.");
116292
+ if (!quoteId) return textResponse6("Error: `quoteId` is required.");
116293
+ if (!productId) return textResponse6("Error: `productId` is required.");
115303
116294
  const resolved = await resolveTeamId(input.teamId);
115304
116295
  if (!resolved.ok) return resolved.response;
115305
116296
  const quote = await loadQuoteInTeam(quoteId, resolved.teamId);
115306
116297
  if (!quote) {
115307
- return textResponse4(`Quote ${quoteId} not found or not owned by this team.`);
116298
+ return textResponse6(`Quote ${quoteId} not found or not owned by this team.`);
115308
116299
  }
115309
- if (quote.status !== "draft") return notDraftResponse(quote);
115310
- const products = await loadProductsInTeam([productId], quote.teamId);
116300
+ if (quote.status !== "draft") return notDraftResponse2(quote);
116301
+ const products = await loadProductsInTeam2([productId], quote.teamId);
115311
116302
  const product = products.get(productId);
115312
116303
  if (!product) {
115313
- return textResponse4(
116304
+ return textResponse6(
115314
116305
  `Product ${productId} not found or not owned by this team.`
115315
116306
  );
115316
116307
  }
@@ -115336,7 +116327,7 @@ async function handleAddProductToQuote(input) {
115336
116327
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
115337
116328
  }).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
115338
116329
  if (!updated) {
115339
- return textResponse4(`Failed to add product to quote ${quoteId}.`);
116330
+ return textResponse6(`Failed to add product to quote ${quoteId}.`);
115340
116331
  }
115341
116332
  await db.update(schema_exports.invoiceProducts).set({
115342
116333
  usageCount: sql`${schema_exports.invoiceProducts.usageCount} + 1`,
@@ -115352,7 +116343,7 @@ async function handleAddProductToQuote(input) {
115352
116343
  if (meta5.includedItems && meta5.includedItems.length > 0) {
115353
116344
  metaParts.push(`included=[${meta5.includedItems.join(", ")}]`);
115354
116345
  }
115355
- return textResponse4(
116346
+ return textResponse6(
115356
116347
  `\u2705 **Product added to draft quote ${updated.quotationNumber ?? updated.id}**
115357
116348
 
115358
116349
  Line item: ${newItem.name} \xD7 ${newItem.quantity}${newItem.unit ? ` ${newItem.unit}` : ""} @ ${newItem.price} ${snap.currency}
@@ -121087,7 +122078,7 @@ function formatDeleteAttachmentRefusal(reason, context2) {
121087
122078
  }
121088
122079
 
121089
122080
  // src/tools/ticket-attachments.ts
121090
- function textResponse5(text3) {
122081
+ function textResponse7(text3) {
121091
122082
  return { content: [{ type: "text", text: text3 }] };
121092
122083
  }
121093
122084
  async function findAttachment(attachmentId) {
@@ -121160,7 +122151,7 @@ ${url3}`
121160
122151
  async function handleUploadTicketAttachment(input) {
121161
122152
  const ctx = getAuthContext() ?? authContext;
121162
122153
  if (!ctx) {
121163
- return textResponse5("Error: Not authenticated.");
122154
+ return textResponse7("Error: Not authenticated.");
121164
122155
  }
121165
122156
  const access = await loadAccessibleTicket(input.teamId, input.ticketId);
121166
122157
  if (!access.ok) return access.response;
@@ -121176,12 +122167,12 @@ async function handleUploadTicketAttachment(input) {
121176
122167
  userId: ctx.userId
121177
122168
  });
121178
122169
  if (!resolved.ok) {
121179
- return textResponse5(resolved.message);
122170
+ return textResponse7(resolved.message);
121180
122171
  }
121181
122172
  const { buffer: buffer2, fileName, mimeType, stagingStorageKey } = resolved;
121182
122173
  const validationError = validateAttachmentBuffer(buffer2, mimeType);
121183
122174
  if (validationError) {
121184
- return textResponse5(validationError.message);
122175
+ return textResponse7(validationError.message);
121185
122176
  }
121186
122177
  const storageKey = `${ticket.teamId}/tickets/${ticket.id}/${Date.now()}_${fileName}`;
121187
122178
  try {
@@ -121192,7 +122183,7 @@ async function handleUploadTicketAttachment(input) {
121192
122183
  options: { contentType: mimeType, upsert: true }
121193
122184
  });
121194
122185
  } catch (error49) {
121195
- return textResponse5(
122186
+ return textResponse7(
121196
122187
  `Upload failed: ${error49 instanceof Error ? error49.message : String(error49)}`
121197
122188
  );
121198
122189
  }
@@ -121221,7 +122212,7 @@ async function handleUploadTicketAttachment(input) {
121221
122212
  url3 = signed.url;
121222
122213
  } catch {
121223
122214
  }
121224
- return textResponse5(
122215
+ return textResponse7(
121225
122216
  `\u{1F4CE} **Attached to ${ticket.ticketNumber}**
121226
122217
  File: ${fileName}
121227
122218
  Type: ${mimeType}
@@ -121235,18 +122226,18 @@ ${url3}` : "")
121235
122226
  async function handleDeleteTicketAttachment(input) {
121236
122227
  const ctx = getAuthContext() ?? authContext;
121237
122228
  if (!ctx) {
121238
- return textResponse5("Error: Not authenticated.");
122229
+ return textResponse7("Error: Not authenticated.");
121239
122230
  }
121240
122231
  const inputError = validateDeleteAttachmentInput(input.attachmentId);
121241
122232
  if (inputError) {
121242
- return textResponse5(formatDeleteAttachmentRefusal(inputError, { ticketNumber: input.ticketId }));
122233
+ return textResponse7(formatDeleteAttachmentRefusal(inputError, { ticketNumber: input.ticketId }));
121243
122234
  }
121244
122235
  const access = await loadAccessibleTicket(input.teamId, input.ticketId);
121245
122236
  if (!access.ok) return access.response;
121246
122237
  const ticket = access.ticket;
121247
122238
  const attachment = await findAttachment(input.attachmentId);
121248
122239
  if (!attachment) {
121249
- return textResponse5(
122240
+ return textResponse7(
121250
122241
  formatDeleteAttachmentRefusal("attachment_not_found", {
121251
122242
  attachmentId: input.attachmentId,
121252
122243
  ticketNumber: ticket.ticketNumber
@@ -121254,7 +122245,7 @@ async function handleDeleteTicketAttachment(input) {
121254
122245
  );
121255
122246
  }
121256
122247
  if (!validateAttachmentBelongsToTicket(attachment.ticketId, ticket.id)) {
121257
- return textResponse5(
122248
+ return textResponse7(
121258
122249
  formatDeleteAttachmentRefusal("wrong_ticket", {
121259
122250
  attachmentId: input.attachmentId,
121260
122251
  ticketNumber: ticket.ticketNumber,
@@ -121266,7 +122257,7 @@ async function handleDeleteTicketAttachment(input) {
121266
122257
  const table = attachment.source === "ticket" ? schema_exports.ticketAttachments : schema_exports.ticketCommentAttachments;
121267
122258
  const [deletedRow] = await db.delete(table).where(eq(table.id, input.attachmentId)).returning({ id: table.id });
121268
122259
  if (!deletedRow) {
121269
- return textResponse5(
122260
+ return textResponse7(
121270
122261
  `Failed to delete attachment ${input.attachmentId}. It may have been removed already.`
121271
122262
  );
121272
122263
  }
@@ -121296,7 +122287,7 @@ async function handleDeleteTicketAttachment(input) {
121296
122287
  fileName: attachment.fileName,
121297
122288
  source: attachment.source
121298
122289
  });
121299
- return textResponse5(JSON.stringify(result, null, 2));
122290
+ return textResponse7(JSON.stringify(result, null, 2));
121300
122291
  }
121301
122292
 
121302
122293
  // src/tools/tiptap-text.ts
@@ -121713,7 +122704,7 @@ function formatTagUsage(usage) {
121713
122704
  }
121714
122705
 
121715
122706
  // src/tools/tag-management.ts
121716
- function textResponse6(text3) {
122707
+ function textResponse8(text3) {
121717
122708
  return { content: [{ type: "text", text: text3 }] };
121718
122709
  }
121719
122710
  var TAG_COLUMNS = {
@@ -121754,24 +122745,24 @@ function scopeFilter(projectId) {
121754
122745
  return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
121755
122746
  }
121756
122747
  async function handleUpdateTag(input) {
121757
- if (!input.tagId) return textResponse6("Error: `tagId` is required.");
122748
+ if (!input.tagId) return textResponse8("Error: `tagId` is required.");
121758
122749
  const resolved = await resolveTeamId(input.teamId);
121759
122750
  if (!resolved.ok) return resolved.response;
121760
122751
  const existing = await loadTagInTeam(input.tagId, resolved.teamId);
121761
122752
  if (!existing) {
121762
- return textResponse6(
122753
+ return textResponse8(
121763
122754
  `Tag ${input.tagId} not found, or it is not owned by this team.`
121764
122755
  );
121765
122756
  }
121766
122757
  const renaming = input.name !== void 0;
121767
122758
  const rescoping = input.projectId !== void 0;
121768
122759
  if (!renaming && !rescoping) {
121769
- return textResponse6(
122760
+ return textResponse8(
121770
122761
  "No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
121771
122762
  );
121772
122763
  }
121773
122764
  if (renaming && !isValidTagName(input.name)) {
121774
- return textResponse6("Error: `name` cannot be empty.");
122765
+ return textResponse8("Error: `name` cannot be empty.");
121775
122766
  }
121776
122767
  const nextName = renaming ? input.name.trim() : existing.name;
121777
122768
  const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
@@ -121784,13 +122775,13 @@ async function handleUpdateTag(input) {
121784
122775
  )
121785
122776
  ).limit(1);
121786
122777
  if (collision) {
121787
- return textResponse6(
122778
+ return textResponse8(
121788
122779
  `\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
122780
  );
121790
122781
  }
121791
122782
  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(
122783
+ if (!updated) return textResponse8(`Failed to update tag ${input.tagId}.`);
122784
+ return textResponse8(
121794
122785
  `\u2705 **Tag updated**
121795
122786
 
121796
122787
  ${describeTag(updated)}
@@ -121799,34 +122790,34 @@ Existing ticket/customer/project/transaction tag relations are preserved.`
121799
122790
  );
121800
122791
  }
121801
122792
  async function handleDeleteTag(input) {
121802
- if (!input.tagId) return textResponse6("Error: `tagId` is required.");
122793
+ if (!input.tagId) return textResponse8("Error: `tagId` is required.");
121803
122794
  const mode = input.mode ?? "delete_if_unused";
121804
122795
  const resolved = await resolveTeamId(input.teamId);
121805
122796
  if (!resolved.ok) return resolved.response;
121806
122797
  const existing = await loadTagInTeam(input.tagId, resolved.teamId);
121807
122798
  if (!existing) {
121808
- return textResponse6(
122799
+ return textResponse8(
121809
122800
  `Tag ${input.tagId} not found, or it is not owned by this team.`
121810
122801
  );
121811
122802
  }
121812
122803
  const usage = await getTagUsage(existing.id);
121813
122804
  const total = totalTagUsage(usage);
121814
122805
  if (mode === "archive") {
121815
- return textResponse6(
122806
+ return textResponse8(
121816
122807
  `\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
121817
122808
 
121818
122809
  Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
121819
122810
  );
121820
122811
  }
121821
122812
  if (total > 0) {
121822
- return textResponse6(
122813
+ return textResponse8(
121823
122814
  `\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
121824
122815
 
121825
122816
  Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
121826
122817
  );
121827
122818
  }
121828
122819
  await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
121829
- return textResponse6(
122820
+ return textResponse8(
121830
122821
  `\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
121831
122822
  );
121832
122823
  }
@@ -121836,7 +122827,7 @@ async function resolveMergeTarget(teamId, input) {
121836
122827
  if (!tag) {
121837
122828
  return {
121838
122829
  ok: false,
121839
- response: textResponse6(
122830
+ response: textResponse8(
121840
122831
  `Target tag ${input.targetTagId} not found, or it is not owned by this team.`
121841
122832
  )
121842
122833
  };
@@ -121846,7 +122837,7 @@ async function resolveMergeTarget(teamId, input) {
121846
122837
  if (!isValidTagName(input.targetName)) {
121847
122838
  return {
121848
122839
  ok: false,
121849
- response: textResponse6(
122840
+ response: textResponse8(
121850
122841
  "Error: provide either `targetTagId` or a non-empty `targetName`."
121851
122842
  )
121852
122843
  };
@@ -121864,14 +122855,14 @@ async function resolveMergeTarget(teamId, input) {
121864
122855
  }
121865
122856
  const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
121866
122857
  if (!created) {
121867
- return { ok: false, response: textResponse6("Failed to create target tag.") };
122858
+ return { ok: false, response: textResponse8("Failed to create target tag.") };
121868
122859
  }
121869
122860
  return { ok: true, tag: created, created: true };
121870
122861
  }
121871
122862
  async function handleMergeTags(input) {
121872
122863
  const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
121873
122864
  if (rawSourceIds.length === 0) {
121874
- return textResponse6("Error: `sourceTagIds` must contain at least one tag id.");
122865
+ return textResponse8("Error: `sourceTagIds` must contain at least one tag id.");
121875
122866
  }
121876
122867
  const resolved = await resolveTeamId(input.teamId);
121877
122868
  if (!resolved.ok) return resolved.response;
@@ -121885,7 +122876,7 @@ async function handleMergeTags(input) {
121885
122876
  const foundIds = new Set(sourceTags.map((t8) => t8.id));
121886
122877
  const missing = rawSourceIds.filter((id) => !foundIds.has(id));
121887
122878
  if (missing.length > 0) {
121888
- return textResponse6(
122879
+ return textResponse8(
121889
122880
  `Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
121890
122881
  );
121891
122882
  }
@@ -121893,7 +122884,7 @@ async function handleMergeTags(input) {
121893
122884
  if (!target.ok) return target.response;
121894
122885
  const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
121895
122886
  if (sourcesToMerge.length === 0) {
121896
- return textResponse6(
122887
+ return textResponse8(
121897
122888
  "Error: nothing to merge \u2014 the only source tag is the same as the target tag."
121898
122889
  );
121899
122890
  }
@@ -121990,7 +122981,7 @@ async function handleMergeTags(input) {
121990
122981
  const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
121991
122982
  const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
121992
122983
  const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
121993
- return textResponse6(
122984
+ return textResponse8(
121994
122985
  `\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
121995
122986
 
121996
122987
  Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
@@ -122357,15 +123348,15 @@ var TRIP_LOCKED_FIELDS = [
122357
123348
  "invoiceId",
122358
123349
  "isInvoiced"
122359
123350
  ];
122360
- function round22(value) {
123351
+ function round23(value) {
122361
123352
  return Math.round(value * 100) / 100;
122362
123353
  }
122363
123354
  function deriveTripAmount(input) {
122364
123355
  if (input.amount != null) return input.amount;
122365
123356
  if (input.rate == null) return null;
122366
- if (input.billingType === "per_trip") return round22(input.rate);
123357
+ if (input.billingType === "per_trip") return round23(input.rate);
122367
123358
  if (input.billingType === "per_km" && input.distance != null) {
122368
- return round22(input.distance * input.rate);
123359
+ return round23(input.distance * input.rate);
122369
123360
  }
122370
123361
  return null;
122371
123362
  }
@@ -122376,7 +123367,7 @@ function attemptedLockedFields(update) {
122376
123367
  // src/tools/trips.ts
122377
123368
  var TRIP_TYPES = ["private", "business"];
122378
123369
  var BILLING_TYPES2 = TRIP_BILLING_TYPES;
122379
- function textResponse7(text3) {
123370
+ function textResponse9(text3) {
122380
123371
  return { content: [{ type: "text", text: text3 }] };
122381
123372
  }
122382
123373
  function jsonResponse(payload) {
@@ -122432,19 +123423,19 @@ var TRIP_RELATIONS = {
122432
123423
  };
122433
123424
  async function handleGetTrips(input) {
122434
123425
  if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
122435
- return textResponse7(
123426
+ return textResponse9(
122436
123427
  `Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
122437
123428
  );
122438
123429
  }
122439
123430
  if (input.billingType && !BILLING_TYPES2.includes(input.billingType)) {
122440
- return textResponse7(
123431
+ return textResponse9(
122441
123432
  `Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
122442
123433
  );
122443
123434
  }
122444
123435
  const scope = await resolveTeamScope(input.teamId);
122445
123436
  if (!scope.ok) return scope.response;
122446
123437
  if (scope.teamIds.length === 0) {
122447
- return textResponse7("No accessible teams found.");
123438
+ return textResponse9("No accessible teams found.");
122448
123439
  }
122449
123440
  const filters = [inArray(schema_exports.trips.teamId, scope.teamIds)];
122450
123441
  if (input.dateFrom) filters.push(gte(schema_exports.trips.date, input.dateFrom));
@@ -122489,10 +123480,10 @@ async function handleGetTrips(input) {
122489
123480
  return jsonResponse({
122490
123481
  count: rows.length,
122491
123482
  totals: {
122492
- businessKm: round22(totals.businessKm),
122493
- privateKm: round22(totals.privateKm),
122494
- totalKm: round22(totals.totalKm),
122495
- totalAmount: round22(totals.totalAmount)
123483
+ businessKm: round23(totals.businessKm),
123484
+ privateKm: round23(totals.privateKm),
123485
+ totalKm: round23(totals.totalKm),
123486
+ totalAmount: round23(totals.totalAmount)
122496
123487
  },
122497
123488
  trips: rows.map(formatTrip)
122498
123489
  });
@@ -122546,20 +123537,20 @@ async function validateInvoice(invoiceId, teamId) {
122546
123537
  }
122547
123538
  async function handleCreateTrip(input) {
122548
123539
  const ctx = getAuthContext();
122549
- if (!input.date) return textResponse7("Error: `date` (YYYY-MM-DD) is required.");
123540
+ if (!input.date) return textResponse9("Error: `date` (YYYY-MM-DD) is required.");
122550
123541
  if (!input.startLocation || !input.endLocation) {
122551
- return textResponse7(
123542
+ return textResponse9(
122552
123543
  "Error: `startLocation` and `endLocation` are required."
122553
123544
  );
122554
123545
  }
122555
123546
  if (!input.tripType || !TRIP_TYPES.includes(input.tripType)) {
122556
- return textResponse7(
123547
+ return textResponse9(
122557
123548
  `Error: \`tripType\` is required and must be one of: ${TRIP_TYPES.join(", ")}.`
122558
123549
  );
122559
123550
  }
122560
123551
  const billingType = input.billingType ?? "not_billable";
122561
123552
  if (!BILLING_TYPES2.includes(billingType)) {
122562
- return textResponse7(
123553
+ return textResponse9(
122563
123554
  `Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
122564
123555
  );
122565
123556
  }
@@ -122571,7 +123562,7 @@ async function handleCreateTrip(input) {
122571
123562
  customerId: input.customerId,
122572
123563
  vehicleId: input.vehicleId
122573
123564
  });
122574
- if (linkError) return textResponse7(`Error: ${linkError}`);
123565
+ if (linkError) return textResponse9(`Error: ${linkError}`);
122575
123566
  if (!input.allowDuplicate) {
122576
123567
  const dupFilters = [
122577
123568
  eq(schema_exports.trips.teamId, teamId),
@@ -122588,7 +123579,7 @@ async function handleCreateTrip(input) {
122588
123579
  }
122589
123580
  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
123581
  if (dup) {
122591
- return textResponse7(
123582
+ return textResponse9(
122592
123583
  `\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
123584
  );
122594
123585
  }
@@ -122618,7 +123609,7 @@ async function handleCreateTrip(input) {
122618
123609
  vehicleId: input.vehicleId ?? null,
122619
123610
  snapshotId: input.snapshotId ?? null
122620
123611
  }).returning({ id: schema_exports.trips.id });
122621
- if (!created) return textResponse7("Failed to create trip.");
123612
+ if (!created) return textResponse9("Failed to create trip.");
122622
123613
  const trip = await loadTripInTeams(created.id, [teamId]);
122623
123614
  return {
122624
123615
  content: [
@@ -122633,14 +123624,14 @@ ${JSON.stringify(formatTrip(trip), null, 2)}`
122633
123624
  }
122634
123625
  async function handleUpdateTrip(input) {
122635
123626
  const ctx = getAuthContext();
122636
- if (!input.id) return textResponse7("Error: `id` is required.");
123627
+ if (!input.id) return textResponse9("Error: `id` is required.");
122637
123628
  if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
122638
- return textResponse7(
123629
+ return textResponse9(
122639
123630
  `Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
122640
123631
  );
122641
123632
  }
122642
123633
  if (input.billingType && !BILLING_TYPES2.includes(input.billingType)) {
122643
- return textResponse7(
123634
+ return textResponse9(
122644
123635
  `Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
122645
123636
  );
122646
123637
  }
@@ -122649,7 +123640,7 @@ async function handleUpdateTrip(input) {
122649
123640
  const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
122650
123641
  const existing = await loadTripInTeams(input.id, accessibleTeamIds);
122651
123642
  if (!existing) {
122652
- return textResponse7(
123643
+ return textResponse9(
122653
123644
  `Trip ${input.id} not found or you don't have access to it. Call get-trips to find a valid id.`
122654
123645
  );
122655
123646
  }
@@ -122660,7 +123651,7 @@ async function handleUpdateTrip(input) {
122660
123651
  input
122661
123652
  );
122662
123653
  if (attempted.length > 0) {
122663
- return textResponse7(
123654
+ return textResponse9(
122664
123655
  `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
123656
  );
122666
123657
  }
@@ -122670,10 +123661,10 @@ async function handleUpdateTrip(input) {
122670
123661
  customerId: input.customerId ?? void 0,
122671
123662
  vehicleId: input.vehicleId ?? void 0
122672
123663
  });
122673
- if (linkError) return textResponse7(`Error: ${linkError}`);
123664
+ if (linkError) return textResponse9(`Error: ${linkError}`);
122674
123665
  if (input.invoiceId) {
122675
123666
  const invoiceError = await validateInvoice(input.invoiceId, teamId);
122676
- if (invoiceError) return textResponse7(`Error: ${invoiceError}`);
123667
+ if (invoiceError) return textResponse9(`Error: ${invoiceError}`);
122677
123668
  }
122678
123669
  const updates = {};
122679
123670
  if (input.date !== void 0) updates.date = input.date;
@@ -122721,7 +123712,7 @@ async function handleUpdateTrip(input) {
122721
123712
  if (derived != null) updates.amount = derived;
122722
123713
  }
122723
123714
  if (Object.keys(updates).length === 0) {
122724
- return textResponse7(
123715
+ return textResponse9(
122725
123716
  "No fields to update. Provide at least one editable field."
122726
123717
  );
122727
123718
  }
@@ -122748,7 +123739,7 @@ async function handleGetVehicles(input) {
122748
123739
  const scope = await resolveTeamScope(input.teamId);
122749
123740
  if (!scope.ok) return scope.response;
122750
123741
  if (scope.teamIds.length === 0) {
122751
- return textResponse7("No accessible teams found.");
123742
+ return textResponse9("No accessible teams found.");
122752
123743
  }
122753
123744
  const filters = [inArray(schema_exports.vehicles.teamId, scope.teamIds)];
122754
123745
  if (input.q) filters.push(ilike(schema_exports.vehicles.name, `%${input.q}%`));
@@ -122774,7 +123765,7 @@ async function handleGetTripTemplates(input) {
122774
123765
  const scope = await resolveTeamScope(input.teamId);
122775
123766
  if (!scope.ok) return scope.response;
122776
123767
  if (scope.teamIds.length === 0) {
122777
- return textResponse7("No accessible teams found.");
123768
+ return textResponse9("No accessible teams found.");
122778
123769
  }
122779
123770
  const filters = [inArray(schema_exports.tripTemplates.teamId, scope.teamIds)];
122780
123771
  const userId = input.userId ?? ctx.userId;
@@ -122810,14 +123801,14 @@ async function handleGetTripTemplates(input) {
122810
123801
  async function handleGetFrequentTripsForProject(input) {
122811
123802
  const ctx = getAuthContext();
122812
123803
  if (!input.projectId) {
122813
- return textResponse7("Error: `projectId` is required.");
123804
+ return textResponse9("Error: `projectId` is required.");
122814
123805
  }
122815
123806
  const resolved = await resolveTeamId(input.teamId);
122816
123807
  if (!resolved.ok) return resolved.response;
122817
123808
  const teamId = resolved.teamId;
122818
123809
  const projectIds = await getAccessibleProjectIds(ctx.userId, teamId);
122819
123810
  if (!projectIds.includes(input.projectId)) {
122820
- return textResponse7(
123811
+ return textResponse9(
122821
123812
  `Project not found or no access: ${input.projectId}. Call get-projects first.`
122822
123813
  );
122823
123814
  }
@@ -122852,7 +123843,7 @@ async function handleGetFrequentTripsForProject(input) {
122852
123843
  endLocation: g6.endLocation,
122853
123844
  tripType: g6.tripType,
122854
123845
  count: g6.count,
122855
- avgDistance: g6.avgDistance != null ? round22(toNumber2(g6.avgDistance)) : null,
123846
+ avgDistance: g6.avgDistance != null ? round23(toNumber2(g6.avgDistance)) : null,
122856
123847
  lastUsedDate: g6.lastUsedDate
122857
123848
  }))
122858
123849
  });
@@ -123442,6 +124433,22 @@ function createMcpServer() {
123442
124433
  );
123443
124434
  case "get-invoices":
123444
124435
  return await handleGetInvoices(asToolArgs(toolArgs));
124436
+ case "get-invoice-by-id":
124437
+ return await handleGetInvoiceById(
124438
+ asToolArgs(toolArgs)
124439
+ );
124440
+ case "update-invoice":
124441
+ return await handleUpdateInvoice(
124442
+ asToolArgs(toolArgs)
124443
+ );
124444
+ case "update-invoice-lines":
124445
+ return await handleUpdateInvoiceLines(
124446
+ asToolArgs(toolArgs)
124447
+ );
124448
+ case "add-product-to-invoice":
124449
+ return await handleAddProductToInvoice(
124450
+ asToolArgs(toolArgs)
124451
+ );
123445
124452
  case "link-document-to-invoice":
123446
124453
  return await handleLinkDocumentToInvoice(
123447
124454
  asToolArgs(toolArgs)
@@ -123464,6 +124471,18 @@ function createMcpServer() {
123464
124471
  return await handleArchiveProduct(
123465
124472
  asToolArgs(toolArgs)
123466
124473
  );
124474
+ case "get-customer-agreements":
124475
+ return await handleGetCustomerAgreements(
124476
+ asToolArgs(toolArgs)
124477
+ );
124478
+ case "create-customer-agreement":
124479
+ return await handleCreateCustomerAgreement(
124480
+ asToolArgs(toolArgs)
124481
+ );
124482
+ case "update-customer-agreement":
124483
+ return await handleUpdateCustomerAgreement(
124484
+ asToolArgs(toolArgs)
124485
+ );
123467
124486
  case "get-trips":
123468
124487
  return await handleGetTrips(asToolArgs(toolArgs));
123469
124488
  case "create-trip":