<!--
	covers: commands/BillingoController.php commands/PaymentsController.php helpers/BillingoHelper.php helpers/PaymentHelper.php models/Payment.php models/PaymentItem.php models/PaymentMethod.php models/PaymentStatus.php models/PaymentStatusChange.php models/Rent.php models/RentEvent.php models/RentEventType.php models/RentStatus.php models/RentStatusChange.php models/TaxRate.php modules/admin/helpers/DocumentHelper.php
	verified: 3c56e1b
-->
# Rentals and billing

The ERP half: a contract on a storage unit, the payments it generates, and the invoices Billingo issues.

## `Rent` — the contract

[`models/Rent.php`](../../models/Rent.php), 482 LOC.

| Column group | Columns |
|--------------|---------|
| Origin | `proposalRequestId`, `proposalRequestItemId` |
| Subject | `storageId` |
| Party | `companyId` (the renting-out entity), `customerId` |
| Pricing, net forint | `firstMonthPriceNet`, `monthlyPriceNet`, `lastMonthPriceNet`, `servicesPriceNet`, `depositPriceNet` |
| Term | `dateFrom`, `dateTo` (both nullable), `isFixedTerm`, `isPrepaid` |
| Access | `lockKey`, `alarmCode` (8), `password` |
| Content | `storedContent` |
| State | `status`, `paymentMethod`, `depositPaymentMethod` |

**`dateTo IS NULL` means open-ended**, which is why `isFixedTerm` exists as a separate flag — the dashboard's
"expiring rents" notification filters on `isFixedTerm = true` so open-ended contracts never look overdue.

Prices are five separate net integers because the first month, the last month, the recurring months, the
add-on services and the deposit are billed differently. There is no line-item table on the rent itself; the
breakdown becomes `PaymentItem` rows later.

Relations: `proposalRequest`, `proposalRequestItem`, `storage`, `company`, `customer`, `events`,
`attachments`, `payments`, `phoneNumbers`.

`Rent` implements `HasNotifications` (`getNotificationHtml()`), which is what puts it on the admin dashboard.

**The schema is unchanged by the 2026 migration**: the units carried over from the previous system are *not*
rentals. An `ACTIVE` rental generates payments and Billingo invoices them, so three hundred placeholder
contracts would have become three hundred invoices. They are occupancy overrides on the storage instead —
see [ADR 0033](../adr/0033-occupancy-overrides-live-on-the-storage.md).

