# Short Stay — the Airbnb tab built on real operator payouts

**Module:** `src/ShortStay` · **Surface:** the **Airbnb** tab inside Investment
Analysis on [Project Detail](/docs/modules_handbook/main/project-detail/readMe.md)
(`GET /{country}/projects/{slug}` → `#invest/airbnb`)

## What it does

Answers "what would this unit actually earn on Airbnb, and what does that leave
me after the loan" from a short-stay **operator's own payment records** — the
money it really transferred to owners, month by month — instead of from scraped
listings.

That distinction is the entire module. AirDNA-style rentalizers multiply
occupancy by nightly rate because they have nothing better. Where a programme
exists here we have 44 months of actual payments, so the ledger is the engine
and occupancy/ADR are demoted to evidence shown alongside it. For ViiA the two
approaches disagree by roughly **2×** in every month on file (2025: 75.1% at
RM 477/night implies ~RM 10,900 per unit per month; the ledger over the same
managed area gives ~RM 5,400), which is why nothing on the tab is ever derived
from the reported demand figures.

The tab shows, in this order:

1. **Which operator unit type this layout reports under** — a stored mapping.
2. **Which end of the projection to plan on** — Minimum · Average · Maximum.
3. **Both renovation packages**, always side by side, with each one's last-12-
   months actual (or last-6, see below) and next-12-months projection.
4. **A statement** — expected annual income and its range, the shared loan
   instalment, monthly cashflow, gross ROI, and the same unit's plain
   long-term-tenancy return for comparison.
5. **Every month the operator paid**, with the projection dashed inside a band,
   ruled off at the month the evidence stops.
6. **Occupancy and the implied nightly rate**, evidence-only.

**The whole-building P&L tiles are gone** (2026-09-07, on request). Gross
revenue, square feet under management and the operator's share are the
OPERATOR's business, not this unit's, and the occupancy tile repeated the chart
directly above it. `ShortStayReport::building()` still sends `ttm`; nothing
renders it.

### The scenario control (2026-09-07)

`SCENARIOS` is one segmented control above the package cards, and **everything
downstream follows it** — both package cards, all three headline figures, the
yield comparison bars, the ledger *and* the dashed line on the history chart. A
scenario that moved the yield but not the chart would put two answers to one
question on one screen, which is the failure this whole panel exists to avoid.

**Minimum and Maximum are the forecast's own band edges, not a floor and not a
ceiling** — `annual_low` / `annual_high`, ±1 SD of the log residual, about two
months in three inside. The page says so directly under the control, because a
"maximum" a buyer reads as a ceiling would be the one figure on this tab that
promises what the ledger cannot. Where a package has no forecast the range falls
back to twelve times the trailing window's 10th / 90th percentile MONTH — "twelve
months like the weak ones", a different claim, and the code says which is which.

The spread is the point. On ViiA A1 (RM 853,500, long-let benchmark 4.31%):

| | Signature | Premium |
|---|---|---|
| Minimum | RM 37,212 · **4.36%** (+0.05 pts) | RM 49,163 · **5.76%** |
| Average | RM 42,221 · **4.95%** (+0.64 pts) | RM 54,306 · **6.36%** |
| Maximum | RM 47,905 · **5.61%** (+1.30 pts) | RM 59,987 · **7.03%** |

Signature on a weak year is a **coin flip against simply letting the unit** —
+0.05 points — which the single central figure the panel used to print could not
say. Premium wins on every scenario. That is the answer a buyer came for, and it
only exists because the range is selectable rather than a footnote.

### The actuals window — 12 or 6 months (2026-09-27)

A second segmented control beside *Plan on* — **Actual paid, last · 12 months |
6 months** — sets how far back every ACTUAL figure looks: the package cards'
*last N months actual … a month* line, and the ledger's *Last N months, actually
paid*, *Average month, actually paid* (with its ×12 annualised figure and the
yield that would be on the quoted price) and *A normal month* percentile band.
Asked for by a reader who wanted to see the average over the past six months as
well as twelve.

