Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/company-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/service-settings": minor
---

feat(settings): `company` settings — legal organization identity

Adds a `company` SettingsManifest for the workspace's **legal entity** identity, distinct from `branding` (public name/logo/theme). Organization-level (`tenant` scope), all keys optional for v1.

Grouped Identity / Registered address / Contact: `legal_name`, `registration_number`, `tax_id`, `address_line1`/`address_line2`/`city`/`state`/`postal_code`/`country`, `phone`, `website`, `primary_contact_name`, `primary_contact_email`. Benchmarked against Salesforce "Company Information" and Stripe's business profile.

These feed invoices/receipts, email footers (CAN-SPAM requires a physical postal address), contracts, and compliance exports. Ships with en + zh-CN translations and a manifest test.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { SettingsManifestSchema } from '@objectstack/spec/system';
import { companySettingsManifest } from './company.manifest';

describe('companySettingsManifest', () => {
it('parses against SettingsManifestSchema', () => {
expect(() => SettingsManifestSchema.parse(companySettingsManifest)).not.toThrow();
});

it('declares namespace=company, scope=tenant, version=1', () => {
const parsed = SettingsManifestSchema.parse(companySettingsManifest);
expect(parsed.namespace).toBe('company');
expect(parsed.scope).toBe('tenant');
expect(parsed.version).toBe(1);
});

it('groups keys into identity / address / contact', () => {
const specs = companySettingsManifest.specifiers as any[];
const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
expect(groups).toEqual(['identity', 'address', 'contact']);
});

it('uses the right specifier types for website (url) and contact email', () => {
const specs = companySettingsManifest.specifiers as any[];
const byKey = (k: string) => specs.find((s) => s.key === k);
expect(byKey('website').type).toBe('url');
expect(byKey('primary_contact_email').type).toBe('email');
expect(byKey('country').pattern).toBe('^[A-Za-z]{2}$');
});

it('has no required fields — every key is optional for v1', () => {
const specs = companySettingsManifest.specifiers as any[];
for (const s of specs.filter((x) => x.key)) {
expect(s.required ?? false).toBe(false);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { SettingsManifest } from '@objectstack/spec/system';

/**
* Company — the workspace's legal organization identity.
*
* Distinct from `branding` (the public-facing workspace name / logo / theme):
* this is the **legal entity** — registered name, address, tax IDs, and the
* primary contact. Downstream consumers are invoices/receipts, email footers
* (CAN-SPAM requires a physical postal address), contracts, and compliance
* exports. Benchmarked against Salesforce "Company Information" and Stripe's
* business profile.
*
* Scope is `tenant`: one org per physical tenant (ADR-0002).
*/
export const companySettingsManifest: SettingsManifest = {
namespace: 'company',
version: 1,
label: 'Company',
icon: 'Building2',
description: 'Legal entity identity — registered name, address, tax IDs, and primary contact.',
scope: 'tenant',
readPermission: 'setup.access',
writePermission: 'setup.write',
category: 'Workspace',
order: 3,
specifiers: [
// ── Identity ──────────────────────────────────────────────────────────
{ type: 'group', id: 'identity', label: 'Identity', required: false },
{
type: 'text', key: 'legal_name', label: 'Legal name', required: false,
description: 'Registered legal name of the organization (may differ from the workspace name).',
maxLength: 200,
},
{
type: 'text', key: 'registration_number', label: 'Registration number', required: false,
description: 'Company registration / incorporation number (e.g. EIN, company no.).',
maxLength: 80,
},
{
type: 'text', key: 'tax_id', label: 'Tax / VAT ID', required: false,
description: 'Tax identifier shown on invoices (e.g. VAT, GST, ABN).',
maxLength: 80,
},

// ── Registered address ────────────────────────────────────────────────
{ type: 'group', id: 'address', label: 'Registered address', required: false },
{ type: 'text', key: 'address_line1', label: 'Address line 1', required: false, maxLength: 200 },
{ type: 'text', key: 'address_line2', label: 'Address line 2', required: false, maxLength: 200 },
{ type: 'text', key: 'city', label: 'City', required: false, maxLength: 120 },
{ type: 'text', key: 'state', label: 'State / Province', required: false, maxLength: 120 },
{ type: 'text', key: 'postal_code', label: 'Postal code', required: false, maxLength: 32 },
{
type: 'text', key: 'country', label: 'Country', required: false,
description: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).',
pattern: '^[A-Za-z]{2}$', minLength: 2, maxLength: 2,
},

// ── Contact ───────────────────────────────────────────────────────────
{ type: 'group', id: 'contact', label: 'Contact', required: false },
{
type: 'text', key: 'phone', label: 'Phone', required: false,
description: 'Primary business phone (E.164 recommended, e.g. +1 415 555 0100).',
maxLength: 40,
},
{
type: 'url', key: 'website', label: 'Website', required: false,
description: 'Example: https://example.com',
},
{ type: 'text', key: 'primary_contact_name', label: 'Primary contact name', required: false, maxLength: 120 },
{
type: 'email', key: 'primary_contact_email', label: 'Primary contact email', required: false,
description: 'Example: ops@example.com',
},
],
};
3 changes: 3 additions & 0 deletions packages/services/service-settings/src/manifests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
} from './ai.manifest.js';
export { knowledgeSettingsManifest, knowledgeTestActionHandler } from './knowledge.manifest.js';
export { localizationSettingsManifest } from './localization.manifest.js';
export { companySettingsManifest } from './company.manifest.js';

import { authSettingsManifest } from './auth.manifest.js';
import { mailSettingsManifest } from './mail.manifest.js';
Expand All @@ -22,10 +23,12 @@ import { storageSettingsManifest } from './storage.manifest.js';
import { aiSettingsManifest } from './ai.manifest.js';
import { knowledgeSettingsManifest } from './knowledge.manifest.js';
import { localizationSettingsManifest } from './localization.manifest.js';
import { companySettingsManifest } from './company.manifest.js';

/** Convenience aggregate — pass to `SettingsServicePlugin({ manifests })`. */
export const builtinSettingsManifests = [
brandingSettingsManifest,
companySettingsManifest,
localizationSettingsManifest,
authSettingsManifest,
mailSettingsManifest,
Expand Down
25 changes: 25 additions & 0 deletions packages/services/service-settings/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ export const en: TranslationData = {
},
},

company: {
title: 'Company',
description: 'Legal entity identity — registered name, address, tax IDs, and primary contact.',
groups: {
identity: { title: 'Identity' },
address: { title: 'Registered address' },
contact: { title: 'Contact' },
},
keys: {
legal_name: { label: 'Legal name', help: 'Registered legal name (may differ from the workspace name).' },
registration_number: { label: 'Registration number', help: 'Company registration / incorporation number (e.g. EIN).' },
tax_id: { label: 'Tax / VAT ID', help: 'Shown on invoices (e.g. VAT, GST, ABN).' },
address_line1: { label: 'Address line 1' },
address_line2: { label: 'Address line 2' },
city: { label: 'City' },
state: { label: 'State / Province' },
postal_code: { label: 'Postal code' },
country: { label: 'Country', help: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).' },
phone: { label: 'Phone', help: 'E.164 recommended, e.g. +1 415 555 0100.' },
website: { label: 'Website', help: 'Example: https://example.com' },
primary_contact_name: { label: 'Primary contact name' },
primary_contact_email: { label: 'Primary contact email', help: 'Example: ops@example.com' },
},
},

localization: {
title: 'Localization',
description: 'Default timezone, language, currency, and date/number formats.',
Expand Down
25 changes: 25 additions & 0 deletions packages/services/service-settings/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ export const zhCN: TranslationData = {
},
},

company: {
title: '公司',
description: '法律实体信息——注册名称、地址、税号及主要联系人。',
groups: {
identity: { title: '主体信息' },
address: { title: '注册地址' },
contact: { title: '联系方式' },
},
keys: {
legal_name: { label: '法定名称', help: '注册的法定名称(可能与工作区名称不同)。' },
registration_number: { label: '注册号', help: '公司注册/工商登记号(如 EIN、统一社会信用代码)。' },
tax_id: { label: '税号 / VAT', help: '发票上显示的税务标识(如 VAT、GST、ABN)。' },
address_line1: { label: '地址行 1' },
address_line2: { label: '地址行 2' },
city: { label: '城市' },
state: { label: '省/州' },
postal_code: { label: '邮政编码' },
country: { label: '国家/地区', help: 'ISO 3166-1 二位代码(如 US、GB、CN)。' },
phone: { label: '电话', help: '建议 E.164 格式,如 +86 21 5555 0100。' },
website: { label: '网站', help: '示例:https://example.com' },
primary_contact_name: { label: '主要联系人' },
primary_contact_email: { label: '主要联系人邮箱', help: '示例:ops@example.com' },
},
},

localization: {
title: '本地化',
description: '默认时区、语言、货币及日期/数字格式。',
Expand Down
Loading