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.
Three independent catalogs that share no tables and no struct: voucher (products + vendor_products, denomination-range), top-up (topup_products + topup_variants, amount + input-fields), and eSIM (esim_products + esim_variants, data/validity). This is the field-by-field reference. See Products & Catalog for the conceptual model and Vendor Config for how variants map to a vendor SKU.
Three-catalog comparison
| Aspect | Voucher | Top-up | eSIM |
|---|---|---|---|
| Master table | products | topup_products | esim_products |
| SKU/variant table | vendor_products | topup_variants | esim_variants |
| Vendor pinned at | vendor_product row | product row (vendor_id) + variant | product row + variant |
| Vendor call key | vendor_products.vendor_product_id | topup_variants.vendor_variant_id (OCTO composite pid:vid) | esim_variants.vendor_variant_id (DT One product_id) |
| Price model | denomination range (min/max on VP) | amount range OR fixed | fixed amount only |
| Fixed flag | is_fixed_denomination | is_fixed_amount | always fixed (no flag) |
| Currency source | parent Product.CurrencyID | topup_variants.currency_code | esim_variants.currency_code |
| Client override | client_product_details (per vendor_product) | client_topup_products (per product) | client_esim_variants (per variant) |
| Extra input | none | input fields (player_id / mobile_number) | none |
| Selection fn | FindBestDiscount | FindBestTopupVariant | direct variant lookup |
Voucher: products
models.Product (product.go:8). Notable: country, currency, and denomination live on vendor_products, not here — there is no IsFixedDenomination/Min/Max on Product.
| Field | Column | Controls |
|---|---|---|
ID | id | FK target of vendor_products, CPD, orders |
Name | name | display + search |
Terms / Details / HowToUse | resp. | legal / description / redemption instructions |
Category / SubCategory | resp. | free-text VARCHAR grouping (not enums), indexed |
CountryID / CurrencyID | resp. | product face-value country/currency (FK); drives forex/eligibility |
OriginalVendorID | original_vendor_id | provenance only (who first supplied it) |
DeliveryMode / DeliveryTime / Validity | resp. | display strings |
IsLinkEnabled | is_link_enabled | gates the reveal-via-link delivery path (was is_gv_link_enabled) |
LinkType | link_type | PUBLIC / PROTECTED link access model |
PreFetchEnabled | pre_fetch_enabled | pre-fetch vs on-demand stock |
IsActive | is_active | catalog visibility (default TRUE) |
Dropped columns (dead, restored only on down-migration): rank, delivery_type, classification, internal_reference_id, affiliate_link, store_name, is_barcode_enabled, min_kyc_enabled, gst_enabled, gst_fee. Note delivery_type still has a write branch (product.go:1252) though the column is gone — a dead write (only fires if a caller passes it). VendorProductSummary/VendorProductCount are computed, not columns.
Voucher: vendor_products
models.VendorProduct (vendor_product.go:9) — the product → vendor SKU mapping, carrier of denomination range + discount.
| Field | Column | Controls |
|---|---|---|
ProductID / VendorID | resp. | which master product / which vendor |
VendorProductID | vendor_product_id | external SKU — the call key when ordering |
VendorProductSKU / VendorProductName | resp. | secondary SKU / vendor's own name |
MinDenomination / MaxDenomination | resp. | allowed face-value range (DECIMAL(15,2)) |
IsFixedDenomination | is_fixed_denomination | if true, only one denomination valid |
Discount | (computed, not stored) | the client discount — calculated at read time |
VendorDiscount | vendor_discount | discount the vendor gives us; base for margin |
ClientProductDetailID | (computed) | set when a CPD override matched |
PinMappedField / CodeMappedField / ClaimURLMappedField / VoucherReferenceCodeMappedField | resp. | which vendor-response field holds each secret |
IsActive | is_active | mapping enabled; skipped in FindBestDiscount if false |
Dead/legacy: min_value/max_value still exist in DB but aren't in the model (superseded by min_denomination/max_denomination); display_discount + the stored discount column were dropped (discount is now purely computed); priority is unread. There is no currency column (currency comes from the parent Product) and no IsBulk/reference-support flags on this table. The (product_id, vendor_id) unique constraint was dropped, so one product can have multiple rows per vendor (different denomination bands).
The discount model
const DEFAULT_DISCOUNT = 1.0 (vendor_product.go:20). getDiscountForVendorProduct computes each VP's client Discount at read time:
- CPD row exists for
(client_id, vendor_product_id)→Discount = cpd.Discount. - Else →
Discount = VendorDiscount − 1%. A negative result is a markup (client pays more) — intended.
FindBestDiscount(denomination, vps) picks the best VP for a denomination: skips inactive VPs and VPs whose [min,max] excludes it, tracks the max discount, and returns {VendorProductID, VendorID, Discount, VendorDiscount}. VendorProductID == 0 means "denomination out of range" — the range-validation gate.
Two divergent discount implementations
The live path is repo.getDiscountForVendorProduct + FindBestDiscount (−1%). A second, divergent utils.GetDiscountForVendorProduct (discount_calc.go:349) uses different rules and a hardcoded −4.0 surcharge — a parallel/legacy path. This is the discount divergence flagged in Client Config and Known Issues; verify which path each caller uses.
Top-up: topup_products + topup_variants
topup_products (topup_product.go:59) — natural key UNIQUE(vendor_id, vendor_category_id):
| Field | Column | Controls |
|---|---|---|
VendorID | vendor_id | pins the product to one vendor — resolution flows through this |
VendorCategoryID | vendor_category_id | vendor-side product id (DT One operator_id, SEAGM category_id) |
TopupType | topup_type | GAMING/MOBILE/UTILITY (Scan defaults GAMING) |
CountryCode / CurrencyCode | resp. | destination country/currency |
Unit | unit | admin override for the in-game unit label ("Diamonds"/"UC"); nil → inferred at read; not clobbered by re-sync |
IsActive | is_active | visibility |
topup_variants (topup_variant.go:9) — natural key UNIQUE(vendor_id, vendor_variant_id):
| Field | Column | Controls |
|---|---|---|
VendorVariantID | vendor_variant_id | vendor's variant id = the order call key. For OCTO a composite "productID:variantID" (split on : to recover the parent); SEAGM = type_id |
Name / Category | resp. | display / UI grouping |
CurrencyCode | currency_code | variant price currency (default USD) |
MinAmount / MaxAmount | resp. | variable-amount range (DECIMAL(15,4)) |
IsFixedAmount | is_fixed_amount | if true → baseAmount forced to MinAmount |
VendorDiscount | vendor_discount | vendor margin |
IsActive / LastSyncedAt | resp. | visibility / sync freshness |
Input fields — two tables (a naming trap): topup_product_input_fields (product-level, the union across variants — this is what order validation reads, since orders carry product_id) and topup_variant_input_fields (per-variant, the vendor source-of-truth). Fields: field_name, field_type (text/number/select/phone), is_required, validation_regex, options in topup_product_input_options. See Top-up & eSIM Flows.
Supporting: topup_variant_fields (key-value: par_value, par_value_currency — server-internal, filtered from the public API and folded into unit), topup_variant_regions. Per-client discount is now client_topup_products (keyed per product) — the file client_topup_variant.go is stale-named but holds ClientTopupProduct.
eSIM: esim_products + esim_variants
esim_products (esim_product.go:9): VendorID, Code (legacy "DTONE_ESIM-4909", display-only), VendorCategoryID (operator_id), Country/CountryCode/CurrencyCode, SortOrder, IsActive.
esim_variants (esim_variant.go:9):
| Field | Column | Controls |
|---|---|---|
VendorVariantID | vendor_variant_id | DT One product_id = order call key |
Amount | amount | fixed wholesale price (eSIM has no range) DECIMAL(12,4) |
VendorDiscount | vendor_discount | margin |
DataAmountGB / ValidityDays | resp. | plan quota / validity |
IsActive / LastSyncedAt | resp. | visibility / freshness |
Supporting: esim_variant_fields (wholesale/retail amount, fx_rate, service_id, operator_id), esim_variant_regions, client_esim_variants (per-variant discount).
Fixed vs variable — enforcement
IsFixedDenomination is mostly display, not a validator
For vouchers, the real amount check is the min/max range compare, not the boolean. The range gate is implicit via FindBestDiscount (out-of-range → VendorProductID==0 → rejected with "denomination out of range"), plus explicit compares in the order flow (create_voucher_order.go:2412/2609) and cart. IsFixedDenomination itself is largely set-and-displayed — the API-response layer even treats min==max as fixed regardless of the flag. So don't rely on the boolean for validation.
- Top-up:
IsFixedAmount && MinAmount!=nil→baseAmountforced toMinAmount(topup_order.go:161,topup.go:351); variable amounts range-match inFindBestTopupVariant. - eSIM: no range —
amountis the fixed wholesale price, always charged as-is.
Catalog sync (reference)
- Voucher:
services/vendor_product_import.go(bulk import → temp table → enrich → upsert); CPD resolution during import usesFindBestDiscount. - Top-up:
topup.go:1442loops the vendor catalog, upsertsTopupVarianton(vendor_id, vendor_variant_id), then variant fields + product input fields. - eSIM:
esim_order.gocatalog paths +admin_esim.go, upsert on(esim_product_id, vendor_variant_id).
LastSyncedAt records freshness (top-up/eSIM only). Admin-owned fields (topup_products.unit, is_active) are not clobbered by re-sync. Driven by the vendor-catalog-sync cron. See Products & Catalog → catalog sync.
Dead / computed / defaults summary
| Table | Dead / computed |
|---|---|
| Product | delivery_type dead write; 10 dropped columns; VendorProductSummary computed |
| VendorProduct | min_value/max_value/priority unread; display_discount+stored discount dropped; Discount/ClientProductDetailID computed; no currency/IsBulk |
| TopupProduct | dropped code/country/auto_delivery/sort_order; Unit admin-or-inferred |
| TopupVariant | dropped description/image_url/auto_delivery; par_value_currency server-internal; two input-field tables; stale filename |
| EsimVariant | Code legacy display-only; no fixed flag |
| Discount | −1% live vs −4.0 legacy — divergent |
Key files
- Models:
database/models/{product,vendor_product,topup_product,topup_variant,esim_product,esim_variant,client_product_detail}.go - Discount:
database/repo/vendor_product.go:20,155,utils/discount_calc.go:249-389,utils/charges.go:44-83 - Fixed enforcement:
create_voucher_order.go:2412/2609,services/topup_order.go:161,http/handler/topup.go:351 - Composite OCTO variant id:
services/external_vendors/direct_topup/octopus/octopus_vendor.go:251,530
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.
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.