Issue #9 introduces the first persisted domain slice. The model is deliberately tenant/building-aware while the pilot still operates with one building.
| Entity | Status | Purpose |
|---|---|---|
| Building | Implemented | Building/tenant ownership boundary. |
| Unit | Implemented | Residential unit belonging to a building. |
| UserAccount | Implemented foundation | Stable identity/profile anchor. Credentials, login sessions and roles are deferred to #10. |
| ResidentMembership | Implemented | Authorized relationship between a user, building and unit. |
| Amenity | Implemented (issue #19) | Reservable resource such as SUM, pool or barbecue/grill. |
| AmenityAvailabilityWindow | Implemented (issue #19) | Recurring weekly operating window for an amenity. |
| AmenityUnavailablePeriod | Implemented (issue #19) | Maintenance/blackout override for an amenity. |
| Reservation | Implemented (issues #20, #21, #23) | Booking aggregate root; Shared/Exclusive Leisure and Event; hold/expiration lifecycle. |
| ReservationResource | Implemented (issues #20, #21) | Resource(s) attached to a reservation, with per-resource exclusivity; Event attaches base + add-ons. |
| ReservationParticipant | Planned/TBD | Participants in compatible/shared usage if required by the final reservation model. |
| EventSlotDefinition | Implemented (issue #21) | Configured, bookable time-of-day window for Event reservations. |
| PriceRule | Implemented (issue #22) | Configurable, effective-dated pricing rule. |
| ReservationPriceLine | Implemented (issues #20, #21) | Historical snapshot of a PriceQuoteLine, copied onto a reservation at booking time; one row per priced component (base + each add-on). |
| Payment | Implemented (issue #24, Mercado Pago only) | One payment attempt for a reservation: provider-neutral status, amount/currency copied from the reservation’s price snapshot, persisted idempotency key, provider order id. |
| PaymentProviderEvent | Implemented (issue #24) | Identifier-only record of a signature-verified provider notification; the unique key that makes webhook processing idempotent. |
| Message | Post-MVP | Reservation-scoped communication. |
| Notification | Post-MVP | Notification intent/delivery record. |
| AuditLog | Planned | Important administrative/security/domain actions. |
erDiagram
BUILDING ||--o{ UNIT : contains
UNIT ||--o{ RESIDENT_MEMBERSHIP : authorizes
USER_ACCOUNT ||--o{ RESIDENT_MEMBERSHIP : has
BUILDING {
uuid Id PK
varchar Name
varchar TimeZoneId
boolean IsActive
}
UNIT {
uuid Id PK
uuid BuildingId FK
int Floor
varchar Door
varchar Label
boolean IsActive
}
USER_ACCOUNT {
uuid Id PK
varchar Email
varchar NormalizedEmail UK
varchar DisplayName
boolean IsActive
timestamptz CreatedAtUtc
}
RESIDENT_MEMBERSHIP {
uuid Id PK
uuid BuildingId
uuid UnitId
uuid UserId FK
int Status
timestamptz StartedAtUtc
timestamptz EndedAtUtc
}
ResidentMembership stores both BuildingId and UnitId. Its database foreign key is composite:
ResidentMembership(BuildingId, UnitId)
↓
Unit(BuildingId, Id)
This deliberately prevents a membership in one building from referencing a unit owned by another building, even if persistence is accessed outside the normal application flow.
Development startup seeds one pilot building plus these units:
1A 1B
2A 2B
3A 3B
4A 4B
5A 5B
The seed runs only in the ASP.NET Core Development environment and is idempotent by building/unit identifiers. It is development bootstrap data, not production onboarding logic.
erDiagram
BUILDING ||--o{ AMENITY : offers
AMENITY ||--o{ AMENITY_AVAILABILITY_WINDOW : "recurs on"
AMENITY ||--o{ AMENITY_UNAVAILABLE_PERIOD : "blocked by"
AMENITY {
uuid Id PK
uuid BuildingId FK
varchar Name
varchar Kind
boolean AllowsSharedUse
boolean AllowsExclusiveUse
boolean IsActive
}
AMENITY_AVAILABILITY_WINDOW {
uuid Id PK
uuid AmenityId FK
varchar DayOfWeek
time StartTime
time EndTime
}
AMENITY_UNAVAILABLE_PERIOD {
uuid Id PK
uuid AmenityId FK
timestamptz StartsAtUtc
timestamptz EndsAtUtc
varchar Reason
}
The availability query (AmenityAvailabilityCalculator) is a pure, DB-independent
function of an amenity’s windows/periods plus its building’s TimeZoneId. It has no
knowledge of Reservations; see Data Dictionary
and docs/03-architecture/module-boundaries.md for the ownership boundary.
PriceRule is owned by the Pricing module and referenced by AmenityId only
(no EF navigation to Amenity), per the module-boundary rule against
sharing another module’s entities. PricingCalculator is a pure function of
already-loaded rules — no DB/HTTP dependency — that selects the rule
effective at a given instant per (amenity, component, use type) and sums a
base component with any add-ons into a PriceQuote.
Historical correctness (RB-008, RF-009) comes from PriceRule.Supersede
closing a rule’s EffectiveToUtc instead of mutating its Amount: a quote
calculated for a past instant keeps resolving to the rule that was active
then. ReservationPriceLine does not exist yet — Reservations will
persist a copy of each PriceQuoteLine (rule id, amount, currency) once it
can attach them to a real reservation.
See Data Dictionary for column detail and the pilot’s placeholder amounts.
erDiagram
RESERVATION ||--o{ RESERVATION_RESOURCE : books
RESERVATION ||--o{ RESERVATION_PRICE_LINE : "priced by"
RESERVATION {
uuid Id PK
uuid BuildingId
uuid CreatedByMembershipId
varchar UseType
varchar Status
timestamptz StartsAtUtc
timestamptz EndsAtUtc
timestamptz CreatedAtUtc
timestamptz CancelledAtUtc
}
RESERVATION_RESOURCE {
uuid Id PK
uuid ReservationId FK
uuid AmenityId
boolean IsExclusive
}
RESERVATION_PRICE_LINE {
uuid Id PK
uuid ReservationId FK
uuid PriceRuleId
uuid AmenityId
varchar ComponentType
varchar Currency
numeric Amount
timestamptz QuotedAtUtc
}
Covers RF-004 (shared leisure), RF-005 (exclusive leisure), RB-003, RB-004 and
RB-005. UseType reuses Pricing’s ReservationUseType enum directly (see
Data Dictionary) rather than duplicating
it, since Reservations already depends on Pricing per
docs/03-architecture/module-boundaries.md and both need the exact same
value to select a price rule. The enum already has an Event member so
issue #21 does not need a model-breaking change; this issue’s endpoint
rejects Event explicitly (not yet implemented) rather than silently
accepting it.
As of #20/#21 only Confirmed and Cancelled existed and every reservation
was Confirmed immediately. Issue #23 introduces the Pending/Expired
hold lifecycle described below — see
“Concurrency and holds (issue #23)”.
Centralized in ReservationCompatibility (pure, unit-tested): two ranges
overlap when existing.Start < requested.End && requested.Start <
existing.End (contiguous ranges do not overlap), and a conflict exists when
the ranges overlap and either side is exclusive. Shared+Shared is always
compatible today. Evaluated per ReservationResource/Amenity, never per
building or per reservation, so booking the SUM does not block unrelated
amenities.
DEC-014/RB-020 explicitly decides that Shared Leisure has no maximum simultaneous capacity. Any number of compatible Shared Leisure reservations may coexist for the same resource/time; the product shows existing unit participation before confirmation instead of rejecting by count.
Conflict detection now runs inside a transaction guarded by a per-Amenity PostgreSQL advisory lock (issue #23) — see “Concurrency and holds (issue #23)” for why, and for what makes RNF-005 actually hold under real concurrency.
ReservationPriceLine is populated at creation time by calling
PricingCalculator.Calculate (Pricing module) and copying each
PriceQuoteLine verbatim — PriceRuleId, AmenityId, ComponentType,
Currency and Amount — onto the reservation. PriceRuleId/AmenityId are
plain historical ids with no foreign key to PriceRules/Amenities, so
this history stays valid even if that rule is later superseded or the
amenity changes; the reservation’s total is always the sum of its own
ReservationPriceLine.Amount values, never a re-calculation against current
rules.
Reservation creation calls AmenityAvailabilityCalculator.CalculateOpenIntervals
(Amenities module) directly — the calculator itself is not duplicated. The
requested range must be fully covered by the returned open intervals or the
request is rejected; this already accounts for maintenance/unavailable
periods.
Extends the same Reservation/ReservationResource/ReservationPriceLine
model from #20 to ReservationUseType.Event — no parallel model or service.
ReservationCreationService resolves a use-type-specific list of
(Amenity, IsExclusive) “planned resources” (one for Leisure; base + add-ons
for Event) and then runs one shared pipeline — availability, conflict
detection, pricing, persistence — over that list, so Leisure and Event never
duplicate those stages.
An Event’s base resource must be an active Amenity in the same building
with Kind == AmenityKind.Sum and AllowsExclusiveUse == true — checked
against the kind enum, never the "SUM" name/label. Each optional add-on
must have Kind in { Pool, Barbecue } (AmenityKind.Other is explicitly
rejected — issue #21 does not open a general add-on mechanism), belong to
the same building, be active, allow exclusive use, and not repeat the base
or another add-on.
Every resource an Event books — the SUM and each add-on — is reserved with
IsExclusive = true for the whole range: the event blocks any other
incompatible use of each of its resources, not just the SUM. Conflict
detection is evaluated per AmenityId exactly as in #20
(ReservationCompatibility.ConflictsWith); if any one resource conflicts
(e.g. the SUM is free but the Pool has an incompatible booking), the entire
Event is rejected and nothing is persisted (one SaveChanges call for the
whole aggregate).
Pool’s pilot seed data now sets AllowsExclusiveUse = true (previously only
shared) specifically so it can serve as an exclusive Event inclusion. DEC-014
/ RB-023 decides Pool and Barbecue are never independently bookable outside
an Event built on the SUM.
AmenityAvailabilityWindow (#19) expresses when a resource is
physically/operationally usable at all. EventSlotDefinition is a
separate entity expressing the commercial policy of which windows within
that availability may be booked as an Event (e.g. an “afternoon” or
“evening” shift). Conflating the two was explicitly avoided: it would force
Amenities to encode reservation/business policy it does not own.
erDiagram
EVENT_SLOT_DEFINITION {
uuid Id PK
uuid BuildingId
varchar Name
time StartTime
time EndTime
boolean IsActive
}
A request’s startsAtUtc/endsAtUtc, converted to the building’s local
time zone, must match an active EventSlotDefinition’s StartTime/EndTime
exactly, including whether that slot crosses midnight. An Event that
happens to fall inside the SUM’s general
availability but does not match a configured slot (e.g. 13:17–16:43) is
rejected — availability and slot policy are independent checks and both must
pass.
DEC-014/RB-019 decides the pilot Event slots: afternoon 12:00-18:00 and night 20:00-03:00 next day, building-local time. OQ-003 full-day remains deferred and is not implemented; if it is added later it is expected to be a composition of slots or a configured full-day slot, not a new hardcoded rule.
Unchanged from #22/#20: PricingCalculator.Calculate(rules, baseAmenityId,
ReservationUseType.Event, addOnAmenityIds, atUtc) selects the effective
SUM/Base/Event rule plus one .../AddOn/Event rule per add-on, and
currency consistency across all of them is already enforced by
PricingCalculator itself (reused, not reimplemented). Each returned
PriceQuoteLine becomes one ReservationPriceLine. DEC-014/RB-018 revised
the pilot policy to a flat ARS 5,000 Event total inclusive of SUM, Pool and
Barbecue, with free Leisure reservations; pricing remains effective-dated
configuration and historical snapshots still come from the reservation’s own
price lines.
Superseded by issue #23, exactly like #20 — an Event is created Pending
(a hold) and its multiple resources are all locked/checked together within
the same guarded transaction. See
“Concurrency and holds (issue #23)”.
Covers RF-010, RF-011, RF-012, RNF-005, RB-009, RB-010.
The plain read-then-write conflict check from #20/#21 has a real race: two concurrent requests can both read “no conflict” before either writes. Three alternatives were evaluated:
EXCLUDE USING gist over
(AmenityId, tstzrange(...)), requiring the btree_gist extension) — but
RB-003/RB-005’s compatibility rule is asymmetric: any number of Shared
bookings may overlap each other, while an Exclusive booking must conflict
with everything overlapping it, shared or exclusive. An exclusion
constraint’s predicate is evaluated per stored row (like a partial index):
it cannot express “this row conflicts with rows of a different kind”
without forcing every booking through the same equality key — which would
reintroduce a symmetric rule and break Shared+Shared coexistence.Serializable isolation — rejected because it would push a retry loop
onto every caller for serialization failures, and its actual guarantees
are easy to get subtly wrong without dedicated testing this issue’s time
budget did not include (the issue explicitly warned against relying on it
“without testing the real behavior”).pg_advisory_xact_lock) — chosen. A transaction acquires one lock per
distinct AmenityId it is about to book, in a fixed order (sorted by the
Amenity’s own Guid, so two requests wanting overlapping resource sets in
different orders can never deadlock each other), then re-runs the
already-correct read-then-write check. Because the lock fully serializes
access per Amenity, that check now always sees any concurrently-committed
sibling transaction — closing the race. Locks release automatically on
commit or rollback (pg_advisory_xact_lock, not the session-scoped
variant), so there is no separate cleanup path to forget. See
Modules/Reservations/Infrastructure/Persistence/ResourceAdvisoryLock.cs.This required no exclusion-constraint schema changes and, unlike
Serializable, needed no retry logic anywhere — ReservationCreationService
gained one transaction + a lock-acquisition step, nothing else changed
structurally for Leisure vs. Event.
ReservationStatus gained Pending and Expired:
stateDiagram-v2
[*] --> Pending: reservation created (hold)
Pending --> Expired: ExpiresAtUtc passed, unpaid
Pending --> Cancelled
Confirmed --> Cancelled
Expired --> [*]
Cancelled --> [*]
Pending is the hold (RB-009): every reservation is created in this
state now, with an ExpiresAtUtc computed from the configurable
Reservations:Hold:DurationMinutes setting (ReservationHoldOptions —
RF-011). A Pending hold blocks resources exactly like Confirmed.now >= ExpiresAtUtc — checked at
conflict-detection query time, not only when the expiration job has run —
and transitions to Expired (RB-010) via
ReservationExpirationService.Pending → Confirmed transition; issue #24
adds it, driven only by a trusted Mercado Pago confirmation (see
Payments and Mercado Pago). Cash
confirmation (#25) is still to come.Confirmed and Cancelled are otherwise unchanged from #20.DEC-015/RB-025 decides the pilot hold duration as 24 hours, represented as
Reservations:Hold:DurationMinutes = 1440 in configuration. The duration
remains a deployment/building-policy setting, not a domain constant.
ReservationExpirationService.ExpirePastHoldsAsync is a single EF Core
ExecuteUpdateAsync — one atomic UPDATE ... WHERE "Status" = 'Pending' AND
"ExpiresAtUtc" <= @now — rather than loading entities into the change
tracker. This is what makes it safe under real concurrent execution with no
locking of its own: PostgreSQL locks and re-evaluates each candidate row’s
WHERE predicate individually, so if two expiration runs race, whichever
commits first flips a row to Expired; the second run’s predicate no longer
matches that row (it is no longer Pending), so it is simply skipped — no
exception, no double-processing. Calling it twice in a row is equally
idempotent (the second call matches nothing new and returns 0). It never
touches Confirmed/Cancelled rows and never “revives” an
already-Expired one, because the WHERE clause only ever matches rows
still Pending.
ReservationExpirationHostedService (a BackgroundService using
PeriodicTimer, interval configurable via
Reservations:Expiration:IntervalSeconds) calls this automatically so
RF-012 (“release expired unpaid holds automatically”) does not depend on any
manual/admin trigger. One tick’s failure is logged and never stops future
ticks or crashes the host.
ReservationCreationService and ReservationExpirationService both take a
TimeProvider (the standard .NET clock abstraction, not a bespoke
interface) instead of calling DateTimeOffset.UtcNow directly, so
expiration tests can control “now” deterministically without
Thread.Sleep. Production registers TimeProvider.System.
Unchanged in spirit from #21, now inside the lock-guarded transaction: an
Event’s advisory locks (one per resource), conflict check, price quote and
Reservation/ReservationResource/ReservationPriceLine inserts all
happen before a single SaveChanges + Commit. If any resource fails its
conflict check, the transaction is rolled back by disposing it uncommitted —
no resource, hold or price line for that Event persists partially.
Pending → Confirmed transition
(Mercado Pago and the transition arrived in #24; cash in #25).Covers RF-013, RF-014, RNF-003, RNF-006, RB-011. Payments depends on Reservations, never the reverse.
Payments owns Payment, PaymentProviderEvent, the Mercado Pago
integration, provider order ids/statuses, webhook processing and
idempotency. Reservations owns the reservation and its lifecycle
(Pending/Confirmed/Expired/Cancelled) and its price snapshot.
Payments never reads or writes Reservation tables. It talks to Reservations
through IReservationPaymentContract:
GetPayableReservationAsync — status, hold deadline, total and currency,
derived only from ReservationPriceLine (the historical snapshot,
RB-008) — never from current PriceRules and never from the client.ConfirmPaidReservationAsync — Payments reports a trusted payment;
Reservations decides whether Pending → Confirmed is still valid and
answers Confirmed, AlreadyConfirmed, RejectedExpired,
RejectedCancelled or NotFound. No Mercado Pago DTO or status string
appears on this contract or anywhere in Modules/Reservations.Payment = one attempt. Amount/currency are copied from the snapshot when
the attempt is created. Status is provider-neutral —
Created, Pending, Approved, Rejected, Cancelled — and the provider’s
own strings live separately in ProviderStatus/ProviderStatusDetail.
Refunded is intentionally not modelled: refund logic is out of scope
and a member with no behaviour behind it would be dead surface.
PaymentMethod gained Cash in #25 (see Cash payments); it is the same Payment model.
ExternalReference (sent to the provider and re-validated on every
reconciliation) is the payment’s own id. ReservationOutcome records what
Reservations decided when an approval was reported (see “Late payment”).
Verified against Mercado Pago’s current documentation when implemented:
POST /v1/orders with type=online, processing_mode=manual,
total_amount (decimal string), external_reference,
expiration_time (ISO-8601 duration), one item, and
config.back_urls; headers Authorization: Bearer <access token> and
X-Idempotency-Key. The response carries id, status, status_detail,
checkout_url, currency. The Preferences API is not used. Note that
currency is determined by the seller’s country and is not sent on
creation, so it is validated against the local snapshot rather than trusted.
expiration_time is computed from the remaining hold time
(Reservation.ExpiresAtUtc - now, whole seconds), never a hardcoded
duration; ExpiresAtUtc stays the source of truth for availability, and
opening Mercado Pago never extends the hold. It is persisted on the
Payment so retries send an identical body.
There is deliberately no DB transaction around the external call:
Payment — with its IdempotencyKey and the exact
expiration_time to send — is committed first.If the connection drops after Mercado Pago created the order but before we
saved the answer, the row is still there. The user’s retry finds a Payment
with no provider order, and calls Mercado Pago again with the same key and
the same body, so the provider returns the existing order instead of
creating another. A filtered unique index
(UX_Payments_ActivePerReservation, over Created/Pending/Approved)
allows one live attempt per reservation, so concurrent requests cannot fork
into two attempts. Technical retry ≠ new attempt: a Rejected/Cancelled
attempt drops out of that index, and the next request (while the hold lasts)
creates a new Payment with a new key.
POST /api/webhooks/mercadopago is public (no user cookie/token — Mercado
Pago calls it) but is authenticated by verifying x-signature (HMAC-SHA256;
algorithm below). An invalid signature stores nothing, fetches nothing and
returns 401. The signature does not cover the body, so the order id is
taken from the signed data.id query parameter only; body or query
status claims are never trusted.
After verification the order is re-fetched with GET /v1/orders/{id} using
the private token and cross-checked against the local payment: order id,
external_reference, total_amount, currency. Any mismatch changes
nothing. The fetch happens before any state change and outside any
transaction.
Approval is status = processed and status_detail = accredited
(current official Orders API model). created/processing/action_required
stay Pending; failed → Rejected; canceled/expired → Cancelled;
anything else (refunds, chargebacks, other processed details) is recorded
but never treated as approval.
PaymentProviderEvent has a unique (Provider, ProviderEventId) key (the
signed x-request-id) and stores identifiers only — no payload, payer data
or signature. Re-delivering a processed event returns immediately. Even if
a duplicate slips through (different x-request-id, concurrent delivery, or
a crash mid-way), every step is state-based and idempotent: a payment moves
to Approved once, and Confirmed → Confirmed is a no-op, so repeated
notifications cannot duplicate any effect. An event whose processing failed
(e.g. provider outage) stays unprocessed and answers 503 so Mercado Pago
redelivers; an unknown order is acknowledged and left unprocessed.
ReservationExpirationService does Pending → Expired (one atomic
UPDATE ... WHERE Status = 'Pending' AND ExpiresAtUtc <= now), while a
webhook wants Pending → Confirmed. They must never both win. Chosen
mechanism: a row-level lock. ConfirmPaidReservationAsync runs
SELECT ... FOR UPDATE on the reservation row inside a transaction and then
applies Reservation.Confirm on the freshly-read state. PostgreSQL
serializes it with the expiration UPDATE on that row:
WHERE
and skips the now-Confirmed row;Expired, and Confirm
refuses to revive it.Confirm also refuses a Pending reservation already past
ExpiresAtUtc even if the job has not swept it: conflict detection stopped
counting that hold at its deadline, so its resources may already be booked
by someone else. Row lock chosen over an advisory lock (the contended
resource is the row) and over a conditional UPDATE (it lets the domain
method stay the single source of truth for what “can be confirmed” means).
Proven with a deliberate RED/GREEN cycle: with FOR UPDATE removed, a
deterministic test (a test transaction holds the row lock while a
confirmation is in flight, then commits an expiration) shows the
confirmation reviving an Expired reservation; with the lock restored it
returns RejectedExpired.
Expired + a late approved payment ≠ Confirmed. The provider really
did take the money, so the Payment is recorded Approved, but the
reservation stays Expired, is not re-booked, and no other resident’s
reservation is touched. Payment.ReservationOutcome says why:
ApprovedAfterExpiry (or ApprovedForCancelledReservation /
ApprovedForMissingReservation), and RequiresManualReview is true so the
inconsistency is explicit and queryable for later compensation/refund.
Automatic refund is out of scope for #24.
config.back_urls (MercadoPago:SuccessUrl/PendingUrl/FailureUrl) are
UX only (RB-011). The return screen asks GET /api/payments/{id}, which
reports backend state; there is no code path where a URL or query string
confirms a reservation.
x-signature verificationPer the official documentation and the official SDKs’ validators (e.g.
mercadopago/sdk-go pkg/webhook): header ts=<ts>,v1=<hex>; manifest
id:<data.id lowercased>;request-id:<x-request-id>;ts:<ts>; (a pair whose
value is absent is omitted); v1 = hex(HMAC-SHA256(webhook secret, manifest))
compared in constant time. ts may be seconds or milliseconds. A timestamp
tolerance (MercadoPago:WebhookToleranceSeconds, default 600) is defence in
depth only — replays are harmless because processing is idempotent and always
re-fetches. Tested with an independent openssl reference vector, plus
invalid/missing/malformed/manipulated cases.
The official mercadopago-sdk (3.x, .NET 8+) has an Orders client, but this
integration needs exact control of the persisted X-Idempotency-Key,
persisted expiration_time, config.back_urls and the checkout_url
response field, and those specifics could not be confirmed from the SDK’s
published surface. The needed surface is two endpoints plus a small HMAC
check, so a small typed HttpClient (IMercadoPagoClient in
Payments.Application, MercadoPagoHttpClient in Payments.Infrastructure)
keeps the request explicit, keeps tests offline (fake client / fake
handler), and avoids a dependency of unverified coverage.
Refunds and automatic compensation of late payments, payer/customer data, admin views of payments needing review (#26), audit (#27), and any frontend.
Covers RF-015, RF-016, RB-012.
Cash declared → Payment(Cash, Pending) → Reservation stays Pending (hold unchanged)
Cash received → Payment Approved → Reservations decides Pending → Confirmed
Cash received after expiry
→ Payment Approved + ApprovedAfterExpiry (manual review)
→ Reservation stays Expired
Payment model, provider-neutralPayment no longer assumes a provider. General concepts live on every
payment (id, reservation, method, status, amount, currency, outcome,
timestamps). Method-specific data is nullable and set only by that method’s
factory:
| Data | Mercado Pago (CreateMercadoPago) |
Cash (CreateCash) |
|---|---|---|
IdempotencyKey, RequestedExpirationTime |
required | NULL |
ProviderOrderId, CheckoutUrl, ProviderStatus, ProviderStatusDetail |
set as the provider answers | NULL (mutators throw) |
CashDeclaredAtUtc, CashConfirmedAtUtc, CashConfirmedByUserId |
NULL | set by declaration / confirmation |
initial Status |
Created |
Pending |
No placeholder values are stored for the fields a method does not use. The
unique indexes on IdempotencyKey and ProviderOrderId keep working because
PostgreSQL treats NULLs as distinct. PaymentMethod is MercadoPago | Cash.
POST /api/reservations/{id}/payments/cash creates Method=Cash,
Status=Pending. Pending for cash means an authorized person has not
confirmed receipt yet. There is no PendingCashConfirmation
ReservationStatus: the reservation stays Pending and the difference lives
inside Payments, which keeps the module boundary clean. Declaring does not
touch Reservation.ExpiresAtUtc; DEC-015/RB-025 sets the pilot hold policy
to 24 hours via configuration.
Amount and currency come only from the ReservationPriceLines snapshot.
The caller needs building access and must be the membership that created
the reservation (PayableReservation.CreatedByMembershipId, exposed through
the Reservations contract — Payments never reads Reservation tables). An
Administrator keeps privileged access. No user/membership/amount/status
value is accepted from the request. This is stricter than Mercado Pago
initiation, which is unchanged.
UX_Payments_ActivePerReservation (Created/Pending/Approved) is
unchanged, so Mercado Pago and cash can never be active together on a
reservation. A cash declaration while a Mercado Pago payment is active — or
the reverse — answers 409. Switching method is not implemented; it can be
modelled if it becomes a requirement.
Declaring twice returns the same payment (no X-Idempotency-Key needed):
the service looks up the active payment first, and the unique index is the
safety net for concurrent requests (the loser re-reads and reuses the
winner’s payment).
POST /api/payments/{id}/cash/confirm requires the Administrator policy.
DEC-015/RB-026 decides this as the final pilot policy: cash receipt is
confirmed by an authenticated Administrator account reserved for building
administration. A Resident gets 403. The confirming actor is taken from
the authenticated session (UserManager.GetUserAsync); nothing about who
confirmed is read from the request.
The payment records the answer to “who confirmed receiving the money and
when”: CashConfirmedByUserId, CashConfirmedAtUtc (plus
CashDeclaredAtUtc), next to Amount, ReservationId and
ReservationOutcome. CashConfirmedByUserId is a historical id, not a
foreign key to Identity. The resident-facing GET /api/payments/{id} shows
the confirmation time but not the administrator’s id; the administrator’s
confirm response includes it. The cross-cutting AuditLog for administrative
actions remains issue #27; this issue makes the cash confirmation
intrinsically traceable without building that module.
SELECT ... FOR UPDATE on the payment row,
Payment.ConfirmCashReceived(actor, now) moves Pending → Approved and
stamps actor/time (no external call is made in this transaction). A second
click waits for the lock, sees Approved, and changes nothing — the
original actor and timestamps are kept.IReservationPaymentContract.ConfirmPaidReservationAsync. There is no
cash-specific contract method: Reservations only learns “a trusted payment
succeeded” and never whether it was Mercado Pago or cash.PaymentReservationOutcomeMapper)
and recorded once. If a crash left Approved with outcome None, repeating
the confirmation completes it.The race reuses the #24 mechanism unchanged: the reservation row lock
(FOR UPDATE) versus the atomic conditional expiration UPDATE. Exactly one
wins. A concurrent test (100 cash-confirmation/expiration pairs on real
PostgreSQL) plus a deterministic lock-holding test assert that a reservation
is never both, and that the payment outcome always matches.
“Confirm cash” asserts the money was received, so the payment is
Approved in every case. If Reservations answers RejectedExpired the
outcome is ApprovedAfterExpiry; for a Cancelled reservation it is
ApprovedForCancelledReservation. Both set RequiresManualReview, and the
reservation is never revived. There is no automatic refund — for cash the
compensation is likely a manual return.
Refunds, the central AuditLog (#27), the admin dashboard/DTOs (#26),
cancellation/reschedule, payment-method switching, notifications, receipts,
accounting/reconciliation reports, and any frontend.
Covers RF-021, RNF-007, RB-014 (the “reason” part is enforced by #26).
Audit records business facts: who did what, when, to which object, in which building. It is not technical logging and does not replace Application Insights. It is never an authority: no availability, payment or authorization decision reads it; the operational tables remain the source of truth.
AuditLog (append-only)Id, OccurredAtUtc, BuildingId?, ActorType, ActorUserId?, Action,
TargetType, TargetId?, CorrelationId?, MetadataJson? (jsonb).
AppDbContext throws if a tracked AuditLog is modified or deleted. A
correction is a new event. (No cryptographic tamper-proofing yet.)BuildingId is set whenever the object belongs to a building and is NULL
otherwise (e.g. a failed login); it is never invented.CorrelationId reuses the ASP.NET Core trace id for request-driven events;
background jobs have none.ActorType |
ActorUserId |
Examples |
|---|---|---|
User |
required | resident creates a reservation; admin confirms cash |
System |
NULL | expiration job; Reservations confirming through the trusted-payment contract; a failed login (no resolvable user) |
ExternalProvider |
NULL | Mercado Pago approving/rejecting a payment |
The invariant is enforced by the AuditLog constructor and stated at call
sites by AuditRecord.ByUser / BySystem / ByExternalProvider.
AuditAction)Actions are a closed enum, never free strings; a new audited fact means a new member. Stored by name.
| Area | Action | Actor | Target | Recorded when |
|---|---|---|---|---|
| Identity | AuthenticationSucceeded |
User | User | successful login |
| Identity | AuthenticationFailed |
System | User (no id) | failed login; metadata has only a general failure category |
| Identity | Logout |
User | User | logout |
| Reservations | ReservationCreated |
User | Reservation | reservation persisted |
| Reservations | ReservationConfirmed |
System | Reservation | the real Pending → Confirmed transition (not on a repeat) |
| Reservations | ReservationExpired |
System | Reservation | each real Pending → Expired transition |
| Reservations | ReservationCancelled |
User (administrator) | Reservation | admin cancellation (#26), metadata reason |
| Reservations | ReservationRescheduled |
User (administrator) | Reservation | admin reschedule (#26), previous/new range and reason |
| Pricing | PriceRuleCreated / PriceRuleSuperseded |
User (administrator) | PriceRule | new rule / rule closed by it (#26); amenity, use type, component, currency, amount, effective range |
| Amenities | AmenityAvailabilityChanged |
User (administrator) | Amenity | windows replaced, maintenance period added/removed (#26); operation and count only |
| Reservations | EventSlotCreated / EventSlotUpdated / EventSlotDeactivated |
User (administrator) | EventSlot | Event slot configuration (#26); name, times, active flag |
| Payments | MercadoPagoPaymentInitiated |
User | Payment | attempt created (not on a resumed attempt) |
| Payments | CashPaymentDeclared |
User | Payment | declaration created (not on an idempotent repeat) |
| Payments | CashPaymentConfirmed |
User (the confirmer) | Payment | outcome recorded after cash confirmation |
| Payments | PaymentApproved |
ExternalProvider | Payment | Mercado Pago approval, outcome recorded |
| Payments | PaymentRejected / PaymentCancelled |
ExternalProvider | Payment | real status transition only |
| Payments | PaymentRequiresManualReview |
as the payment (User for cash, provider for Mercado Pago) | Payment | outcome ApprovedAfterExpiry / ApprovedForCancelledReservation / ApprovedForMissingReservation |
| Provider | MercadoPagoWebhookProcessed |
ExternalProvider | Payment | once per processed webhook event (metadata: result category) |
ReservationConfirmed is attributed to the system deliberately: Reservations
only learns that “a trusted payment succeeded” and never a provider or
method. The real actor is on the Payments event (CashPaymentConfirmed names
the administrator; PaymentApproved names the provider). Targets are ids
(TargetType + TargetId), never human text.
Metadata is small, structured, server-built and allowlisted. AuditMetadata
has no public constructor and exposes only typed factories (ids, enums,
amounts, currency, instants). There is no way to pass a Dictionary or
object, and all keys come from AuditMetadata.AllowedKeys: useType,
startsAtUtc, endsAtUtc, resourceCount, status, originalExpiresAtUtc,
reason, reservationId, amount, currency, method, reservationOutcome,
outcome, result, failure.
Never audited: passwords, tokens (access/refresh), cookies, Authorization
headers, secrets (Mercado Pago access token, webhook secret), x-signature,
connection strings, checkout URLs, idempotency keys, request/response
bodies, card data, payer personal data, and the e-mail submitted at login.
There is no middleware that stores requests. reason (for the cancel and
reschedule of #26) is truncated to 500 characters.
IAuditRecorder.Record only adds the entry to the current
AppDbContext; the use case that owns the transition calls SaveChanges, so
the transition and its audit entry persist together or not at all:
ReservationCreated: same SaveChanges/transaction as the reservation.ReservationConfirmed: inside the FOR UPDATE transaction of
ConfirmPaidReservationAsync, only when this call made the transition.ReservationExpired: see below.MercadoPagoPaymentInitiated, CashPaymentDeclared: with the Payment insert.PaymentApproved / CashPaymentConfirmed /
PaymentRequiresManualReview): with the outcome update, in one transaction.PaymentRejected/Cancelled, MercadoPagoWebhookProcessed: with the local
state change / the event being marked processed.No transaction is opened around external HTTP: the provider is fetched first and only the resulting local transitions are audited when they are persisted. There is no outbox and no distributed transaction. Login/logout audit is best-effort (a failure to write it is logged and does not break the request, since audit is history, not authority). Tests force a failing audit write on real PostgreSQL and check the confirmation and the expiration roll back too.
ReservationExpirationService remains one set-based statement, now
UPDATE ... WHERE Status = 'Pending' AND ExpiresAtUtc <= now RETURNING Id,
BuildingId, ExpiresAtUtc in a transaction with the audit inserts. RETURNING
yields exactly the rows this statement transitioned; under READ COMMITTED a
concurrent run (or a confirmation) that already resolved a row makes the
WHERE no longer match it, so that row is neither returned nor audited. Each
Pending → Expired transition therefore produces exactly one
ReservationExpired, without a per-row loop.
Audit entries are only created for real transitions: a repeated webhook, a
repeated cash confirmation or declaration, an already-confirmed reservation,
and concurrent expiration workers do not add entries. The payment outcome is
written with UPDATE ... WHERE ReservationOutcome = 'None'; only the caller
that changed the row audits it. Known residual: PaymentRejected/Cancelled
is detected from the tracked state, so two truly simultaneous, different
webhook events for the same order could both record it (no row lock there yet).
GET /api/admin/audit (Administrator policy; a Resident gets 403). Filters:
buildingId, actorUserId, action, targetType, targetId, fromUtc
(inclusive), toUtc (exclusive), page, pageSize. Newest first; default
page size 50, capped at 100. Returns id, occurred-at, building, actor type and
id, action, target type and id, correlation id and the metadata object. It
does not join Identity for names or e-mails. It is read-only.
Not implemented: for the MVP audit entries are kept indefinitely. The retention/archival policy is deferred to production/FinOps.
Issue #26 (admin operations) now records cancel, reschedule, price rule, availability and Event slot changes through IAuditRecorder in the same transaction as the change, with a mandatory reason for cancel and reschedule (RB-014). Payments
needing manual review are queryable with action=PaymentRequiresManualReview.
Covers RF-017, RF-018, RF-019, RF-020, RB-013, RB-014.
The Administration module only authenticates, maps HTTP and composes read models. It has no repositories and touches no other module’s tables: every command is a contract implemented by the module that owns the data, which enforces its own invariants.
| Contract | Owner | Use |
|---|---|---|
IReservationAdminQuery |
Reservations | admin reservation list / detail (read model) |
IReservationAdminContract |
Reservations | cancel, reschedule |
IEventSlotAdminContract |
Reservations | Event slot configuration |
IPaymentAdminQuery |
Payments | admin payment list, payments per reservation (read-only) |
IPricingAdminContract |
Pricing | list / create price rules |
IAmenityAdminContract |
Amenities | availability windows, maintenance periods, amenity lookup |
Every route lives under /api/admin/... (group policy Administrator; a
Resident gets 403, an anonymous caller 401). The actor is always the session
user; a client-sent role or actor id is ignored. The audit query is
GET /api/admin/audit (#27) and cash confirmation is
POST /api/payments/{id}/cash/confirm (#25); neither is duplicated.
Projections with AsNoTracking, no aggregate loading, two queries per page
(reservations with resources and totals in SQL, then all payments of the page
in one query). Page size defaults to 50 and is capped at 100.
GET /api/admin/reservations — filters buildingId, status, useType,
fromUtc/toUtc (on the start of the booking), membershipId, page,
pageSize. Newest created first (so a dashboard shows recent activity; the
time filters answer “what is coming up”). Each item carries lifecycle
timestamps, cancellationReason, creator membership, resources, total,
currency and the reservation’s payments.GET /api/admin/reservations/{id} — the same plus the price snapshot lines.GET /api/admin/payments — filters buildingId, method, status,
requiresManualReview, reservationId, fromUtc/toUtc, page,
pageSize. requiresManualReview=true lists the payments whose outcome is
ApprovedAfterExpiry, ApprovedForCancelledReservation or
ApprovedForMissingReservation. Read-only: no refund, no “resolve review”,
no editing.Payment views never expose the access token, webhook secret, idempotency key,
checkout URL, provider order id or any provider payload. Payment.BuildingId
(copied from the reservation when a payment is created) gives Payments its own
building scope without reading Reservations’ tables.
requiresFinancialReview is computed by Administration (never stored, never
changing the payment): true when a payment is flagged for manual review, or
when an approved payment sits on a cancelled reservation.
POST /api/admin/reservations/{id}/cancel with a mandatory reason
(trimmed, non-empty, at most 500 characters — AuditMetadata.MaxReasonLength).
| Current state | Result |
|---|---|
Pending, Confirmed |
→ Cancelled; CancelledAtUtc and CancellationReason stored on the reservation |
Cancelled |
idempotent 200; original timestamp/reason kept; no second audit entry |
Expired |
409, nothing changes (Expired is terminal) |
CancellationReason lives on the reservation as well as in the audit entry:
the current state must not depend on audit. Nothing is deleted (RB-013).
A payment is never touched by a cancellation: an approved payment stays
Approved, is not marked rejected and nothing is refunded automatically. The
financial/refund consequence remains an explicit deferred follow-up. The
ReservationCancelled audit entry is written in the same transaction
(actor = the administrator, metadata reason).
POST /api/admin/reservations/{id}/reschedule with startsAtUtc, endsAtUtc
and a mandatory reason. Only the time range moves — never building, use
type, resources, price snapshot, payment or ExpiresAtUtc.
| Current state | Result |
|---|---|
Confirmed |
allowed |
Pending with an active hold |
allowed (the hold is not extended) |
Pending past its hold |
409 |
Expired, Cancelled |
409 |
The new range must satisfy the same invariants as creation — valid range,
availability windows, maintenance periods, Event slot match (for Event, against
the slots active now), and shared/exclusive conflicts — through the shared
ReservationScheduleValidator (creation uses it too; nothing is duplicated).
The reservation is excluded from its own conflict check. Moving to the exact
same range is a no-op (no audit).
Concurrency, inside one transaction: (1) lock the reservation row
(FOR UPDATE, the lock payment confirmation and expiration also contend on);
(2) take the per-Amenity advisory locks in ascending order (the same locks
creation uses); (3) re-read and check the state; (4) validate; (5) conflict
check excluding itself; (6) change the range; (7) record ReservationRescheduled
(previous and new range and the reason); (8) commit. The order reservation row →
advisory locks cannot deadlock: creation takes only advisory locks, and
confirmation/cancellation only the row lock.
Pricing is not recalculated on reschedule: the historical snapshot stays. No stakeholder rule says moving the date reprices; if one is decided it needs its own business rule/ADR.
GET/POST /api/admin/pricing/rules. Rules stay effective-dated and immutable:
the amount of an existing rule is never edited. Creating a rule for a
building + amenity + component + use type:
effectiveFromUtc (default now); backdating is refused
(422) so the history of which price applied is never rewritten;IAmenityAdminContract, 404 otherwise).It runs under the amenity advisory lock (also ordering it against reservation
creation) and records PriceRuleCreated / PriceRuleSuperseded in the same
transaction. Existing reservation snapshots never change. DEC-014/RB-018
decides the pilot prices, and pricing remains configuration rather than
hardcoded domain/client constants.
GET /api/admin/amenities/{id}/availability?buildingId= — current windows and
maintenance periods.PUT /api/admin/amenities/{id}/availability { buildingId, windows[] } —
replaces the weekly windows: 1–28 windows, each ending after it starts (no
overnight), no overlapping or duplicate windows on a day (422). To close an
amenity use a maintenance period instead of an empty list.POST /api/admin/amenities/{id}/unavailable-periods and
DELETE .../unavailable-periods/{periodId}?buildingId= — add or remove a
maintenance/blackout period (an identical duplicate is a 409).The amenity must belong to the given building (404 otherwise). A change only
shapes future availability: existing reservations are never modified or
cancelled (an administrator can reschedule or cancel them explicitly). Each
operation runs under the amenity advisory lock and records one
AmenityAvailabilityChanged entry (operation and count, plus the reason for a
period — never the window list).
GET/POST /api/admin/buildings/{buildingId}/event-slots,
PUT /api/admin/event-slots/{id}, POST .../deactivate, POST .../activate.
Slots are activated/deactivated, never deleted. Invariants are the creation
ones: start before end, at most one midnight boundary, and no full-day
(OQ-003 deferred); exact times stay configurable, with DEC-014/RB-019 as the
pilot policy. A duplicate
(building, start, end) is a 409. A deactivated slot stops matching new Event
reservations and reschedules; existing reservations are untouched. Audit:
EventSlotCreated, EventSlotUpdated (also used for re-activation, with the
active flag), EventSlotDeactivated; only real changes are audited.
A resident (not just Administrator) discovers a building’s active Event
slots via the separate, narrower GET /api/buildings/{buildingId}/event-slots?date=
(issue #62) — a distinct contract, never a relaxed policy on the admin
route above. It expands each active slot to a UTC startsAtUtc/endsAtUtc
occurrence for the requested calendar date using Building.TimeZoneId
server-side (rejecting a DST-invalid/ambiguous local time with 422
instead of guessing an offset), and does not consult
Reservations/pricing/amenity availability — it reports configured slots,
not final bookability; POST /api/reservations remains authoritative.
Every command writes its audit entry in the same SaveChanges/transaction as
the change (tests force a failing audit write on real PostgreSQL and check the
change rolls back for cancel, reschedule and price rules).
Issue #2 is closed for MVP/pilot policy. Full-day reservations remain deferred (OQ-003), and the financial/refund consequence of a late/disallowed Event cancellation remains an explicit follow-up; no automatic refund behavior is assumed.
Two gaps closed, one read model added — no schema change, no new command.
Object-level authorization on GET /api/reservations/{id}. Before #66
it only checked IBuildingMembershipAuthorizer.HasAccessAsync (building
membership), so any resident of the same building could read another
resident’s reservation by id — the GUID was doing authorization work it was
never meant to do. It now also requires, for a non-Administrator caller,
that their own active membership (GetActiveMembershipIdAsync) equals the
reservation’s CreatedByMembershipId. Administrator keeps its bypass. This
is the same ownership check reservation creation and cash declaration
already use; #66 just closes the one read path that had been left on the
weaker, building-level check.
“My reservations” (GET /api/reservations?buildingId=&page=&pageSize=)
is a new read side, ResidentReservationQuery/IResidentReservationQuery,
deliberately separate from ReservationAdminQuery: same efficient
single-page projection (AsNoTracking, no aggregate loaded, no N+1,
CreatedAtUtc DESC/Id DESC ordering with the same default/max page size),
but mandatorily filtered to one resolved membership — it can never become a
general building listing, and its row type never carries
CreatedByMembershipId or any other actor id the way AdminReservationRow
does. Ownership is resolved from the caller’s session exactly like
reservation creation; a membership/user id sent by the client is not a
recognized parameter and has no effect.
Resident payment history (GET
/api/reservations/{reservationId}/payments) stays inside Payments and
resolves ownership through the existing IReservationPaymentContract.
GetPayableReservationAsync — despite its name, it already returns
BuildingId/CreatedByMembershipId for a reservation regardless of status,
which is exactly what a history view of an old (possibly Expired/
Cancelled) reservation needs; Payments still never reads a Reservations
table directly. The endpoint is a list, not a singular resource, on purpose:
a Rejected/Cancelled attempt does not block a new one while the hold is
still valid, so a reservation can legitimately have 0..N payments.
requiresManualReview is read off Payment.RequiresManualReview (the one
place that rule is defined, from ReservationOutcome) — never
reimplemented for this endpoint.
UserAccount exists now because memberships need a stable user foreign key. It intentionally does not implement authentication credentials or authorization roles.
Issue #10 owns:
This preserves the architecture boundary between identity data and building membership.
3A are display/business identifiers scoped to a building, not global IDs.See Data Dictionary for the persisted v0.2 fields and constraints.