**The projection does not follow it.** Minimum / Average / Maximum, the hero,
the yield bars and the chart's dashed line are fitted to the whole series with
the seasons divided out; a six-month window is a fact about the recent past that
the forecast already contains. The two averages answer two questions — *what has
it been paying lately* against *what should I plan on* — and the caption that
appears under the ledger at 6 months says why the shorter one is not to be
quoted on its own: half the year's seasons are missing and one odd month (July
2026, +59% building revenue) moves it twice as far.

**Computed in the browser**, not the report. `report.series` already carries
every month, so `trailing(values, count)` in `ProjectInvestmentStr.vue` takes
the last `count` PAID months (gaps skipped, as `summarise()` does), sums them,
averages them and reads the same `floor(0.1·(n−1))` / `ceil(0.9·(n−1))`
percentiles. Verified on ViiA Type A: the 12-month reading reproduces the
backend's `ttm_total` / `ttm_average` / `ttm_low` / `ttm_high` exactly, so no
`CACHE_VERSION` bump was needed. `ShortStayReport` still sends `ttm` and
`summary.ttm_*`; the panel now reads `ttm_*` only as the no-forecast range
fallback, and `ttm.from/to` not at all — each package's own window carries its
dates, which is more accurate for a package that started late.

ViiA Type A, both windows (Jul 2026 last month on file):

| | Signature · 12 | Signature · 6 | Premium · 12 | Premium · 6 |
|---|---|---|---|---|
| Paid | RM 44,930 | RM 21,841 | RM 54,449 | RM 27,192 |
| Average month | RM 3,744 | RM 3,640 | RM 4,537 | RM 4,532 |
| Normal month | 3,134 – 4,589 | 2,698 – 5,234 | 3,686 – 5,319 | 3,275 – 6,723 |

The averages barely move; the *bands* widen — which is the honest picture of
the last half-year and exactly what the caption warns about.

### Every figure carries its period (2026-09-07)

The comparison card compared two investments **in percentage points alone** —
`Short-stay · Signature 4.95%` against `Long-term tenancy 5.32%` — then explained
the benchmark underneath as *RM 5.63 psf/month × 672 sq ft = RM 3,781 a month,
divided by the same RM 853,500*. A monthly rent divided by a price is not a
yield; the ×12 was never written down, so the one line that was supposed to make
the comparison checkable is where it stopped being checkable.

Both sides now print **a monthly and an annual figure** beside the percentage,
the footnote spells the multiplication out, and the verdict is restated in money:
*Short-stay earns RM 263 less a month than a plain tenancy — RM 3,156 a year, or
0.37 points of yield.* A yield gap in percentage points is the correct comparison
and an unreadable headline. The same rule applies upward: the hero's annual
headline carries its monthly equivalent, and so does each package card.

The bars still share one scale (`barMax`), so length remains comparable by eye,
and both sides still divide by the same `value` — see the rent-not-a-ratio rule
above, which is what makes the two lengths safe to compare at all.

### The nightly rate is worked backwards from the payments (2026-09-07)

The panel used to plot the operator's own reported ADR beside occupancy. That
figure is the **whole building's blended rate** across layouts this buyer is not
looking at, and it is the same number whose product with occupancy overstates
real income twofold. So the arithmetic now runs the other way, from the one
figure that was actually banked:

```
rate = payout ÷ occupancy ÷ 30 nights ÷ (share of a night's takings the owner keeps)
```

That last divisor is **two deductions in one**: the operator pays a night's
running costs — utilities, cleaning, linen — out of gross, then splits what is
left with the owner. Nominally 15% and an 80/20 split (0.80 × 0.85 = 68%), which
is what `ADR_OWNER_SHARE` / `ADR_AFTER_COSTS` say.

