Skip to content

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.

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"
ColumnTypeNotes
idUUID PK
tenant_idUUIDapp.tenantsRLS key. ON DELETE CASCADE.
nameTEXTAs the shop writes it.
normalized_nameTEXTCase- and punctuation-folded. Backs the browse index and duplicate detection.
company_numberTEXTThe shop’s own account number. UNIQUE (tenant_id, company_number).
tax_exempt_idTEXTResale or exemption certificate. Null means not exempt.
terms_daysSMALLINTPayment terms as a number of days, default 0.
phone, website, notesTEXT
created_at, updated_at, archived_atTIMESTAMPTZArchive, 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);

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');
ColumnTypeNotes
idUUID PK
tenant_idUUIDRLS key.
company_idUUID nullable → app.companiesON DELETE CASCADE. Nullable for one-off ship-to addresses.
kindapp.address_kindDefault both.
attention_toTEXTThe receiving contact.
company_nameTEXTThe name on the label, which is not always the account name.
line1, city, region, postal_codeTEXT NOT NULL
line2TEXT
countryCHAR(2)ISO-3166 alpha-2, default US.
is_defaultBOOLEAN
SELECT app.apply_tenant_rls('app.addresses');
CREATE INDEX addresses_by_company ON app.addresses (tenant_id, company_id);
MethodPathPermission
GET/v1/companiescompany.read
GET/v1/companies/{id}company.read
POST/v1/companiescompany.write
PATCH/v1/companies/{id}company.write
POST/v1/companies/{id}/archivecompany.write
GET/v1/companies/{id}/addressescompany.read
POST/v1/companies/{id}/addressescompany.write
PATCH/v1/addresses/{id}company.write
GET/v1/companies/{id}/customerscustomer.read
GET/v1/companies/{id}/dealsdeal.read

Listing supports q (trigram over name, via companies_trgm), archived, and keyset pagination.

  • 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 how line_items.catalog_snapshot freezes 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_id would 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.