> **`Rent::afterSave()` clears the unit's occupancy override.** Saving a rental in a non-ending status resets
> `storage.availability` to `AUTO` and `availabilityUntil` to null, with a plain `updateAll()`. That is what
> makes the carried-over overrides drain away as the real contracts get entered — and it is the other half of
> the rule `Storage::rules()` enforces, that a unit with a live rental accepts no manual override. See
> [STORAGE-AND-LOCATIONS.md](STORAGE-AND-LOCATIONS.md#occupancy).

### Status machine

[`models/RentStatus.php`](../../models/RentStatus.php) — a plain class of constants and static helpers, **not
an ActiveRecord**:

```
null → NEW → ACTIVE → CLOSED
                    → COMPLETED
```

`RentStatus::next( $current )` returns the legal successors; `asNextOptions()` turns them into a dropdown, so
**the admin form can only offer valid transitions**. `endingStatuses()` is `[ COMPLETED, CLOSED ]` and is what
occupancy queries exclude.

Labels come from `bt( 'rentStatuses_' . $status )`; icons from `RentStatus::icon()`.

Every transition is appended to `RentStatusChange` (rent, user, timestamp) — **the history is a separate
table, the current value is denormalised onto the record.**

### Conflicts

`Rent::addConflictingRentsWhere()` and `getConflictingRentsCount()` detect overlapping contracts on the same
storage. The admin uses the count to warn before saving.

### Events

`RentEvent` + `RentEventType` — a typed, dated log against a contract (with its own attachments and
payments). `LocationEvent` + `LocationEventType` is the parallel structure for locations, and it carries
`reminderAt`, which drives the dashboard reminder notifications.

## `Payment` — one billable line

[`models/Payment.php`](../../models/Payment.php).

| Column group | Columns |
|--------------|---------|
| Direction | `direction` — `DIRECTION_IN = 1`, `DIRECTION_OUT = 2` |
| Origin | `rentId`, `rentEventId` (both nullable) |
| Context | `locationId`, `storageId`, `companyId`, `customerId` |
| Money | `taxRateId`, `amountNet`, `amountGro`, `paymentMethod` |
| Period | `deadline`, `year`, `month`, `isFirstMonth`, `isLastMonth`, `isDeposit` |
| State | `status`, `name` |
| Invoice | `billingoId`, `invoiceNumber`, `invoiceFileId` |

`year` + `month` as separate integer columns is what makes "has this month already been generated?" a cheap
indexed lookup — that is the idempotence key for the generation cron.

Net and gross are **both stored**, not derived. `TaxRate` supplies the percentage; `PaymentHelper` and
`PaymentItem` do the arithmetic.

### Status machine

[`models/PaymentStatus.php`](../../models/PaymentStatus.php):

```
null → NEW      → BILLED → PAID
     → CLOSED             → CANCELLED
     NEW → CLOSED
```

`endingStatuses()` is `[ PAID, CANCELLED ]`. `CLOSED` is reachable from `null` and from `NEW` but is **not**
an ending status — it is the "never bill this" escape hatch.

Transitions are logged to `PaymentStatusChange`. `Payment` also implements `HasNotifications`.

### `PaymentItem`

The invoice lines belonging to a payment: name, quantity, unit price, its own `taxRateId`. Rendered in the
admin through `getInlineGridRow()` and reachable only as a submodule of `Payment`.

## Generation — `./yii payments/generate-by-rents [date]`

[`commands/PaymentsController.php`](../../commands/PaymentsController.php). Run daily. Walks the active
rentals and creates the `Payment` rows due, keyed by `rentId` + `year` + `month` so a second run on the same
day is a no-op.

The optional date argument backdates the run to catch up a missed day.

## Invoicing — Billingo

Three commands, run every minute in this order
([`commands/BillingoController.php`](../../commands/BillingoController.php), helper
[`helpers/BillingoHelper.php`](../../helpers/BillingoHelper.php)):

| Command | Does |
|---------|------|
| `billingo/sync-partners` | pushes customer/company data to Billingo as partners |
| `billingo/invoice` | issues invoices for the billable payments; stores `billingoId`, `invoiceNumber`, and downloads the PDF into `invoiceFileId` |
| `billingo/sync` | reads invoice state back and advances the payment status |

Client library: `deviddev/billingo-api-v3-php-sdk`. `BILLINGO_SEND_EMAIL` controls whether Billingo mails the
invoice to the customer on creation.

**Billingo is the system of record for invoice numbers.** The application never generates one; it stores what
Billingo returns. An invoice that exists in Billingo but not here is recoverable by `billingo/sync`; the
reverse is not.

## Documents

[`modules/admin/helpers/DocumentHelper.php`](../../modules/admin/helpers/DocumentHelper.php), 383 LOC, plus
the `GenerateRentDocument` and `GenerateOffer` admin actions. Contracts and offers are produced in-app with
`phpoffice/phpword` and `dompdf` — there is no external document service.

`Storage.nameInContract` and `StorageProperty.displayInContract` exist for this: the contract shows a
different name than the website, and only the properties flagged for it.

## Parties

- **`Company`** — the renting-out legal entity. `CompanyStorageType` limits which storage types a company
  offers.
- **`Customer`** — the renting party; `CustomerCompany` is the corporate variant.
- **`User`** — the login account. A customer may or may not have one. See
  [USERS-AND-ACCESS.md](USERS-AND-ACCESS.md).

## Traps

1. **`RentStatus` and `PaymentStatus` are not ActiveRecords.** There is no `rent_status` table; the strings
   live in the `status` column and the class is the only schema.
2. **Status history is append-only, current status is denormalised.** Writing `status` directly without a
   `*StatusChange` row leaves the audit trail wrong, and nothing enforces it.
3. **`CLOSED` is not an ending status for payments.** Filters using `endingStatuses()` will still pick up
   closed payments — check whether that is what you want.
4. **`year`/`month` are the idempotence key** for payment generation. Changing how they are set risks
   duplicate billing.
5. **Net and gross are both stored.** Changing a `TaxRate` does not retroactively fix existing payments.
6. **Open-ended rentals have `dateTo = NULL`**, and `AvailabilityService` substitutes a date two years out
   for the calendar. Any other query over `dateTo` has to handle the null itself — the admin grid's status
   expression did not, and showed a unit with a live open-ended contract as free until 2026-09-07.
7. **Saving a rental writes to the `storage` table too**, clearing the occupancy override. It is the only
   place `Rent` touches a unit's columns.
