Skip to main content

Nexgen V3 pricing

Every online subscription price in the V3 apps is a row in the nexgen MySQL database. Chargebee is the payment processor and the subscription ledger, but it is not the price list. The app never asks Chargebee what something costs.

The one thing to remember

product_items.price is both the price the shopper sees and the price the customer is charged. When the app creates a Chargebee estimate or a subscription, it sends the Chargebee item price id and the local price as a unitAmount override.

Two consequences follow, and they are the source of most pricing confusion:

  • Changing a price in the Chargebee catalogue has no effect on the app. The shop will keep showing the old number and Chargebee will keep charging it, because the app overrides the catalogue amount on every call.
  • Changing product_items.price changes what customers are charged, immediately, with no deploy. It also silently disagrees with the Chargebee catalogue from that point on.

Keep the two in sync anyway. A future change to the override behaviour, a manual subscription created in the Chargebee UI, or a support person reading the catalogue will all assume they match.

The data model

Three tables carry the catalogue.

products is the marketing SKU: title, description, images, slug, display_order, is_active, do_not_display, and category_id. It has no price column. products.product_id holds the Chargebee item id.

product_items is the purchasable variant, one row per term. This is where the money lives:

  • product_id - a foreign key to products.id, the local auto-increment. It is not the Chargebee item id, despite sharing a name with products.product_id. Product::items() is a plain hasMany, so it joins product_items.product_id to products.id. Get this wrong and the relation comes back empty, which surfaces as Undefined array key 0 from $product->items[0] on the product detail page rather than as anything that names the real problem.
  • contract_id - the Chargebee item price id. This is the join key used everywhere else in the app, including orders_subscriptions.contract_id and the webhook handlers.
  • price - the price in USD.
  • duration - the term in days (30, 90, 180, 365).
  • duration_label - what the shopper sees in the Duration dropdown, for example Monthly or 12 Months.
  • is_subscription - 1 for recurring, 0 for one-time.
  • region_id - the item's nominal region. It is not what entitlement reads.

product_item_regions and product_item_languages are the pivots keyed on product_items.id. These are what actually grant regions and languages to the buyer. One item row can carry every region, which is how an all-regions SKU works.

The word "contract" is overloaded and worth pinning down:

  • product_items.contract_id and orders_subscriptions.contract_id are the Chargebee item price id.
  • client_contracts.contract_id is the local product_items.id.
  • The axxya_contracts table and its ContractsSeeder are legacy and unused. The checkout view still receives the rows, but nothing prices from them. Do not update that table when prices change.

Who owns what

MySQL owns:

  • The list price of every plan and every term.
  • Which regions and languages a plan grants.
  • Which licensing module a plan unlocks, through products.category_id.
  • Whether a plan appears in the shop, through products.is_active.

Chargebee owns:

  • The customer, the subscription, and the invoice.
  • Tax and coupon calculation, through the purchase estimate.
  • Renewal charges and term dates, which arrive back through the paymentSucceeded webhook.
  • The plan's identity. The item price id is the shared key between the two systems.

How a price reaches the screen

The shop grid at /shop/our-packages shows no prices at all. It lists products rows where is_active = 1, ordered by display_order. If a product is missing from the shop, its is_active is 0 for that environment's database connection.

Price appears only on the product detail page, and it arrives by AJAX. Selecting a term posts to /shop/product-update, which returns that product_items row's price. The logged-in buy-more and upgrade pages use the equivalent manage/getPrice/{contract_id} and manage/getValidity/{product_id} endpoints.

Adding to cart snapshots the whole product_items row, price included, into the shop_carts table. Cart and checkout subtotals are arithmetic over that snapshot. Only the final estimate and the purchase itself go to Chargebee, and both carry the local price as the unit amount.

The billing frequency is derived, never stored. The app computes billingCycles = round(12 / (duration / 30)), so a 30-day item becomes 12 cycles and a 365-day item becomes 1. Set duration correctly and the billing period follows.

Entitlement follows the purchase

After a successful purchase the app writes orders_subscriptions, client_contracts, user_subscriptions, and copies the item's region and language pivots into client_user_regions and client_user_languages.

