Skip to content

Commit 53a20a7

Browse files
os-zhuangclaude
andcommitted
feat(settings): company settings — legal organization identity (P0)
Adds a `company` SettingsManifest for the workspace's legal entity (distinct from `branding`'s public name/logo). Org-level (tenant scope), all keys optional for v1. Grouped Identity / Registered address / Contact: legal_name, registration_number, tax_id, address_line1/2, city, state, postal_code, country, phone, website, primary_contact_name/email. Benchmarked vs Salesforce "Company Information" + Stripe business profile. Feeds invoices, email footers (CAN-SPAM physical address), contracts, compliance exports. en + zh-CN translations + manifest test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9afeb2d commit 53a20a7

6 files changed

Lines changed: 180 additions & 0 deletions

File tree

.changeset/company-settings.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-settings": minor
3+
---
4+
5+
feat(settings): `company` settings — legal organization identity
6+
7+
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.
8+
9+
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.
10+
11+
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.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SettingsManifestSchema } from '@objectstack/spec/system';
5+
import { companySettingsManifest } from './company.manifest';
6+
7+
describe('companySettingsManifest', () => {
8+
it('parses against SettingsManifestSchema', () => {
9+
expect(() => SettingsManifestSchema.parse(companySettingsManifest)).not.toThrow();
10+
});
11+
12+
it('declares namespace=company, scope=tenant, version=1', () => {
13+
const parsed = SettingsManifestSchema.parse(companySettingsManifest);
14+
expect(parsed.namespace).toBe('company');
15+
expect(parsed.scope).toBe('tenant');
16+
expect(parsed.version).toBe(1);
17+
});
18+
19+
it('groups keys into identity / address / contact', () => {
20+
const specs = companySettingsManifest.specifiers as any[];
21+
const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
22+
expect(groups).toEqual(['identity', 'address', 'contact']);
23+
});
24+
25+
it('uses the right specifier types for website (url) and contact email', () => {
26+
const specs = companySettingsManifest.specifiers as any[];
27+
const byKey = (k: string) => specs.find((s) => s.key === k);
28+
expect(byKey('website').type).toBe('url');
29+
expect(byKey('primary_contact_email').type).toBe('email');
30+
expect(byKey('country').pattern).toBe('^[A-Za-z]{2}$');
31+
});
32+
33+
it('has no required fields — every key is optional for v1', () => {
34+
const specs = companySettingsManifest.specifiers as any[];
35+
for (const s of specs.filter((x) => x.key)) {
36+
expect(s.required ?? false).toBe(false);
37+
}
38+
});
39+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SettingsManifest } from '@objectstack/spec/system';
4+
5+
/**
6+
* Company — the workspace's legal organization identity.
7+
*
8+
* Distinct from `branding` (the public-facing workspace name / logo / theme):
9+
* this is the **legal entity** — registered name, address, tax IDs, and the
10+
* primary contact. Downstream consumers are invoices/receipts, email footers
11+
* (CAN-SPAM requires a physical postal address), contracts, and compliance
12+
* exports. Benchmarked against Salesforce "Company Information" and Stripe's
13+
* business profile.
14+
*
15+
* Scope is `tenant`: one org per physical tenant (ADR-0002).
16+
*/
17+
export const companySettingsManifest: SettingsManifest = {
18+
namespace: 'company',
19+
version: 1,
20+
label: 'Company',
21+
icon: 'Building2',
22+
description: 'Legal entity identity — registered name, address, tax IDs, and primary contact.',
23+
scope: 'tenant',
24+
readPermission: 'setup.access',
25+
writePermission: 'setup.write',
26+
category: 'Workspace',
27+
order: 3,
28+
specifiers: [
29+
// ── Identity ──────────────────────────────────────────────────────────
30+
{ type: 'group', id: 'identity', label: 'Identity', required: false },
31+
{
32+
type: 'text', key: 'legal_name', label: 'Legal name', required: false,
33+
description: 'Registered legal name of the organization (may differ from the workspace name).',
34+
maxLength: 200,
35+
},
36+
{
37+
type: 'text', key: 'registration_number', label: 'Registration number', required: false,
38+
description: 'Company registration / incorporation number (e.g. EIN, company no.).',
39+
maxLength: 80,
40+
},
41+
{
42+
type: 'text', key: 'tax_id', label: 'Tax / VAT ID', required: false,
43+
description: 'Tax identifier shown on invoices (e.g. VAT, GST, ABN).',
44+
maxLength: 80,
45+
},
46+
47+
// ── Registered address ────────────────────────────────────────────────
48+
{ type: 'group', id: 'address', label: 'Registered address', required: false },
49+
{ type: 'text', key: 'address_line1', label: 'Address line 1', required: false, maxLength: 200 },
50+
{ type: 'text', key: 'address_line2', label: 'Address line 2', required: false, maxLength: 200 },
51+
{ type: 'text', key: 'city', label: 'City', required: false, maxLength: 120 },
52+
{ type: 'text', key: 'state', label: 'State / Province', required: false, maxLength: 120 },
53+
{ type: 'text', key: 'postal_code', label: 'Postal code', required: false, maxLength: 32 },
54+
{
55+
type: 'text', key: 'country', label: 'Country', required: false,
56+
description: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).',
57+
pattern: '^[A-Za-z]{2}$', minLength: 2, maxLength: 2,
58+
},
59+
60+
// ── Contact ───────────────────────────────────────────────────────────
61+
{ type: 'group', id: 'contact', label: 'Contact', required: false },
62+
{
63+
type: 'text', key: 'phone', label: 'Phone', required: false,
64+
description: 'Primary business phone (E.164 recommended, e.g. +1 415 555 0100).',
65+
maxLength: 40,
66+
},
67+
{
68+
type: 'url', key: 'website', label: 'Website', required: false,
69+
description: 'Example: https://example.com',
70+
},
71+
{ type: 'text', key: 'primary_contact_name', label: 'Primary contact name', required: false, maxLength: 120 },
72+
{
73+
type: 'email', key: 'primary_contact_email', label: 'Primary contact email', required: false,
74+
description: 'Example: ops@example.com',
75+
},
76+
],
77+
};