⚠️ **But this building files its own monthly P&L, and it disagrees with those
constants.** The split is **75/25 in most months** (`default_share_ratio` 0.25),
80/20 in a few, and running costs swing between **14% and 31%** of gross, not a
flat 15%. Over the trailing year the owner actually keeps **60.2% of gross, not
68%** — so the constants overstate the operator's generosity and *understate* the
rate by about 12%. The month's real `owner_net ÷ gross_revenue` is therefore used
wherever the P&L reaches, and the constants are the fallback for a programme that
files none. Same formula; only one of the two is an assumption, and the caption
on the page says which was used.

Derived Type A rates land at **RM 197–311/night** (Signature) and **RM 240–399**
(Premium) against the RM 517–648 the operator quotes building-wide — the gap is
unit mix. It stays labelled evidence: nothing above the demand panels is derived
from either series.

**Both packages are plotted** (2026-09-07, on request). The nightly rate is
precisely what separates the two products — a Premium night fetches more than a
Signature one — so drawing only the selected package left the reader inferring
the other from a yield further up the page.

**One palette for the whole panel**, in `strChart.js`: Signature slate, Premium
blue, on the payout chart and the rate chart alike, with the selected package
leading on both. A package's colour has to mean the same thing on both or the
legend is learned twice. Occupancy — a whole-building figure belonging to
neither package — moved off Premium's blue onto `BUILDING_TONE`, because sitting
on a package colour beside a two-line rate chart invited exactly the pairing it
must not suggest.

⚠️ **The `× 85% × 80%` round trip does not close, and that is the point.** Running
a shown rate back the other way with the nominal constants overshoots:
`399.09 × 88% × 30 × 68% = RM 7,164` against an actual payout of **RM 6,722.78**.
It closes on the filed ratio — `399.09 × 88% × 30 × 63.81% = RM 6,722.86`. The
opex half of the founder's estimate is nearly exact (July 2026: 14.9% against an
assumed 15%); **the split is the half that is wrong — 75/25, not 80/20**, which is
what `default_share_ratio = 0.2500` says and what 11 of the last 13 months show.
Had the page used 68%, the same payout would print **RM 374.49** instead and the
round trip would close on 68% — each basis is internally consistent, they simply
attribute a different share of the night to the operator. Open question for the
operator: whether the 80/20 refers to a different agreement, cohort or fee, in
which case the constants become the right basis and the filed ratio the wrong
one.

⚠️ **This derivation is documented HERE and nowhere else.** The nine-line caption
that used to spell it out under the charts was removed on request the same day;
the chart subtitle keeps only the load-bearing half — *implied by the payments
for one Type A · 1 bedroom on Premium* — plus the never-multiplied warning. If
the formula is ever questioned on the page, this section is the answer, and the
constants are in `ProjectInvestmentStr.vue`.

### The projection band had never actually rendered (2026-09-07)

`fill: '+1'` on the band was a **no-op from the day the panel shipped**.
`ProjectInvestmentChart.vue` registers only the controllers, scales, `Legend`
and `Tooltip` its consumers share — **Chart.js's `Filler` is not among them** —
so the minimum-to-maximum range was configured in the dataset, described in the
chart's own caption, and never once drawn. The founder spotted it from the copy
promising a range that was not there.

`Filler` is now registered **per chart**, in `StrHistoryChart`'s
`plugins: [divider, crosshair, Filler]` array, not globally in the wrapper —
`config.plugins` merges with the global registry for that instance only
(`allPlugins()`), which leaves the wrapper's other consumers, several of which
pass `fill: false` and expect a line, exactly as they were.

**Every package carries a range; only the selected one carries the FILL**
(2026-09-07, on request). A product whose uncertainty is invisible reads as the
certain one, which is the opposite of true — but two translucent fills
overlapping produce a third tone belonging to neither, and on ViiA these two
collide in **4 of Type A's 12 projected months and 7 of Type B's**. So the
unselected package draws the same two values as 1px `[3, 3]` hairlines in its own
tone: same information, no mixed fill. The `+1` fill target is positional, so a
package's high dataset must be pushed immediately before its own low.