Module access is then computed on every login, not stored:

orders_subscriptions.contract_id
> product_items.contract_id
> product_items.product_id (a foreign key to products.id)
> products.id
> products.category_id
> licensing_modules.category_id
> auth_item

getPermission runs the last hop as Product::whereIn('id', $productIds) where each $productId is the matched product_items.product_id. So a product_items row pointing at the wrong parent both breaks the shop page and grants the buyer no module after a successful payment.

The categories are 1 Food Labeling, 2 Diet Analysis, 3 Menu Creation, 4 Other. A new SKU with a missing or wrong category_id will sell perfectly well and grant the buyer nothing.

The current Food Labeling catalogue

Five active label SKUs, one per region and language combination. The Chargebee item id is on the product, the item price ids are on the variants.

.USA English Nutrition Food Labels (item 1119394): monthly 3990956 at $49, 3 months 3990958 at $132, 12 months 3990960 at $432.

.USA Spanish, English Food Labels (item 1119392): monthly 3990950 at $55, 3 months 3990952 at $147, 12 months 3990954 at $492.

.Canadian Nutrition Food Labels (item 1119424): monthly 3990908 at $39, 3 months 3990910 at $105, 12 months 3990912 at $348.

.UK EU Nutrition Food Labels (item 1119386): monthly 3990914 at $55, 3 months 3990916 at $147, 12 months 3990918 at $492.

.China Hong Kong Nutrition Labels (item 1119384): monthly 3990944 at $55, 3 months 3990946 at $147, 12 months 3990948 at $492.

The older 3290xxx item prices, sitting under products such as USA Nutrition Food Label Online - English Only, are a superseded generation and are not what customers buy today.

Pending change. These five are being replaced by a single all-regions label plan at $299 a year or $36 a month, tracked in issue 464. Follow "Taking a new plan live" below, then "Retiring a plan" for the five above. Update this section once the switch is done.

Where a price is stated

A label price appears in five places. Only the first two decide what a customer is charged:

  1. product_items.price in the nexgen MySQL database. This is what the app displays and charges.
  2. The Chargebee item price. Currently overridden by the app, but it is what a manually created subscription would use.
  3. The in-app shop, at /shop/product-detail/{slug}. Reads from 1.
  4. nutritionistpro.com/pricing. Hand-maintained WordPress.
  5. nutritionistpro.com/nutrition-food-labeling-pricing. Hand-maintained WordPress.

The two WordPress pages live on AWS Lightsail in the old Axxya tenant, not the nexgen-v3 stack, and are edited through wp-admin. Nothing syncs them, so they drift silently after a price change.

Taking a new plan live

The full sequence, from nothing to a plan customers can buy. Do the phases in order. Phase 1 writes to Chargebee and phases 3 to 5 write to a database, so run those yourself. Everything the plan needs other than three ids is decided in the nexgen codebase; see "Where each plan value is defined" below for exactly which file holds what.

1. Create the plan in Chargebee

There are two Chargebee sites and both need the objects: nutritionistpro (live, used by prod) and nutritionistpro-test (used by staging, test, and local). Ids are per site, so create the plan in both with the same ids. If you let Chargebee generate them you get two different sets, the app can only carry one, and staging ends up charging against ids that do not exist there.

The ids must be plain whole numbers, and Chargebee will not suggest one. The UI auto-fills the id from the name, so a plan called "Food Labels Online - All Regions" is offered Food-Labels-Online---All-Regions. That is valid in Chargebee and fatal here. Overwrite the suggested id with a number every time.

Two things break if you do not:

  • Three columns hold Chargebee plan and price ids, and all three are int: products.product_id, product_items.contract_id, and orders_subscriptions.contract_id. (product_items.product_id and product_images.product_id look like they belong on that list but do not; they are local foreign keys to products.id.) MySQL runs with sql_mode = NO_ENGINE_SUBSTITUTION, so it is not in strict mode: a non-numeric id does not raise an error, it silently stores 0. Every lookup that joins on it then misses, and the failure surfaces much later as a customer with no module access. These columns are shared with the V2 app on the same database, so widening them is not a local decision.
  • Freshsales deal names follow 30-.USA English Nutrition Food Labels-3990956, and the marketer tooling buckets a deal by parsing the trailing hyphen-delimited segment. A hyphenated plan id makes that segment a word.

