@classytic/party
v0.3.0
Published
Engine-factory Party / Business-Partner identity kernel — one canonical party (person | company | group) carrying customer/supplier/contact/employee roles, contact points, addresses, B2B hierarchy, tax identity, and 1:many auth-user links. Dedup + merge +
Readme
@classytic/party
The Party / Business-Partner identity kernel for MongoDB. One canonical identity per real-world person, company, or group — carrying non-exclusive roles (customer / supplier / contact / employee / partner), contact points, addresses, tax identity, B2B hierarchy, and 1:many auth-user links. With race-safe de-duplication, merge, and a reusable resolver port.
Built on Mongoose + @classytic/mongokit
@classytic/primitives+@classytic/repo-core. Any commerce / ERP / SaaS app on MongoDB can adopt it.
Why
Most stacks model "the customer" in several places — a storefront customer, a CRM contact, an accounting partner, a payout recipient — none sharing an identity. That fragmentation is where "is this the same person?" bugs live.
party is the single identity spine. It follows the SAP Business Partner
shape: a thin identity that carries roles, and lets each domain keep its own
facet keyed by partyId. It is deliberately not an Odoo res.partner
god-object — no accounting accounts, credit limits, loyalty tiers, KYC, or
payout methods live on the party. Those stay in @classytic/ledger,
@classytic/loyalty, @classytic/payee, @classytic/crm, … and point at a
partyId.
@classytic/party (WHO) ← one deduped identity + roles
├── ledger finance facet (AR/AP, credit, terms) → partyId
├── crm funnel facet (stage, campaigns) → partyId
├── loyalty rewards facet (points, tier) → partyId
├── payee payout facet (methods, KYC) → partyId
├── commission earnings facet (plans, records) → partyId
└── referral attribution (who referred whom) → partyIdInstall
npm i @classytic/partyPeers: @classytic/mongokit >=3.20, @classytic/primitives >=0.9.1,
@classytic/repo-core >=0.9, mongoose >=9.4.1, zod >=4.
Quick start
import mongoose from 'mongoose';
import { defineParty } from '@classytic/party';
const party = defineParty({
multiTenant: false, // identity is company-wide here; pass true for per-tenant SaaS
defaultPhoneRegion: 'BD',
}).bind(mongoose.connection);
// Resolve-or-create by ANY identity signal — race-safe, dedup by email/phone/vat.
const { party: ada, created } = await party.port.resolveOrCreate(
{
displayName: 'Ada Lovelace',
emails: ['[email protected]'],
phones: ['+8801712345678'],
roles: ['customer'],
},
{},
);
// Same person, different casing + a role added → SAME party, no duplicate.
await party.port.resolveOrCreate({ emails: ['[email protected]'], roles: ['supplier'] }, {});
// Freeze a snapshot onto an order/invoice (never a live join).
const snap = await party.port.snapshot(String(ada._id), {});
// Merge a duplicate — survivor absorbs identity; the loser becomes a redirect.
await party.repositories.party.merge(dupId, survivorId, {});Contacts are not always proof of identity
Email and phone are authoritative identity keys by default, preserving the race-safe behavior existing commerce hosts rely on. Domains where people often share contact details can keep those values as contact data without allowing them to collapse two parties:
const party = defineParty({
multiTenant: true,
defaultPhoneRegion: 'GB',
identityContactTypes: [],
}).bind(mongoose.connection);Tax identifiers and explicit external references remain authoritative. A
domain-specific facet (for example a legal Client) can perform candidate-match
review and then attach its stable external reference or explicitly merge the
confirmed duplicate. This setting shapes persisted identityKeys; migrate
existing rows before changing it on a live deployment.
The PartyPort — the reusable contract
Consuming domains depend on engine.port (a PartyPort), not the models:
interface PartyPort<TParty> {
resolveOrCreate(input, ctx): Promise<{ party: TParty; created: boolean }>;
resolveMany(inputs, ctx): Promise<Array<{ party: TParty; created: boolean }>>;
snapshot(partyId, ctx): Promise<PartySnapshot>; // follows merge redirects
}Wire it into order / crm / ledger as the single identity source. Swap the kernel's storage without touching a consumer.
What it guarantees
- Race-safe dedup — a unique multikey index on
identityKeysis the authoritative guard; concurrent creates collapse to one party. - Non-lossy merge — the survivor absorbs the loser's keys, roles, contacts,
refs, and user links; the loser stays as a terminal
mergedredirect, so historical references (andsnapshot) still resolve. - Arc-native errors — every throwable implements repo-core's
HttpError. - Event catalog —
party:party.*events with a Zod source catalog for arc'sEventRegistry(validation + OpenAPI). Bring any arcEventTransport.
Roles are flags, never tables
A party with roles: ['customer', 'supplier'] is simultaneously both. Grant with
addRole, revoke with removeRole. No separate customer/vendor/contact
entities — that's the duplication this kernel exists to remove.
License
MIT
Trademark
Code is MIT-licensed. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see TRADEMARK.md.