Hovering a projected month prints one row per package —
`Signature range: RM x – RM y` — naming the same two ends the scenario control
does. Each floor dataset stays silent; a `range high` and a `range low` as two
separate tooltip rows read as two mystery series.

**Any chart added to `ProjectInvestmentChart` that needs `fill` must register
`Filler` itself.** There is no error when it does not; the fill simply does not
happen.

### Hover the column, not the dot

Chart.js defaults to `mode: 'nearest', intersect: true`, so reading a month off
these charts meant landing the pointer on a 2.5px point — a pixel hunt on a
44-month axis, and the reason the evidence panels read as decoration. Every
short-stay chart now shares `strCrosshair.js`: `CROSSHAIR_INTERACTION`
(`index` / `intersect: false`) on **both** `interaction` and `hover` — one
drives the crosshair's active elements, the other the point highlight under it —
plus a `crosshair` plugin drawing the vertical rule and a `monthTitle` tooltip
heading that says *Jul 2026* where the axis only has room for `07/26`.

### The history chart's legend names the products, and nothing else

Two entries — Signature and Premium. It carried four, each package plus a
`· projected` twin, which turned a two-product chart into a four-series one and
asked the reader to match colours across two lines to learn what the dashes
already say. Band edges and dashed twins are tagged `strLegend: false` and
filtered out of the legend.

In exchange the join is now **explicit**: a dotted vertical rule labelled
`Projected →` at the last actual month, drawn as a chart-level plugin
(`strProjectionDivider`) rather than a dataset, so it earns no legend entry, no
tooltip row and no place in the y scale's extent.

### There is no seasonality chart

A "When the money arrives" bar chart of the seasonal index opened the evidence
block until 2026-09-07. The projection already puts the seasonal shape back into
every forecast month and the history chart draws the result in ringgit; a second,
unitless 1.00-is-average chart of the same effect asked the reader to hold two
scales at once for one fact. `ShortStayReport::seasonality()` still computes and
still sends it — `SeasonalForecast` needs it — it simply has no panel. The old
heading is a forbidden string in `ProjectInvestmentStr.test.js`, which is how it
stays gone.

**This is NOT the older `ProjectInvestmentAirbnb*` family.** Those components
read `normalizeAirbnb()` (scraped listings, percentile bands, nearby
comparables), left the strip on 2026-09-03, and remain in the tree unmounted.
They are a different data source with a different contract and must not be
revived inside this panel — the whole claim here is that the income figures were
really paid. `ProjectTabsParity.test.js` enforces that they are neither imported
nor rendered by `ProjectInvestmentAnalysis.vue`.

## How it works

**The two naming schemes collide, so the mapping is stored, never inferred.**
The operator files income by its OWN unit type (`A`, `B`, `C`, `D`). Its Type B
is a two-bedroom it writes as "1+1"; its Type C is *also* a two-bedroom. Our
catalogue has A1–A6 (1 bed) and B1–B4 (2 bed). Same letters, different meanings,
and reading it the wrong way is worth about **RM 13,900 a year** to the buyer.
Nothing in either dataset resolves it, so `short_stay_floor_plans` holds an
explicit admin-set row per layout. Two consequences fall out of that one rule:

- **The menu only offers types some layout points at.** ViiA's Types C and D
  cover units that are not for sale; their data still imports (the building P&L
  needs it) but they can never reach a buyer, and can never be interpolated in
  later.
- **An unmapped layout renders an explanation, not a guess** (`v-else-if="!typeCode"`).

**The mapping deliberately does NOT live on `catalog_floor_plans`.** That table
is the shared master catalogue, which a site deploy must never ALTER
(`database/migrations/catalogue/README.md`), and the link is not a fact about
the layout — it is a fact about *one operator's agreement at one project*. A
second operator on the same building maps the same layouts its own way without
fighting over one column.

