@grupolapa/cotizador-sdk 0.1.0

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.
Files changed (50) hide show
  1. package/README.md +125 -0
  2. package/dist/bundle-payment-schedule.calc.d.ts +2 -0
  3. package/dist/bundle-payment-schedule.calc.js +160 -0
  4. package/dist/calculate-quote.d.ts +2 -0
  5. package/dist/calculate-quote.js +382 -0
  6. package/dist/corporate-actions.d.ts +95 -0
  7. package/dist/corporate-actions.js +87 -0
  8. package/dist/corporate-rent.d.ts +5 -0
  9. package/dist/corporate-rent.js +43 -0
  10. package/dist/delivery-date.d.ts +6 -0
  11. package/dist/delivery-date.js +22 -0
  12. package/dist/entrada.d.ts +111 -0
  13. package/dist/entrada.js +139 -0
  14. package/dist/format.d.ts +7 -0
  15. package/dist/format.js +37 -0
  16. package/dist/http.d.ts +28 -0
  17. package/dist/http.js +133 -0
  18. package/dist/index.d.ts +15 -0
  19. package/dist/index.js +22 -0
  20. package/dist/installment-allocation.d.ts +30 -0
  21. package/dist/installment-allocation.js +69 -0
  22. package/dist/inventory-availability.d.ts +10 -0
  23. package/dist/inventory-availability.js +105 -0
  24. package/dist/live-inventory-availability.d.ts +16 -0
  25. package/dist/live-inventory-availability.js +88 -0
  26. package/dist/payment-schemes.d.ts +22 -0
  27. package/dist/payment-schemes.js +99 -0
  28. package/dist/post-delivery-value.d.ts +3 -0
  29. package/dist/post-delivery-value.js +61 -0
  30. package/dist/product-types.d.ts +16 -0
  31. package/dist/product-types.js +82 -0
  32. package/dist/query-state.d.ts +5 -0
  33. package/dist/query-state.js +157 -0
  34. package/dist/quote-outcome-graph.d.ts +31 -0
  35. package/dist/quote-outcome-graph.js +113 -0
  36. package/dist/quote.d.ts +19 -0
  37. package/dist/quote.js +18 -0
  38. package/dist/selection-state.d.ts +34 -0
  39. package/dist/selection-state.js +156 -0
  40. package/dist/server.d.ts +16 -0
  41. package/dist/server.js +17 -0
  42. package/dist/sitemap.d.ts +22 -0
  43. package/dist/sitemap.js +45 -0
  44. package/dist/social-quote.d.ts +8 -0
  45. package/dist/social-quote.js +51 -0
  46. package/dist/tax.d.ts +5 -0
  47. package/dist/tax.js +11 -0
  48. package/dist/types.d.ts +502 -0
  49. package/dist/types.js +1 -0
  50. package/package.json +51 -0
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Cotizador SDK
2
+
3
+ Framework-agnostic TypeScript helpers for building a cotizador against the
4
+ deployed Grupo LAPA public API.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm install @grupolapa/cotizador-sdk
10
+ ```
11
+
12
+ ```bash
13
+ pnpm add @grupolapa/cotizador-sdk
14
+ ```
15
+
16
+ The SDK has separate browser-safe and trusted-server entry points. Keep API keys
17
+ on a server and pass only short-lived session tokens to browser code.
18
+
19
+ ## Browser Reads and Quotes
20
+
21
+ ```ts
22
+ import { createCotizadorClient } from "@grupolapa/cotizador-sdk";
23
+ import {
24
+ calculateQuote,
25
+ getResolvedSchemeForUnit,
26
+ } from "@grupolapa/cotizador-sdk/quote";
27
+ import {
28
+ getSelectableUnitHotspots,
29
+ resolveInitialSitemapMap,
30
+ } from "@grupolapa/cotizador-sdk/sitemap";
31
+
32
+ const client = createCotizadorClient({
33
+ baseUrl: "https://grupolapa.com/api/public/v1/cotizador",
34
+ });
35
+
36
+ const siteKey = "viamare.mx";
37
+ const [{ snapshot }, inventory, sitemap, paymentMethods] = await Promise.all([
38
+ client.getSnapshot({ siteKey }),
39
+ client.getInventory({ siteKey }),
40
+ client.getSitemap({ siteKey }),
41
+ client.getPaymentMethods({ siteKey }),
42
+ ]);
43
+
44
+ const map = resolveInitialSitemapMap(sitemap);
45
+ const hotspots = map ? getSelectableUnitHotspots(map) : [];
46
+ const unit = inventory.inventory.find(
47
+ (item) => item.id === hotspots[0]?.unitId,
48
+ );
49
+ const scheme = unit
50
+ ? getResolvedSchemeForUnit(unit, snapshot.content.paymentSchemes, null)
51
+ : null;
52
+
53
+ const quote =
54
+ unit && scheme
55
+ ? calculateQuote(
56
+ unit,
57
+ {
58
+ unitId: unit.id,
59
+ scheme: scheme.id,
60
+ plusvaliaAnnualPct:
61
+ snapshot.content.financeRules.defaults.plusvaliaAnnualPct,
62
+ postDeliveryYears:
63
+ snapshot.content.financeRules.defaults.postDeliveryYears,
64
+ downPaymentPct: snapshot.content.financeRules.defaults.downPaymentPct,
65
+ includeIva: false,
66
+ },
67
+ scheme,
68
+ snapshot.content.financeRules,
69
+ )
70
+ : null;
71
+ ```
72
+
73
+ ## Server Session Flow
74
+
75
+ ```ts
76
+ import { createCotizadorServerClient } from "@grupolapa/cotizador-sdk/server";
77
+
78
+ const serverClient = createCotizadorServerClient({
79
+ baseUrl: "https://grupolapa.com/api/public/v1/cotizador",
80
+ apiKey: process.env.COTIZADOR_API_KEY!,
81
+ });
82
+
83
+ export async function createBrowserSession(origin: string) {
84
+ return await serverClient.createBrowserSession({
85
+ siteKey: "viamare.mx",
86
+ lookup: "host",
87
+ origin,
88
+ });
89
+ }
90
+ ```
91
+
92
+ Do not expose `lapa_ctz_<prefix>_<secret>` API keys to browser code. Browser
93
+ reservation writes should use `createCotizadorClient` with the short-lived
94
+ session token returned by the server.
95
+
96
+ ## Sitemap Rendering
97
+
98
+ The API returns image and overlay asset URLs plus SVG-native shape metadata.
99
+ The SDK does not mutate the DOM. Use the sitemap helpers to choose maps and
100
+ resolve unit hotspots, then render the overlay with the frontend framework of
101
+ your choice.
102
+
103
+ ```ts
104
+ import {
105
+ getShapeForUnit,
106
+ getSitemapMapAssetPlan,
107
+ resolveInitialSitemapMap,
108
+ } from "@grupolapa/cotizador-sdk/sitemap";
109
+
110
+ const map = resolveInitialSitemapMap(sitemap);
111
+ if (map) {
112
+ const assets = getSitemapMapAssetPlan(map);
113
+ const shape = getShapeForUnit(map, selectedUnitId);
114
+ }
115
+ ```
116
+
117
+ ## Exports
118
+
119
+ - `@grupolapa/cotizador-sdk`: browser-safe client, public types, and
120
+ `CotizadorApiError`.
121
+ - `@grupolapa/cotizador-sdk/server`: trusted server API-key client.
122
+ - `@grupolapa/cotizador-sdk/quote`: quote, selection, payment, inventory,
123
+ formatting, Entrada/action, and product helpers.
124
+ - `@grupolapa/cotizador-sdk/sitemap`: sitemap asset, hotspot, and shape helpers.
125
+ - `@grupolapa/cotizador-sdk/types`: domain and public REST TypeScript types.
@@ -0,0 +1,2 @@
1
+ import type { QuoteResult } from "./types.js";
2
+ export declare function aggregateQuoteResults(quotes: QuoteResult[]): QuoteResult;
@@ -0,0 +1,160 @@
1
+ function sumBy(items, getValue) {
2
+ return items.reduce((total, item) => total + getValue(item), 0);
3
+ }
4
+ function roundPct(value) {
5
+ return Math.round(value * 100) / 100;
6
+ }
7
+ function getTimelineRowAtOrAfterDelivery(quote, month) {
8
+ return (quote.timelineRows[month] ??
9
+ quote.timelineRows.at(-1) ??
10
+ quote.timelineRows[0]);
11
+ }
12
+ function joinLabels(labels) {
13
+ const uniqueLabels = [...new Set(labels.filter(Boolean))];
14
+ if (uniqueLabels.length === 0) {
15
+ return "—";
16
+ }
17
+ if (uniqueLabels.length === 1) {
18
+ return uniqueLabels[0];
19
+ }
20
+ return uniqueLabels.join(" + ");
21
+ }
22
+ function aggregateTimelineRows(quotes) {
23
+ const maxDeliveryMonths = Math.max(...quotes.map((quote) => quote.deliveryMonths));
24
+ return Array.from({ length: maxDeliveryMonths + 1 }, (_, month) => {
25
+ const exactRows = quotes
26
+ .map((quote) => quote.timelineRows[month] ?? null)
27
+ .filter((row) => Boolean(row));
28
+ const accumulatedRows = quotes.map((quote) => getTimelineRowAtOrAfterDelivery(quote, month));
29
+ const paymentLabels = exactRows
30
+ .filter((row) => row.paymentMXN > 0.005)
31
+ .map((row) => row.paymentLabel);
32
+ const milestoneLabels = exactRows
33
+ .filter((row) => row.isMilestone)
34
+ .map((row) => row.milestoneLabel);
35
+ return {
36
+ month,
37
+ paymentMXN: sumBy(exactRows, (row) => row.paymentMXN),
38
+ paymentLabel: joinLabels(paymentLabels),
39
+ cashbackMXN: sumBy(exactRows, (row) => row.cashbackMXN),
40
+ plusvaliaDeltaMXN: sumBy(exactRows, (row) => row.plusvaliaDeltaMXN),
41
+ accumulatedCashbackMXN: sumBy(accumulatedRows, (row) => row.accumulatedCashbackMXN),
42
+ accumulatedPlusvaliaMXN: sumBy(accumulatedRows, (row) => row.accumulatedPlusvaliaMXN),
43
+ assetValueMXN: sumBy(accumulatedRows, (row) => row.assetValueMXN),
44
+ isMilestone: milestoneLabels.length > 0,
45
+ milestoneLabel: joinLabels(milestoneLabels),
46
+ };
47
+ });
48
+ }
49
+ function aggregateProjectionPoints(quotes) {
50
+ const maxYearOffset = Math.max(...quotes.map((quote) => quote.projectionPoints.at(-1)?.yearOffset ?? 0));
51
+ return Array.from({ length: maxYearOffset + 1 }, (_, yearOffset) => {
52
+ const points = quotes.map((quote) => quote.projectionPoints.find((point) => point.yearOffset === yearOffset) ??
53
+ quote.projectionPoints.at(-1) ??
54
+ quote.projectionPoints[0]);
55
+ return {
56
+ yearOffset,
57
+ label: yearOffset === 0 ? "Entrega" : `Año +${yearOffset}`,
58
+ assetValueMXN: sumBy(points, (point) => point.assetValueMXN),
59
+ rentalAccumulatedMXN: sumBy(points, (point) => point.rentalAccumulatedMXN),
60
+ valueWithRentMXN: sumBy(points, (point) => point.valueWithRentMXN),
61
+ };
62
+ });
63
+ }
64
+ function aggregateBreakdownItems(items) {
65
+ const itemMap = new Map();
66
+ for (const item of items) {
67
+ const existing = itemMap.get(item.label);
68
+ if (existing) {
69
+ existing.valueMXN += item.valueMXN;
70
+ }
71
+ else {
72
+ itemMap.set(item.label, { ...item });
73
+ }
74
+ }
75
+ return [...itemMap.values()];
76
+ }
77
+ function countTimelineRows(rows, matchesLabel) {
78
+ return rows.filter((row) => row.paymentMXN > 0.005 && matchesLabel(row.paymentLabel)).length;
79
+ }
80
+ function sumTimelinePayments(rows, matchesLabel) {
81
+ return sumBy(rows.filter((row) => row.paymentMXN > 0.005 && matchesLabel(row.paymentLabel)), (row) => row.paymentMXN);
82
+ }
83
+ function labelIncludes(label, search) {
84
+ return label.toLocaleLowerCase("es-MX").includes(search);
85
+ }
86
+ export function aggregateQuoteResults(quotes) {
87
+ if (quotes.length === 0) {
88
+ throw new Error("At least one quote is required to aggregate a schedule.");
89
+ }
90
+ const [representativeQuote] = quotes;
91
+ const timelineRows = aggregateTimelineRows(quotes);
92
+ const projectionPoints = aggregateProjectionPoints(quotes);
93
+ const listPriceMXN = sumBy(quotes, (quote) => quote.listPriceMXN);
94
+ const downPaymentAmountMXN = sumBy(quotes, (quote) => quote.downPaymentAmountMXN);
95
+ const balanceAmountMXN = sumBy(quotes, (quote) => quote.balanceAmountMXN);
96
+ const cashbackTotalMXN = sumBy(quotes, (quote) => quote.cashbackTotalMXN);
97
+ const installmentDiscountMXN = sumBy(quotes, (quote) => quote.installmentDiscountMXN);
98
+ const netCostMXN = sumBy(quotes, (quote) => quote.netCostMXN);
99
+ const monthlyPaymentCount = countTimelineRows(timelineRows, (label) => labelIncludes(label, "mensualidad"));
100
+ const monthlyPaymentTotalMXN = sumTimelinePayments(timelineRows, (label) => labelIncludes(label, "mensualidad"));
101
+ const deferredPaymentsCount = countTimelineRows(timelineRows, (label) => labelIncludes(label, "anualidad"));
102
+ const deferredPaymentsTotalMXN = sumTimelinePayments(timelineRows, (label) => labelIncludes(label, "anualidad"));
103
+ const deliveryPaymentAmountMXN = sumTimelinePayments(timelineRows, (label) => labelIncludes(label, "contra entrega"));
104
+ const finalTimelineRow = timelineRows.at(-1);
105
+ const finalProjectionPoint = projectionPoints.at(-1);
106
+ return {
107
+ ...representativeQuote,
108
+ listPriceMXN,
109
+ deliveryMonths: finalTimelineRow.month,
110
+ cashbackMonths: Math.max(...quotes.map((quote) => quote.cashbackMonths)),
111
+ isCashbackCapped: quotes.some((quote) => quote.isCashbackCapped),
112
+ downPaymentPct: listPriceMXN > 0
113
+ ? roundPct((downPaymentAmountMXN / listPriceMXN) * 100)
114
+ : 0,
115
+ downPaymentAmountMXN,
116
+ balanceAmountMXN,
117
+ cashbackMonthlyMXN: sumBy(quotes, (quote) => quote.cashbackMonthlyMXN),
118
+ cashbackTotalMXN,
119
+ installmentDiscountPct: listPriceMXN > 0
120
+ ? roundPct((installmentDiscountMXN / listPriceMXN) * 100)
121
+ : 0,
122
+ installmentDiscountMXN,
123
+ monthlyPaymentPct: listPriceMXN > 0
124
+ ? roundPct((monthlyPaymentTotalMXN / listPriceMXN) * 100)
125
+ : 0,
126
+ monthlyPaymentCount,
127
+ monthlyPaymentAmountMXN: monthlyPaymentCount > 0
128
+ ? monthlyPaymentTotalMXN / monthlyPaymentCount
129
+ : 0,
130
+ deferredPaymentPct: listPriceMXN > 0
131
+ ? roundPct((deferredPaymentsTotalMXN / listPriceMXN) * 100)
132
+ : 0,
133
+ deferredPaymentsTotalMXN,
134
+ deferredPaymentsCount,
135
+ deferredPaymentMonths: timelineRows
136
+ .filter((row) => row.paymentMXN > 0.005 &&
137
+ labelIncludes(row.paymentLabel, "anualidad"))
138
+ .map((row) => row.month),
139
+ deferredPaymentAmountMXN: deferredPaymentsCount > 0
140
+ ? deferredPaymentsTotalMXN / deferredPaymentsCount
141
+ : 0,
142
+ deliveryPaymentPct: listPriceMXN > 0
143
+ ? roundPct((deliveryPaymentAmountMXN / listPriceMXN) * 100)
144
+ : 0,
145
+ deliveryPaymentAmountMXN,
146
+ netCostMXN,
147
+ plusvaliaAtDeliveryMXN: finalTimelineRow.accumulatedPlusvaliaMXN,
148
+ plusvaliaPostDeliveryMXN: sumBy(quotes, (quote) => quote.plusvaliaPostDeliveryMXN),
149
+ totalGainMXN: sumBy(quotes, (quote) => quote.totalGainMXN),
150
+ finalAssetValueMXN: finalTimelineRow.assetValueMXN,
151
+ projectedAssetValueMXN: finalProjectionPoint.assetValueMXN,
152
+ rentalMonthlyMXN: sumBy(quotes, (quote) => quote.rentalMonthlyMXN),
153
+ rentalAccumulatedMXN: finalProjectionPoint.rentalAccumulatedMXN,
154
+ projectionPoints,
155
+ timelineRows,
156
+ visibleTimelineRows: timelineRows.filter((row) => row.paymentMXN > 0.005 || row.isMilestone),
157
+ costBreakdown: aggregateBreakdownItems(quotes.flatMap((quote) => quote.costBreakdown)),
158
+ gainBreakdown: aggregateBreakdownItems(quotes.flatMap((quote) => quote.gainBreakdown)),
159
+ };
160
+ }
@@ -0,0 +1,2 @@
1
+ import type { FinanceRules, InventoryUnit, PaymentSchemeDefinition, QuoteInput, QuoteResult } from "./types.js";
2
+ export declare function calculateQuote(unit: InventoryUnit, input: QuoteInput, scheme: PaymentSchemeDefinition, rules: FinanceRules): QuoteResult;
@@ -0,0 +1,382 @@
1
+ import { getFinanceRulesRentalRateForUnit, getUnitEstimatedRentalAnnualMXN, getUnitEstimatedRentalMonthlyMXN, getUnitDeliveryMonths, getUnitListPriceMXN, isCashbackScheme, isInstallmentScheme, MAX_CASHBACK_MONTHS, } from "./payment-schemes.js";
2
+ import { getInstallmentAllocation, getInstallmentDiscountPct, INSTALLMENT_DEFAULT_DOWN_PAYMENT_PCT, INSTALLMENT_DEFAULT_MONTHLY_PCT, } from "./installment-allocation.js";
3
+ import { getEscalatedAnnualAmountMXN, isCorporateRentalUnit, } from "./corporate-rent.js";
4
+ import { applyIvaModeMXN } from "./tax.js";
5
+ function getVisibleTimelineRows(rows, deferredPaymentMonths, deliveryMonths, isCashback, cashbackMonths) {
6
+ const visibleMonths = new Set([
7
+ 0,
8
+ 1,
9
+ 2,
10
+ 3,
11
+ Math.round(deliveryMonths / 2),
12
+ deliveryMonths,
13
+ ]);
14
+ for (let year = 1; year <= Math.ceil(deliveryMonths / 12); year += 1) {
15
+ visibleMonths.add(year * 12);
16
+ }
17
+ if (isCashback) {
18
+ visibleMonths.add(cashbackMonths);
19
+ }
20
+ else {
21
+ deferredPaymentMonths.forEach((month) => visibleMonths.add(month));
22
+ }
23
+ return [...visibleMonths]
24
+ .filter((month) => month >= 0 && month <= deliveryMonths)
25
+ .sort((left, right) => left - right)
26
+ .map((month) => rows[month]);
27
+ }
28
+ export function calculateQuote(unit, input, scheme, rules) {
29
+ const listPriceMXN = applyIvaModeMXN(getUnitListPriceMXN(unit), input.includeIva);
30
+ const plusvaliaRate = input.plusvaliaAnnualPct / 100;
31
+ const plusvaliaMonthlyRate = plusvaliaRate / 12;
32
+ const deliveryMonths = getUnitDeliveryMonths(unit);
33
+ const isCashback = isCashbackScheme(scheme);
34
+ const cashbackMonths = isCashback
35
+ ? Math.min(deliveryMonths, MAX_CASHBACK_MONTHS)
36
+ : 0;
37
+ const isCashbackCapped = isCashback && cashbackMonths < deliveryMonths;
38
+ const installmentAllocation = isInstallmentScheme(scheme)
39
+ ? getInstallmentAllocation(input.installmentDownPaymentPct ?? INSTALLMENT_DEFAULT_DOWN_PAYMENT_PCT, input.installmentMonthlyPct ?? INSTALLMENT_DEFAULT_MONTHLY_PCT)
40
+ : null;
41
+ const downPaymentPct = isCashback
42
+ ? input.downPaymentPct
43
+ : (installmentAllocation?.downPaymentPct ?? 0);
44
+ const downPaymentAmountMXN = listPriceMXN * (downPaymentPct / 100);
45
+ const balanceAmountMXN = listPriceMXN - downPaymentAmountMXN;
46
+ const cashbackMonthlyMXN = isCashback
47
+ ? (downPaymentAmountMXN * scheme.cashbackRate) / 12
48
+ : 0;
49
+ const cashbackTotalMXN = cashbackMonthlyMXN * cashbackMonths;
50
+ const rawMonthlyPaymentPct = installmentAllocation?.monthlyPct ?? 0;
51
+ const rawDeferredPaymentPct = installmentAllocation?.deferredPct ?? 0;
52
+ const preDeliveryPaymentMonths = Math.max(deliveryMonths - 1, 0);
53
+ const deferredPaymentsCount = installmentAllocation && rawDeferredPaymentPct > 0
54
+ ? Math.floor(preDeliveryPaymentMonths / 12)
55
+ : 0;
56
+ const deferredPaymentMonths = Array.from({ length: deferredPaymentsCount }, (_, index) => (index + 1) * 12);
57
+ const deferredPaymentMonthSet = new Set(deferredPaymentMonths);
58
+ const monthlyPaymentMonths = installmentAllocation && rawMonthlyPaymentPct > 0
59
+ ? Array.from({ length: preDeliveryPaymentMonths }, (_, index) => index + 1).filter((month) => !deferredPaymentMonthSet.has(month))
60
+ : [];
61
+ const monthlyPaymentCount = monthlyPaymentMonths.length;
62
+ const monthlyPaymentMonthSet = new Set(monthlyPaymentMonths);
63
+ const monthlyPaymentPct = monthlyPaymentCount > 0 ? rawMonthlyPaymentPct : 0;
64
+ const monthlyPctRolledToDelivery = rawMonthlyPaymentPct - monthlyPaymentPct;
65
+ const monthlyPaymentTotalMXN = listPriceMXN * (monthlyPaymentPct / 100);
66
+ const monthlyPaymentAmountMXN = monthlyPaymentCount > 0 ? monthlyPaymentTotalMXN / monthlyPaymentCount : 0;
67
+ const deferredPaymentPct = deferredPaymentsCount > 0 ? rawDeferredPaymentPct : 0;
68
+ const deferredPctRolledToDelivery = rawDeferredPaymentPct - deferredPaymentPct;
69
+ const deferredPaymentsTotalMXN = listPriceMXN * (deferredPaymentPct / 100);
70
+ const deferredPaymentAmountMXN = deferredPaymentsCount > 0
71
+ ? deferredPaymentsTotalMXN / deferredPaymentsCount
72
+ : 0;
73
+ const deliveryPaymentPct = installmentAllocation
74
+ ? installmentAllocation.deliveryPct +
75
+ monthlyPctRolledToDelivery +
76
+ deferredPctRolledToDelivery
77
+ : 100 - downPaymentPct;
78
+ const deliveryPaymentAmountMXN = installmentAllocation
79
+ ? listPriceMXN * (deliveryPaymentPct / 100)
80
+ : balanceAmountMXN;
81
+ const installmentDiscountPct = installmentAllocation
82
+ ? getInstallmentDiscountPct(installmentAllocation.downPaymentPct)
83
+ : 0;
84
+ const installmentDiscountMXN = listPriceMXN * (installmentDiscountPct / 100);
85
+ let installmentDiscountRemainingMXN = installmentDiscountMXN;
86
+ const deliveryDiscountMXN = Math.min(deliveryPaymentAmountMXN, installmentDiscountRemainingMXN);
87
+ installmentDiscountRemainingMXN -= deliveryDiscountMXN;
88
+ const adjustedDeliveryAmountMXN = deliveryPaymentAmountMXN - deliveryDiscountMXN;
89
+ const deferredDiscountMXN = Math.min(deferredPaymentsTotalMXN, installmentDiscountRemainingMXN);
90
+ installmentDiscountRemainingMXN -= deferredDiscountMXN;
91
+ const adjustedDeferredTotalMXN = deferredPaymentsTotalMXN - deferredDiscountMXN;
92
+ const adjustedDeferredAmountMXN = deferredPaymentsCount > 0
93
+ ? adjustedDeferredTotalMXN / deferredPaymentsCount
94
+ : 0;
95
+ const monthlyTotalGrossMXN = monthlyPaymentAmountMXN * monthlyPaymentCount;
96
+ const monthlyDiscountMXN = Math.min(monthlyTotalGrossMXN, installmentDiscountRemainingMXN);
97
+ const adjustedMonthlyTotalMXN = monthlyTotalGrossMXN - monthlyDiscountMXN;
98
+ const adjustedMonthlyAmountMXN = monthlyPaymentCount > 0 ? adjustedMonthlyTotalMXN / monthlyPaymentCount : 0;
99
+ const milestones = {};
100
+ if (isCashback) {
101
+ milestones[1] = rules.milestones.cashbackStart;
102
+ milestones[Math.round(deliveryMonths / 2)] = rules.milestones.midpoint;
103
+ if (isCashbackCapped) {
104
+ milestones[cashbackMonths] = "Último cashback";
105
+ }
106
+ milestones[deliveryMonths] = rules.milestones.delivery;
107
+ }
108
+ else {
109
+ deferredPaymentMonths.forEach((month) => {
110
+ milestones[month] =
111
+ `${rules.milestones.annualityPrefix} ${deferredPaymentAmountMXN.toLocaleString("es-MX", {
112
+ style: "currency",
113
+ currency: "MXN",
114
+ maximumFractionDigits: 0,
115
+ })}`;
116
+ });
117
+ milestones[deliveryMonths] = rules.milestones.delivery;
118
+ }
119
+ const timelineRows = [
120
+ {
121
+ month: 0,
122
+ paymentMXN: downPaymentAmountMXN,
123
+ paymentLabel: "Enganche",
124
+ cashbackMXN: 0,
125
+ plusvaliaDeltaMXN: 0,
126
+ accumulatedCashbackMXN: 0,
127
+ accumulatedPlusvaliaMXN: 0,
128
+ assetValueMXN: listPriceMXN,
129
+ isMilestone: true,
130
+ milestoneLabel: "Enganche",
131
+ },
132
+ ];
133
+ let previousAssetValueMXN = listPriceMXN;
134
+ let accumulatedCashbackMXN = 0;
135
+ let accumulatedPlusvaliaMXN = 0;
136
+ for (let month = 1; month <= deliveryMonths; month += 1) {
137
+ const assetValueMXN = listPriceMXN * Math.pow(1 + plusvaliaMonthlyRate, month);
138
+ const plusvaliaDeltaMXN = assetValueMXN - previousAssetValueMXN;
139
+ accumulatedPlusvaliaMXN += plusvaliaDeltaMXN;
140
+ const cashbackForMonthMXN = isCashback && month <= cashbackMonths ? cashbackMonthlyMXN : 0;
141
+ let paymentMXN = 0;
142
+ let paymentLabel = "—";
143
+ if (isCashback) {
144
+ if (month === deliveryMonths) {
145
+ paymentMXN = balanceAmountMXN;
146
+ paymentLabel = "Saldo contra entrega";
147
+ }
148
+ }
149
+ else if (installmentAllocation) {
150
+ const hasMonthlyPaymentForMonth = monthlyPaymentPct > 0 && monthlyPaymentMonthSet.has(month);
151
+ const hasDeferredPaymentForMonth = deferredPaymentMonthSet.has(month);
152
+ if (hasMonthlyPaymentForMonth) {
153
+ paymentMXN += adjustedMonthlyAmountMXN;
154
+ paymentLabel = "Mensualidad";
155
+ }
156
+ if (hasDeferredPaymentForMonth) {
157
+ paymentMXN += adjustedDeferredAmountMXN;
158
+ paymentLabel = "Anualidad";
159
+ }
160
+ if (month === deliveryMonths && adjustedDeliveryAmountMXN > 0) {
161
+ paymentMXN += adjustedDeliveryAmountMXN;
162
+ paymentLabel = `Contra entrega ${deliveryPaymentPct}%`;
163
+ }
164
+ }
165
+ accumulatedCashbackMXN += cashbackForMonthMXN;
166
+ timelineRows.push({
167
+ month,
168
+ paymentMXN,
169
+ paymentLabel,
170
+ cashbackMXN: cashbackForMonthMXN,
171
+ plusvaliaDeltaMXN,
172
+ accumulatedCashbackMXN,
173
+ accumulatedPlusvaliaMXN,
174
+ assetValueMXN,
175
+ isMilestone: Boolean(milestones[month]),
176
+ milestoneLabel: milestones[month] ?? "",
177
+ });
178
+ previousAssetValueMXN = assetValueMXN;
179
+ }
180
+ const finalTimelineRow = timelineRows.at(-1);
181
+ if (!finalTimelineRow) {
182
+ throw new Error("Unable to build the quote timeline.");
183
+ }
184
+ const projectionPoints = [
185
+ {
186
+ yearOffset: 0,
187
+ label: "Entrega",
188
+ assetValueMXN: finalTimelineRow.assetValueMXN,
189
+ rentalAccumulatedMXN: 0,
190
+ valueWithRentMXN: finalTimelineRow.assetValueMXN,
191
+ },
192
+ ];
193
+ let projectedAssetValueMXN = finalTimelineRow.assetValueMXN;
194
+ let rentalAccumulatedMXN = 0;
195
+ const rentalRate = getFinanceRulesRentalRateForUnit(unit, rules);
196
+ const baseRentalAnnualMXN = getUnitEstimatedRentalAnnualMXN(unit, rentalRate, input.includeIva);
197
+ const usesCorporateRentalInflation = isCorporateRentalUnit(unit);
198
+ for (let year = 1; year <= input.postDeliveryYears; year += 1) {
199
+ projectedAssetValueMXN *= 1 + plusvaliaRate;
200
+ rentalAccumulatedMXN += usesCorporateRentalInflation
201
+ ? getEscalatedAnnualAmountMXN(baseRentalAnnualMXN, rules.corporateInflationRate, year)
202
+ : projectedAssetValueMXN * rentalRate;
203
+ projectionPoints.push({
204
+ yearOffset: year,
205
+ label: `Año +${year}`,
206
+ assetValueMXN: projectedAssetValueMXN,
207
+ rentalAccumulatedMXN,
208
+ valueWithRentMXN: projectedAssetValueMXN + rentalAccumulatedMXN,
209
+ });
210
+ }
211
+ const netCostMXN = isCashback
212
+ ? listPriceMXN - cashbackTotalMXN
213
+ : listPriceMXN - installmentDiscountMXN;
214
+ const plusvaliaAtDeliveryMXN = finalTimelineRow.accumulatedPlusvaliaMXN;
215
+ const plusvaliaPostDeliveryMXN = projectedAssetValueMXN - finalTimelineRow.assetValueMXN;
216
+ const totalGainMXN = plusvaliaAtDeliveryMXN +
217
+ plusvaliaPostDeliveryMXN +
218
+ cashbackTotalMXN +
219
+ installmentDiscountMXN +
220
+ rentalAccumulatedMXN;
221
+ const cashbackTotalLabel = isCashbackCapped
222
+ ? `Cashback total recibido (${cashbackMonths} meses)`
223
+ : "Cashback total recibido";
224
+ const costBreakdown = isCashback
225
+ ? [
226
+ {
227
+ label: `Enganche (${downPaymentPct}%)`,
228
+ valueMXN: -downPaymentAmountMXN,
229
+ tone: "negative",
230
+ },
231
+ {
232
+ label: `Saldo contra entrega (${100 - downPaymentPct}%)`,
233
+ valueMXN: -balanceAmountMXN,
234
+ tone: "negative",
235
+ },
236
+ {
237
+ label: cashbackTotalLabel,
238
+ valueMXN: cashbackTotalMXN,
239
+ tone: "auric",
240
+ },
241
+ {
242
+ label: "Costo neto",
243
+ valueMXN: -netCostMXN,
244
+ tone: "neutral",
245
+ },
246
+ ]
247
+ : installmentAllocation
248
+ ? [
249
+ {
250
+ label: `Enganche inicial (${installmentAllocation.downPaymentPct}%)`,
251
+ valueMXN: -downPaymentAmountMXN,
252
+ tone: "negative",
253
+ },
254
+ ...(monthlyPaymentPct > 0
255
+ ? [
256
+ {
257
+ label: `Mensualidades (${monthlyPaymentPct}%)`,
258
+ valueMXN: -monthlyPaymentTotalMXN,
259
+ tone: "negative",
260
+ },
261
+ ]
262
+ : []),
263
+ ...(deferredPaymentPct > 0
264
+ ? [
265
+ {
266
+ label: `Anualidades diferidas (${deferredPaymentPct}%)`,
267
+ valueMXN: -deferredPaymentsTotalMXN,
268
+ tone: "negative",
269
+ },
270
+ ]
271
+ : []),
272
+ ...(deliveryPaymentPct > 0
273
+ ? [
274
+ {
275
+ label: `Contra entrega (${deliveryPaymentPct}%)`,
276
+ valueMXN: -deliveryPaymentAmountMXN,
277
+ tone: "negative",
278
+ },
279
+ ]
280
+ : []),
281
+ ...(installmentDiscountMXN > 0
282
+ ? [
283
+ {
284
+ label: `Descuento por enganche (${installmentDiscountPct}%)`,
285
+ valueMXN: installmentDiscountMXN,
286
+ tone: "auric",
287
+ },
288
+ ]
289
+ : []),
290
+ {
291
+ label: "Total invertido",
292
+ valueMXN: -netCostMXN,
293
+ tone: "neutral",
294
+ },
295
+ ]
296
+ : [];
297
+ const gainBreakdown = [
298
+ {
299
+ label: "Plusvalía acumulada al escriturar",
300
+ valueMXN: plusvaliaAtDeliveryMXN,
301
+ tone: "positive",
302
+ },
303
+ {
304
+ label: "Plusvalía posterior a la escritura",
305
+ valueMXN: plusvaliaPostDeliveryMXN,
306
+ tone: "positive",
307
+ },
308
+ ...(isCashback
309
+ ? [
310
+ {
311
+ label: cashbackTotalLabel,
312
+ valueMXN: cashbackTotalMXN,
313
+ tone: "auric",
314
+ },
315
+ ]
316
+ : []),
317
+ ...(!isCashback && installmentDiscountMXN > 0
318
+ ? [
319
+ {
320
+ label: `Descuento por enganche (${installmentDiscountPct}%)`,
321
+ valueMXN: installmentDiscountMXN,
322
+ tone: "auric",
323
+ },
324
+ ]
325
+ : []),
326
+ {
327
+ label: "Valor proyectado del activo",
328
+ valueMXN: projectedAssetValueMXN,
329
+ tone: "neutral",
330
+ },
331
+ {
332
+ label: "Renta acumulada proyectada",
333
+ valueMXN: rentalAccumulatedMXN,
334
+ tone: "positive",
335
+ },
336
+ {
337
+ label: "Ganancia total estimada",
338
+ valueMXN: totalGainMXN,
339
+ tone: "positive",
340
+ },
341
+ ];
342
+ return {
343
+ unit,
344
+ scheme,
345
+ input,
346
+ listPriceMXN,
347
+ deliveryMonths,
348
+ isCashback,
349
+ cashbackMonths,
350
+ isCashbackCapped,
351
+ downPaymentPct,
352
+ downPaymentAmountMXN,
353
+ balanceAmountMXN,
354
+ cashbackMonthlyMXN,
355
+ cashbackTotalMXN,
356
+ installmentDiscountPct,
357
+ installmentDiscountMXN,
358
+ monthlyPaymentPct,
359
+ monthlyPaymentCount,
360
+ monthlyPaymentAmountMXN,
361
+ deferredPaymentPct,
362
+ deferredPaymentsTotalMXN,
363
+ deferredPaymentsCount,
364
+ deferredPaymentMonths,
365
+ deferredPaymentAmountMXN,
366
+ deliveryPaymentPct,
367
+ deliveryPaymentAmountMXN,
368
+ netCostMXN,
369
+ plusvaliaAtDeliveryMXN,
370
+ plusvaliaPostDeliveryMXN,
371
+ totalGainMXN,
372
+ finalAssetValueMXN: finalTimelineRow.assetValueMXN,
373
+ projectedAssetValueMXN,
374
+ rentalMonthlyMXN: getUnitEstimatedRentalMonthlyMXN(unit, rentalRate, input.includeIva),
375
+ rentalAccumulatedMXN,
376
+ projectionPoints,
377
+ timelineRows,
378
+ visibleTimelineRows: getVisibleTimelineRows(timelineRows, deferredPaymentMonths, deliveryMonths, isCashback, cashbackMonths),
379
+ costBreakdown,
380
+ gainBreakdown,
381
+ };
382
+ }