Org::Company
Purpose
An Org::Company represents an organisation that uses the Jod Platform. It is the root entity that owns all operational resources on the platform: team members, job postings (Careers and Gig), outlets, campaigns, and a billing account.
Every company that posts jobs, manages workers, or purchases credits on Jod is an Org::Company. If an organisation does not use the platform, it is not an Org::Company — even if it pays invoices or signs service agreements on behalf of a company that does.
This distinction is important. In the real world, the company that uses a service and the company that pays for it are often different. For example, Studio M Hotel Singapore posts jobs on Jod, but Republic Iconic Hotel Pte Ltd pays the invoices. Only Studio M is an Org::Company. Republic Iconic Hotel exists as a Billing::BillToProfile on Studio M's billing account — a billing-domain concept, not an org-domain concept.
Key Concepts
Operational Identity
Org::Company answers one question: "Who uses Jod?"
A company qualifies as an Org::Company when it:
- Has users who log in to the platform
- Posts job listings (Careers or Gig)
- Manages workers and shifts
- Consumes entitlements (placement credits, gig credits)
A company does not qualify as an Org::Company just because it:
- Pays invoices on behalf of another company
- Signs service agreements with Jod
- Appears on commercial documents as the bill-to party
These commercial roles are handled by Billing::BillToProfile, a model in the Billing domain that captures who appears on invoices and agreements. Each Billing::Account can have multiple bill-to profiles. See the Invoice model spec for details.
Why Jod Does Not Model Corporate Structure
Real-world corporate relationships form a graph, not a tree:
| Relationship | Example | Why it breaks a tree |
|---|---|---|
| Joint Venture | Company A owns 60%, Company B owns 40% | Two parents — a tree only allows one |
| Franchise | Hanbaobao Pte Ltd (brand owner) and XYZ Food Services (franchisee) | They are independent entities, not parent-child |
| Management | Hotel owned by Entity A, managed by Entity B, Entity B posts jobs on Jod | Neither is the "parent" of the other |
| Restructuring | Company splits, merges, or changes ownership | The graph changes outside Jod's knowledge |
Org::Company supports only a tree (one parent via parent_company_id). This is intentional — not a limitation. A tree can answer "who manages whom on the platform" — a question that always has one answer. A graph of corporate ownership, franchise, and management relationships does not have one answer. It changes frequently without Jod knowing, and it is not Jod's responsibility to maintain.
The principle: model what you can keep true, not what would be nice to know. Jod can verify "who uses our platform" — they log in, they post jobs, this is something Jod can observe. Jod cannot verify "who owns whom in the corporate world" — that changes through mergers, acquisitions, company restructuring, and government filings that happen outside Jod's control.
Display Name vs Legal Name
A company's publicly known name is not always its registered legal name:
| Display Name | Legal Name | Registration No (UEN) |
|---|---|---|
| McDonalds | Hanbaobao Pte Ltd | 199401341H |
| GoGoX Singapore | GOGOVAN SINGAPORE PTE. LTD. | 201409568C |
These are the same legal entity with two names. The name field stores the display name (shown in the platform UI and job listings). The legal_name field stores the registered legal name (used in commercial documents via Billing::BillToProfile).
This is a naming problem — one entity, two labels. It is different from the Studio M / Republic Iconic Hotel case, where two different registration numbers mean two separate legal entities. The test is simple:
- Same registration number = same entity, different names. Use
nameandlegal_nameon oneOrg::Company. - Different registration numbers = different entities. The one that uses Jod is an
Org::Company. The one that only pays is aBilling::BillToProfile.
Slug Generation
Each company has a slug — a URL-friendly version of the company's name, generated using Rails' parameterize method. For example:
| name | slug |
|---|---|
| McDonalds | mcdonalds |
| NTUC Fairprice Co-operative Ltd Supermarkets (South) | ntuc-fairprice-co-operative-ltd-supermarkets-south |
| Studio M Hotel Singapore | studio-m-hotel-singapore |
The slug is derived from name (the display name), not from legal_name. This is intentional — URLs should use the name that people search for and recognise ("mcdonalds"), not the registered legal name ("hanbaobao-pte-ltd").
Where the slug is used:
- Company landing page:
/companies/{slug}— the public-facing company profile page where job seekers view the company and its active job listings - Listings domain:
Listings::Jobreferencescompany_slugto build job detail page URLs and for SEO - Sitemap generation: company URLs are included in the sitemap for search engine indexing
Business rules:
slugis unique across all companies (enforced by database unique index)slugis generated automatically at company creation time — it is not user-editable- If
namechanges, theslugshould be regenerated. However, this breaks existing URLs. Consider keeping the old slug as a redirect, or making slug immutable after creation. - Within the same parent company,
(company_id, slug)is unique on outlets — outlet slugs are scoped to their company
Operational Hierarchy (Parent and Child Companies)
Org::Company supports a single level of nesting through parent_company_id:
- A root company has
parent_company_id = null. It is the top-level entity. - A child company has
parent_company_idpointing to a root company.
This hierarchy controls who can manage whom on the platform:
- Root company users can view and manage all child companies
- Child company users can only manage their own company
- Only root company users can create new child companies
This hierarchy is not corporate ownership. It represents who manages whom on the Jod Platform. Republic Iconic Hotel (which pays Studio M's invoices) does not appear in this hierarchy — it does not use Jod.
Enterprise Divisions
Large enterprise clients often have internal divisions that are not separate legal entities. These divisions need to operate independently on the platform: each managing their own outlets, budgets, users, and jobs.
For example, NTUC Fairprice Co-Operative Limited is a single legal entity (UEN S83CS0191L) with regional divisions like Supermarkets (South), Supermarkets (Central East), and Finest. Each division manages different outlets across Singapore and has its own budget for gig worker hiring.
These divisions are modeled as child Org::Company records under a root company:
Org::Company: NTUC Fairprice Co-Operative Limited (root, UEN S83CS0191L)
├── Org::Company: NTUC Fairprice Supermarkets (South) ← child, same UEN
│ ├── Billing::Account (own credits, own invoices)
│ ├── Outlets: FairPrice Bukit Ho Swee, FairPrice Redhill, ...
│ └── Users: area managers, location managers
├── Org::Company: NTUC Fairprice (Finest) ← child, same UEN
│ ├── Billing::Account (own credits, own invoices)
│ ├── Outlets: FairPrice Finest Waterway Point, ...
│ └── Users: area managers, location managers
└── ... more divisions
Each division:
- Has its own
Billing::Accountwith its own credit balance and entitlement history - Has its own
Billing::Agreementwith negotiated terms (terms may be identical across divisions) - Receives its own invoices (billed via
Billing::BillToProfile, e.g., "NTUC Fairprice Co-Operative Limited (South)") - Owns its own jobs, outlets, and user assignments
- Can have credits transferred to or from other divisions by Jod admin (admin adjustment between
Billing::Accountrecords)
Child companies share their parent's registration_no. The uniqueness constraint on registration_no is enforced only for root companies (via a partial unique index), allowing divisions to share the parent legal entity's UEN.
Simple vs Enterprise: Same Model, Different Data
The system uses the same model for both simple and enterprise companies. There is no feature flag or is_enterprise column. The difference is only in the data — not in the code:
| Aspect | Simple Company (majority) | Enterprise Company |
|---|---|---|
| Structure | One Org::Company (root, no children) | Root Org::Company + child Org::Company records |
| Who is operational? | The root company itself | The child companies (divisions) |
| Billing::Account | Active, on the root | Active on each child; unused on the root |
| Jobs, outlets, users | Belong to the root company | Belong to child companies |
| registration_no | Unique per country | Root is unique; children share parent's value |
A simple company is just an enterprise company that has no children. The same code works for both cases — no if/else branching is needed.
The Enterprise Root Company
For enterprise clients, the root Org::Company serves as the parent record that holds the legal entity information and groups all divisions together:
- It stores the legal entity's UEN and legal name
- It groups the operational divisions (child companies) under one parent
- It may have management-level users, though in practice these accounts are rarely used
The root company's Billing::Account is created automatically (same as all companies) but is unused — zero credit balance, no invoices, no active agreements. All billing activity happens on the child companies' accounts.
Billing::Agreement records are created per division account, not on the root account. This keeps invoice generation simple: each division's invoices are generated from its own account without needing to look up the root. If the legal entity renegotiates terms, Jod admin updates each division's agreement individually.
Model Context
| Context | Details |
|---|---|
| Entity | Org::Company (aggregate root — owns Memberships, Outlets, and all operational resources) |
| Layer | Organisation Structure |
| Upstream dependencies | Geo::Country (country of registration), Geo::Area (office location) |
| Downstream dependents | Org::Membership (team members), Org::Outlet (branches), Billing::Account (1:1), Careers::Job, Gig::TempJob |
State Machine
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | active | UC-1a: Employer self-serve sign-up | SMB employer creates company during sign-up. Active immediately. |
| (new) | disabled | UC-1b or UC-2: Admin creates company | Admin onboarding or division creation. Requires explicit enable (UC-4). |
disabled | active | UC-4: Admin enables the company | Company can now post jobs and use the platform. |
active | disabled | UC-5: Admin disables the company | Company cannot post new jobs. Existing jobs and data are preserved. |
active | deleted | UC-7: Employer soft-deletes | Only child companies. Root companies cannot be deleted. |
disabled | deleted | UC-7: Employer soft-deletes | Only child companies. |
State mapping to database columns:
disabled=is_enabled: false,is_deleted: falseactive=is_enabled: true,is_deleted: falsedeleted=is_deleted: true,is_deleted_at: <timestamp>
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1a | Employer signs up and creates a company | SMB employer registers on the website | Employer (new) |
| UC-1b | Admin onboards a root company | Enterprise or special client is onboarded by ops | Admin |
| UC-2 | Create a child company (division) | Enterprise client adds an operational division | Admin or Employer (root) |
| UC-3 | Edit company details | Company information changes (address, name, etc.) | Admin or Employer |
| UC-4 | Enable a company | Company is ready to use the platform | Admin |
| UC-5 | Disable a company | Company pauses or stops using the platform | Admin |
| UC-6 | View companies | Employer manages their company and divisions | Employer |
| UC-7 | Delete a child company | Division no longer uses the platform | Employer (root) |
| UC-8 | View company on marketplace | Job seeker views the company profile | Public |
UC-1a: Employer signs up and creates a company
| Field | Details |
|---|---|
| Actor | Identities::User who does not yet have an Org::Membership |
| Trigger | Employer logs in and the system detects they have no Membership — shows the company creation form |
Preconditions:
Identities::Useralready exists (created earlier via the sign-up/registration endpoint)- The user has no existing
Org::Membership - No existing
Org::Companywith the sameregistration_noandcountry_idwhereparent_company_idis null - No existing
Org::Companywith the samename - No existing
Org::Companywith the sameemail
System Behavior:
- User logs in. System detects they have no
Org::Membershipand shows the company creation form. - Employer fills in: company name, registration number, address, postal code, email, and their title
- System creates
Org::Companywithparent_company_id = nullandis_enabled = true(active immediately) - System creates
Org::Membershiplinking the user to the company with rolehq_manager,is_owner: true,is_default: true - System generates UUID and slug for the company
- System determines the
Geo::Areabased on the address - System automatically provisions a
Billing::Accountfor the company
Business Rules:
- This is the primary path for SMB companies joining the platform
Identities::Useris created separately via the registration endpoint — this use case only handlesOrg::Company+Org::Membershipcreation- Company and Membership are created together in one transaction
- Company starts as active — the employer can post jobs immediately
- A root company has no parent (
parent_company_id = null) registration_nomust be unique among root companies within the samecountry_idemailandnamemust be unique across all companies- The employer becomes the first user of this company via
Org::Membership(role:hq_manager,is_owner: true) - Creating a company automatically creates a
Billing::Account(see Billing Use Cases)
Postconditions:
- Root company exists with
is_enabled = true Billing::Accountexists for the companyOrg::Membershipexists linking the employer to the company- Employer can immediately post jobs and use the platform
UC-1b: Admin onboards a root company
| Field | Details |
|---|---|
| Actor | Identities::Admin (during onboarding) |
| Trigger | Enterprise or special client is onboarded by ops |
Preconditions:
- No existing
Org::Companywith the sameregistration_noandcountry_idwhereparent_company_idis null - No existing
Org::Companywith the samename - No existing
Org::Companywith the sameemail
System Behavior:
- Admin enters company details: name, legal name, registration number, address, postal code, email
- System determines the
Geo::Areabased on the address - System generates a UUID and a URL-friendly slug
- System creates the company with
parent_company_id = nullandis_enabled = false - System automatically provisions a
Billing::Accountfor the company
Business Rules:
- This path is used for enterprise clients and special onboarding cases managed by Jod ops
- Company starts as disabled — admin must explicitly enable it (UC-4)
- A root company has no parent (
parent_company_id = null) registration_nomust be unique among root companies within the samecountry_idemailandnamemust be unique across all companies- Creating a company automatically creates a
Billing::Account(see Billing Use Cases) - For simple companies, the root company IS the operational entity — its Billing::Account will be active once enabled
- For enterprise clients, the root company groups divisions together — its Billing::Account will remain unused while division accounts are active
Postconditions:
- Root company exists with
is_enabled = false Billing::Accountexists for the company- Admin can now create users, outlets, and child companies
UC-2: Create a child company (division)
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership (employer from root company) |
| Trigger | Enterprise client adds an operational division |
Preconditions:
- Actor belongs to a root company (
parent_company_id = null) or is an admin - No existing
Org::Companywith the samename - No existing
Org::Companywith the sameemail
System Behavior:
- Actor enters division details: name, address, postal code, email
- System sets
parent_company_idto the root company's ID - System copies the root company's
registration_noandcountry_idto the child - System determines the
Geo::Area, generates UUID and slug - System creates the child company with
is_enabled = false - System automatically provisions a
Billing::Accountfor the child company
Business Rules:
- Only users from a root company (or admins) can create child companies
- Only one level of nesting is allowed: a child company cannot create its own children
- Child companies inherit the parent's
registration_no— this is not editable on the child - Each child company gets its own
Billing::Accountwith its own credit balance and entitlement history - Billing::Agreement must be created separately on the child's account for invoice generation
Postconditions:
- Child company exists with
parent_company_idset to the root company - Child company shares the parent's
registration_no - Child company has its own
Billing::Account - Root company users can now manage this division
Open Questions:
- Should child companies be allowed to have a different
country_idthan the parent? (Future: multi-country expansion)
UC-3: Edit company details
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership |
| Trigger | Company information changes |
Preconditions:
- Company exists and is not deleted
- If actor is an employer: they belong to this company (if root), or to the parent company (if child)
System Behavior:
- Actor modifies company fields
- System validates uniqueness constraints
- System updates the company record
Business Rules:
- Editable fields:
name,legal_name,primary_address,postal_code,email,website_url,description, logo fields - Immutable fields:
registration_no,country_id,parent_company_id— these define the company's identity and cannot change after creation - Root company users can edit both the root company and its child companies
- Child company users can only edit their own company
- Changing
nameoremailmust not conflict with existing companies
Postconditions:
- Company reflects updated values
UC-4: Enable a company
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Company is ready to use the platform |
Preconditions:
- Company exists with
is_enabled = false - Company is not deleted
System Behavior:
- Admin enables the company
- System sets
is_enabled = true
Business Rules:
- Only admins can enable companies (not employers)
- Enabling allows the company's users to post jobs and use platform features
Postconditions:
- Company
is_enabled = true - Company is fully operational on the platform
UC-5: Disable a company
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Company pauses or stops using the platform |
Preconditions:
- Company exists with
is_enabled = true
System Behavior:
- Admin disables the company
- System sets
is_enabled = false
Business Rules:
- Only admins can disable companies
- Disabling does not delete data — jobs, users, billing history are all preserved
- Existing active jobs and shifts are not automatically cancelled
- The company cannot create new jobs while disabled
Postconditions:
- Company
is_enabled = false - Company cannot post new jobs
Open Questions:
- Should disabling a parent company also disable all its child companies?
- Should existing job postings be automatically closed when a company is disabled?
UC-6: View companies
| Field | Details |
|---|---|
| Actor | Org::Membership (employer) |
| Trigger | Employer manages their company and divisions |
Preconditions:
- Actor belongs to an
Org::Company
System Behavior:
- Employer navigates to company management
- System returns companies based on the actor's position:
- Root company user: their own company and all child companies (divisions)
- Child company user: only their own company
Business Rules:
- Root company users see the full group (own company + all children)
- Child company users see only their own company
- Deleted companies are excluded
Postconditions:
- Read-only operation — no data changes
UC-7: Delete a child company
| Field | Details |
|---|---|
| Actor | Org::Membership (employer belonging to the root company) |
| Trigger | Division no longer uses the platform |
Preconditions:
- Target company is a child company (
parent_company_idis not null) - Actor belongs to the parent (root) company
System Behavior:
- Actor requests deletion of a child company
- System performs a soft delete: sets
is_deleted = trueandis_deleted_at = now
Business Rules:
- Root companies cannot be deleted — only disabled (UC-5)
- Only child companies can be soft-deleted
- Only users from the parent company can delete a child
- Soft delete preserves all data for audit and billing history
- Deleted companies do not appear in normal queries
- Active jobs under the deleted company remain until completed — they are not cancelled
Postconditions:
- Company
is_deleted = true - Company no longer visible in company listings
- Historical data (jobs, invoices, ledger entries) preserved
UC-8: View company on marketplace
| Field | Details |
|---|---|
| Actor | Public (job seeker) |
| Trigger | Job seeker views the company profile |
Preconditions:
- Company exists, is enabled, and is not deleted
System Behavior:
- Job seeker navigates to the company's marketplace page (via slug)
- System displays public information: name, description, logo, location, active job listings
Business Rules:
- Only enabled, non-deleted companies are visible on the marketplace
- The display
nameis shown (notlegal_nameorregistration_no) - Job listings shown are filtered to open/active jobs only
Postconditions:
- Read-only operation — no data changes
Invariants
- An
Org::Companyrepresents either an organisation that uses the Jod Platform, or a parent record that groups enterprise divisions — never create one for an entity that only pays or signs agreements registration_nois unique within the samecountry_idfor root companies only (enforced via partial unique index whereparent_company_id IS NULL)- Child companies inherit and share their parent's
registration_no— this value is immutable on both root and child nameandemailare globally unique across all companies (root and child)parent_company_idsupports only one level of nesting — a child company cannot have children of its own- A root company (
parent_company_id = null) cannot be soft-deleted — it can only be disabled - Companies are never hard-deleted — deactivation uses
is_enabled, removal usesis_deleted - Every
Org::Companyhas exactly oneBilling::Account, created automatically at company creation country_idandregistration_noare immutable after creation — they define the company's legal identity- The parent-child relationship represents platform management hierarchy, not corporate ownership or billing responsibility
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Org::Company (self) | Company optionally belongs_to parent | Single-level operational hierarchy. Root company users manage child companies on the platform. |
Org::Membership | Company has_many Memberships | Team members. Each membership links an Identities::User to a specific company with a role. |
Org::Outlet | Company has_many Outlets | Physical branches where work takes place. Each outlet belongs to exactly one company. |
Org::PayRate | PayRate scoped to Company | Pay rates for gig roles. Can be company-wide defaults or scoped to a specific outlet. |
Geo::Country | Company belongs_to Country | Country of registration. Root companies must have unique registration_no within the same country. |
Geo::Area | Company belongs_to Area | Geographic area where the company office is located. |
Billing::Account | Company has_one Account | Created automatically when company is created. Holds entitlement balances, invoices, and agreements. Unused for enterprise root companies. |
Billing::BillToProfile | Account has_many BillToProfiles | Bill-to identities for commercial documents. May reference a different legal entity than the company itself. |
Billing::Agreement | Account has_many Agreements | Created per-division account (not on root). Captures negotiated terms for invoice pricing. |
Careers::Job | Company has_many Jobs | Full-time and part-time career job postings. |
Careers::JobApplication | Company has_many JobApplications | Applications received for the company's career jobs. |
Gig::TempJob | Company has_many TempJobs | Temporary gig job postings. Linked via remote_id during legacy migration. |
Schema Gaps
| Gap | Impact | Suggested Resolution |
|---|---|---|
No legal_name column | Cannot distinguish display name from registered legal name (e.g., "McDonalds" vs "Hanbaobao Pte Ltd"). Billing::BillToProfile needs the legal name for commercial documents. | Resolved in DBML. Add legal_name string column (nullable — defaults to name when not set) |
registration_no has a full unique index (with country_id) | Enterprise divisions (child companies) share their parent's UEN and cannot be created with the current constraint | Resolved in DBML. Replace with a partial unique index: UNIQUE(registration_no, country_id) WHERE parent_company_id IS NULL |
created_by / updated_by are plain bigint | Cannot distinguish if the actor was an Identities::Admin or Identities::User. No FK constraint. | Resolved in DBML. Replace with polymorphic columns: creator_type/creator_id and updater_type/updater_id. Rails belongs_to :creator, polymorphic: true. Values: 'Identities::Admin' or 'Identities::User'. |
is_enabled + is_deleted used instead of a single status column | Two booleans create a 2x2 matrix where one combination (enabled + deleted) is meaningless. Queries require checking both columns (WHERE is_enabled = true AND is_deleted = false). | Consider replacing with a single status enum (active, disabled, deleted). If other models also use is_deleted for soft-delete consistency, keep the current approach but document the valid combinations. |