OctoWiki

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. Carries Name, 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

FieldMeaning
ProductID / VendorIDFKs to products / vendors
VendorProductIDvendor-side SKU/ID (for GH, the voucher_type ID, filled after push)
MinDenomination / MaxDenominationthe denomination range (*float64)
IsFixedDenominationtrue = single fixed value; false = variable/range
VendorDiscountstored — discount % the vendor gives the platform (cost side)
Discountcomputed — the client-effective discount % (see pricing)
PinMappedField / CodeMappedField / ClaimURLMappedField / VoucherReferenceCodeMappedFieldtell the voucher parser which key in the vendor's response JSON holds the PIN / code / claim URL / reference code
IsActiveinactive 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 CPDdiscount = VendorDiscount − DEFAULT_DISCOUNT, where DEFAULT_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:

CaseRepresentation
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:

  1. NonDiscountedTotal = qty × denomination
  2. DiscountAmount = CalculatePercentage(NonDiscountedTotal, effectiveDiscount)
  3. TotalAmount = SubtractPercentage(NonDiscountedTotal, effectiveDiscount)
  4. If wallet currency ≠ product currency, look up GetForexValue(source, dest)NetAmount = TotalAmount × rate; conversionFee = 0 today (hardcoded but fully plumbed via DefaultConversionFeePercent); 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, else VendorDiscount − 1.0.
  • utils/discount_calc.go:350-389: uses CPD only if cpdDiscount > 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 blacklistClient-vendor blacklist
Tableclient_product_blacklistsclient_vendor_blacklists
Granularityone specific product_idan entire vendor_id
Effectproduct hidden from that client's catalogall of that vendor's vendor_products hidden for the client
Cascadenoneif a product's only usable vendors are all blacklisted, the product also vanishes
Also affectsproduct access check + orderingvendor 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:

  1. products.is_active = true AND deleted_at IS NULL.
  2. There EXISTS ≥1 vendor_products row (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.
  3. The product is not in client_product_blacklists for 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/exportExportClientProductCatalog (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 same GetProducts(clientCtx, …, true) as the client-facing API — so blacklisted vendors/products are excluded and the Discount column 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.go fans out with an errgroup (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, requires HasAPI); display via GET /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)

MethodPathPurpose
GET/api/v1/productsPaginated product list for the client (blacklists applied). Filters: category, country_id, currency_id. X-* pagination headers.
GET/api/v1/products/:idProduct detail incl. available denominations (min/max, discount, fixed?). 404 if blacklisted.
POST/api/v1/products/:id/chargesCalculate purchase charges (denomination + qty; applies client discount + forex).
POST/api/v1/products/:id/availabilityAvailability 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).

MethodPathPurpose
GET/client/api/productsPaginated products (pagination in JSON body). Params: search, category, country_id, currency_id, sort_by, sort_dir.
GET/client/api/products/:idProduct detail incl. denominations.
POST/client/api/products/:id/chargesCharges (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.

AreaKey endpoints
ProductsGET /products, /products/:id, /products/:id/edit; POST /products/create, /products/:id/update; search + denominations + vendor-products JSON
Vendor productsPOST /products/:id/vendor-products/add · .../:vp_id/update · .../:vp_id/delete
BlacklistsPOST/DELETE /clients/:id/blacklists/products[/:blacklist_id], .../blacklists/vendors[/:blacklist_id]
Catalog downloadGET /clients/:client_id/product-catalog/export (per-client)
Catalog syncPOST /vendors/:id/catalog/sync ({force}), GET .../catalog/sync/status, .../catalog/snapshots, .../catalog/unmapped, POST .../catalog/bulk-map, .../create-product-and-map
Vendor walletGET /vendors/:id/balances, POST /vendors/:id/balances/sync
Vendor discountsGET /vendors/:id/discounts/export, POST /vendors/:id/discounts/import
ImportsPOST /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().

AreaPage(s)What an admin can do
Productsproducts_list.jet, product_detail.jet, product_edit.jetCreate/edit; activate/deactivate (inactive products auto-save); set category/country/currency/delivery mode/type/validity; import products & vendor-products
Vendor productsproduct_edit.jet → Vendor Products tabAdd/edit/delete the product↔vendor mapping: vendor_id, vendor_product_id, SKU, name, min/max denomination, discount (accepts negative for markup), active
Vendors + walletvendors_list.jet, vendor_detail.jet, vendor_edit.jetCreate/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
Blacklistsclient_detail.jet → Product/Vendor Blacklist tabsAdd/remove per-client product & vendor blacklists; "show all vs curated allow-list" toggle
Catalog + importvendor_detail.jet Catalog tab, vendor_catalog_unmapped.jet, grasshopper_products.jet, import_jobs_list.jetTrigger sync (Force / Sync-all-variants); map unmapped rows (bulk / create-and-map); push to Grasshopper; track import jobs + download error files
Inventoryinventory_dashboard.jet, inventory_list.jetDashboard (stats, expiring-soon, distributions); import/tag/untag inventory (cache auto-pumps after)

Gotchas checklist

  • Discount / ClientProductDetailID on vendor_products are computed at read time, never columns.
  • Two default-discount policies (−1.0 vs −4.0) — verify the caller before touching discount math.
  • Cache-invalidation web for blacklists; DeleteClientVendorBlacklist doesn't self-invalidate.
  • Product-blacklist enforcement is a Go id NOT IN list, 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-retry runs every minute, vendor-balance-sync every 15 min.
  • Schema drift: client_product_blacklists uses TIMESTAMP (no TZ) + a DB unique index; client_vendor_blacklists uses TIMESTAMPTZ with Go-enforced uniqueness only.

On this page