Client Configuration
Every field on the client (tenant) record and what it actually controls, plus the per-client config tables — discounts, payout limits, IP whitelist, portal users, blacklists — and the create-client flow. Includes the dead fields and default mismatches.
The clients table / models.Client (database/models/client.go:17) is the tenant record. This traces every field to its real read sites — several are set-but-never-read — plus the per-client config tables that hang off it.
The live struct is much leaner than the table's history — is_active, is_bulk_limit_enabled, is_fill_or_kill_enabled, show_all_products and others were added then dropped or orphaned. See Dead fields & mismatches.
models.Client fields
| Field / column | Type | What it controls (traced) | Default | Gotchas |
|---|---|---|---|---|
ID / id | uint64 PK | Tenant identity; types.TenantIDKey drives all tenant-scoped queries | serial | StoreClientWithID can force an ID for seeding |
Name / name | string | Display + search only; GetClientByName case-insensitive | required | ≥3 chars on create; no uniqueness constraint |
Timezone / timezone | string | Stored; dashboard/reporting | 'UTC' | required non-empty; not validated against a tz DB |
Currency / currency | string (FK) | Base/reporting currency — provisions the initial wallet + ReportingClientCurrency on orders. NOT the order-time wallet key (see below) | required | must exist, exactly 3 chars |
Status / status | ClientStatus | The single access gate — IsActive() = Status==ACTIVE, checked in API + portal auth | 'ACTIVE' | enum INVITED/ACTIVE/SUSPENDED/DELETED; only ACTIVE logs in |
BulkLimit / bulk_limit | int | Max order quantity — enforced at every order entry | 100 | required >0 on create; blank edit silently sets 0 (blocks all orders) |
IsLinkEnabled / is_link_enabled | bool | Whether the client may place GV-Link (claimable) orders | — | column was originally is_gv_link_enabled, renamed; not editable via the admin edit form |
ShopifyEnabled / shopify_enabled | bool | Master Shopify toggle; gates credential probe + GetShopifyEnabledClients | FALSE | requires shop name + token when on |
ShopifyShopName | string | *.myshopify.com host for the API client | '' | must contain myshopify.com |
ShopifyAccessToken | string (encrypted) | Admin API token, AES-GCM at rest | '' | blank on edit keeps existing token |
ShopifyAPIVersion | string | Shopify API version | DB '2025-01' / code '2026-04' | default mismatch (see below) |
ShopifyLastSyncAt / …CancelSyncAt | *time.Time | Separate poller watermarks (order sync vs cancel sync) | NULL | kept distinct so pollers don't trample |
ShopifySyncEnabled | bool | Auto-sync toggle; both shopify_enabled AND shopify_sync_enabled needed | DB TRUE | create takes it from an unchecked form box → false unless ticked |
ParentID / HierarchyLevel | *uint64 / int | Stored, but no behavioral read site — hierarchy actually runs through sub_clients | NULL / 1 | edit can silently zero HierarchyLevel |
ImageURL | *string | Client logo | NULL | no create/edit field wires it — effectively always NULL |
CreatedAt / UpdatedAt / DeletedAt | time | Audit + soft-delete (every read filters deleted_at IS NULL) | — | — |
BulkLimit — where the cap bites
Enforced at every order entry: charges preview (charges.go:82), voucher create (create_voucher_order.go:569), portal product order (client_products.go:402), and as the ceiling in utils.GetMaxQuantity. Non-link orders are additionally clamped to a hard-coded 5000 (utils/charges.go:176), so effective cap = min(BulkLimit, 5000); link orders can exceed 5000 up to BulkLimit.
Currency — reporting, not the runtime wallet
Client.Currency provisions the initial Prepaid wallet at create and is recorded as ReportingClientCurrency on orders. But at order time the wallet is resolved against the product's currency, not the client's, with RequireCurrencyMatch=false (create_voucher_order.go:675) — so a client can transact against wallets in other currencies, with FX applied. See Wallet & Ledger → resolution.
Status / activation — what deactivation blocks
The old is_active bool was dropped (20260710000001); active-state derives solely from Status. AdminClientToggleStatusHandler flips ACTIVE↔SUSPENDED (refuses DELETED) and, on deactivation, revokes all client tokens for an instant cutover. A non-ACTIVE client is blocked from API JWT auth (middleware/auth.go:93 → 403) and portal cookie auth (client_cookie_auth.go:98, a whole-tenant gate even if the individual ClientUser is active). INVITED is also treated as inactive. See Auth & Access.
The discount default divergence, resolved
There's no discount field on Client — defaults are computed. Two "default" constants exist; the live one is −1%:
- Live path (listing and checkout):
repo.getDiscountForVendorProductusesDEFAULT_DISCOUNT = 1.0→discount = vendor_discount − 1%when no CPD row exists. - Dead path:
utils.GetDiscountForVendorProductreturns a−4.0surcharge — defined and unit-tested but never called in production.
So the create-handler success note claiming "−4% surcharge during checkout" (admin_client.go:376) is stale/inaccurate — checkout actually applies −1%. See Known Issues.
No rate/credit/postpaid/prepaid fields live on Client — prepaid/postpaid is a wallet property (models.Wallet.Type), and create always provisions a Prepaid wallet.
Per-client config tables
Everything configurable per tenant lives in dedicated tables, not on the client row:
| Table | Purpose | Key columns / notes |
|---|---|---|
client_product_details (CPD) | Per-(client, vendor_product) discount override | discount, is_active, link_type; present → overrides the −1% default. See Products & Catalog |
client_payout_configs | Payout limits + enable | payouts_enabled, daily/monthly/min/max, allowed currencies/providers (JSONB); ⚠️ auto_queue_payouts + require_beneficiary_verification are columns with no Go fields. See Payouts |
ip_whitelist_entries | Per-client IP allow-list | cidr, is_active; empty = allow-all, fails open on DB error. See Auth |
client_credentials | Machine API keys | username/hashed password, name, is_active; unique (client_id, name) |
client_users | Human portal logins + roles | email (globally unique), role (owner/admin/write/read), status, 2FA cols. Permissions in client_user.go:50 |
client_product_blacklists | Hide products from a client | product_id, is_active |
client_vendor_blacklists | Hide a vendor's products | vendor_id, is_active |
client_product_configs | Per-(client, product) link/prefetch toggles | is_link_blacklisted, is_prefetch_blacklisted |
client_topup_products / client_esim_variants | Per-client top-up/eSIM discounts | discount, is_active; no row → discount 0, all active accessible |
client_payout_webhooks | Payout event webhooks | url, secret, events (JSONB), failure tracking |
sub_clients | The real hierarchy mechanism (vs the inert Client.ParentID) | client_id, parent_id |
client_credentials (API keys) and client_users (portal logins) overlap post-migration 20251209181115, which migrated old credential rows into client_users as owner. Both coexist: credentials = machine keys, client_users = humans. Related: client_backup_code (2FA recovery), client_password_reset_token (single-use SHA-256 tokens).
Creating a client
AdminCreateClientHandler (admin_client.go:137), single tx:
Not created at signup: no CPD rows, no payout config, no IP whitelist, no API client_credentials key, no blacklists. The success note tells the admin to configure product discounts manually or the client sees vendor_discount − 1%.
Update (AdminClientUpdateDetailsHandler) can change name, timezone, currency, hierarchy_level, bulk_limit, and Shopify fields — but not status, is_link_enabled, or image_url. Blank bulk_limit/hierarchy_level default to 0 here (the silent-zero gotcha). Activation is a separate handler.
Dead fields & default mismatches
| Item | Status |
|---|---|
show_all_products (column) | Completely dead — not on the struct, never read/written; the whitelist-via-blacklist toggle was never wired |
Client.ParentID / HierarchyLevel | Stored, no behavioral read; hierarchy runs through sub_clients; edit can zero the level |
is_active, is_bulk_limit_enabled, is_fill_or_kill_enabled | Added then dropped |
is_gv_link_enabled | Original column name, renamed to is_link_enabled — watch when reading old migrations |
| Shopify API version | DB default '2025-01' vs code '2026-04' — raw-SQL inserts get 2025-01 (see Shopify) |
ShopifySyncEnabled | DB default TRUE, but create sets it from an unchecked box → false unless ticked |
−4.0 discount surcharge | Dead code; live path is −1% (above) |
client_payout_configs.auto_queue_payouts / require_beneficiary_verification | Columns with no Go fields — set-but-never-read |
Client.ImageURL | No form field wires it — always NULL |
Key files
- Model/repo:
database/models/client.go,database/repo/client.go - Create/update:
http/handler/admin_client.go,http/handler/admin_ui.go(toggle:1554, update:1401) - Enforcement:
middleware/auth.go,middleware/client_cookie_auth.go,middleware/ip_whitelist.go,utils/charges.go - Config tables:
database/models/client_*.go,database/models/{ip_whitelist,auth_token,client_user}.go
Catalog Schema
Field-by-field reference for the three product catalogs — voucher (products + vendor_products), top-up (topup_products + topup_variants), and eSIM (esim_products + esim_variants) — with the discount model, fixed-vs-variable enforcement, and the dead columns.
Order Lifecycle
The end-to-end voucher order engine — single vs bulk, single- vs multi-vendor, sync vs async, the checkpoint state machine, retries, the recreate protocol, money, and webhooks.