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 lists products rows where is_active = 1, ordered by display_order, and prints one price line per priced product_items row on each card. If a product is missing from the shop, its is_active is 0 for that environment's database connection. The two education plans are the exception: they are listed while inactive, see "Plans sold only by account managers" below.

On the product detail page the price 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.

This is the migration route, which is the right one for a plan that ships to every environment at once. For a plan created by hand in the admin shop, including any plan that grants more than one licensing module, see "Adding a plan through /admin/shop" below instead. Phase 1 is the same either way.

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, in both its local .env and copilot/dashboard/dashboard.env, which the deployed dashboard reads. Both are gitignored, and neither inherits from the other or 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;

Adding a plan through /admin/shop​

The section above builds the catalogue rows from a migration. A plan can also be created by hand in the admin shop, which is how the all-modules plan was added. Use this route when the plan is one you want to stand up and verify on a single environment before it goes anywhere near a migration, or when it grants more than one licensing module, which /admin/shop can express and products.category_id on its own cannot.

The worked example throughout is the all-modules plan: Chargebee item 7000002, item prices 7000021 (annual) and 7000022 (monthly).

1. Create the plan in Chargebee​

Do the test site (nutritionistpro-test) first, then the live site (nutritionistpro), with the same ids on both.

Clear each auto-suggested id and type the number. The three columns these land in are int, MySQL is not in strict mode here, and a text id stores as 0 with no error. Select the suggested value and replace it; do not type after it.

  • Plan (item) 7000002, "Nutritionist Pro Online - All Modules", product family Axxya-Systems. Never create a new family. The app never reads the family, but Chargebee only allows a plan change within one family, and the freeze plan lives in Axxya-Systems.
  • Item price 7000021: USD, Per Unit, 1 year, 499.00.
  • Item price 7000022: USD, Per Unit, 1 month, 59.00.

Per Unit matters. The app sells seats, so Flat Fee makes Chargebee ignore quantity and a multi-seat purchase invoices once at the single-seat price. A pricing model cannot be changed after the price point is created; a Flat Fee price has to be deleted and recreated.

Once they exist, read the live ones back and confirm the ids and amounts before going further.

2. Add the ids to the marketing dashboard​

Put 7000021,7000022 into ONLINE_PRODUCT_IDS in the axxya-dashboard .env and in its copilot/dashboard/dashboard.env, then restart the local dashboard and redeploy the hosted one. Both backend manifests (copilot/nexgen-backend/manifest.yml and copilot/nexgen-free-backend/manifest.yml) already carry the two ids, so nothing is needed there for this plan.

If you are adding a plan whose item price ids are NOT already in those manifests, add them there too and redeploy the backend. Either way the ids have to be in the config before the first sale: without them ProductBucket classifies the sale as "other" and the Freshsales cf_products_purchased / cf_active_products segment fields are wrong from that purchase onwards. The dashboard list and the manifest lists are separate and neither inherits from the other.

3. Run the migration on prod, for both backends​

A multi-module plan needs the product_modules table, which is created by a migration. Production never runs migrations on deploy, so run it by hand after the backend deploy:

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\"'"
copilot svc exec -a nexgen-v3 -e prod -n nexgen-free-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan migrate --force\"'"

The code runs safely before this. ProductModules treats a missing table as "no extra modules" rather than failing every login, so the window between the deploy and the migration is not an outage. What it does mean is that the plan will not grant Diet or Menu until the table exists, so do not sell it before this step has run on both backends.

4. Create the product in /admin/shop​

Do this on each environment you want the plan on, which for the all-modules plan means staging and then prod. It is created hidden, so a prod product that nobody can buy yet is safe to have sitting there.

  1. Add Product:
    • Title "Nutritionist Pro Online - All Modules"
    • Product ID (CB) 7000002
    • Product Category "Food Labeling Module"
    • Also Grants Modules: "Diet Analysis Module" and "Menu Creation Module"
    • Product Type "Cloud Product"
    • Leave Available to Purchase unticked
    • Description and the meta fields
  2. On the product page, add two items, each with All launched regions ticked and Is Recurring Item ticked:
    • Contract ID (CB) 7000021, duration 365, duration label "12 Months", price 499
    • Contract ID (CB) 7000022, duration 30, duration label "Monthly", price 59
  3. Upload a primary image and mark it primary. The product detail page renders an empty panel when there is none.

Product Category is the module the plan grants on its own, and Also Grants Modules is what adds the other two. Both halves are needed: the category alone sells all three modules and grants one.

