Publishes customer.customer.*; null publishes nothing (read-only apps, scripts, tests).
Protected ReadonlycacheProtected ReadonlydbStaticinstanceThe process default: built from each dependency's own instance().
Insert the customer row. Nothing else.
This used to create the customer AND its primary organization in one
transaction, because customer.organization_id and
organization.customer_id pointed at each other and neither row could be
written standalone with both ends populated. That data-model cycle forced a
package cycle: customer-services had to reach into entity-services for
organizations, related people, entity events and RouteFusion onboarding —
while entity-services needed customers, so it read them through a
hand-written structural port to keep tsc --build able to order the graph.
organization.is_main_org replaced the customer-side pointer, so the
reference is one-directional and the primary org is created through the
ordinary OrganizationService.create({ isMainOrg: true }) path.
@cfxlabsinc/onboarding-services sequences the two, and this method is
back to what a create on a leaf service should be: one insert plus its
cache invalidation.
Returns a ProvisioningCustomer, not a Customer: the primary
org does not exist yet, so the org-derived fields (organizationId,
legalEntityName) have no value to carry and the customer is not yet
visible to get/search.
Idempotent on a caller-supplied id: re-running the same onboarding
attempt hits the customer.external_id unique constraint and returns
DUPLICATE, which the onboarding activity treats as "already done" rather
than inserting a second customer.
create's insert through the caller's executor, so a caller can write the
customer's grants in the same transaction. Call invalidateCreated after
the commit. A DUPLICATE rolls back only this insert's own savepoint.
The customer takes workspaceId, which CustomerDashboardWorkspaceAdminService
created: WORKSPACE_UNAVAILABLE when it is not a free CUSTOMER workspace.
Soft-delete a customer: it can no longer transact and no longer resolves
through get/search unless the caller asks for deleted rows.
AND deleted_at IS NULL in the WHERE is what makes a repeat delete a
CUSTOMER_NOT_FOUND rather than a second write that moves the timestamp.
Optionalid?: stringOptionalincludeDeleted?: booleanResolve a soft-deleted customer instead of returning null. Folds onto
search's parameter of the same name. Operator surfaces that list
deleted customers pass it so the row they render can still be opened;
nothing that transacts should.
OptionalworkspaceId?: stringDrop this customer's cached reads.
Lives here rather than on the writer because it's the cache's own
concern — it names the folded-get keys and the membership tag — so both
the reader (CustomerQueryService) and the writer (CustomerService)
inherit it. The actual caller is customerOnboardingActivities
(@cfxlabsinc/onboarding-services), which calls this once a primary
organization exists for the customer: a customer is invisible to
get/search until its primary org exists, so every read taken earlier
in onboarding cached a negative entry with a 24h L2 TTL, and this is what
clears it.
Clear each customer's membership tag and each record tag wherever cached,
and always this namespace's collection tag. keys are exact deletes in this
instance's key space: the only bust a null client honours.
Busts what a committed create stales. A new customer can match any cached
search, including empty ones, so this busts the collection unconditionally.
keys covers memory mode, where tag invalidation no-ops: it drops the
negative folded-get entries onboarding already planted for this id and
workspace id.
Busts what a committed update stales. Unconditional: the predecessor gated
the collection bust on a hand-kept list of "searchable fields" that omitted
status, so activating a customer left every statuses-filtered page
stale for the full 24h L2 TTL.
keys drops the folded single-id / single-workspace get entries by exact
key, the only honest bust in memory mode, where tag invalidation no-ops.
Points the customer at its "Operating" ledger account. Linking the account already linked succeeds; linking a different one is refused, and so is an account that is missing or belongs to another customer.
Mark a customer FAILED after its onboarding run failed terminally.
Separate from update() because a customer can fail BEFORE its primary
organization is created, and update() returns a Customer, which
by definition has one. Idempotent: a repeat publishes
customer.customer.statusUpdated again, sent before the write commits as in update().
Customer↔org activation join: the customer flips ACTIVE only once BOTH
sides are done — provisioning (data.provisionedAt set, recorded by
createCustomerWorkflow's final step) AND the primary org itself ACTIVE
(AiPrise-approved, and RouteFusion-verified too when RF-enabled — see
resolveOrganizationActivation in @cfxlabsinc/entity-services).
Called from BOTH sides of that race — the workflow end (the org may already be ACTIVE) and the org-ACTIVE transition (provisioning may already be done) — and is safe regardless of which finishes first: it's a pure read-then-maybe-write that no-ops whenever either half is missing, and no-ops for any customer that is not still PENDING or FAILED.
Reads the primary org's status with a direct query on organizationTable
rather than an injected org service/class: entity-services already
depends on customer-services (OrganizationService imports
CustomerQueryService), so a CustomerQueryService → OrganizationAdminService
edge would cycle back. The raw table read stays a leaf dependency, same as
the existing organizationTable join in search().
activated is true only for the call whose write flipped the customer
ACTIVE: the write re-reads the status under a row lock, so of two racing
callers one activates and the other reads ACTIVE. Only that call publishes
customer.customer.statusUpdated.
OptionalcreatedAt?: DbTimestampCriteriaFilter to customers created within this window.
OptionalidAfter?: stringKeyset cursor: customers whose id sorts after this one. Pair with orderBy on externalId.
Optionalids?: string[]OptionalincludeDeleted?: booleanReturn soft-deleted customers too. Ignored when statuses names
DELETED: that list already states which deletedness the caller wants,
and applying the default on top of ["DELETED"] would return nothing
for a query that plainly asked for deleted customers.
OptionalincludeWithoutMainOrg?: IncludeWithoutMainOrgTurn the primary-organization INNER JOIN into a LEFT JOIN, so customers
that have no primary organization yet are returned too — with
organizationId / legalEntityName absent (see
MaybeOnboardedCustomer).
A deliberate, narrow escape hatch from the rule that a Customer
is a fully-onboarded one. Two callers:
OrganizationService.create({ isMainOrg: true }), which has to read the
customer whose primary org it is in the middle of inserting; without this
it can never see it and retries forever. And customer-dashboard's
/apply/[id]/verify, which reads only status to show provisioning,
for a workspace the caller's membership already proved. Nothing else
user-facing should pass it.
Results are NOT cached when this is set: a half-built customer changes shape within seconds, and the 24h L2 TTL would outlive that by a long way.
OptionalisInternal?: booleanCFX's own customers (true) or everyone else (false).
OptionalmatchAdminText?: booleanAlso match q against admin-only text: data (operator notes, fee
settings), description and attio_id. Admin surfaces only; a customer
or bank caller must leave it unset, or q probes other customers' notes.
Optionalname?: stringOptionalnameLike?: stringOptionaloperatingAccountLinked?: booleanWhether operatingAccountId is set.
OptionalorderBy?: DbOrderByCriterion<Optionalpage?: numberDefaults to 1
OptionalpageSize?: numberDefaults to 50
OptionalprodCustomerIds?: string[]Filter to dev customers twinning these prod customers (cust_… in the prod database).
OptionalprogramIds?: string[]Filter to customers holding a grant on any of these programs (prog_…).
Optionalq?: stringUnified free-text search. Case-insensitive substring match against the
customer name, the customer external id (cust_…), or the legal entity
(organization) name. Whitespace-only values are a no-op.
Optionalstatuses?: ("PENDING" | "ACTIVE" | "FAILED" | "DISABLED" | "DELETED")[]Filter to customers in these statuses — the provisioning lifecycle
(PENDING / ACTIVE / FAILED) and the two operator states
(DISABLED / DELETED).
OptionalupdatedAt?: DbTimestampCriteriaFilter to customers updated within this window.
OptionalworkspaceIds?: string[]ProtectedsearchTags for a page: the scope its query searched (each customer's membership
tag, else the collection tag), so an empty page is bustable too, plus the
records it projects and the fields that chose or ordered its rows.
Publishes customer.customer.statusUpdated whenever status is given, so a
retry republishes, and customer.customer.updated when the name,
description or self-verification changed. A logo or provisioning write publishes nothing here.
The status event is sent before the write commits: a refused send rolls the write back, so the caller's retry makes the change afresh.
update's write through the caller's executor, so a caller can change the
customer's grants in the same transaction. Call invalidateUpdated after
the commit.
StaticgetAs getCacheKey, for the get({ workspaceId }) fold.
StaticgetThe exact cache key CustomerQueryService.get({ id }) reads through.
get folds onto search, so its entry is an ordinary single-row search
page — these args must stay identical to the ones get passes
({ ids: [id], pageSize: 1 }, plus search's page default of 1). Every
other filter is undefined there, and serviceCacheKey canonicalizes with
JCS, which drops undefined properties, so naming only the three that are
set produces the same digest. customerCache.test.ts pins the equivalence.
Returns the key arguments; the cache hashes and surface-prefixes them. Never pre-hash at a call site.
Writes to customers: the insert, the generic update, the terminal-failure stamp, and the customer↔org activation join.
Extends CustomerQueryService — so
get/searchare inherited, and every write busts the instance it reads through, which is what keeps read-after-write correct inside a single request.invalidatecomes from CustomerCache at the root of the hierarchy.Everything the old
create()reached for — organizations, related people, entity events, RouteFusion onboarding — moved toonboarding-services, which sits ABOVE both this package andentity-services. That is what makes the dependency one-directional (onboarding → entity → customer) and letsentity-servicesimportCustomerQueryServicedirectly rather than through a structural port.