The readable string is not lost. Chargebee's Plan ID is described in its own UI as "Reference used by Chargebee to identify this plan", which is internal; the customer-facing string is External name, and both External and Internal name should carry the full plan title.

Clear the field, do not type after it. The auto-filled value stays unless you select all and replace it, and it is easy to end up with Food-Labels-Online---All-Regions----7000001. Chargebee ids are immutable on both plans and prices, so there is no fixing this afterwards: create a new plan with the correct id and delete the wrong one (deleting frees the id and name for reuse; archiving only deactivates it). The price points auto-fill their ids the same way, usually as <PlanID>-USD-Yearly, so they need the same care.

Pick numbers free in both sites, clear of the reserved blocks 9000001 to 9000005 (module-variant test users) and 9100001 to 9100003 (staff access). The 3xxxxxx, 5492xxx and 60000xx ranges are in use. Existing SKUs are all 7 digits. The all-regions label plan uses 7000001 for the plan and 7000011 / 7000012 for its annual and monthly price points.

In the UI, Product Catalog > Plans > Create a New Plan:

  1. Choose the product family. Use the one the existing SKUs are in: Axxya-Systems on live, All Product on the test site. Do not create a new family for a plan. The app never reads the family, and the two sites already use different names, but Chargebee only allows a plan change within a single family, and the app depends on that: the freeze plan (item 5492130, wired into FREEZE_PLAN_CONTRACT_ID) is in Axxya-Systems, so a plan in another family cannot be frozen, cannot be upgraded or extended through Subscription::updateForItems, and cannot receive existing subscribers except by cancelling and recreating their subscription. A subscription cannot mix families either, so addons would have to be duplicated. Catalogue grouping for reporting is already handled by the *_PRODUCT_IDS bucket lists.
  2. Set External name and Internal name to the plan title.
  3. Replace the auto-filled Plan ID with the number. This becomes products.product_id.
  4. Continue to the pricing step and add one price point per term: currency USD, taxable, period 1 year for the annual term and 1 month for the monthly one. Each price point has its own id; replace that auto-filled value with a number too. Each one becomes a product_items.contract_id.
  5. Set the pricing model to Per Unit, not Flat Fee. This app sells seats: the product page has a quantity input, getCartTotal multiplies quantity by price, and the buy-more-users flow adds seats to an existing subscription. A flat-fee price makes Chargebee ignore quantity, so a five-seat purchase shows five times the price in the cart and then invoices once. Every existing SKU is per_unit. A pricing model cannot be edited once set, so a price point created as Flat Fee has to be deleted and recreated.
  6. Repeat the whole thing in the other site with the same ids.
  7. Leave the old plan's prices active for now. They are archived in phase 7, after the new plan is live.

If the pricing step will not let you edit a price point's id, create the objects through the API instead, where the id is an explicit parameter:

curl https://{site}.chargebee.com/api/v2/items \
-u {api_key}: \
-d id="7000001" \
-d name="Food Labels Online - All Regions" \
-d type="plan" \
-d item_family_id="{family}"

curl https://{site}.chargebee.com/api/v2/item_prices \
-u {api_key}: \
-d id="7000011" \
-d item_id="7000001" \
-d name="Food Labels Online - All Regions Yearly" \
-d pricing_model="per_unit" \
-d price=29900 \
-d period=1 \
-d period_unit="year" \
-d currency_code="USD"

price is in cents. Repeat the second call with id=7000012, price=3600, period=1, period_unit="month". The amounts entered here are not what the app charges, because the app overrides them, but enter the correct ones anyway so the catalogue and the database agree.

Naming convention. The existing SKUs put the id in the internal name and keep the external name clean: USA Food Labels English -- 1119394 internally, USA Food Labels English externally, and the same on each price (USA Food Labels English Yearly -- 3990960). Follow that, so the numeric id is visible wherever staff look the plan up.

