Products, Vendors & Catalog
The three-table catalog model, fixed vs variable denominations, pricing/discount/margin, blacklists & visibility, catalog sync/import/export, and vendor wallet balance auto-sync — with the APIs and admin controls that drive them.
This is the core commerce domain: how a product a client buys is modelled, priced, made visible (or hidden), kept in sync with vendors, and exported. It ties together the data model, the vendor adapters, and the cron jobs.
The three-table catalog model
The catalog is a three-level hierarchy. Money lives on vendor_products, not on products.
products— the master brand/SKU a customer sees. CarriesName,Category,SubCategory,CountryID,CurrencyID(the face-value currency),ImageURL,Terms/Details/HowToUse,DeliveryMode/DeliveryTime,Validity,OriginalVendorID,IsLinkEnabled/LinkType(claim-URL delivery),PreFetchEnabled,IsActive.database/models/product.go.vendor_products— one vendor's offering of a product, with the denomination range and vendor economics.database/models/vendor_product.go.client_product_details(CPD) — a per-client discount override on a specific vendor_product.database/models/client_product_detail.go.
Two computed fields that are NOT columns
On vendor_products, Discount (the client-effective discount %) and ClientProductDetailID are populated at read time by attachClientProductDetails (repo/vendor_product.go:167-190) — they are never persisted. Only VendorDiscount is stored (migration 20251017000002_fix_discount_columns.sql). Don't try to write Discount.
vendor_products fields that matter
| Field | Meaning |
|---|---|
ProductID / VendorID | FKs to products / vendors |
VendorProductID | vendor-side SKU/ID (for GH, the voucher_type ID, filled after push) |
MinDenomination / MaxDenomination | the denomination range (*float64) |
IsFixedDenomination | true = single fixed value; false = variable/range |
VendorDiscount | stored — discount % the vendor gives the platform (cost side) |
Discount | computed — the client-effective discount % (see pricing) |
PinMappedField / CodeMappedField / ClaimURLMappedField / VoucherReferenceCodeMappedField | tell the voucher parser which key in the vendor's response JSON holds the PIN / code / claim URL / reference code |
IsActive | inactive VPs are filtered out of pricing and visibility |
What happens when a client has NO CPD row
getDiscountForVendorProduct (repo/vendor_product.go:129-164):
- CPD exists → client discount =
cpd.Discount. - No CPD →
discount = VendorDiscount − DEFAULT_DISCOUNT, whereDEFAULT_DISCOUNT = 1.0. So the platform keeps a 1% margin by default.
A missing CPD row means discount 0/default, not invisibility. All clients see all active products by default — the old CPD-gated visibility was removed (repo/client_product_detail.go:915-925). Visibility is governed by vendor-active + blacklists only (see Visibility).
Fixed vs variable denominations
There is no is_range / denominations list / product_type / step field. The distinction is carried entirely by three columns on vendor_products:
| Case | Representation |
|---|---|
| Fixed (e.g. a $25 card) | IsFixedDenomination = true, MinDenomination == MaxDenomination (the single value) |
| Variable / range (any amount in a band) | IsFixedDenomination = false, MinDenomination < MaxDenomination |
At order/charge time both cases go through the same range check — FindBestDiscount (utils/discount_calc.go:249-347):
A $25 fixed card only matches denomination == 25; a range card matches anything in [min, max]. If nothing matches, the charge call fails with denomination %.2f is out of range for this product (utils/charges.go:45-51). When multiple vendors offer the same range, MergeRanges collapses them to the one with the highest VendorDiscount.
The parallel topup/eSIM families use the same idea with different names: TopupVariant has MinAmount/MaxAmount + IsFixedAmount + VendorDiscount (+ a runtime-only ClientDiscount as a 0–1 fraction). Topup orders use variant.MinAmount as the base when fixed.
Pricing, discount & margin
Margin is the gap between what the vendor gives and what the client gets: margin = VendorDiscount − client Discount. The default 1% (DEFAULT_DISCOUNT) is the platform's baseline margin when there's no CPD. A CPD lets an admin set an explicit client discount (higher or lower). A negative discount is a markup/premium — the client pays more than face value, which is intentional (utils/charges.go:67-71).
The canonical formula
The documented single source of truth (services/order_charges.go:46) — discount applied before FX, a single round at the wallet boundary (NUMERIC(24,2)), conversion fee additive:
amount = round( face × qty × (1 − discount/100) × rate , 2 )Voucher path (utils/charges.go CalculateCharges) and topup/eSIM path (services/order_charges.go ComputeOrderChargeBreakdown) share this shape:
NonDiscountedTotal = qty × denominationDiscountAmount = CalculatePercentage(NonDiscountedTotal, effectiveDiscount)TotalAmount = SubtractPercentage(NonDiscountedTotal, effectiveDiscount)- If wallet currency ≠ product currency, look up
GetForexValue(source, dest)→NetAmount = TotalAmount × rate;conversionFee = 0today (hardcoded but fully plumbed viaDefaultConversionFeePercent);TotalPayable = NetAmount + HandlingFee.
All arithmetic goes through the decimal-safe Money helper (utils/money.go) to avoid float error. Forex rates come from the admin-managed forex_values table; a separate utils/forex.go pulls live XE.com mid-market rates (XE_USERNAME/XE_PASSWORD) for admin rate population, not order-time pricing.
Two divergent default-discount policies — verify which applies
There are two GetDiscountForVendorProduct-style resolvers with different defaults:
repo/vendor_product.go:155(the live read path): CPD →cpd.Discount, elseVendorDiscount − 1.0.utils/discount_calc.go:350-389: uses CPD only ifcpdDiscount > 0 && > vendorDiscount; else vendorDiscount if≥ 1.0; else a −4.0 surcharge (+4% markup).
Confirm which callers hit which before changing discount logic. A completed catalog sync even emits a warning when 0%-discount products would fall into this −1%/−4% behaviour (vendor_catalog_service.go:766-779).
During vendor sync
For imported vendor products that carry a price (not a discount), services/discount_calculator.go derives the % : same currency → ((face − price)/face)×100; cross-currency → convert price to face currency via forex_values first. Negative result = markup, kept as-is; missing rate → discount 0.
Grasshopper (GH): the gift-card sales surface
All gift-card products sit under the vendor whose code = "GH" (resolved via GetVendorByCode(ctx, "GH")). Gift cards are minted from fixed-amount topup variants and pushed to GH:
pushVariantToGrasshopper (http/handler/admin_grasshopper.go:785-904) does a two-sided upsert (GH + Octopus catalog) and links the GH voucher_type ID back onto the vendor_product. Only fixed-amount variants are pushable; the batch handler skips the rest. admin_esim.go mirrors this for eSIM. See the Memory Appendix for why gift cards must live under GH.
Blacklists & visibility
Two independent blacklist tables scope what a client can see. Both are admin-only — there is no client self-service.
| Client-product blacklist | Client-vendor blacklist | |
|---|---|---|
| Table | client_product_blacklists | client_vendor_blacklists |
| Granularity | one specific product_id | an entire vendor_id |
| Effect | product hidden from that client's catalog | all of that vendor's vendor_products hidden for the client |
| Cascade | none | if a product's only usable vendors are all blacklisted, the product also vanishes |
| Also affects | product access check + ordering | vendor selection at order/charge time (isVendorBlacklistedForClient, charges.go:500) |
A product blacklist is a scalpel; a vendor blacklist is a shotgun.
Enforcement — two different mechanisms
Inconsistent enforcement style (a real gotcha)
The vendor blacklist is a clean SQL subquery; the product blacklist on the product listing is materialized in Go into a flat id NOT IN (...). For clients with very large blacklists this produces long IN lists.
A product is visible to a client when all hold:
products.is_active = true AND deleted_at IS NULL.- There EXISTS ≥1
vendor_productsrow (is_active, not deleted) whose vendor is not blacklisted for the client and not globally inactive. The vendor being active is what matters — the visibility query never references CPD. - The product is not in
client_product_blacklistsfor the client.
Cache-invalidation web
Blacklist mutations must invalidate a chain of caches: client:product_blacklist:%d / client_vendor_blacklist:%d, client:products_without_vendors:%d, %d-products:*, and vendor_products:*:client:%d. DeleteClientVendorBlacklist does NOT self-invalidate — only the admin handler calls InvalidateClientVendorBlacklistCache (admin_ui.go:1980). Any new caller of that repo method must invalidate manually or clients will see stale catalogs.
Vendor catalog sync
Keeps the platform's copy of each vendor's catalog fresh. Backed by two tables (database/models/vendor_catalog.go): vendor_catalog_snapshots (one per vendor/day) and vendor_catalog_products (the rows).
Streaming persist architecture — the core of every sync (services/vendor_catalog_service.go):
- Each page is committed in its own transaction so it lands immediately; a batch failure rolls back that page and marks the snapshot
failed. - The daily cron path also runs the DiscountCalculator per batch (cross-currency price→discount via
forex_values) before insert; the async/manual path does not. - Seed vs manual mapping: only the seed (first) vendor auto-creates master
products(one per brand) +vendor_products. Automated catalog→product matching was removed for all other vendors — admins map unmapped rows manually (see admin controls).
Crons (see Jobs): vendor-catalog-sync (daily 2 AM), vendor-catalog-cleanup (Sun 3 AM, drops snapshots >30 days), vendor-catalog-retry (every minute — the inline comment saying "every 5 minutes" is stale). Manual trigger: POST /admin/vendors/:id/catalog/sync ({force:true} deletes today's snapshot first), status polled via .../catalog/sync/status.
Import pipeline (Excel upload)
Bulk create/update products, vendor_products, inventory, or client discounts from an uploaded .xlsx. Four import types (database/models/import_job.go): INVENTORY, PRODUCTS, VENDOR_PRODUCTS, CLIENT_PRODUCT_DETAILS. Each runs through a staged state machine driven by the import-processor cron (every minute):
Each processor (processor/*_processor.go) implements Dump/Sanitize/Enrich/Validate/Commit/GenerateErrorExcel. The orchestrator (services/import_orchestrator.go) is fail-fast: any stage error fails the whole job. After a validate pass with error rows, it generates a styled error .xlsx (only the bad rows + an Errors column). After an INVENTORY commit it pumps just that import's products to Valkey. Uploads cap at 50 MB, .xlsx/.xls only. Sample templates are downloadable per type.
VENDOR_PRODUCTS import is the file-based alternative to catalog-seed/manual-mapping for creating product↔vendor mappings. import-cleanup (daily midnight) drops temp tables and deletes original + error files.
Catalog download / export
Per-client, admin-only: GET /admin/clients/:client_id/product-catalog/export → ExportClientProductCatalog (admin_client.go:936).
- Format: Excel (
.xlsx, excelize), sheet "Product Catalog", streamed as<ClientName>_Product_Catalog_<ts>.xlsx. No CSV variant. - Per-client filtering: yes. It builds a client tenant context (
context.WithValue(ctx, types.TenantIDKey, clientID)) and calls the sameGetProducts(clientCtx, …, true)as the client-facing API — so blacklisted vendors/products are excluded and theDiscountcolumn is that client's effective discount. Pulls up to 10,000 products. - 16 columns: Product ID, Name, Category, Sub Category, Image URL, Terms, Details, How To Use, Delivery Mode, Delivery Time, Validity, Fixed Denominations, Variable Denominations (Min-Max), Discount, Currency, Country. Up to two rows per product (one fixed, one variable).
Related exports: ExportVendorDiscounts (per-vendor discount sheet) and the various *Sample import templates. (A non-client-filtered "all products" export exists in the same file but isn't wired to a route.)
Vendor wallet balance auto-sync
Keeps each vendor's prepaid balance current for ops visibility. Stored per vendor per currency in vendor_wallets (database/models/vendor_wallet.go); the legacy scalar vendors.balance mirrors the first wallet for backward compatibility.
- Job
jobs/vendor_balance_sync_job.gofans out with anerrgroup(limit = batch size); one vendor failing never aborts the run. Each vendor call runs under a 30s timeout. - Which vendors report balance (implement
GetBalance): vouchers — epinforce, neo, grasshopper, irewardify, octopus, runa, trs, wupex; topup — seagm, wupex, dtone, octopus; eSIM — dtone, octopus. - Manual refresh:
POST /admin/vendors/:id/balances/sync(SyncVendorWalletBalance, requiresHasAPI); display viaGET /admin/vendors/:id/balances.
No proactive low-balance alerting
The sync only records balances — there is no low-balance threshold or alert on vendor_wallets/vendors.balance. The only balance checks are order-time rejections (ErrInsufficientBalance in topup/esim order services, and vendor INSUFFICIENT-BALANCE statuses). If ops needs proactive alerts, that's a gap to build. Also note the vendor-balance-sync inline comment "Every 3 hours" is stale — it runs every 15 minutes.
Inventory pump (how it becomes sellable)
Synced catalog + imported inventory become sellable stock only once pushed to Valkey. Two triggers: (a) after an INVENTORY import commit, the orchestrator pumps just that import's product IDs; (b) the inventory-pump cron (~every 6h) pumps broadly. Mechanics (reserved/general pools, prefetched stock) live in services/inventory.go PumpInventoryToValkey — see Jobs & cache. There is no manual "pump" button in admin; the cache refresh runs automatically after tag/untag/import operations.
APIs reference
External client API — /api/v1/* (JWT + IP whitelist)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/products | Paginated product list for the client (blacklists applied). Filters: category, country_id, currency_id. X-* pagination headers. |
| GET | /api/v1/products/:id | Product detail incl. available denominations (min/max, discount, fixed?). 404 if blacklisted. |
| POST | /api/v1/products/:id/charges | Calculate purchase charges (denomination + qty; applies client discount + forex). |
| POST | /api/v1/products/:id/availability | Availability check for the client. |
| GET | /api/v1/categories, /subcategories (+ /:id) | Reference lists. |
There is no dedicated catalog-download endpoint on /api/v1 — external clients page through /products (denominations + discounts are embedded per product).
Client portal — /client/api/* (cookie auth)
| Method | Path | Purpose |
|---|---|---|
| GET | /client/api/products | Paginated products (pagination in JSON body). Params: search, category, country_id, currency_id, sort_by, sort_dir. |
| GET | /client/api/products/:id | Product detail incl. denominations. |
| POST | /client/api/products/:id/charges | Charges (applies CPD discounts, wallet/forex, max-qty, link-blacklist check). |
| GET | /client/api/{categories,countries,currencies,wallets} | Dropdown/reference data + wallet balances. |
Blacklists are applied transparently; the portal has no blacklist-management view.
Admin API — /admin/* (cookie session; writes behind writeMiddleware)
| Area | Key endpoints |
|---|---|
| Products | GET /products, /products/:id, /products/:id/edit; POST /products/create, /products/:id/update; search + denominations + vendor-products JSON |
| Vendor products | POST /products/:id/vendor-products/add · .../:vp_id/update · .../:vp_id/delete |
| Blacklists | POST/DELETE /clients/:id/blacklists/products[/:blacklist_id], .../blacklists/vendors[/:blacklist_id] |
| Catalog download | GET /clients/:client_id/product-catalog/export (per-client) |
| Catalog sync | POST /vendors/:id/catalog/sync ({force}), GET .../catalog/sync/status, .../catalog/snapshots, .../catalog/unmapped, POST .../catalog/bulk-map, .../create-product-and-map |
| Vendor wallet | GET /vendors/:id/balances, POST /vendors/:id/balances/sync |
| Vendor discounts | GET /vendors/:id/discounts/export, POST /vendors/:id/discounts/import |
| Imports | POST /products/import, /vendor-products/import, /inventory/import (+ /…/sample), GET /import-jobs, /import-jobs/:id/error |
Admin panel controls
Server-rendered Jet (views/admin/*.jet). Every mutating action is gated by writeMiddleware and hidden for the viewer role. House rules: showToast(msg, type) not alert(); confirmAction({...})/modal not confirm().
| Area | Page(s) | What an admin can do |
|---|---|---|
| Products | products_list.jet, product_detail.jet, product_edit.jet | Create/edit; activate/deactivate (inactive products auto-save); set category/country/currency/delivery mode/type/validity; import products & vendor-products |
| Vendor products | product_edit.jet → Vendor Products tab | Add/edit/delete the product↔vendor mapping: vendor_id, vendor_product_id, SKU, name, min/max denomination, discount (accepts negative for markup), active |
| Vendors + wallet | vendors_list.jet, vendor_detail.jet, vendor_edit.jet | Create/edit vendor; toggles: is_active, has_api, has_webhook, is_async, is_bulk, is_link_enabled, pre_fetch_enabled, has_reference_id_support; vendor_type; attributes (credentials K/V); wallet balances tab with Sync + Refresh; vendor discounts export/import |
| Blacklists | client_detail.jet → Product/Vendor Blacklist tabs | Add/remove per-client product & vendor blacklists; "show all vs curated allow-list" toggle |
| Catalog + import | vendor_detail.jet Catalog tab, vendor_catalog_unmapped.jet, grasshopper_products.jet, import_jobs_list.jet | Trigger sync (Force / Sync-all-variants); map unmapped rows (bulk / create-and-map); push to Grasshopper; track import jobs + download error files |
| Inventory | inventory_dashboard.jet, inventory_list.jet | Dashboard (stats, expiring-soon, distributions); import/tag/untag inventory (cache auto-pumps after) |
Gotchas checklist
Discount/ClientProductDetailIDonvendor_productsare computed at read time, never columns.- Two default-discount policies (
−1.0vs−4.0) — verify the caller before touching discount math. - Cache-invalidation web for blacklists;
DeleteClientVendorBlacklistdoesn't self-invalidate. - Product-blacklist enforcement is a Go
id NOT INlist, not a subquery (long lists for big blacklists). - Automated catalog→product matching was removed — only the seed vendor auto-creates products; others need manual mapping.
- No proactive vendor low-balance alerting.
- Stale cron comments:
vendor-catalog-retryruns every minute,vendor-balance-syncevery 15 min. - Schema drift:
client_product_blacklistsusesTIMESTAMP(no TZ) + a DB unique index;client_vendor_blacklistsusesTIMESTAMPTZwith Go-enforced uniqueness only.
Table Catalog
The exhaustive per-table reference (~95 live tables) grouped by domain, the core ER diagram, the ID/soft-delete/tenancy conventions, the six native enums vs VARCHAR+CHECK, the commented-out FKs, and the dead/renamed tables.
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.