# Customer foundation — Phases 2B.1 and 2B.2

`apps.customers` owns company-scoped business parties. A Customer is a person or
organization receiving after-sales service; `accounts.User` is an employee/system
login identity. Customers have no authentication fields and need no User record.

## Model and ownership

`Customer` has UUID `id`, required protected `company`, generated
`customer_number`, `customer_type`, `full_name`, `organization_name`,
`primary_mobile`, `primary_email`, `is_active` (default true), `created_at` and
`updated_at`. `display_name` selects the appropriate name for the customer type.
`__str__` uses only the customer number to avoid unnecessarily copying names or
contacts into reference labels/admin logs.

Company is the ownership boundary. Customer is not attached to Region,
ServiceCenter or Department. Company and customer number cannot change through
supported model saves, services or admin. Customer type is selected at creation
in normal service/admin workflows; a type-conversion workflow is not provided.
Validated direct model saves may correct type and names together.

Phase 2B.2 adds separate address and additional-contact records, described below.
No NID/passport, date of birth, gender, actors or audit-log framework are introduced.
All tests use synthetic identities.

## Names and contact normalization

- `INDIVIDUAL` requires a usable `full_name` and blank `organization_name`.
- `ORGANIZATION` requires `organization_name`; `full_name` may be blank.
- Names have surrounding whitespace stripped; internal spacing/case is retained.
- Both primary contacts are optional. Names, phones and emails are not unique,
  within a company or globally. Shared family/office contacts do not trigger merges.
- Mobile normalization strips surrounding whitespace and removes ASCII spaces,
  hyphens and parentheses. The result contains 7–15 ASCII digits, optionally
  preceded by `+`. International notation cannot begin `+0`. Local numbers retain
  leading zeros; no country code is inferred and `00` is not rewritten to `+`.
  Letters, extensions, internal tabs/newlines, misplaced plus signs and non-ASCII
  digits are rejected. This is conservative syntax validation, not proof of a
  real/assigned/mobile number. No telephony dependency is added.
- Email normalization trims surrounding whitespace and lowercases the domain
  only, preserving the local part. Django's EmailField validation checks syntax.
  It does not prove ownership/deliverability or make email an identity key.

## Number allocation and transactions

`CustomerNumberSequence` is internal state: one protected Company primary-key
relationship and positive-big-integer `next_value`, initially 1. Rows are created
lazily; no customer or sequence data is seeded and the allocator has no admin.

All supported Customer writes use the default PostgreSQL database and acquire
the Company row with `SELECT FOR UPDATE` before Customer/sequence locks. This
matches the existing Company lifecycle lock without changing organization code.
Creation runs in `transaction.atomic()`, locks Company, locks/creates its counter,
formats the current value, advances the counter and validates/inserts Customer in
the same transaction. The Company lock closes the absent-counter first-creation
race. Existing counter access also uses `select_for_update()`.

`format_customer_number(value)` is the sole formatter: `CUS-00000001`, minimum
eight digits, growing up to eighteen digits. Capacity exhaustion raises validation
instead of wrapping. There is no count/max allocation. `(company, customer_number)`
database uniqueness is the final duplicate guard. Two companies may each issue
`CUS-00000001`; displays/integrations must retain company context.

Normal `Customer.save()` creation uses the same allocator and rejects manually
supplied numbers. Sequence increment and insert roll back together, including an
enclosing transaction rollback. A rolled-back number may be reused because it
was never committed. Deleting a committed customer does not rewind the counter.
**Gaplessness is not a business guarantee**: deletion, privileged intervention or
future allocation strategies can leave gaps. Never expose a number as committed
until its transaction commits; discard/refetch model instances after rollback.

The Company lock deliberately serializes writes within one company, including
updates to different customers. Different companies do not block one another in
the tested allocation path. This conservative foundation may need measured
throughput work later; no unproven deadlock-freedom guarantee is made.

## Public services

Import from `apps.customers.services`:

```python
create_customer(
    *, company, customer_type, full_name="", organization_name="",
    primary_mobile="", primary_email="",
)
update_customer(
    *, customer, full_name=_UNSET, organization_name=_UNSET,
    primary_mobile=_UNSET, primary_email=_UNSET,
)
deactivate_customer(*, customer)
reactivate_customer(*, customer)
```