5. Verify on staging​

While signed in as staff, open /shop/product-detail/<slug> and check that:

  • Both prices show in the Duration dropdown.
  • The page lists all three modules.
  • The product does NOT appear on /shop/our-packages, since it is hidden.
  • A signed-out browser gets a 404 on that product-detail URL.

Then buy it, log in as the buyer, and confirm the buyer has all three modules plus the four launched regions: USA, Canada, UK/EU, and China/Hong Kong. India is never included. Add a user in Manage Users and confirm that user gets the same regions.

Cancel the test subscription afterwards.

Plans sold only by account managers​

Two plans are listed in the shop but cannot be bought there. An Axxya account manager sells them.

  • Nutritionist Pro Online - University Staff or Class - All modules: Chargebee item 7000003, item price 7000031, $11 per seat per month (billed annually), so $132.00 per seat per year.
  • Nutritionist Pro Online - Student - All modules: Chargebee item 7000004, item price 7000041, $4 per seat per month (billed annually), so $48.00 per seat per year.

Each grants all three modules (Food Labeling through products.category_id, Diet Analysis and Menu Creation through product_modules) and the four launched regions. Each has one product_items row: 365 days, "12 Months", recurring.

How they are kept out of the checkout​

The rows are stored hidden, is_active = 0 and do_not_display = 1, so every gate that refuses a hidden product refuses these: the product detail page returns 404 to guests and customers, the price lookup and add-to-cart refuse them, and neither the app's plan tiles nor the in-app "add more product" dropdown lists them. Staff (users.is_admin) can still open the detail page, as for any hidden product.

Two things are specific to these plans, and both key on the Chargebee item ids held in Axxya\Support\EduPlans::PLANS, not on a database flag:

  • /shop/our-packages lists them anyway, after the purchasable products, as a card with the per seat price and a Contact Axxya to purchase button that opens the website contact page. The card has no Details button and no link to a detail page, and the plans are left out of the page's structured data.
  • The logged-in checkout (/app/manage/cart-checkout, pre-checkout, cart-checkout-post, and the upgrade checkout) refuses them with "This plan is available through your Axxya account manager." The one exception is a client that already holds the plan (a client_contracts row for that item), which can still add seats itself.

Do not tick Available to Purchase on these products in /admin/shop. That sets is_active = 1 and opens the ordinary shop purchase path.

Chargebee setup​

Create the objects in both sites, nutritionistpro-test first and then nutritionistpro, with the same numeric ids. Follow "1. Create the plan in Chargebee" above for the id and family rules.

  • Plan 7000003: internal name "Nutritionist Pro Online - University Staff -- 7000003", external name "Nutritionist Pro Online - University Staff or Class - All modules". Item price 7000031: USD, Per Unit, 1 year, 132.00, taxable.
  • Plan 7000004: internal name "Nutritionist Pro Online - Student - All modules - 7000004", external name "Nutritionist Pro Online - Student - All modules". Item price 7000041: USD, Per Unit, 1 year, 48.00, taxable.

The external name is what the customer sees on a Chargebee invoice. The title in the shop is products.title, set in EduPlans::PLANS, and it is kept the same as the external name. Nothing enforces that: the app joins on the ids, so a rename in one place has to be repeated in the other by hand.

The checkout on staging, test and local uses the test site, so a plan that exists only on the live site cannot be sold or renewed in those environments. Create it in both.

Both plans are in the Axxya-Systems family on both sites. The test site has that family as well as All Product, and the all-modules plan 7000002 is in Axxya-Systems there too.

The catalogue amounts matter for these plans. The app overrides the Chargebee price only on purchases made through the app. An account manager creates these subscriptions in the Chargebee console, and a subscription created there charges the catalogue amount. So 132.00 and 48.00 in Chargebee are what the customer pays, and they must match product_items.price. Both plans were repriced on 2026-10-20, from 300.00 and 60.00 (advertised as $25 and $5 per seat per month).

Getting the rows onto an environment​

The rows come from the AddEduPlans migration and its twin EduPlansSeeder, both of which call EduPlans::ensure(). It is idempotent, matches the product by Chargebee item id and the item by item price id, and does not overwrite a row that already exists, so copy edited in /admin/shop survives. There are two exceptions, both in EduPlans::PLANS: a title listed under superseded_titles is renamed to the current one, and a product_items.price equal to an amount listed under superseded_annual_prices is moved on to the current annual_price. A title or price edited in /admin/shop to anything else is left alone. That is how to change a title or a price on every environment; see "Changing a price" below.