Fields you can ignore. The app never reads the Chargebee catalogue, so no descriptive field on a plan reaches it:

  • Unit of Measure only changes how a quantity reads on Chargebee invoices and the self-serve portal. Every existing SKU leaves it blank. The quantity this app sends is a seat count, so "user" would be accurate if you ever want one.
  • Redirect URL is for Chargebee hosted checkout pages. This app has none; checkout is its own Blade forms posting to Customer::create and Purchase::create, so the field never fires.
  • Enabled for checkout and Enabled in portal govern the same Chargebee-hosted surfaces. Leave them at their defaults.

2. Set the ids in the migration

The catalogue rows are created by a migration rather than by hand, so every environment gets the same rows and a re-run is a no-op. For the all-regions label plan that is nexgen/backend/database/migrations/2026_08_12_000002_add_all_regions_label_plan.php, and its ids are already set to match phase 1:

const CHARGEBEE_ITEM_ID = 7000001; // products.product_id
const ITEM_PRICE_ANNUAL = 7000011; // product_items.contract_id, 365 days
const ITEM_PRICE_MONTHLY = 7000012; // product_items.contract_id, 30 days

If the ids you actually created in Chargebee differ from these, change them here. The migration is armed: with non-zero ids it inserts the rows on the next migrate, which on staging and test happens automatically on deploy. So the Chargebee objects must exist first, or the shop lists a plan whose price Chargebee does not recognise and checkout fails. Setting any of the three back to 0 disarms it; it then records itself as run and writes nothing.

Confirm TITLE, SLUG, TERMS, and CATEGORY_ID in the same file are what you want, and that the same title appears in ShopProductsSeeder and ProductCategoryMappingSeeder.

3. Prove it locally

docker compose exec nexgen-backend php artisan migrate --force
docker compose exec nexgen-backend php artisan migrate --force # second run must write nothing
docker compose exec nexgen-backend php artisan db:seed --force

migrate --pretend is not a useful preview here. Under pretend mode every select returns an empty result, so the migration's region lookup comes back empty and it exits before reaching an insert. The printed output is a couple of schema checks and nothing else, which reads like "this will do nothing" but is an artefact of pretend mode. Run it for real against a local database instead.

Then open http://localhost:8010/shop/our-packages, confirm the plan is listed, open its detail page, and confirm the Duration dropdown offers both terms and shows the right price for each. Add it to the cart and check the cart total.

4. Staging and test

Merge to main. CI deploys the changed services, and the backend entrypoint runs php artisan migrate --force automatically on every non-production environment, so the rows appear on their own.

Then do a real end-to-end purchase on staging against the Chargebee test site, with a test card, and confirm afterwards that the buyer has the expected module and regions. That is the only way to prove the item price ids, the entitlement pivots, and the category mapping are all correct together.

5. Production

Production skips migrations. The entrypoint refuses to run them when APP_ENV=production, so nothing happens on a prod deploy until you run it by hand:

copilot svc exec -a nexgen-v3 -e prod -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan migrate --force\"'"

Always run artisan as www-data. Running it as root leaves root-owned log files behind and the next request 500s.

Seeders never run on production either, which is why the migration sets is_active = 1 itself rather than relying on ShopProductsSeeder.

Note that nexgen-free runs the same codebase against its own database, so the migration creates the rows there too. That is harmless: the trial site redirects every /shop request to the nexgen shop and never renders its own catalogue.

6. Bucket lists and the marketing site

  • Add every new item price id to ONLINE_PRODUCT_IDS in copilot/nexgen-backend/manifest.yml and redeploy the backend. Without this, ProductBucket classifies each sale as "other" and the Freshsales cf_products_purchased / cf_active_products segment fields are wrong from the first purchase.
  • Add the same ids to the matching bucket variable in the Axxya dashboard's .env. It is a separate list and does not inherit from the manifest.
  • Update both WordPress pricing pages through wp-admin: nutritionistpro.com/pricing/ and nutritionistpro.com/nutrition-food-labeling-pricing/. Nothing syncs them.
  • Optional: add a product image. Without one the shop falls back to a shared placeholder PNG, so a missing image is cosmetic, not a failure.