All return a Customer. `_UNSET` is an internal omission sentinel; callers omit
unchanged keyword arguments and use `""` to explicitly clear an optional value.
Creation requires a persisted active Company. Mutation requires a persisted
Customer; nonexistent records are not silently recreated. Invalid data raises
`ValidationError`; deleted mutation targets can raise `Customer.DoesNotExist`.
These services do not accept a company/number/type change in an update.

Updates lock Company then refetch/lock Customer, apply only supplied fields to
current database state and validate the resulting row. Different-field updates
preserve both changes; same-field updates serialize with the later write winning.
Caller-supplied stale lifecycle/ownership attributes are never copied by services.
Partial model saves similarly preserve omitted persisted fields. Ordinary full
model saves are not an optimistic concurrency API and can replace editable state;
use services for business workflows.

## Lifecycle and Company interaction

Deactivation retains the customer and prevents operational selection. Repeated
deactivation/reactivation is idempotent and avoids unnecessary timestamp changes.
Reactivation always requires an active Company, even on a repeated request.
Creating an active customer or directly changing inactive to active also validates
Company state under the Company lock.

Company deactivation **does not update Customer flags or timestamps**. Operational
queries require both Customer and Company to be active, so the customer immediately
becomes unavailable. Metadata correction remains allowed while Company is inactive.
Company reactivation makes customers whose own flags stayed active available again;
individually deactivated customers still need explicit reactivation. This differs
intentionally from organization hierarchy cascades; no lifecycle hooks or frozen
organization code were changed.

Company deletion is protected by both customers and allocated sequence state.
Admin offers no customer deletion; normal business workflow uses deactivation.
Native Django delete permission still exists. Privileged ORM deletion of an
unreferenced Customer remains possible; this phase does not promise soft deletion
or immutable historical records. Future transaction foreign keys must deliberately
preserve referenced customer identities.

## Queries and authorization boundary

Import from `apps.customers.queries`:

```python
active_customers_for_company(company)
find_customers(*, company, query)
```

Both return lazy Customer QuerySets, restricted to the supplied persisted Company
and current active Company/Customer flags. Invalid or unsaved Company input yields
an empty QuerySet. Search uses case-insensitive substring matching over number,
both names, mobile and email; it also tries the normalized phone representation.
Blank/non-string search input returns no results. Ordering is customer number then
UUID (lexical number ordering, not a promise of chronological ordering once width
grows). Results cannot cross company boundaries. No fuzzy search or automatic merge
is performed. Company FK and company/number uniqueness supply ordinary indexes;
substring search is intentionally simple at this scale.

Constructing the active QuerySet uses zero queries; evaluating it uses one SQL
statement, as tested. Database state is checked at evaluation. Already evaluated
QuerySets/lists are snapshots, not ongoing authorization guarantees. Future
transaction creation must revalidate lifecycle state within its own transaction.

**Company isolation is not caller authorization.** These services have no `user`
argument and do not implement RBAC. Customer remains an unsupported target in the
frozen Phase 1 scope engine, including for an active superuser using that API.
Standard Django add/change/delete/view permissions are generated, with no business
role seeds. Customer access integration must be designed explicitly in a later
phase; callers must not expose unrestricted company parameters to end users.

## Django Admin

Admin remains the trusted global interface, governed by native model permissions.
It lists number, type, display name, company, mobile, email, active state and creation
date, with company/type/active filters and identity/contact search. Company is
selected on creation. Customer number, lifecycle and timestamps are readonly;
company and type additionally become readonly on edit.

Creation delegates to `create_customer`; edit delegates only changed contact/name
fields to `update_customer`. No number-generation or lifecycle logic is duplicated
in ModelAdmin. Stale metadata edits cannot restore an old active flag. Lifecycle
actions require change permission and call the corresponding services per record.
A rejected reactivation produces a generic error message without contact details.
Actions are per-record transactions, not an all-or-nothing batch.

Admin form validation handles ordinary invalid inputs. A conflicting change between
form validation and the locked service write can still require an operator retry;
there is no custom concurrent-edit resolution screen in this phase. Normal CSRF and
view/change permission protections remain enabled.

## Database and raw/bulk boundary

