@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.
- package/README.md +125 -0
- package/dist/bundle-payment-schedule.calc.d.ts +2 -0
- package/dist/bundle-payment-schedule.calc.js +160 -0
- package/dist/calculate-quote.d.ts +2 -0
- package/dist/calculate-quote.js +382 -0
- package/dist/corporate-actions.d.ts +95 -0
- package/dist/corporate-actions.js +87 -0
- package/dist/corporate-rent.d.ts +5 -0
- package/dist/corporate-rent.js +43 -0
- package/dist/delivery-date.d.ts +6 -0
- package/dist/delivery-date.js +22 -0
- package/dist/entrada.d.ts +111 -0
- package/dist/entrada.js +139 -0
- package/dist/format.d.ts +7 -0
- package/dist/format.js +37 -0
- package/dist/http.d.ts +28 -0
- package/dist/http.js +133 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +22 -0
- package/dist/installment-allocation.d.ts +30 -0
- package/dist/installment-allocation.js +69 -0
- package/dist/inventory-availability.d.ts +10 -0
- package/dist/inventory-availability.js +105 -0
- package/dist/live-inventory-availability.d.ts +16 -0
- package/dist/live-inventory-availability.js +88 -0
- package/dist/payment-schemes.d.ts +22 -0
- package/dist/payment-schemes.js +99 -0
- package/dist/post-delivery-value.d.ts +3 -0
- package/dist/post-delivery-value.js +61 -0
- package/dist/product-types.d.ts +16 -0
- package/dist/product-types.js +82 -0
- package/dist/query-state.d.ts +5 -0
- package/dist/query-state.js +157 -0
- package/dist/quote-outcome-graph.d.ts +31 -0
- package/dist/quote-outcome-graph.js +113 -0
- package/dist/quote.d.ts +19 -0
- package/dist/quote.js +18 -0
- package/dist/selection-state.d.ts +34 -0
- package/dist/selection-state.js +156 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +17 -0
- package/dist/sitemap.d.ts +22 -0
- package/dist/sitemap.js +45 -0
- package/dist/social-quote.d.ts +8 -0
- package/dist/social-quote.js +51 -0
- package/dist/tax.d.ts +5 -0
- package/dist/tax.js +11 -0
- package/dist/types.d.ts +502 -0
- package/dist/types.js +1 -0
- 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,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,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
|
+
}
|