Resource types and gateway resolution¶
Design and implementation plan for moving the behavioral profile off the individual resource and onto a shared, admin-managed resource type, and for resolving the payment gateway through a tenant-aware chain rather than hardcoding it.
The problem¶
Today workflow, gateway (effectively), and a pile of behavioral settings
live on each yoyaku_resource. Two things break because of that:
- The order, not the resource, is the unit of payment and lifecycle. An
order (transaction) can group bookings from several resources, paid once and
driven by one workflow. With the profile on the resource, a multi-resource
order is ambiguous:
BookingWorkflowStartersilently uses the first held line's resource workflow, and a per-resource gateway would have no single answer. - The gateway is hardcoded in several places.
CheckoutControllerpasses'simulator'literally, and each orchestra payment action carries its owngatewaysetting, so switching gateways means editing code and config in parallel, and the example already ships a blob-vs-config mismatch.
Forcing "one order = one resource" would fix resolution but is too limiting: a cart of several resources is a legitimate, desirable case.
The real fix: the resource is the wrong anchor for behavior. Move the profile to a resource type, and resolve the gateway through a tenant-aware chain.
Overview¶
- A new
yoyaku_resource_typeconfig entity carries the behavioral profile. yoyaku_resourcereferences a type and keeps only instance data.- The type's
tenantis nullable: NULL means a shared type any tenant can use; set means private to that tenant. - Submodules attach their own settings to the type via
third_party_settings, so the core type stays feature-agnostic. - The gateway resolves through
type override -> order tenant default -> site default. - Invariant: one order = one resource type (so also one tenant), enforced in
BookingManager::holdGroup().
The resource type entity¶
yoyaku_resource_type, a config entity (consistent with the existing Tenant
config entity). It is referenced by the resource, not used as a content bundle:
a standalone entity plus a reference field is a lighter migration than
converting the resource to bundles, lets a resource change type, and sidesteps
per-tenant bundles.
Core properties:
id,labeltenant(nullable string): NULL = shared, set = private to that tenantbooking_cutoff,lead_time,lead_time_unit,hold_ttl,default_capacity: the core behavioral defaults
Submodule settings ride in third_party_settings, keyed by the providing
module, so the core entity never references orchestra, payment, or calendar:
yoyaku_orchestra:workflow,auto_confirmyoyaku_payment:gateway(override),collect_mode,collect_value,price,payment_inherit,caution_enabled,caution_amount,caution_mode,noshow_feeyoyaku_calendar:calendar_selection,selection_min_days,selection_max_days,selection_contiguous,date_only,single_unit,uniform_quantity,show_availability_count
Each submodule adds its block to the type form with a hook_form_alter and
reads its block back through the type, exactly as form alters and
third_party_settings work for field formatters today.
Setting levels: order-level vs per-line¶
There are two kinds of setting, resolved differently. Getting this split right is the crux: not everything on the type is uniform across its resources.
Order-level (type-only, no per-resource override). Seeded once when the order's workflow starts, so a per-resource override would make a multi-resource order ambiguous. These are the settings that motivated the type, and they are authoritative on the type:
| Setting | Provider |
|---|---|
| workflow, auto_confirm | yoyaku_orchestra |
| gateway | yoyaku_payment (resolved type -> tenant -> site) |
Per-line (type default + per-resource override; empty resource value = inherit). These apply to each resource or booking individually and may legitimately differ between resources in one order, so the resource keeps its own field. The type value is just the default:
| Setting | Provider |
|---|---|
| booking_cutoff, lead_time(+unit), hold_ttl, capacity | core |
| price, collect_mode, collect_value, payment_inherit, caution_enabled, caution_amount, caution_mode, noshow_fee | yoyaku_payment |
| calendar_selection, selection_*, date_only, single_unit, uniform_quantity, show_availability_count | yoyaku_calendar |
Resolution for a per-line setting is resource override (if set) -> type
default. The getters (getBookingCutoff(), the price and caution lookups,
etc.) resolve resource then type, the same fallback shape already shipped for
booking_cutoff and show_availability_count. So price and caution_amount
stay fully overridable on each resource; the type only supplies a starting
value.
Stays on the resource regardless (pure instance data): label, status,
tenant, the new type reference, created/changed, slots, managers,
opens_at, and the node binding.
So the resource keeps most of its current fields, now meaning "override, empty
inherits from the type." Only the order-level trio (workflow, auto_confirm,
gateway) is type-only with no resource override. Migration therefore does not
empty the per-line resource fields; it only seeds type defaults and moves the
order-level settings off the resource.
Gateway resolution¶
A PaymentGatewayResolver service (mirroring PaymentPolicyResolver) with
gatewayForOrder(OrderInterface): string, walking:
resource type gateway override (third_party_settings)
-> order tenant default (Tenant config entity property)
-> site default (yoyaku.settings:default_gateway)
- The order's tenant is always well defined (an order is single-tenant), so the middle step resolves even when the type is shared.
- A shared type normally leaves the override empty and falls through to the using tenant's default, because a gateway is a merchant-account concern, not something a shared type should pin. A shared type may still pin a gateway deliberately (a site-run type), which the chain allows.
Tenantgains adefault_gatewayproperty; the Booking settings form gains a site-widedefault_gatewayselect, populated from the installed gateway plugins.
Rewiring:
CheckoutController::toPayment()calls the resolver instead of'simulator'.- The orchestra payment actions resolve the gateway from the order rather than
from per-node config; the per-node
gatewaysetting is dropped (or kept as an optional advanced override, defaulting to the resolver). - Simulator-on-prod becomes a test resource type (or test tenant) pointed at
simulator; no code, no deploy.
The one-type invariant¶
Enforce "one order = one resource type" in BookingManager::holdGroup(), the
single funnel every entry point passes through. Reject a line group whose slots'
resources resolve to more than one type, the same way SelectionBooker already
rejects multi-resource groups today; SelectionBooker then defers to the core
rule. Because a type has at most one tenant and shared types carry none, this
also yields one order = one tenant via the resources' own tenant.
This keeps multi-resource orders fully supported (any resources sharing a type), while making workflow and gateway resolve through a single type and tenant.
Workflow rewiring¶
BookingWorkflowStarter::onHeld() reads workflow and auto_confirm from the
held line's resource type (via the order's resource), not from the resource. The
per-resource workflow field is removed. The starter is otherwise unchanged:
first line starts, siblings join, the invariant guarantees they agree.
Migration (pre-1.0, no update hooks)¶
yoyaku ships no update hooks, so schema changes ride a fresh install and live sites are patched by hand.
- Add the
yoyaku_resource_typeentity and the resourcetypereference field (installFieldStorageDefinitionon the live site). - Create a default shared type (tenant NULL) seeded with the current values as
its defaults; create per-tenant types only where the order-level settings
(
workflow,auto_confirm) diverge. - Assign every existing resource to a type. Copy the order-level settings onto the type; leave the per-line fields on the resource as overrides (they keep working unchanged, now read as "override, empty inherits").
- Remove the field storage only for the order-level settings that become
type-only (
workflow,auto_confirm); the per-line fields stay on the resource. - Live: a one-off script performs steps 2 to 4 against stored entities.
- Example module: ship resource types in
config/installand update the seed soyoyaku_orchestra_exampleinstalls types, not per-resource profiles.
Tenancy and access¶
tenantnullable on the type: NULL = shared, set = private.- A config entity access handler: site admins manage shared (NULL) types; a tenant admin manages that tenant's types. Per-resource managers cannot edit types, so money routing and lifecycle stay protected.
- Validation: a resource may reference a shared type or one of its own tenant's types, never another tenant's private type.
Phasing¶
- Entity + schema + access handler + admin UI (list, add/edit form), core fields only.
- Resource
typereference field + the migration (default shared type, assign resources). - Submodule
third_party_settingsblocks + form alters; getters resolve resource override then type default for per-line settings, and type-only for the order-level trio; remove only the order-level resource fields. PaymentGatewayResolver+Tenant.default_gateway+ sitedefault_gateway; rewireCheckoutControllerand the payment actions.- The one-type invariant in
holdGroup();SelectionBookerdefers to it. - Tests (kernel: resolver chain, invariant, workflow-from-type; functional: type CRUD and access, resource form referencing a type) and the live migration.
Open decisions¶
- Config entity + third_party_settings vs content entity + base fields.
This is the pivotal choice the whole mechanism hangs on. The plan above
assumes a config entity, with submodules attaching their settings via
third_party_settings. The alternative is a content entity, with submodules adding base fields throughhook_entity_base_field_info, exactly as they do on the resource today. - Config + third_party_settings: idiomatic for a "type", shareable via
config/install, exportable, and the core entity never references a submodule. Cost: more per-submodule boilerplate (a…third_party.<module>schema, a form-alter block, a read accessor), weaker typing (values in a nested array rather than first-class fields), and hand-wired form UX. - Content + base fields: keeps the familiar, well-typed base-field injection
the submodules already use, with no third-party boilerplate. Cost: the type
becomes data, not config, so it loses
config/installshareability and export, and tenant-created types are content rather than config. - The trade is decoupled-and-idiomatic-but-more-boilerplate (config) vs
familiar-and-typed-but-a-type-as-data (content). Everything else in this
plan is written for the config option; switching to content would replace
the
third_party_settingsblocks withentity_base_field_infoentries and drop theconfig/installshipping of shared types. - How much moves in phase 3. Could move only
workflow+gatewayfirst and leave the rest on the resource, or move the whole profile at once. Moving everything is cleaner but a bigger migration. - Per-node gateway override. Drop the orchestra action's
gatewaysetting entirely, or keep it as an optional advanced override that defaults to the resolver. Keeping it is more flexible, but it reintroduces a second place to set the gateway. - Capacity. Whether the type carries only a default capacity (resource sets the real number) or whether some types fix capacity. Default-only is assumed here.
- Governance permission names. The exact permissions for shared vs tenant-scoped type management.