diff --git a/.changeset/company-settings.md b/.changeset/company-settings.md new file mode 100644 index 0000000000..2d75dadca5 --- /dev/null +++ b/.changeset/company-settings.md @@ -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. diff --git a/packages/services/service-settings/src/manifests/company.manifest.test.ts b/packages/services/service-settings/src/manifests/company.manifest.test.ts new file mode 100644 index 0000000000..5887957a26 --- /dev/null +++ b/packages/services/service-settings/src/manifests/company.manifest.test.ts @@ -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); + } + }); +}); diff --git a/packages/services/service-settings/src/manifests/company.manifest.ts b/packages/services/service-settings/src/manifests/company.manifest.ts new file mode 100644 index 0000000000..2bdf7b6254 --- /dev/null +++ b/packages/services/service-settings/src/manifests/company.manifest.ts @@ -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', + }, + ], +}; diff --git a/packages/services/service-settings/src/manifests/index.ts b/packages/services/service-settings/src/manifests/index.ts index 3b9d636a2b..afec6e3751 100644 --- a/packages/services/service-settings/src/manifests/index.ts +++ b/packages/services/service-settings/src/manifests/index.ts @@ -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'; @@ -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, diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index c34834e6f3..cd6e3917ff 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -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.', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 53c36ed492..866e683397 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -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: '默认时区、语言、货币及日期/数字格式。',