Kulcare Docs
DeveloperAuthentication DocumentationService_packages

Identity::ServicePackage polymorphic owner

Identity::ServicePackage supports two ownership shapes via a polymorphic owner association:

  • Identity::Tenant — clinic-owned packages. The original shape; tenant_id and facility_id are set; the package belongs to a facility under a tenant; CMS users with manage_services permission author them.
  • Identity::Vendor — wellness-vendor self-authored packages (V1, PR #1306). tenant_id and facility_id are NULL; the package belongs to a Vendor whose user_id is 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:

  1. Parallel VendorPackage table. Clean separation, but doubles checkout / subscription / link-shortener infrastructure. Two paths through Razorpay, two query surfaces, two spec suites.
  2. 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: true

tenant 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)
end

The 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:

ActionTenant-ownedVendor-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?samesame

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
end

A 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:

CallerServiceNotes
CMS / clinic-ownerIdentity::Packages::SaveServiceExisting service. Untouched. Handles facility lookup + components + legacy care_package_details payload shape.
Vendor self-serveIdentity::Vendors::SavePackageServiceNew 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

SurfaceControllerRoutes
CMSIdentity::ServicePackagesController/identity/service_packages (index/show/create/update/destroy)
VendorIdentity::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/:id

Payload 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/:id

Payload 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()                                   // mutation

Cache 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:

  1. Add the type string to Identity::ServicePackage::ALLOWED_OWNER_TYPES.
  2. Decide whether the new type's rows carry tenant_id (like Tenant) or not (like Vendor). Add conditional validators accordingly.
  3. Update ServicePackagePolicy — add a new branch for the new owner type in show?/create?/update?/destroy? and Scope#resolve.
  4. 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.
  5. 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_packages is NOT a defined association (the polymorphic side names it owner, not vendor). Use Identity::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.md Phase 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

On this page