7. Retire the old plans

Only once the new plan is confirmed working in production. Follow "Retiring a plan" below: archive the old item prices in Chargebee, then set is_active = 0 and do_not_display = 1 on the old products. Do not delete anything, and decide separately whether existing subscribers are moved across in Chargebee.

8. Final checks

  • The shop lists the new plan and no longer lists the retired ones.
  • A test purchase grants the right module and regions.
  • The Chargebee invoice amount matches product_items.price.
  • The two WordPress pages match the database.

The read-only verification query, once the rows exist:

SELECT p.title, p.category_id, p.is_active,
pi.id, pi.contract_id, pi.duration, pi.duration_label, pi.price,
(SELECT COUNT(*) FROM product_item_regions r WHERE r.item_id = pi.id) AS regions,
(SELECT COUNT(*) FROM product_item_languages l WHERE l.item_id = pi.id) AS languages
FROM products p
JOIN product_items pi ON pi.product_id = p.id
WHERE p.title = 'YOUR NEW TITLE'
ORDER BY pi.duration;

Where each plan value is defined

Chargebee holds a customer, a subscription, an invoice, and the ids. Everything that makes a plan a product lives in the nexgen codebase, written in more than one place, and those places do not validate each other. A typo in any of them is silent.

Title. Four places, all matching character for character:

  • AddAllRegionsLabelPlan::TITLE in nexgen/backend/database/migrations/2026_08_12_000002_add_all_regions_label_plan.php, which is what gets inserted.
  • The products.title column, the row that constant creates.
  • The $titles array in nexgen/backend/database/seeders/ShopProductsSeeder.php, which activates the SKU on dev and staging.
  • The $titleToCategory map in nexgen/backend/database/seeders/ProductCategoryMappingSeeder.php, which sets the category.

Both seeders match on exact title and silently skip a row they cannot find, so a mismatch shows up as a product that never appears in the shop or never gets a category, with no error anywhere.

Slug. AddAllRegionsLabelPlan::SLUG in the migration, stored in products.slug. It becomes the shop URL /shop/product-detail/{slug}, and the SPA builds its Buy Now links from the same value through api/v1/exposed/products-list.

