Identity::ServicePackage polymorphic owner
Identity::ServicePackage supports two ownership shapes via a polymorphic owner association:
Identity::Tenant— clinic-owned packages. The original shape;tenant_idandfacility_idare set; the package belongs to a facility under a tenant; CMS users withmanage_servicespermission author them.Identity::Vendor— wellness-vendor self-authored packages (V1, PR #1306).tenant_idandfacility_idare NULL; the package belongs to aVendorwhoseuser_idis the only authorized writer.
This page is the developer reference for the pattern: schema, model rules, policy branching, the controller split, and how to extend to a third owner type if/when needed.
Why polymorphic ownership
The V1 vendor self-serve plan needed wellness vendors to author packages directly — a yoga teacher selling "10-class pack", a nutritionist selling "Diet plan + 4 follow-ups". Two design options:
- Parallel
VendorPackagetable. Clean separation, but doubles checkout / subscription / link-shortener infrastructure. Two paths through Razorpay, two query surfaces, two spec suites. - Polymorphic
ServicePackage.owner. Tenant-owned and vendor-owned packages share the same checkout, subscription rows (PatientPackageSubscription), and patient-app surface (/p/<slug>/packages). The owner branch is a single column away.
Option 2 won. The rest of this document is what that decision implies.
Schema
Migration 20260505000001_polymorphize_identity_service_packages.rb:
ALTER TABLE identity.service_packages
ADD COLUMN owner_type VARCHAR NOT NULL,
ADD COLUMN owner_id UUID NOT NULL;
ALTER TABLE identity.service_packages
ALTER COLUMN tenant_id DROP NOT NULL,
ALTER COLUMN facility_id DROP NOT NULL;
CREATE INDEX idx_service_packages_on_owner
ON identity.service_packages (owner_type, owner_id);Backfill (one-shot during the migration): every pre-existing row was tenant-owned, so owner_type = 'Identity::Tenant', owner_id = tenant_id.
The FK constraints on tenant_id and facility_id survive untouched. Vendor-owned rows leave both NULL — that doesn't violate the FKs, just bypasses them.
Model
backend/domains/identity/models/service_package.rb
Allowed owners
ALLOWED_OWNER_TYPES = %w[Identity::Tenant Identity::Vendor].freeze
belongs_to :owner, polymorphic: true
belongs_to :tenant, class_name: 'Identity::Tenant', optional: true
belongs_to :facility, class_name: 'Identity::Facility', optional: truetenant and facility are explicitly optional. Vendor-owned rows leave both NULL.
Conditional validators
validates :owner_type, inclusion: { in: ALLOWED_OWNER_TYPES }
validates :tenant_id, presence: true, if: :tenant_owned?
validates :facility_id, presence: true, if: :tenant_owned?
validates :tenant_id, absence: true, if: :vendor_owned?
validates :facility_id, absence: true, if: :vendor_owned?The absence guards prevent data drift — a vendor-owned row can never accidentally also carry a tenant_id.
Shared::Concerns::TenantScoped is intentionally NOT included
This is the most important architectural choice in the model file. See ADR 004 for the full reasoning. Short version: the concern's hard belongs_to :tenant, tenant_id presence validator, and tenant-only default scope all break vendor-owned rows.
In its place, the model defines:
default_scope do
if Shared::Current.tenant.present?
where('identity.service_packages.tenant_id = ? OR identity.service_packages.tenant_id IS NULL',
Shared::Current.tenant.id)
else
all
end
end
def self.unscoped_by_tenant
unscope(where: :tenant_id)
endThe OR tenant_id IS NULL clause is what makes vendor rows visible inside a tenant context — they're not "in" any tenant, so they pass through.
before_validation :sync_tenant_from_owner
A tenant-owned package created via ServicePackage.new(owner: tenant, facility: f, ...) gets tenant_id mirrored from owner_id via this callback. Saves callers from passing both arguments. No-op for vendor-owned rows.
Predicates + scopes
def tenant_owned? ; owner_type == 'Identity::Tenant' ; end
def vendor_owned? ; owner_type == 'Identity::Vendor' ; end
scope :for_tenant, ->(tenant) { where(owner_type: 'Identity::Tenant', owner_id: tenant.id) }
scope :for_vendor, ->(vendor) { where(owner_type: 'Identity::Vendor', owner_id: vendor.id) }Policy
backend/domains/identity/policies/service_package_policy.rb branches on owner_type for every action. The two branches:
| Action | Tenant-owned | Vendor-owned |
|---|---|---|
index? | tenant_member? | user.present? |
show? | tenant_member? && facility_in_tenant? | record.is_active? (publicly visible) OR own_vendor_package? |
create? | has_permission?(:manage_services) | own_vendor_package? |
update? | has_permission?(:manage_services) && facility_in_tenant? && modifiable? | own_vendor_package? && modifiable? |
destroy? | same | same |
own_vendor_package? resolves to record.owner.user_id == user.id. The "publicly visible" branch on show? is what enables the patient-app browse surface (Phase 3 of the V1 plan) without a separate public endpoint.
Scope#resolve
class Scope < Shared::BasePolicy::Scope
def resolve
relations = []
if tenant_member?
relations << scope.unscoped_by_tenant
.where(owner_type: 'Identity::Tenant', owner_id: current_tenant.id)
end
if user&.respond_to?(:vendor) && user.vendor.present?
relations << scope.unscoped_by_tenant
.where(owner_type: 'Identity::Vendor', owner_id: user.vendor.id)
end
return scope.none if relations.empty?
relations.size == 1 ? relations.first : relations.reduce { |acc, r| acc.or(r) }
end
endA user who is BOTH a tenant member AND a vendor sees both sets of packages — useful for the rare graduating practitioner who runs a wellness side-business too.
Service split
ADR 005 covers this in depth. Summary:
| Caller | Service | Notes |
|---|---|---|
| CMS / clinic-owner | Identity::Packages::SaveService | Existing service. Untouched. Handles facility lookup + components + legacy care_package_details payload shape. |
| Vendor self-serve | Identity::Vendors::SavePackageService | New in PR #1306. No facility, no components, vendor-scoped uniqueness, modern field names. |
The two services are parallel by design — no branchy "if owner.is_a?(Tenant)" code paths. They may converge in V2 when the vendor surface adds component support.
Controller split
| Surface | Controller | Routes |
|---|---|---|
| CMS | Identity::ServicePackagesController | /identity/service_packages (index/show/create/update/destroy) |
| Vendor | Identity::VendorPackagesController | /identity/vendor_packages (same shape) |
Different routes, different policies, different services. The model is shared.
API surface
CMS (tenant-owned)
GET /identity/service_packages
POST /identity/service_packages
GET /identity/service_packages/:id
PATCH /identity/service_packages/:id
DELETE /identity/service_packages/:idPayload wrapper: { care_package_details: { name, price, ... }, facility_id: ... }. See service-packages-api.md (sibling doc).
Vendor
GET /identity/vendor_packages
POST /identity/vendor_packages
GET /identity/vendor_packages/:id
PATCH /identity/vendor_packages/:id
DELETE /identity/vendor_packages/:idPayload wrapper: { vendor_package: { name, total_price, currency, duration_unit, duration_frequency, is_active, package_type } }. Currency locked to INR for V1. Duration unit ∈ DAYS | WEEKS | MONTHS | YEARS.
Authorization: vendor-only. Other roles get HTTP 403 with a clear "must be onboarded as a wellness vendor" message — clearer than the default policy 422 chain.
DELETE soft-deactivates (sets is_active: false) when the package has active subscriptions; hard-deletes otherwise.
Frontend hooks
Lives in @kulcare/shared-services/identity/hooks.ts:
useVendorPackages({ enabled: signed_up_as === 'vendor' }) // list
useVendorPackage(id) // detail
useCreateVendorPackage() // mutation
useUpdateVendorPackage(id) // mutation
useDeleteVendorPackage() // mutationCache namespace: identityKeys.vendorPackages.*. Separate from identityKeys.servicePackages.* so invalidation stays surgical — editing a vendor package never touches the CMS package cache.
Adding a third owner type
The pattern scales. To add Identity::Demo (or any future business type) as a polymorphic owner:
- Add the type string to
Identity::ServicePackage::ALLOWED_OWNER_TYPES. - Decide whether the new type's rows carry
tenant_id(like Tenant) or not (like Vendor). Add conditional validators accordingly. - Update
ServicePackagePolicy— add a new branch for the new owner type inshow?/create?/update?/destroy?andScope#resolve. - Add a focused service if the write shape differs meaningfully from the existing services. If it's identical to one of them, route through the existing service.
- Optionally add a dedicated controller (mirrors the vendor split). For internal/admin types, you may instead add an admin-namespaced action.
Common gotchas
- Default scope eagerness. When you switch facilities (changing
Shared::Current.tenant), in-memory query objects already built against the old tenant don't recompute. Standard Rails behavior; document it where it bites. vendor.service_packagesis NOT a defined association (the polymorphic side names itowner, notvendor). UseIdentity::ServicePackage.for_vendor(vendor)instead.unscoped_by_tenant.for_vendor(vendor)is the safest read pattern for vendor-owned packages — explicit about both bypassing the default scope and filtering by owner.- The patient-app browse surface (Phase 3) relies on
show?returning true for active vendor packages even without authentication. If you tighten that branch, coordinate with the patient app.
References
- Migration:
backend/db/migrate/20260505000001_polymorphize_identity_service_packages.rb - Model:
backend/domains/identity/models/service_package.rb - Policy:
backend/domains/identity/policies/service_package_policy.rb - Vendor service:
backend/domains/identity/services/vendors/save_package_service.rb - Vendor controller:
backend/domains/identity/controllers/vendor_packages_controller.rb - Frontend page:
frontend/apps/connect/src/pages/VendorPackagesPage.tsx - Plan:
docs/plans/2026-05-05-feat-vendor-self-serve-v1-plan.mdPhase 2 - ADR 004: polymorphic models skip TenantScoped
- ADR 005: parallel focused services for vendor surfaces
- Sibling guide: polymorphic professional pattern
Status: documented (V1 P2) Last Updated: 2026-05-05