Property management for the sites built on omnisense/omnibase:
the organisations that own or manage homes (an SCI, an agency), their buildings and units,
associates, leases and tenants, rent calls, receipts and online payment, contractors and work
orders, invitations, and a private space for each of those people - posting to
omnibase/ledger when the organisation
keeps its books there. It is a Symfony bundle (Base\Estate\EstateBundle) on the core 3 or 4,
with its back office on omnibase/admin. Its words on the pages and in the e-mails are French: it
was written for French residential lettings (the loi du 6 juillet 1989, the IRL revision, the
quittance).
Nobody signs up. The management invites an associate, a tenant, a contractor or a member of its team; the link creates the account or attaches an existing one; what each person may open comes from what they are to the organisation, never from a role stored on the account.
- The management (a
Member, or anyROLE_ADMIN): the back office - every record, the rents to call and collect, the work orders to give, the applications, the messages. - Associates (
/associes): the company's figures, its feed, its deadlines, its documents and - with omnibase/ledger - its accounts and each one's share of the result. - Tenants (
/locataire): their lease, its rents (paid online, notices and receipts to download, a certificate of rent up to date), documents, insurance, messages, the problems they report, the building's notices. - Contractors (
/artisan): the jobs given to them, with the address and the access, and a form to give a date, a quote, an invoice and a report. - The public: the published homes (
/biens), a visit request, an application form.
How this manual is organised
- Installation: requirements and suggestions, the bundle, the routes, the schema, the crons.
- Organisations, homes and leases: every entity, its fields and methods, the enums.
- Rents: calling, reminding, payments, receipts, online payment, the IRL revision, the books.
- Works, documents and messages: work orders, files, the tenants' messages, notices.
- The public site and applications: the homes, visits, applications.
- Invitations: inviting, accepting.
- Spaces and access:
Spaces,EstateVoter, the four spaces, the Twig functions. - The back office: the screens, the actions, the dashboard tiles.
- Configuration reference:
estate:, the services. - Testing.
Proposer une modification docs/index.md
Installation
Requirements
What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3, omnisense/omnibase
3.x or 4.0, omnibridge/symfony 1.x, omnibase/admin, and symfony/config,
dependency-injection, form, http-kernel, mailer and security-bundle 7 or 8.
What it suggests, each loaded only when present (config/services.php):
| Package | Adds |
|---|---|
omnibase/ledger |
rent calls, payments and work invoices posted to the organisation's book (Service\Books); bank lines reconciled with rent calls pay them (Ledger\BankSettlement); associates read the books (Ledger\AssociateBookAccess) and their accounts in their space (Ledger\CompanyAccounts) |
omnisense/omnitrade with omnitrade/stripe |
rents paid online by card or SEPA debit (Service\OnlinePayments) |
dompdf/dompdf |
notices, receipts and certificates as PDF (without it, printable pages) |
omnisense/omnistate (with omnistate/annuaire-entreprises, omnistate/vies) |
a company's identity read from the registers (Service\CompanyRecords, estate:company:refresh) |
The bundle
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Admin\AdminBundle::class => ['all' => true],
Base\Estate\EstateBundle::class => ['all' => true],
];
EstateBundle aliases Base\Estate\Entity\* to App\Entity\Estate\* and the repositories to
App\Repository\Estate\* unless the application declares its own classes there; its
templates/ is @Estate.
The routes
# config/routes.yaml
estate_controller:
resource: "@EstateBundle/src/Controller"
type: attribute
prefix: /
| Route | Path | Who |
|---|---|---|
estate_units |
/biens |
public |
estate_unit |
/biens/{slug} (GET, POST: visit request) |
public |
estate_unit_apply |
/biens/{slug}/candidature |
public |
estate_invitation |
/invitation/{token} (48 hex) |
public |
estate_associate |
/associes (?societe=<slug>) |
an associate |
estate_tenant |
/locataire (?bail=<id>; GET, POST) |
a tenant |
estate_rent_pay |
/locataire/loyer/{id}/payer (POST) |
a tenant of the lease |
estate_rent_paid |
/locataire/loyer/{id}/retour |
the payment's return |
estate_rent_document |
/locataire/loyer/{id}/avis or /quittance |
a tenant of the lease |
estate_tenant_attestation |
/locataire/bail/{id}/attestation |
a tenant of the lease |
estate_contractor |
/artisan |
a contractor |
estate_work_order |
/artisan/intervention/{id} (GET, POST) |
the work order's contractor |
estate_document |
/fichier/{id} |
whoever may see the document |
estate_admin_* |
/admin/estate/... |
ROLE_ADMIN (The back office) |
The spaces need a signed-in user (ROLE_USER); the core's firewall provides the sign-in.
Configuration
# config/packages/estate.yaml (every key optional)
estate:
user_class: App\Entity\User
from_email: '%env(default::MAILER_CONTACT)%'
payment_gateway: stripe
payment_methods: [card, sepa_debit]
invitation_days: 14
reminder_days: 5
documents_route: null
The schema
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
Tables estate_*: organisation, building, unit, unit_photo, associate, member, lease,
lease_party, rent_call, payment, contractor, work_order, work_order_event, document, notice,
deadline, activity, application, message, invitation.
The crons
0 6 25 * * bin/console estate:rent:call # next month's rents, on the 25th
0 7 * * * bin/console estate:rent:late # late rents marked, tenants reminded once
0 3 * * * bin/console estate:applications:purge # applications decided over three months ago
0 4 * * 1 bin/console estate:company:refresh # the companies read again from the registers
| Command | Options |
|---|---|
estate:rent:call |
--month=YYYY-MM (next month by default), --no-mail; run twice, it calls nothing twice |
estate:rent:late |
--no-mail |
estate:applications:purge |
--months=3 |
estate:company:refresh |
- (with omnisense/omnistate); exit 1 when a register did not answer |
Proposer une modification docs/installation.md
Organisations, homes and leases
Amounts are integers in cents. Every record hangs off an Organisation, directly or through
its unit or lease, with onDelete: CASCADE.
The organisation and its people
| Entity | Table | Fields |
|---|---|---|
Organisation |
estate_organisation |
name (not blank), slug (unique), legalName, legalForm, siren, vatNumber, registryCheckedAt, foundedOn, address, postcode, city, email (the management's address: e-mails to it, replies from tenants), phone, iban, bookId (its omnibase/ledger book, when it keeps one); getFullAddress() |
Member |
estate_member, unique on organisation and user |
the management team: name, email, title, user |
Associate |
estate_associate |
firstName, lastName, email, phone, shares, quality (MANAGER, ASSOCIATE), since, public, bio, portrait (4 MB), user |
Contractor |
estate_contractor |
company, contactName, trade, siret, email, phone, iban, user |
Deadline |
estate_deadline |
title, dueOn, yearly (true), notes; next(?$today) - the next date, a yearly one moved year after year |
Activity |
estate_activity |
the associates' feed: kind (ActivityKind), message, amount, unit, happenedAt |
user links a person to their account once they accepted an invitation (SET NULL when the
account goes; a member's cascades).
Homes
Building (estate_building): name, address, postcode, city, builtIn, access (how
to get in, shown to contractors).
Unit (estate_unit):
| Field | Meaning |
|---|---|
kind |
UnitKind: HOUSE, APARTMENT (default), STUDIO, PARKING, COMMERCIAL |
name, slug (unique) |
the public page is /biens/{slug} |
building, address, postcode, city, district, door |
where |
surface (decimal 7,2), rooms, bedrooms, floor, heating |
|
energyClass, energyValue, climateClass |
the DPE's letters and value |
description, features (json list), furnished |
|
rent, charges, deposit |
what a new lease takes |
published |
on the public site |
availableFrom |
free from that date; null: not offered (isAvailable()) |
facade |
the card's colour on the public site (ochre by default) |
position |
|
photos |
UnitPhotos (12 MB each, caption, position) |
leases |
newest first |
getCurrentLease(?$on), isLet(?$on), getCover().
Leases
Lease (estate_lease):
| Field | Meaning |
|---|---|
unit, reference (unique) |
MA-2026-003 when made from an application |
startOn, endOn, noticeOn |
|
rent, charges, deposit |
taken from the unit when created; getMonthly() is rent and charges |
dueDay |
1 to 28, 5 by default: the rent call's due date |
paymentMethod |
PaymentMethod: TRANSFER, CARD, SEPA_DEBIT, CHEQUE, CASH |
status |
LeaseStatus: ACTIVE, NOTICE, ENDED |
insuranceUntil |
the tenants' home insurance, from their certificate |
indexQuarter, indexValue |
the IRL of reference ("T2 2025", decimal) |
revisedOn |
the last revision |
parties |
LeasePartys: role (TENANT, COTENANT, GUARANTOR), name, e-mail (lowercased), phone, IBAN, user |
rentCalls |
newest first |
getTenants() (the tenants and co-tenants), getTenantNames(), isRunningOn($on) (not ended,
started, not past its end).
Rent calls and payments
RentCall (estate_rent_call, unique on lease and period): period (the month's first day),
dueOn (the lease's due day of that month), reference (AVIS-<YYYY-MM>-<lease reference>,
unique), rent and charges (the lease's when called), paid, status (RentStatus:
CALLED, PARTIAL, PAID, LATE), calledAt, paidAt, remindedAt, receiptNumber
(Q-2026-0007), payments. getTotal(), getRemaining(), isPaid(), isLate(?$today).
addPayment() adds to paid: PAID (with paidAt the payment's date) once the total is
reached, PARTIAL before.
Payment (estate_payment): amount, method, paidOn, reference (unique: a payment is
recorded once per reference).
use Base\Estate\Entity\Lease;
use Base\Estate\Entity\LeaseParty;
use Base\Estate\Entity\Organisation;
use Base\Estate\Entity\Payment;
use Base\Estate\Entity\RentCall;
use Base\Estate\Entity\Unit;
use Base\Estate\Enum\PaymentMethod;
$organisation = new Organisation('SCI Les Tilleuls', 'les-tilleuls');
$unit = (new Unit($organisation, 'Appartement 3', 'appartement-3'))->setRent(85000)->setCharges(6000)->setDeposit(85000);
$lease = (new Lease($unit, 'LT-2026-001', new DateTimeImmutable('2026-01-01')))->setDueDay(5);
$lease->addParty(new LeaseParty('Jean', 'Dupont', 'Jean.Dupont@example.org'));
$call = new RentCall($lease, new DateTimeImmutable('2026-10-17'));
echo $call->getReference(), ' due ', $call->getDueOn()->format('Y-m-d'), ' total ', $call->getTotal(), "\n";
$call->addPayment(new Payment($call, 50000, PaymentMethod::TRANSFER, new DateTimeImmutable('2026-10-03')));
echo $call->getStatus()->value, ' remaining ', $call->getRemaining(), ' late on the 6th: ', var_export($call->isLate(new DateTimeImmutable('2026-10-06')), true), "\n";
$call->addPayment(new Payment($call, 41000, PaymentMethod::CARD, new DateTimeImmutable('2026-10-08')));
echo $call->getStatus()->value, ' paid at ', $call->getPaidAt()->format('Y-m-d'), "\n";
echo $lease->getTenantNames(), ' | running on 2026-10-01: ', var_export($lease->isRunningOn(new DateTimeImmutable('2026-10-01')), true), "\n";
Run in omnisense/omnibase's harness image, in memory:
AVIS-2026-10-LT-2026-001 due 2026-10-05 total 91000
partial remaining 41000 late on the 6th: true
paid paid at 2026-10-08
Jean Dupont | running on 2026-10-01: true
Works, documents, messages
| Entity | Fields |
|---|---|
WorkOrder (estate_work_order) |
unit, reportedBy, title, description, category (Autre), urgency (LOW, NORMAL, URGENT), status (REPORTED, ASSIGNED, SCHEDULED, DONE, CLOSED, CANCELLED), contractor, visitAt, quoteAmount, invoiceAmount, invoiceNumber, report, createdAt, closedAt, events (WorkOrderEvent: author, name, body, date) |
Document (estate_document) |
organisation and one of unit, lease, workOrder; kind (LEASE, INVENTORY, DIAGNOSTIC, RULES, INSURANCE, QUOTE, INVOICE, PHOTO, OTHER), title, file (PDF or image, 20 MB, outside public/), tenantVisible (true), validUntil, uploadedBy, uploadedByName |
Message (estate_message) |
lease, author, authorName, fromManagement, body, createdAt, readAt |
Notice (estate_notice) |
a notice to the tenants: organisation, building (none: all), title, body, sentAt |
Application (estate_application) |
The public site and applications |
Invitation (estate_invitation) |
Invitations |
ActivityKind: RENT_CALLED, RENT_PAID, RENT_LATE, LEASE_STARTED, LEASE_ENDED,
WORK_REPORTED, WORK_ASSIGNED, WORK_DONE, DOCUMENT, NOTICE, OTHER. The services write
one line per event (Service\Activities::log()).
Proposer une modification docs/concepts.md
Rents
Calling the rents
Base\Estate\Service\RentCalls::call(\DateTimeImmutable $month, ?Organisation $organisation = null, bool $notify = true)
makes one RentCall per lease running in that month (LeaseRepository::findRunningIn()), and
none twice for a lease and a month. For each (callOne()):
calledAtis a week before the month, 9:00 (or now, if later) - when notices go out;- an activity line "Loyer de octobre 2026 appelé";
- posted to the books, when the organisation keeps them (below);
- with
$notify, the notice (rent_calle-mail, "Avis d'échéance") to every tenant, reply-to the organisation's address.
estate:rent:call [--month=YYYY-MM] [--no-mail] runs it for every organisation (next month by
default); the back office's button calls the current month.
Late rents and reminders
RentCalls::markLate(\DateTimeImmutable $today, bool $remind = true): every call past due and not
paid becomes LATE (from CALLED or PARTIAL, with an activity line); a call that was not
reminded yet and whose due date is estate.reminder_days (5) days past is reminded once
(rent_reminder e-mail, remindedAt set). estate:rent:late [--no-mail], daily.
Payments
Base\Estate\Service\Payments::record(RentCall $call, int $amount, PaymentMethod $method, ?\DateTimeImmutable $paidOn = null, ?string $reference = null, bool $notify = true, bool $post = true): Payment:
- a non-positive amount is an
InvalidArgumentException; - a reference already recorded returns that payment: nothing twice;
- the payment is added to the call; an activity line "Loyer reçu (virement)";
- the call paid in full: its receipt numbered (
Q-<year>-<0001>, per year of the period, in the order rents are paid); posted to the books (unless$postis false: a bank line already posted it); with$notify, the receipt e-mailed to the tenants, the PDF attached when dompdf is installed.
The back office records a payment by hand (amount, method, date, reference).
Receipts, notices and certificates
Base\Estate\Service\Receipts renders @Estate/pdf/receipt.html.twig (quittance),
notice.html.twig (avis d'échéance) and attestation.html.twig (the certificate a tenant hands
a bank, the CAF or a future landlord), as PDF with dompdf (A4, Helvetica, no remote resources),
or as HTML without it. The tenant downloads the notice, the receipt once paid, and the
certificate only when nothing past due is owed.
Online payment
Base\Estate\Service\OnlinePayments, through omnisense/omnitrade's Registry and the gateway
estate.payment_gateway (stripe): available when the registry has that gateway.
start(RentCall $call, string $method, string $returnUrl, string $cancelUrl, ?string $email = null)- a purchase of the remaining amount in EUR, referenced by the call, described "Loyer 10/2026 — <home>", idempotent per call and amount already paid, the method amongestate.payment_methods(card,sepa_debit), metadatarent_call, localefr: the provider's page.complete(RentCall $call, string $reference)- back from the provider (?session=): recorded as a card or SEPA payment once the transaction is paid. A SEPA debit is paid later.
The tenant's "Payer" is a POST with the token rent_pay_<id>; refused for a paid call or when
online payment is off.
The IRL revision
Base\Estate\Service\RentRevisions (loi du 6 juillet 1989, art. 17-1):
nextOn(Lease $lease, ?$today)- a year after the start or the last revision, moved on year by year while it is more than eleven months behind;rentAt(Lease $lease, string $newIndex)- the rent (charges aside) times the new index over the reference index, rounded to the cent; both indices are needed;apply(Lease $lease, string $newQuarter, string $newIndex, \DateTimeImmutable $on)- the new rent, the new reference (quarter and value),revisedOn, an activity line and the tenants' letter (rent_revisione-mail); returns the former rent;due(int $days = 45)- the active leases with a reference index whose revision falls within that many days, for the dashboard.
The back office's revision page takes the quarter ("T2 2025") and the INSEE value, shows the new rent, then applies it from the anniversary - never backdated: from today when asked later.
The books
With omnibase/ledger, and an organisation whose bookId names a book, Base\Estate\Service\Books
posts:
| Event | Entry (journal) | Source |
|---|---|---|
| a rent called | VT: the tenant's account (411, party lease:<reference>) debited the total; 706 credited the rent, 708 the charges |
estate:rent:<call reference> |
| a payment | BQ: 512 (5112 for a card or SEPA payment, until the provider's payout) debited; the tenant credited |
estate:payment:<id> |
| a work order closed with an invoice | AC: 615 debited; the contractor's account (401, party contractor:<id>) credited |
estate:work:<id> |
A call paid in full has its entry and its payments' lettered on the tenant's account. Each posting carries its source: nothing is posted twice.
Base\Estate\Ledger\BankSettlement listens to omnibase/ledger's BankLineReconciled: money in
whose settled open items come from rent calls (estate:rent:<reference>) pays those calls
(Payments::record(..., 'bank:<line id>:<call id>', notify: true, post: false)), the receipt sent,
nothing posted twice.
Proposer une modification docs/usage-rents.md
Works, documents and messages
Work orders
Base\Estate\Service\WorkOrders follows a problem from its report to its invoice. Every step is
a line of the order's timeline (WorkOrderEvent), a line of the associates' feed, and an e-mail
to whoever it concerns.
| Step | Method | Status | E-mails |
|---|---|---|---|
| reported (by a tenant from their space, or the management) | report(Unit, ?User, string $byName, string $title, string $description, string $category = 'Autre', Urgency $urgency = NORMAL) |
REPORTED |
the managers (the members and the organisation's address): "[Intervention #12] ..." |
| given to a contractor | assign(WorkOrder, Contractor, ?\DateTimeImmutable $visitAt, ?User, string $byName = 'Gestion', ?string $note = null) |
SCHEDULED with a visit date, else ASSIGNED |
the contractor; the current lease's tenants |
| the contractor's word | update(WorkOrder, ?User, string $byName, ?$visitAt, ?int $quote, ?string $report, bool $done, ?int $invoice, ?string $invoiceNumber) |
SCHEDULED for a new visit date, DONE when done |
the managers; the tenants for a visit date or the end |
| a note | note(WorkOrder, ?User, string $byName, string $body) |
- | - |
| closed by the management | close(WorkOrder, ?User, string $byName = 'Gestion') |
CLOSED, closedAt |
- ; the invoice posted to the books |
update() writes only what changed; with nothing new it does nothing. A tenant's report form
offers the categories Plomberie, Chauffage, Électricité, Serrurerie, Menuiserie, Humidité, Parties
communes, Autre, and the three urgencies.
The contractor's page (/artisan/intervention/{id}, ESTATE_WORK_DO) shows the address, the
access, who lives there and the timeline, with two forms: the visit date, the quote, the invoice
and its number, the report and "done"; and a file (quote, invoice, photo).
Documents
Base\Estate\Service\Documents:
insurance(Lease, File, \DateTimeImmutable $until, User, string $name)- a tenant's home insurance certificate: a document of kindINSURANCEon the lease, valid until that date; the lease'sinsuranceUntilmoves forward with it; an activity line; the management told.work(WorkOrder, DocumentKind, File, ?string $title, User, string $name, bool $tenantVisible = false)- a contractor's quote, invoice or photo on a work order, titled "Devis — <title>" when none is given; the management told.
Files are streamed by /fichier/{id} (estate_document) to whoever may see them
(Spaces and access), inline, named after the document's title; the
uploads stay out of public/.
Messages
Base\Estate\Service\Messages keeps the thread between a lease's tenants and the management:
post(Lease, string $body, bool $fromManagement, ?User $author, string $authorName)- kept, then e-mailed to the other side: every tenant of the lease (reply-to the organisation), or the organisation's address;read(Lease, bool $byManagement)- the thread, the other side's messages marked read.
The tenant writes from their space; the management from /admin/estate/messages, every lease's
thread, the latest first, with its unread count.
Notices
A Notice (the building's or every building's) is sent from the back office
(estate_admin_notice_send): one e-mail to each tenant of the current leases of the
organisation's units - of that building only when it names one -, sentAt set, an activity
line. The tenants see the five last sent in their space.
Proposer une modification docs/usage-works.md
The public site and applications
The homes
/biens (estate_units) lists the published units - those free first (by availableFrom),
then by position and name - with ?type=<UnitKind value> and ?libres=1 (free only); the page
also gets every published unit, for a filter in the page. /biens/{slug} shows one, three others,
and a visit request (name, e-mail, phone, message): e-mailed to the organisation, reply-to the
visitor. A filled trap field (website) is a robot: thanked all the same, nothing sent.
Applying
/biens/{slug}/candidature (estate_unit_apply) is open while the home is offered
(availableFrom set). The form (Base\Estate\Model\ApplicationRequest):
| Field | Constraints |
|---|---|
firstName, lastName |
not blank, 80 at most |
email |
not blank, an e-mail |
phone |
40 at most |
occupants |
1 to 12 |
moveInOn |
defaults to the later of the home's availability and today |
occupation |
PERMANENT, FIXED_TERM, CIVIL_SERVANT, SELF_EMPLOYED, STUDENT, RETIRED, JOB_SEEKER, OTHER |
income |
the household's net monthly income, in cents, 0 or more |
guarantee |
VISALE, PERSON, COMPANY, NONE |
dossierUrl |
a link to the applicant's file (DossierFacile) |
message |
3000 at most |
consent |
required |
A filled trap is thanked and nothing kept.
From received to let
Base\Estate\Service\Applications:
| Step | What happens |
|---|---|
submit(Application) |
kept (NEW); an activity line; the organisation told; the applicant acknowledged |
shortlist(Application) |
SHORTLISTED; nobody told |
inviteToVisit(Application, \DateTimeImmutable $at, ?string $message) |
VISIT, the date; the applicant invited, with the management's words |
decline(Application, ?string $message, bool $notify = true) |
DECLINED (only an open one); the applicant told politely |
accept(Application, \DateTimeImmutable $startOn, bool $declineOthers = false) |
the lease made - the applicant its tenant, the home's rent, charges and deposit, starting then, its reference <initials>-<year>-<001> -; the home no longer offered; ACCEPTED; a "lease started" line; with $declineOthers, the other open applications declined |
purge(int $months = 3) |
the applications decided (accepted or declined) more than that long ago deleted: estate:applications:purge, daily |
An open application is NEW, SHORTLISTED or VISIT; deciding one sets decidedAt.
Application::getIncomeRatio() is the income over rent and charges (landlords look for about 3).
The lease reference's initials are the organisation name's first letters, without legal forms
and small words (SCI, SARL, DE, LA...).
Proposer une modification docs/usage-applications.md
Invitations
Nobody signs up: the management invites an associate, a lease's party, a
contractor or a member of the team (Invitations::invite()); the link creates
the account or attaches the existing one (/invitation/{token}).
The token, its hash (only the SHA-256 is stored), the expiry, the lookup and the new account are omnisense/omnibase's:
Base\Estate\Entity\InvitationextendsBase\Entity\User\Invitation(a mapped superclass: e-mail, name,tokenHash,createdAt,expiresAt,acceptedAt,invitedBy) and adds the organisation, the kind and the target. Same table (estate_invitation), same columns: no migration.Base\Estate\Service\InvitationsextendsBase\Service\Invitations: it says what an invitation opens (target()) and what accepting does (accept(): the account on the associate, the party, the contractor; ROLE_ADMIN for a member).createUser()andexistingUser()are the core's.
Configuration: estate.invitation_days (14), estate.user_class.
Inviting
Base\Estate\Service\Invitations::invite(Associate|LeaseParty|Contractor|Member $target, ?User $by = null, bool $send = true)
returns the invitation and its token (sent, never stored): its kind (ASSOCIATE, TENANT,
CONTRACTOR, MEMBER), the organisation, the target's id, e-mail and name, expiring after
estate.invitation_days. With $send, the invitation e-mail ("<organisation> vous ouvre son
accès") carries /invitation/{token}, reply-to the organisation.
The back office invites from a record (estate_admin_invite, /admin/estate/invite/{kind}/{id},
kind among member, associate, tenant, contractor, a link with its token).
Accepting
/invitation/{token} (estate_invitation, 48 hex characters):
- an unknown, used or expired token:
invitation_expired.html.twig, status 410; - signed in: a button (POST, token
invitation_<id>) attaches the account; - an account exists for the address: the page says so (sign in first);
- otherwise a form (username, password) creates the account of
estate.user_class, attaches it and signs the user in (form_login, firewallmain); a username taken is a form error.
accept() sets the target's user; a member who is not ROLE_STAFF gets ROLE_ADMIN. What the
invitation opened must still exist (RuntimeException otherwise).
Proposer une modification docs/invitations.md
Spaces and access
Who someone is
Base\Estate\Service\Spaces says, from the relations and never from a role stored on the
account, what a user is to the organisations (memoised per request, reset between requests):
| Method | Returns |
|---|---|
managed(?User) |
the organisations whose back office they run: every one for a ROLE_ADMIN, else those they are a Member of |
manages(?User, Organisation) |
|
associateships(?User), isAssociateOf(?User, Organisation) |
their Associate records |
tenancies(?User), isPartyOf(?User, Lease) |
the leases they are a party of |
contractorships(?User) |
their Contractor records |
of(?User) |
the spaces to offer, in order: associate (estate_associate), tenant (estate_tenant), contractor (estate_contractor) |
EstateVoter
| Attribute | Subject | Granted to (beyond the organisation's management, granted everything) |
|---|---|---|
ESTATE_MANAGE |
Organisation |
nobody else |
ESTATE_ASSOCIATE |
Organisation |
its associates |
ESTATE_LEASE_VIEW |
Lease |
its parties |
ESTATE_RENT_VIEW |
RentCall |
its lease's parties |
ESTATE_UNIT_REPORT |
Unit |
the parties of its current lease |
ESTATE_WORK_VIEW |
WorkOrder |
its contractor, the parties of the unit's current lease, the organisation's associates |
ESTATE_WORK_DO |
WorkOrder |
its contractor |
ESTATE_DOCUMENT_VIEW |
Document |
see below |
Documents
The associates see every document of their company. A work order's document: its contractor, and - when it is tenant-visible - the parties of the unit's current lease. Any other document must be tenant-visible: a lease's to its parties, a unit's to the parties of its current lease while they live there.
The four spaces
- Associates (
/associes,?societe=<slug>among several): the figures (Service\Figures::of(): units, lettable, let, vacant, occupancy, monthly rent, the year's rents called and received and the rate, late rents and their total, the last six months side by side, open work orders, what works cost this year, how rents were paid, the online part), the feed (15 lines), the units, the six next deadlines, the associates and their shares, a link toestate.documents_routewhen set, and - with omnibase/ledger - the accounts (Ledger\CompanyAccounts::of(): the year's compte de résultat so far beside the year before, each associate's share of the result by their shares - an SCI is transparent -, the bank accounts' balances and their eight latest movements). Read-only. - Tenants (
/locataire,?bail=<id>among several): the lease, its rents (pay online, download the notice and the receipt), the certificate, the lease's documents, the insurance certificate form, the messages, the work orders of the unit and a form to report one, the five last notices. - Contractors (
/artisan): the work orders given to them, each at/artisan/intervention/{id}. - The management: the back office.
A user who is none of these gets a 403 on that space.
Twig
Base\Estate\Twig\EstateExtension:
estate_spaces() |
the signed-in user's spaces ({key, route}), for the header and "Mon accès" |
estate_online_payment() |
whether online payment is on |
x|euros(round = false) |
cents as the pages write money: 1 234,56 € (narrow no-break spaces), — for null; round drops the cents |
Proposer une modification docs/usage-spaces.md
The back office
Loaded when omnibase/admin is installed. The CRUDs extend AbstractEstateCrudController, which
opens creating, editing, deleting and the three saves to ROLE_ADMIN (not only the
super-administrator); the action pages are #[IsGranted('ROLE_ADMIN')]. Labels are French.
The screens
| CRUD | Slug | Actions of its own |
|---|---|---|
OrganisationCrudController |
organisations |
Lire les registres (with omnisense/omnistate) |
MemberCrudController |
members |
Inviter |
AssociateCrudController |
associates |
Inviter |
BuildingCrudController |
buildings |
|
UnitCrudController |
units |
|
UnitPhotoCrudController |
unit-photos |
|
LeaseCrudController |
leases |
Réviser le loyer |
LeasePartyCrudController |
lease-parties |
Inviter |
RentCallCrudController |
rent-calls |
no new nor edit; Encaisser (record a payment), Appeler les loyers du mois |
PaymentCrudController |
payments |
read-only |
ContractorCrudController |
contractors |
Inviter |
WorkOrderCrudController |
work-orders |
Confier, Clôturer |
DocumentCrudController |
documents |
|
NoticeCrudController |
notices |
Envoyer aux locataires |
DeadlineCrudController |
deadlines |
|
ApplicationCrudController |
applications |
no new; the next steps (shortlist, visit, decline, accept) while open |
ActivityCrudController |
activities |
no new nor edit |
InvitationCrudController |
invitations |
no new nor edit |
The action pages
Base\Estate\Controller\Admin\ActionController, under /admin/estate:
| Route | Path | |
|---|---|---|
estate_admin_invite |
/invite/{kind}/{id} |
invite, link token estate_invite_<kind>_<id> |
estate_admin_rents_call |
/rents/call (?month=) |
call the month's rents |
estate_admin_payment |
/rent/{id}/payment (GET, POST) |
record a payment: amount (850,00), method, date, reference |
estate_admin_assign |
/work-order/{id}/assign (GET, POST) |
give to one of the organisation's contractors, a visit date, a note |
estate_admin_close |
/work-order/{id}/close |
close |
estate_admin_notice_send |
/notice/{id}/send |
send to the tenants |
estate_admin_application |
/application/{id}/{shortlist,visit,decline,accept} (GET, POST) |
the next step: a date and a word for a visit, a word to decline, the lease's start (and decline the others) to accept |
estate_admin_revise |
/lease/{id}/revise (GET, POST) |
the IRL revision: the quarter (T2 2025) and the value, "calculate", then "apply" |
estate_admin_company_refresh |
/company/{id}/refresh |
the registers (Sirene / RNE, VIES); 404 without omnisense/omnistate |
Links carry a CSRF token in their query (<action>_<id>), forms in _token. After an action the
page goes back to the back-office page it came from, and only to one under /admin.
MessageController: /admin/estate/messages (estate_admin_messages, every lease's thread, the
latest first) and /admin/estate/messages/{id} (a lease's thread, answered from the
organisation's name).
Reading the registers
Base\Estate\Service\CompanyRecords::refresh(Organisation $organisation): bool (with
omnisense/omnistate): a valid SIREN read from Sirene / RNE - the legal name, the legal form, the
creation date, the head office's street (capitals put back in their case: "36 A rue de la
Ganzau"), postcode and city ("Saint-Louis") - and the VAT number from VIES (none when VIES does
not know it, as for an SCI letting homes, which is not liable); registryCheckedAt set. false
without a valid SIREN or for a company the register does not know; a register that does not
answer throws omnistate's UnavailableException and nothing changes.
Dashboard tiles
estate_overview(OverviewWidgetType): per organisation, its figures, the rents to revise within 45 days, the tenants' messages unread, the new applications, the active leases whose insurance is missing or run out.estate_payments(PaymentsWidgetType): the eight latest payments and how, this month's total per method, whether online payment is on.
yield MenuItem::block('estate_overview', 'Gestion', 'fa-solid fa-building');
yield MenuItem::block('estate_payments', 'Encaissements', 'fa-solid fa-euro-sign');
Proposer une modification docs/back-office.md
Configuration reference
estate:
Base\Estate\DependencyInjection\EstateConfiguration, dumped with Symfony's
YamlReferenceDumper in omnisense/omnibase's harness image:
estate:
# The application's user entity: invitations create accounts of this class.
user_class: App\Entity\User
# Who the e-mails to tenants, associates and contractors come from.
from_email: '%env(default::MAILER_CONTACT)%'
# The omnitrade gateway rents are paid online through. Empty: no online payment.
payment_gateway: stripe
# What a tenant may pay with online, as omnitrade names it.
payment_methods:
# Defaults:
- card
- sepa_debit
# How long an invitation link stays valid.
invitation_days: 14
# How many days after its due date an unpaid rent is marked late and reminded (estate:rent:late).
reminder_days: 5
# The application's documents page, linked from the associates' space. Null: none.
documents_route: null
| Option | Type | Default | Read by |
|---|---|---|---|
user_class |
class | App\Entity\User |
Invitations (accounts it creates), InvitationController (usernames taken) |
from_email |
string | %env(default::MAILER_CONTACT)% |
EstateMailer; empty: the mailer's own sender |
payment_gateway |
string | stripe |
OnlinePayments; empty: no online payment |
payment_methods |
list | card, sepa_debit |
OnlinePayments |
invitation_days |
int, 1 or more | 14 |
Invitations |
reminder_days |
int, 1 or more | 5 |
RentCalls::markLate() |
documents_route |
route name or null | null |
AssociateController |
Services
config/services.php autowires and autoconfigures src/ but DependencyInjection/, Entity/,
Enum/, Model/, Exception/, Controller/Admin/ and the bundle class, and leaves out
Service/Books.php and Ledger/ without omnibase/ledger (Base\Ledger\Service\Posting), and
Service/CompanyRecords.php and Command/CompanyRefreshCommand.php without omnisense/omnistate.
Controller/Client/ is tagged controller.service_arguments; Controller/Admin/ too when
omnibase/admin is installed. Books is an optional argument of Payments, RentCalls and
WorkOrders; omnitrade's Registry of OnlinePayments. Spaces implements ResetInterface.
The e-mails are @Estate/email/<name>.html.twig: rent_call, rent_reminder, receipt,
rent_revision, invitation, message, notice, document_added, work_reported,
work_assigned, work_update, visit_request, application_received, application_ack,
application_visit, application_declined.
Proposer une modification docs/configuration.md
Testing
The package's suite is tests/Controller/CrudRequiredFieldsTest.php: on omnibase/admin's
CrudFieldsTestCase, the back office's required fields against what each record needs. In
omnisense/omnibase's harness, with this checkout and its omnibase/ledger sibling mounted:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/estate:ro -v "$PWD/../ledger":/srv/demo/vendor/omnibase/ledger:ro \
omnisense/omnibase-harness test estate
...... 6 / 6 (100%)
OK (6 tests, 7 assertions)
The package has no tests of its services: an application tests them through its own pages and commands. The entities need no database: the in-memory example in Organisations, homes and leases runs in the harness image as it is.