Terms. The AddAllRegionsLabelPlan::TERMS array in the migration, one entry per billing period. Each entry sets four product_items columns: contract_id (the Chargebee price id), duration in days, duration_label (the text in the shop's Duration dropdown), and price. 30 days is monthly and 365 is annual. The duration must agree with the period on the matching Chargebee price, because the app derives the billing cycles from the local duration while Chargebee bills on its own period.

Module. AddAllRegionsLabelPlan::CATEGORY_ID in the migration, stored in products.category_id, and repeated as the value in ProductCategoryMappingSeeder. It must match a licensing_modules.category_id row: 1 Food Labeling, 2 Diet Analysis, 3 Menu Creation. This is the only thing that grants module access.

Marketing copy. The detail page renders products.description above the Duration dropdown, and its five tabs from features (How to Use), benefits, long_description (Description), great_for and technical_specs. Every real SKU carries its whole pitch as HTML in description and leaves the other five empty, so those tabs are blank across the entire shop. Follow that convention rather than filling the five with copy that does not match their headings.

Product image. The detail page renders the product_images row with image_type = 1 and shows an empty panel when there is none; there is no placeholder on that page, unlike the shop grid. A new product needs an image row or it looks broken. The migration copies the primary image from an existing Food Labeling product so the SKU is presentable everywhere, and it can be replaced with real artwork in /admin.

There is a trap in how that image is served. The page prefers the static file public/assets/products/sha1(product_images.id).png and only falls back to route('product.image', id), which decodes the row's base64, when the static file is missing. Those 149 static files are committed to the repo and were generated from production ids, where product_images.id already reaches 408. So on production a new row gets an id past the static set and the correct image is served, while on a local database ids restart low and a new row can collide with a committed file belonging to a different image, showing another product's artwork with nothing logged. If a new product shows the wrong picture locally, compare curl localhost:8010/shop/product-image/{id} against the static file rather than assuming the row is wrong.

Regions and languages. Not a literal anywhere. The migration derives them at run time in launchedRegionIds() (every region except India) and languageIdsFor() (every language belonging to those regions), and writes them to product_item_regions and product_item_languages. To grant a narrower set, change those two methods. product_items.region_id is set to the lowest launched region as a nominal value and is not what entitlement reads.

The three ids. The only values Chargebee decides. They land in CHARGEBEE_ITEM_ID, ITEM_PRICE_ANNUAL and ITEM_PRICE_MONTHLY at the top of the migration, and must be plain integers.

Retiring a plan

Archive, never delete.

  1. Chargebee. Archive the item prices. Existing subscriptions on an archived item price keep renewing; archiving only stops new ones.
  2. products. Set is_active = 0 and do_not_display = 1. The SKU disappears from the shop.
  3. Leave every row in place. products, product_items, client_contracts, and orders_subscriptions are read on every login to work out what a customer is licensed for. Deleting any of them revokes access retroactively for anyone still on that plan, including customers who paid for a year.
  4. Decide separately whether existing subscribers move to the new plan. Retiring a SKU does not migrate anyone; they stay on their current item price until their subscription is changed in Chargebee. It is a same-family plan change, so Chargebee allows it.

Check what you are about to retire, and who is still on it, before running the update. The second query returns the customers who keep their access either way; you need the number to decide whether to move them:

SELECT p.id, p.product_id, p.title, p.is_active, p.do_not_display,
pi.contract_id, pi.duration_label, pi.price
FROM products p
LEFT JOIN product_items pi ON pi.product_id = p.id
WHERE p.title IN (
'.USA English Nutrition Food Labels',
'.USA Spanish, English Food Labels',
'.Canadian Nutrition Food Labels',
'.UK EU Nutrition Food Labels',
'.China Hong Kong Nutrition Labels'
)
ORDER BY p.title, pi.duration;

SELECT pi.contract_id, p.title, COUNT(*) AS live_orders
FROM orders_subscriptions o
JOIN product_items pi ON pi.contract_id = o.contract_id
JOIN products p ON p.id = pi.product_id
WHERE o.in_app_status = 1
AND p.title IN (
'.USA English Nutrition Food Labels',
'.USA Spanish, English Food Labels',
'.Canadian Nutrition Food Labels',
'.UK EU Nutrition Food Labels',
'.China Hong Kong Nutrition Labels'
)
GROUP BY pi.contract_id, p.title
ORDER BY p.title;

Then retire them. This only removes the SKUs from the shop:

UPDATE products
SET is_active = 0,
do_not_display = 1,
updated_at = NOW()
WHERE title IN (
'.USA English Nutrition Food Labels',
'.USA Spanish, English Food Labels',
'.Canadian Nutrition Food Labels',
'.UK EU Nutrition Food Labels',
'.China Hong Kong Nutrition Labels'
)
AND is_active = 1;

To undo, set is_active = 1 and do_not_display = 0 for the same titles.

Gotchas

  • The Chargebee catalogue price is ignored by the app. Fix the database, not Chargebee, when a displayed price is wrong.
  • axxya_contracts is dead. Updating it changes nothing.
  • products.category_id is what grants module access. A label SKU that is not category 1 sells and grants nothing.
  • product_items.duration is in days and drives the Chargebee billing cycles. It is the only place the billing period is expressed.
  • product_items.region_id is nominal. The product_item_regions pivot is what entitlement reads, so an all-regions plan sets one nominal region and many pivot rows.
  • India (region 5) has never launched. Never include it in a region set.
  • Language rows are per region, so the same name exists once per region. An all-regions item carries 16 language rows covering 7 distinct names, and anything that lists them has to de-duplicate on name or the shopper sees "English, French, ... English, French, ..." repeated. ShoppingController::product_detail does this; the admin product views deliberately do not, since staff want the raw set.
  • Prices are USD only. The shop's currency conversion is a BlueSnap display helper, not a second price list.