`customers.0001_initial` creates only Customer and CustomerNumberSequence, depending
on `organization.0002_userorganizationassignment`. Existing migrations are unchanged.
Database protections include:

- Required Company FK and protective Django deletion semantics.
- Company/number uniqueness and canonical `CUS-` plus 8–18 digit shape.
- Valid customer-type enum and basic type-specific nonempty-name/blank-organization
  constraints (usable trimmed names are additionally application validated).
- Positive counter value and unique company counter.

Ownership/number immutability, allocation protocol, trimming, phone/email syntax,
usable non-whitespace names and active-company checks are application/service rules.
Raw SQL, `QuerySet.update()`, `bulk_create()` and `bulk_update()` bypass those paths.
Database constraints do not protect against a privileged writer reassigning a
company, changing a number to another unique valid shape or desynchronizing the
counter. Do not use these APIs for customer business writes. Counter tampering can
cause creation to fail safely on uniqueness; no automatic reconciliation is added.

## Verification and future work

The customer suite covers model/constraint attacks, normalization and shared
contacts, allocator rollback, isolated queries, lifecycle, actual admin writes and
eight PostgreSQL concurrency schedules. Concurrency uses separate connections and
the project's `pg_blocking_pids` observer, not timing-only sleeps:

1. Simultaneous first creation: serialize, both succeed, distinct numbers.
2. Separate companies: both allocate without waiting on the other's Company.
3. Company deactivation then creation: wait, reject, leave no allocator/customer.
4. Creation then Company deactivation: preserve row but exclude operationally.
5. Different-field updates: wait and retain both changes.
6. Deactivation then stale update: preserve inactive state, company and number.
7. Company deactivation then reactivation: wait and reject.
8. Admin edit behind deactivation: update metadata and remain inactive.

Verification on 2026-09-26, PostgreSQL 18.6:

| Command | Result |
| --- | --- |
| `python manage.py test apps.customers --noinput` | 38 passed, 0 failed, 0 skipped (13.250s) |
| `python manage.py check` | No issues |
| `python manage.py makemigrations --check` | No changes detected |
| `python manage.py migrate` | `customers.0001_initial` applied successfully |
| `python manage.py showmigrations customers` | `[X] 0001_initial` |
| `python manage.py test --noinput` | 419 passed, 0 failed, 0 skipped (149.031s) |

Commands used the project's virtual-environment Python. Migration history is
consistent with no graph conflicts. The test runner built/migrated an isolated
PostgreSQL test database; the normal development database was not reset.
All 381 previous tests remained unchanged and passed. The 32 tracked test/migration
files were compared against frozen commit `bad910e`, with no changes. Git review
showed only the new customers app/documentation and the necessary app registration
and architecture note. `.env` stayed ignored/untracked; a bounded tracked-tree/new-
file scan found no credential patterns or tracked environment/database dumps.
Nothing was staged, committed or pushed.

