@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 +1663 -644
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
// ../
|
|
108351
|
-
|
|
108352
|
-
|
|
108353
|
-
|
|
108354
|
-
|
|
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
|
|
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
|
-
|
|
113547
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
114520
|
-
|
|
114521
|
-
|
|
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
|
|
115457
|
+
return textResponse4(text3);
|
|
114524
115458
|
}
|
|
114525
|
-
function
|
|
114526
|
-
|
|
114527
|
-
const
|
|
114528
|
-
|
|
114529
|
-
|
|
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
|
-
|
|
114532
|
-
|
|
114533
|
-
|
|
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
|
-
|
|
114537
|
-
|
|
114538
|
-
|
|
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
|
-
|
|
114542
|
-
|
|
114543
|
-
|
|
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
|
|
114546
|
-
const
|
|
114547
|
-
|
|
114548
|
-
|
|
114549
|
-
|
|
114550
|
-
|
|
114551
|
-
|
|
114552
|
-
if (
|
|
114553
|
-
|
|
114554
|
-
|
|
114555
|
-
|
|
114556
|
-
|
|
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
|
-
|
|
114559
|
-
|
|
114560
|
-
|
|
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 (
|
|
114563
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
115678
|
+
return textResponse5(
|
|
114685
115679
|
`No products found${status !== "all" ? ` (status: ${status})` : ""}.`
|
|
114686
115680
|
);
|
|
114687
115681
|
}
|
|
114688
|
-
return
|
|
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
|
|
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
|
|
115694
|
+
return textResponse5("No accessible teams found.");
|
|
114701
115695
|
}
|
|
114702
|
-
const [row] = await db.select(
|
|
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
|
|
115703
|
+
return textResponse5(
|
|
114710
115704
|
`Product ${productId} not found or you don't have access to it.`
|
|
114711
115705
|
);
|
|
114712
115706
|
}
|
|
114713
|
-
return
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
114751
|
-
if (!created) return
|
|
114752
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
114801
|
-
if (!updated) return
|
|
114802
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
114826
|
-
if (!archived) return
|
|
114827
|
-
return
|
|
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
|
|
115830
|
+
function round22(n3) {
|
|
114837
115831
|
return Math.round(n3 * 100) / 100;
|
|
114838
115832
|
}
|
|
114839
|
-
function
|
|
115833
|
+
function lineFinancials2(quantity, price, defaults) {
|
|
114840
115834
|
const lineTotal = quantity * price;
|
|
114841
115835
|
return {
|
|
114842
|
-
vat: defaults.includeVat ?
|
|
114843
|
-
tax: defaults.includeTax ?
|
|
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:
|
|
114855
|
-
vat:
|
|
114856
|
-
tax:
|
|
114857
|
-
amount:
|
|
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 } =
|
|
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
|
|
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
|
|
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
|
|
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 } =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
|
115225
|
-
return
|
|
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
|
|
115243
|
-
return
|
|
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
|
|
116240
|
+
if (!id) return textResponse6("Error: `id` is required.");
|
|
115250
116241
|
if (input.status !== void 0 && !SAFE_DRAFT_STATUSES.has(input.status)) {
|
|
115251
|
-
return
|
|
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
|
|
116250
|
+
return textResponse6(`Quote ${id} not found or not owned by this team.`);
|
|
115260
116251
|
}
|
|
115261
|
-
if (quote.status !== "draft") return
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
115295
|
-
return
|
|
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
|
|
115302
|
-
if (!productId) return
|
|
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
|
|
116298
|
+
return textResponse6(`Quote ${quoteId} not found or not owned by this team.`);
|
|
115308
116299
|
}
|
|
115309
|
-
if (quote.status !== "draft") return
|
|
115310
|
-
const products = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
122229
|
+
return textResponse7("Error: Not authenticated.");
|
|
121239
122230
|
}
|
|
121240
122231
|
const inputError = validateDeleteAttachmentInput(input.attachmentId);
|
|
121241
122232
|
if (inputError) {
|
|
121242
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
121793
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
123357
|
+
if (input.billingType === "per_trip") return round23(input.rate);
|
|
122367
123358
|
if (input.billingType === "per_km" && input.distance != null) {
|
|
122368
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
122493
|
-
privateKm:
|
|
122494
|
-
totalKm:
|
|
122495
|
-
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
|
|
123540
|
+
if (!input.date) return textResponse9("Error: `date` (YYYY-MM-DD) is required.");
|
|
122550
123541
|
if (!input.startLocation || !input.endLocation) {
|
|
122551
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
123627
|
+
if (!input.id) return textResponse9("Error: `id` is required.");
|
|
122637
123628
|
if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
|
|
122638
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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":
|