**One payload per PROJECT, deferred.** `ShortStayReport::forProject()` returns
the whole ledger, every summary, every projection, the seasonality and the rent
benchmark in a single array — 13 KB on ViiA. It is **not** per layout, because
the operator reports by type: six A-series layouts share one payment history and
switching layout only changes the price the reader divides by. Compare the
neighbouring unit analysis at ~265 KB *per layout*. It arrives as
`Inertia::defer` on a members-only tab, and `null` for guests.

**The projection: multiplicative decomposition with a damped trend**
(`Support\SeasonalForecast`). Everything it does fits in one sentence a buyer
can check — *take what was actually paid, divide out the time of year, extend
the recent direction at half its recent rate, put the time of year back*:

1. **Seasonal shape** — each month over the centred 12-month moving average
   through it, averaged per calendar month, normalised to mean 1.0. ViiA runs
   ~1.20 in December and July, ~0.80 in October. Under 24 months it falls back
   to ratio-to-annual-mean and says so in `method.basis`.
2. **Level and trend** on the deseasonalised series — level is the last twelve
   months' mean, trend is the change from the twelve before that, **damped to
   half** (`DAMPING = 0.5`). One year-on-year reading off one building is a
   single observation, not a trend line; compounding it for another twelve
   months asserts far more than the data supports. The damping is stated on the
   page, never hidden.
3. **Rebuild** — `(level + trend × distance) × seasonal index`, floored at zero.
   `distance` is `k + 5.5`, because a mean of twelve months describes the
   *middle* of that window, not its end. Getting that offset wrong tilts every
   projected month.

The band is ±1 SD of the log residual over the fitted window — log space so it
is a percentage, which is how rental income actually misses. Under 12 months of
history the method returns `null` rather than a confident guess.

**The long-term-rent benchmark is a RENT, never a stored yield percentage**, and
this is not cosmetic. The catalogue's `rental_yield` (4.06% for ViiA) carries
its own hidden denominator — RM 4,500/mo over `price_median` RM 1,331,080. Left
as a fixed percentage while the short-stay side divides by a different price, it
manufactures a short-stay win out of nothing (on the developer price basis it
invented a ~30% advantage). So `benchmark.rent_psf_month` is derived back out as
`rental_yield% × psf_median ÷ 12 ÷ 100` = RM 5.63 psf/month, and **both sides
divide by the same `value`**, so the comparison bar on the panel is safe to read
by length alone. On ViiA A1: Signature loses to long-let, Premium wins.

**One price: the price the page QUOTES.** A1 shows RM 853,500 on its unit card
and that is what the buyer pays, so that is the only number the yields divide
by. A quoted-price / market-value switch shipped here on 2026-09-07 and was
removed the same day on request: a second yield computed on the median
transacted PSF is a figure the buyer cannot reproduce from anything on screen,
and two yields for one unit is two numbers to be quoted out of context. The
rent-not-a-ratio rule above outlives the switch — it is what keeps the two sides
comparable, and it would be needed again the moment a second basis returned.
`benchmark.psf_median` is still sent (the market-value panel on the sibling tab
reads it); nothing on this panel does.

**The loan row is read-only.** `ProjectInvestmentAnalysis` already seeds
`useLoanInputs` (a module singleton) from the selected layout; this panel reads
it and never seeds it. Two owners of one piece of state is how two tabs on one
screen end up printing different instalments for the same unit.

**Import replaces a programme's ledger wholesale** rather than merging. Operators
restate months — ViiA's file disagrees with itself about January 2026 across two
sheets, with a note about a change in accounting — and a merge leaves the page
showing a blend no single document ever said.

### Two source-file traps, baked into the seeder

- **Header typos.** The statement labels Dec-2024 as `2025-12` and Dec-2025 as
  `2026-12`. Column *order* is correct, so the series is sound and only the
  labels lie. Months are derived from column position. An importer that trusts
  the cells files two Decembers under the wrong years and silently wrecks every
  year-on-year figure on the page.
- **July 2026 is a genuine outlier** — building revenue jumped 59% that month.
  It is the reason every headline is a trailing-12-month total rather than the
  latest month.

