Companies
Crate: platyn-shop · Migration: 0004_shop.sql · Status: Schema built API planned
The tables described here exist and are applied. The HTTP surface at the bottom of this page is planned — handler work has not landed yet.
What a company is
Section titled “What a company is”A company is the buying organization: a school district, a booster club, a brewery, a construction firm. It is not the person who calls — that is a customer.
The naming follows the shop’s own language, which is also HubSpot’s, because the shops migrating onto Platyn already think in these terms.
The split matters because one organization has many contacts. A university might have forty people ordering shirts — athletics, each fraternity, three departments, the bookstore — under one tax exemption and one set of payment terms. Collapsing them into forty independent records means forty copies of the tax certificate and no way to answer “what did the university spend with us this year”.
erDiagram
TENANTS ||--o{ COMPANIES : owns
COMPANIES ||--o{ ADDRESSES : "ships and bills to"
COMPANIES ||--o{ CUSTOMERS : employs
COMPANIES ||--o{ DEALS : "is quoted"
COMPANIES ||--o{ DESIGNS : "logo library"
app.companies
Section titled “app.companies”| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
tenant_id | UUID → app.tenants | RLS key. ON DELETE CASCADE. |
name | TEXT | As the shop writes it. |
normalized_name | TEXT | Case- and punctuation-folded. Backs the browse index and duplicate detection. |
company_number | TEXT | The shop’s own account number. UNIQUE (tenant_id, company_number). |
tax_exempt_id | TEXT | Resale or exemption certificate. Null means not exempt. |
terms_days | SMALLINT | Payment terms as a number of days, default 0. |
phone, website, notes | TEXT | |
created_at, updated_at, archived_at | TIMESTAMPTZ | Archive, never delete. |
SELECT app.apply_tenant_rls('app.companies');
CREATE INDEX companies_by_name ON app.companies (tenant_id, normalized_name) WHERE archived_at IS NULL;CREATE INDEX companies_trgm ON app.companies USING gin (name gin_trgm_ops);app.addresses
Section titled “app.addresses”Addresses are their own table because a company has several and they are not interchangeable. Billing goes to accounts payable; shipping goes to a loading dock. Getting them backwards means a pallet of shirts at the wrong door.
CREATE TYPE app.address_kind AS ENUM ('billing','shipping','both');| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
tenant_id | UUID | RLS key. |
company_id | UUID nullable → app.companies | ON DELETE CASCADE. Nullable for one-off ship-to addresses. |
kind | app.address_kind | Default both. |
attention_to | TEXT | The receiving contact. |
company_name | TEXT | The name on the label, which is not always the account name. |
line1, city, region, postal_code | TEXT NOT NULL | |
line2 | TEXT | |
country | CHAR(2) | ISO-3166 alpha-2, default US. |
is_default | BOOLEAN |
SELECT app.apply_tenant_rls('app.addresses');CREATE INDEX addresses_by_company ON app.addresses (tenant_id, company_id);API surface (planned)
Section titled “API surface (planned)”| Method | Path | Permission |
|---|---|---|
GET | /v1/companies | company.read |
GET | /v1/companies/{id} | company.read |
POST | /v1/companies | company.write |
PATCH | /v1/companies/{id} | company.write |
POST | /v1/companies/{id}/archive | company.write |
GET | /v1/companies/{id}/addresses | company.read |
POST | /v1/companies/{id}/addresses | company.write |
PATCH | /v1/addresses/{id} | company.write |
GET | /v1/companies/{id}/customers | customer.read |
GET | /v1/companies/{id}/deals | deal.read |
Listing supports q (trigram over name, via companies_trgm), archived, and keyset
pagination.
Open questions
Section titled “Open questions”- Address snapshots on confirmed deals. Right now a deal references a live address row. If a
company moves between confirmation and shipping, the job silently retargets. The likely fix is
snapshotting the resolved address into the deal at
confirmed, matching howline_items.catalog_snapshotfreezes pricing. - Merging duplicates. Shops accumulate “Riverside Brewing”, “Riverside Brewing Co”, and “riverside brewing co.”. A merge has to reassign deals, customers, addresses, and designs in one transaction and leave a tombstone so old links resolve.
- Parent/child companies. A district with twelve schools, a franchise with nine locations. Not
modelled yet —
parent_idwould be a one-level self-reference, deliberately capped, because arbitrary depth means recursive CTEs on every lookup and an argument about which ancestor’s terms win. - Credit limits and holds. Not modelled. Whether exceeding a limit blocks a deal or warns is a product decision, and the leaning is warn-plus-override, because an owner overruling their own credit rule for a good customer is normal business.