Skip to content

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:

  1. 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: BookingWorkflowStarter silently uses the first held line's resource workflow, and a per-resource gateway would have no single answer.
  2. The gateway is hardcoded in several places. CheckoutController passes 'simulator' literally, and each orchestra payment action carries its own gateway setting, 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_type config entity carries the behavioral profile.
  • yoyaku_resource references a type and keeps only instance data.
  • The type's tenant is 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, label
  • tenant (nullable string): NULL = shared, set = private to that tenant
  • booking_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_confirm
  • yoyaku_payment: gateway (override), collect_mode, collect_value, price, payment_inherit, caution_enabled, caution_amount, caution_mode, noshow_fee
  • yoyaku_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.
  • Tenant gains a default_gateway property; the Booking settings form gains a site-wide default_gateway select, 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 gateway setting 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.

  1. Add the yoyaku_resource_type entity and the resource type reference field (installFieldStorageDefinition on the live site).
  2. 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.
  3. 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").
  4. 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.
  5. Live: a one-off script performs steps 2 to 4 against stored entities.
  6. Example module: ship resource types in config/install and update the seed so yoyaku_orchestra_example installs types, not per-resource profiles.

Tenancy and access

  • tenant nullable 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

  1. Entity + schema + access handler + admin UI (list, add/edit form), core fields only.
  2. Resource type reference field + the migration (default shared type, assign resources).
  3. Submodule third_party_settings blocks + 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.
  4. PaymentGatewayResolver + Tenant.default_gateway + site default_gateway; rewire CheckoutController and the payment actions.
  5. The one-type invariant in holdGroup(); SelectionBooker defers to it.
  6. 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 through hook_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/install shareability 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_settings blocks with entity_base_field_info entries and drop the config/install shipping of shared types.
  • How much moves in phase 3. Could move only workflow + gateway first 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 gateway setting 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.