packages/services/service-settings/src/manifests/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
} from './ai.manifest.js';
1414
export { knowledgeSettingsManifest, knowledgeTestActionHandler } from './knowledge.manifest.js';
1515
export { localizationSettingsManifest } from './localization.manifest.js';
16+
export { companySettingsManifest } from './company.manifest.js';
1617

1718
import { authSettingsManifest } from './auth.manifest.js';
1819
import { mailSettingsManifest } from './mail.manifest.js';
@@ -22,10 +23,12 @@ import { storageSettingsManifest } from './storage.manifest.js';
2223
import { aiSettingsManifest } from './ai.manifest.js';
2324
import { knowledgeSettingsManifest } from './knowledge.manifest.js';
2425
import { localizationSettingsManifest } from './localization.manifest.js';
26+
import { companySettingsManifest } from './company.manifest.js';
2527

2628
/** Convenience aggregate — pass to `SettingsServicePlugin({ manifests })`. */
2729
export const builtinSettingsManifests = [
2830
brandingSettingsManifest,
31+
companySettingsManifest,
2932
localizationSettingsManifest,
3033
authSettingsManifest,
3134
mailSettingsManifest,

packages/services/service-settings/src/translations/en.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,31 @@ export const en: TranslationData = {
7272
},
7373
},
7474

75+
company: {
76+
title: 'Company',
77+
description: 'Legal entity identity — registered name, address, tax IDs, and primary contact.',
78+
groups: {
79+
identity: { title: 'Identity' },
80+
address: { title: 'Registered address' },
81+
contact: { title: 'Contact' },
82+
},
83+
keys: {
84+
legal_name: { label: 'Legal name', help: 'Registered legal name (may differ from the workspace name).' },
85+
registration_number: { label: 'Registration number', help: 'Company registration / incorporation number (e.g. EIN).' },
86+
tax_id: { label: 'Tax / VAT ID', help: 'Shown on invoices (e.g. VAT, GST, ABN).' },
87+
address_line1: { label: 'Address line 1' },
88+
address_line2: { label: 'Address line 2' },
89+
city: { label: 'City' },
90+
state: { label: 'State / Province' },
91+
postal_code: { label: 'Postal code' },
92+
country: { label: 'Country', help: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).' },
93+
phone: { label: 'Phone', help: 'E.164 recommended, e.g. +1 415 555 0100.' },
94+
website: { label: 'Website', help: 'Example: https://example.com' },
95+
primary_contact_name: { label: 'Primary contact name' },
96+
primary_contact_email: { label: 'Primary contact email', help: 'Example: ops@example.com' },
97+
},
98+
},
99+
75100
localization: {
76101
title: 'Localization',
77102
description: 'Default timezone, language, currency, and date/number formats.',

packages/services/service-settings/src/translations/zh-CN.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ export const zhCN: TranslationData = {
6868
},
6969
},
7070

71+
company: {
72+
title: '公司',
73+
description: '法律实体信息——注册名称、地址、税号及主要联系人。',
74+
groups: {
75+
identity: { title: '主体信息' },
76+
address: { title: '注册地址' },
77+
contact: { title: '联系方式' },
78+
},
79+
keys: {
80+
legal_name: { label: '法定名称', help: '注册的法定名称(可能与工作区名称不同)。' },
81+
registration_number: { label: '注册号', help: '公司注册/工商登记号(如 EIN、统一社会信用代码)。' },
82+
tax_id: { label: '税号 / VAT', help: '发票上显示的税务标识(如 VAT、GST、ABN)。' },
83+
address_line1: { label: '地址行 1' },
84+
address_line2: { label: '地址行 2' },
85+
city: { label: '城市' },
86+
state: { label: '省/州' },
87+
postal_code: { label: '邮政编码' },
88+
country: { label: '国家/地区', help: 'ISO 3166-1 二位代码(如 US、GB、CN)。' },
89+
phone: { label: '电话', help: '建议 E.164 格式,如 +86 21 5555 0100。' },
90+
website: { label: '网站', help: '示例:https://example.com' },
91+
primary_contact_name: { label: '主要联系人' },
92+
primary_contact_email: { label: '主要联系人邮箱', help: '示例:ops@example.com' },
93+
},
94+
},
95+
7196
localization: {
7297
title: '本地化',
7398
description: '默认时区、语言、货币及日期/数字格式。',

0 commit comments

Comments
 (0)