Staging and test get the rows on deploy. Production does not run migrations on deploy, so run it by hand on both backends after the backend deploy:

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\"'"
copilot svc exec -a nexgen-v3 -e prod -n nexgen-free-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan migrate --force\"'"

Both item price ids are already in ONLINE_PRODUCT_IDS in the two backend manifests. The dashboard keeps its own copy of the list in two gitignored files, the local .env and copilot/dashboard/dashboard.env (read by the deployed dashboard). Add 7000031,7000041 to ONLINE_PRODUCT_IDS in both, restart the local dashboard, and redeploy the hosted one.

Confirm the rows with the read-only query from "8. Final checks", using each title. Expect is_active = 0, one item at 365 days, four regions.

Changing a price​

A price for these plans is stated in four places that do not check each other, so change all four:

  1. EduPlans::PLANS in nexgen/backend/app/Support/EduPlans.php: set monthly_price (the figure on the shop card) and annual_price (what a seat costs for the year), and add the old annual amount to superseded_annual_prices. The card line reads the constants, so the shop page changes on deploy.
  2. A new migration that calls EduPlans::ensure(), as RepriceEduPlans did on 2026-10-20, so product_items.price moves on every environment that already has the rows. Buy More Users charges a holder for extra seats from that column. Production runs it by hand, see above.
  3. The Chargebee item price on both sites. The amount on an item price can be edited (in the console, or ItemPrice::update($id, ['price' => <cents>]) from tinker with the backend's key for the test site). Existing subscriptions keep their contracted amount; only subscriptions created afterwards use the new one.
  4. This page.

Confirm with the read-only query from "8. Final checks" that product_items.price equals the Chargebee amount on each environment.

Selling one​

  1. In Chargebee, find or create the customer, then create a subscription on item price 7000031 or 7000041 with the quantity set to the number of seats. Note the subscription id.
  2. In the app, sign in as staff and open /admin/client. Create the client, or edit the existing one, and on the products step add a row: the plan (listed as "title - 12 Months"), the Chargebee subscription id, and the number of units (seats).
  3. Saving writes the orders_subscriptions and client_contracts rows, with the expiry set to today plus 365 days, and gives the client's first user the subscription, regions and languages.
  4. Ask the customer to sign in and confirm all three modules are there. The client-admin adds the other users in Manage Users, up to the seat count.

For the client to be matched on renewal, clients.shopper_id must hold the Chargebee customer id. Renewal payments then arrive through the paymentSucceeded webhook and move the expiry forward, matched on the subscription id.

Changing seats​

The webhook does not copy a quantity change from Chargebee into the app. When seats change, change the quantity on the Chargebee subscription and edit the order's quantity in /admin/client. Lowering the quantity there removes user subscriptions, and the page warns before it does.

A customer that already holds the plan can also add seats itself from Buy More Users, at the per seat price in product_items.price.

Adding another plan of this kind​

Add an entry to EduPlans::PLANS in nexgen/backend/app/Support/EduPlans.php (ids, title, slug, the monthly display figure and the annual price), add the title to ProductCategoryMappingSeeder, add the item price id to ONLINE_PRODUCT_IDS in both backend manifests and the dashboard .env, and add a new migration that calls EduPlans::ensure(), since the existing one has already run. The shop card, the checkout refusal and the tests' provisioning all read from PLANS.

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.

Postal codes at checkout​

The shop checkout and the Account Profile billing form require a postal code for every country except a list held in config/shop.php under postal_code_optional_countries. The list holds the roughly sixty countries with no postal code system (Hong Kong, the UAE, Qatar, Jamaica, Ghana, the Bahamas, and so on) plus Lebanon, Panama, and Trinidad and Tobago, where codes exist but are rarely used. The required marker next to the postal code field follows the selected country. A buyer in a listed country can still enter one.

SHOP_POSTAL_CODE_OPTIONAL_COUNTRIES, a comma-separated list of country names, extends the list without a code change. It is unset everywhere today.

Chargebee's Purchase API refuses a purchase with no shipping address and refuses a shipping address with no postal code, for any country. So when a listed country is used, the app sends a stand-in postal code in the shipping address only: 00000, or 0000 for Lebanon and the Cook Islands, which refuse five digits. The billing address is stored without one and nothing is shipped. Expect to see the stand-in on the Chargebee invoice's shipping address for those customers.

V2 was not changed and still requires a postal code on its own billing form.

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.