## Related files

**Backend**

| File | Role |
|---|---|
| `src/ShortStay/ShortStayProgramme.php` | Key model — one operator at one catalogue project. |
| `src/ShortStay/ShortStayUnitType.php` | The operator's own `A`/`B`/`C`/`D`. |
| `src/ShortStay/ShortStayPackage.php` | Renovation packages (Signature, Premium, …). |
| `src/ShortStay/ShortStayFloorPlan.php` | The stored layout → operator-type link. |
| `src/ShortStay/ShortStayPayout.php` | The ledger: month × type × package → net to owner. |
| `src/ShortStay/ShortStayMonth.php` | Whole-building P&L + reported occupancy / ADR. |
| `src/ShortStay/Repositories/ShortStayRepository.php` | Every write, each in `DB::transaction`. |
| `src/ShortStay/Facades/ShortStayRepository.php` | Facade. |
| `src/ShortStay/Services/ShortStayReport.php` | The whole page payload. `CACHE_VERSION` — bump on any shape or maths change. |
| `src/ShortStay/Support/SeasonalForecast.php` | The projection. Pure, no Eloquent, unit-tested. |
| `app/Http/Controllers/Main/Site/ProjectDetailController.php` | `shortStay()` + the deferred prop. |

**Frontend**

| File | Role |
|---|---|
| `resources/js/Components/ProjectDetail/ProjectInvestmentStr.vue` | The panel: type card, the Minimum/Average/Maximum control, the 12-/6-month actuals window (`trailing()`), package choice, the three headline numbers, the yield comparison bars, the ledger — and the back-calculated nightly rate the demand panel plots. |
| `resources/js/Components/ProjectDetail/Str/StrHistoryChart.vue` | Payout history (solid) + projection (dashed, inside a real `Filler` band), ruled off by `strProjectionDivider`; legend filtered to the two packages. |
| `resources/js/Components/ProjectDetail/Str/StrDemand.vue` | Occupancy + implied nightly rate — a chart, not a model; shown, never multiplied. |
| `resources/js/Components/ProjectDetail/Str/strChart.js` | The vocabulary both charts share: the per-package palette (`TONE` / `toneFor`), `BUILDING_TONE`, the hover crosshair and its interaction config, and the tooltip's month/ringgit formatters. |
| `resources/js/Components/ProjectDetail/ProjectInvestmentAnalysis.vue` | Hosts the tab; `tabs` is computed so it appears only where a ledger exists. |
| `resources/js/Pages/Main/Site/ProjectDetail.vue` | Declares and forwards the deferred `shortStay` prop. |

**Migrations** — `database/migrations/2026_09_07_100000_create_short_stay_tables.php`
(site connection; nothing is added to the shared catalogue schema).

**Seeders** — `database/seeds/ViiaShortStaySeeder.php`
(`php artisan db:seed --class='\ViiaShortStaySeeder'` — note the leading
backslash; `database/seeds` is classmapped, not namespaced).

**Tests** — `tests/Unit/ShortStay/SeasonalForecastTest.php`,
`resources/js/Components/ProjectDetail/Str/ProjectInvestmentStr.test.js`,
and the guard in `resources/js/Components/ProjectDetail/ProjectTabsParity.test.js`.

**Routes** — none of its own. The tab is a fragment of the Project Detail route.

## Reference usage

Adding a second project's programme:

1. `ShortStayRepository::store()` — one programme row for the catalogue project.
2. `ShortStayRepository::replaceLedger()` — unit types, packages, payouts and
   building months from the parsed statement. Derive months from **column
   position**, and check the run is unbroken.
3. `ShortStayRepository::mapFloorPlans()` — the complete layout → type map. A
   layout left out is unmapped, which removes it from the simulator; that is the
   intended way to withdraw one, and a re-import never touches this.
4. The tab appears by itself. `ShortStayReport` returns
   `available: false` with a `reason` (`no_programme` / `no_mapping` /
   `no_payouts`) until all three steps are done.