Phase 2B.5 now uses this stable identity through protected ownership periods in
`apps.devices`; see [Device ownership](DEVICES.md#phase-2b5--customer-ownership-and-history).
Customer schema and lifecycle are unchanged; business access integration remains deferred.

## Phase 2B.2 — Address and additional contact records

The preceding Phase 2B.1 verification record is historical. Phase 2B.2 extends
`apps.customers` without changing Customer's fields, allocator, lifecycle or
authorization semantics. No Phase 2B.3 functionality is included.

### Models

`CustomerAddress` fields:

| Field | Definition |
| --- | --- |
| `id` | UUID primary key |
| `customer` | Required Customer FK, `PROTECT`, immutable through supported writes |
| `address_type` | HOME, WORK, BILLING, SHIPPING, OTHER |
| `label` | Optional text, 100 characters |
| `address_line_1` | Required text, 255 characters |
| `address_line_2` | Optional text, 255 characters |
| `area` | Optional text, 100 characters |
| `city` | Required text, 100 characters |
| `district` | Optional text, 100 characters |
| `postal_code` | Optional text, 20 characters |
| `country_code` | Required two-letter uppercase ASCII code |
| `is_primary` | False by default |
| `is_active` | True by default |
| `created_at`, `updated_at` | Existing timestamp convention |

`CustomerContact` fields:

| Field | Definition |
| --- | --- |
| `id` | UUID primary key |
| `customer` | Required Customer FK, `PROTECT`, immutable through supported writes |
| `contact_type` | MOBILE, PHONE, EMAIL, WHATSAPP, OTHER; immutable through supported writes |
| `label` | Optional text, 100 characters |
| `value` | Required entered representation, 254 characters |
| `normalized_value` | Required derived comparison/search value, 254 characters; not editable |
| `is_primary` | False by default, per contact type |
| `is_active` | True by default |
| `created_at`, `updated_at` | Existing timestamp convention |

An app-local abstract `CustomerDetail` shares fields and validated-save behavior;
it creates no table. Reverse accessors are `customeraddress_records` and
`customercontact_records`. There is no duplicated Company FK: ownership comes
only through Customer, preventing independent contradictory company fields.

### Normalization and contact compatibility

Address text is trimmed, and whitespace-only required data is rejected. Country
codes are trimmed and uppercased, then checked for two ASCII letters. This validates
an ISO-style shape, not membership in an ISO country inventory. No country is
inferred and no country/address master table or geographic enum is introduced.

Contact `value` retains entered formatting/case after surrounding whitespace is
trimmed. `normalized_value` is always derived again on a supported save:

- MOBILE/PHONE/WHATSAPP reuse Phase 2B.1's conservative phone normalization exactly.
  No country code is inferred; this checks syntax, not reachability/provider type.
- EMAIL uses Django validation, preserves local-part case and lowercases the domain.
- OTHER trims only; internal spacing and case remain significant.

Active contacts are unique by `(customer, contact_type, normalized_value)`.
Different customers, including in the same company, can share values. The same
value may also occur in different types. Inactive duplicate history is permitted.
Reactivating a historical duplicate while an equivalent active contact exists
fails atomically. There is no automatic merging.

**Customer.primary_mobile and Customer.primary_email remain the canonical quick-
access fields from Phase 2B.1.** Additional contacts and their per-type primaries
are separate configuration. Creation, editing, primary switching and lifecycle
operations never synchronize or migrate those Customer fields. An eventual
unification/access integration requires its own deliberate phase.

### Public address services

Import these functions from `apps.customers.services`; implementations are in
`detail_services.py`:

```python
create_customer_address(
    *, customer, address_type, address_line_1, city, country_code,
    label="", address_line_2="", area="", district="", postal_code="",
    is_primary=False,
)
update_customer_address(
    *, address, address_type=_UNSET, address_line_1=_UNSET,
    city=_UNSET, country_code=_UNSET, label=_UNSET,
    address_line_2=_UNSET, area=_UNSET, district=_UNSET, postal_code=_UNSET,
)
set_primary_customer_address(*, address)
deactivate_customer_address(*, address)
reactivate_customer_address(*, address)
```

### Public contact services

```python
create_customer_contact(
    *, customer, contact_type, value, label="", is_primary=False,
)
update_customer_contact(*, contact, value=_UNSET, label=_UNSET)
set_primary_customer_contact(*, contact)
deactivate_customer_contact(*, contact)
reactivate_customer_contact(*, contact)
```

All return the affected concrete record. `_UNSET` denotes an internal omission
sentinel; omit unchanged fields and use `""` to clear optional ones. Owner and
contact type are not accepted update parameters. Validation failures raise
`ValidationError`; deleted persisted targets may raise the model's `DoesNotExist`.
Unsupported/unsaved input is rejected. Services treat incoming instances as IDs
and fetch current ownership, type, lifecycle and primary state after locking.

### Primary and lifecycle semantics

There is at most one active primary address per customer and at most one active
primary contact per customer **and contact type**. MOBILE, EMAIL and WHATSAPP
primaries may coexist; address primary state is independent of every contact type.
An inactive record cannot be primary.

Creation defaults to nonprimary, even for the first record. Passing
`is_primary=True` deliberately switches the primary to the newly created record
within the same transaction. Setting primary on an existing record requires it to
be active and its Customer/Company to be operational. Switching a contact primary
only clears the previous primary of the same type. Repeated primary selection is
idempotent. No observer can see a partially committed switch; a failure restores
the complete previous primary configuration.

Deactivation retains the record and clears primary. It does not choose a
replacement. Reactivating an inactive record requires an active Customer and
Company and leaves it nonprimary. Repeated lifecycle calls do not change state or
timestamps; repeated reactivation still verifies operational parents. Metadata
correction is allowed on inactive/historical records, subject to normal validation.

Customer or Company deactivation does not cascade into detail flags, timestamps
or rows. Operational queries become empty. If the parents later become active,
preserved active details and primary selections become effective again; individually
deactivated details stay inactive. Child creation ordered after parent deactivation
is rejected. Creation ordered before deactivation can remain persisted and locally
active, but is not operationally usable after its parent becomes inactive.

Normal business deletion is disabled in detail admin. Both child FKs protect
Customer deletion, including when the children are inactive. Privileged direct
deletion of a detail is still possible; this is not soft deletion or an immutable
audit/history store. Future transaction references must choose preservation
semantics explicitly.

### Operational query APIs

Import from `apps.customers.queries`:

```python
active_addresses_for_customer(customer)
primary_address_for_customer(customer)
active_contacts_for_customer(customer, *, contact_type=None)
primary_contact_for_customer(customer, *, contact_type)
```

Active-list functions return lazy QuerySets ordered by creation timestamp then
UUID. Primary functions return a concrete record or `None`; contact primary always
requires a valid explicit type. Invalid/unsaved customer inputs yield no usable
records. Invalid type filters return no contacts, and invalid primary types return
`None`. Company/Customer/child active flags are all checked in SQL at evaluation.

Queries are constrained by the supplied Customer PK and cannot mix customers or
companies. Normal model managers remain available for authorized historical/admin
inspection. List construction costs zero queries; list evaluation and each primary
lookup cost one query, as tested. Evaluated results are snapshots and do not replace
transaction-time validation for future relationships.

These APIs enforce integrity and isolation, **not caller authorization**. They
take no user and must not be exposed to end users without an access layer. Customer,
address and contact remain unsupported targets of the frozen scope engine, even
for an active superuser using that API. Native Django model permissions exist;
no RBAC logic, adapters or role seeds were added.

### Transactions and lock order

`detail_locks.locked_customer()` implements the order:

1. Resolve the persisted Customer's Company.
2. Acquire Company `FOR SHARE` on the default PostgreSQL connection.
3. Acquire Customer `SELECT FOR UPDATE` and read fresh state.
4. Lock the target/relevant child rows; perform validated writes in the same atomic
   transaction. Previous-primary rows are ordered by UUID before locking.

Company SHARE conflicts with the exclusive Company lock used by the existing
Customer and organization lifecycle operations. It does not serialize child-only
writes under different customers in one company. Customer is the coordination
row for duplicate checks, primary switches and child lifecycle. Same-customer
contact types serialize, but switches never clear other types.

The only new bulk update is the controlled previous-primary clear, after Customer
and relevant detail locking and within the transaction containing the new primary
save. Both the clear and a newly inserted row roll back if setting primary fails.
Different-field service edits patch only supplied values onto fresh state. Same-
field edits serialize with later supplied values winning; this is not versioned
optimistic concurrency. Partial model saves preserve omitted state and recompute
derived contact normalization. Ordinary full model saves remain explicit full-state
writes; use services for stale business/admin mutations.

The tested schedules do not prove formal deadlock freedom for arbitrary outer
transactions. When composing parent and child writes, acquire the strongest parent
lock first: concurrent transactions that take shared Company locks and later
upgrade both to exclusive can deadlock. No frozen lifecycle lock protocol was
changed to solve arbitrary caller composition.

### Admin and privacy

Separate address/contact admins provide customer/type/label/state columns,
address city/country or contact value, timestamps, search and filters. Owners are
readonly after creation; contact type is also readonly after creation. Normalized
value, primary and active flags are always readonly. Creation uses services and
starts nonprimary; a dedicated action selects exactly one record as primary.
Lifecycle actions call services per record, not as an all-or-nothing batch.

Edits send only changed descriptive fields to the services, preserving concurrent
lifecycle/primary changes and omitted metadata. View-only staff cannot mutate;
CSRF protection remains enabled. Ordinary validation errors stay on the form. A
validation conflict after form validation rolls back the enclosing admin write and
redirects with a generic retry message. No address/contact values are included in
that message or model string labels. Deliberate administrative display/search still
contains personal data and remains governed by native Django permissions.

No geolocation, personal identifiers, contact-person hierarchy, delivery/channel
integration, notifications or analytics are introduced. Test data is synthetic.

### Database constraints and bypass boundary

The new migration is `customers.0002_customeraddress_customercontact`, depending
only on `customers.0001_initial`. It creates two tables and no seeded data.

PostgreSQL constraints enforce valid type enums, uppercase two-letter country-code
shape, basic nonempty required text/value, primary-implies-active, address primary
partial uniqueness, contact per-type primary partial uniqueness and active
normalized-contact duplicate prevention. The Customer foreign keys preserve
referential integrity, with Django `PROTECT` for parent deletion.

Cross-table operational state, ownership/type immutability, text trimming, usable
non-whitespace strings, contact syntax and equality between entered and normalized
values are application/service rules, not database CHECK guarantees. Raw SQL,
`QuerySet.update()`, `bulk_create()` and `bulk_update()` bypass model validation and
the lock protocol. In particular, a privileged writer can forge normalized values
or move an owner while still satisfying ordinary FK/unique constraints. Those
writes are unsupported for normal business operations. No triggers were added.

### Phase 2B.2 focused verification

`python manage.py test apps.customers.test_details --noinput` passed **45 tests,
0 failed, 0 skipped** in 28.587s on PostgreSQL. The suite includes model/database
attacks, primary and creation rollback, historical duplicates, quick-field
compatibility, isolated one-query lookups, actual admin writes and twelve
PostgreSQL concurrency test methods (some exercise both record types):

- Address primary versus primary and same-type contact primary versus primary.
- Different contact-type primaries preserve one another.
- Equivalent contact creation rejects the second supported writer; an additional
  bypassing bulk-write race verifies the database constraint independently.
- Deactivation versus metadata update; deactivation versus primary selection.
- Customer deactivation versus creation in both serialization orders.
- Company deactivation before creation.
- Different customers in the same company do not block each other's child writes.
- Primary switch versus stale HTTP admin edit, for both models.

Tests use separate PostgreSQL connections and the established `pg_blocking_pids`
observer, not timing alone. Original Phase 1, Phase 2A and Phase 2B.1 tests are
preserved without changes.

Final Phase 2B.2 verification on 2026-09-26, PostgreSQL 18.6:

| Command | Result |
| --- | --- |
| `python manage.py check` | No issues |
| `python manage.py makemigrations --check` | No changes detected |
| `python manage.py migrate` | `customers.0002_customeraddress_customercontact` applied successfully |
| `python manage.py showmigrations customers` | Both 0001 and 0002 applied |
| `python manage.py test --noinput` | **464 total, 464 passed, 0 failed, 0 skipped** (187.228s) |

Commands used the project's virtual-environment Python. The original **419 tests
remain unchanged and passed**, with 45 new tests. All 35 tracked test/migration
files were compared against Phase 2B.1 commit `4bd3e63` and were unchanged, including
`customers.0001_initial` and frozen Phase 1/2A migrations. Migration history is
consistent and conflict detection returned `{}`. The full suite migrated and
destroyed an isolated test database; the normal development database was not reset.

Git status/diff/stat and whitespace checks found only the intended customer-domain
and documentation changes. `.env` remains ignored/untracked. A bounded tracked-tree
and new-file scan found no credential patterns or tracked environment/database
dumps. No secret values were printed, and nothing was staged, committed or pushed.
Phase 2B.3 has not begun.


## Device ownership integration (Phase 2B.5)

`apps.devices.CustomerDeviceRelationship` records OWNER periods with a protected
Customer FK. A Customer can own multiple Devices; no device count or owner cache
is stored. Device history establishes permanent company affinity, including after
ownership ends. Assignment/transfer requires active Customer and Company under
Company SHARE then Customer UPDATE locks before locking Device. Deactivation
preserves recorded ownership; operational owned-device queries omit inactive
Customer/Company/Device. Reactivation does not create relationships. Current owner
and audit history remain queryable. Purchaser, warranty, presenter and requester
are separate facts; future service transactions must capture their own actors.
See [Devices](DEVICES.md) for exact APIs, temporal rules and authorization limits.
