st
staging

Plan pricing editing

How ST-admin edits regional subscription prices in the addins database — same rows checkout uses via Pricing.php.

DBplan_prices
APIGET /plan-prices
UIPricing matrix
RuntimeCheckout

Data model (addins)

plans describes capability limits and whether the plan is subscription or event (isEvent). Prices live elsewhere.

TableRole
price_schemesRegion label + currency (e.g. Europe/EUR, Global/USD). isDefault is a fallback.
currenciesISO code, symbol, display pattern for checkout formatting.
countries.priceSchemeIdMaps a billing country to which scheme (and thus which column) applies at checkout.
plan_prices One row per (plan, scheme). price and priceMonth are integer cents.

Field semantics (subscription vs event)

The matrix shows Y and M for subscription plans, matching Pricing::getPricing():

  • Y (price) — per month when the customer pays yearly (annual billing uses price × 12 as billed yearly total in PHP).
  • M (priceMonth) — per month when the customer chooses monthly billing.
  • Event plans — only one amount: price (one-time). The UI hides M; the API rejects priceMonth on event plans.

ST-admin displays and edits major units (e.g. 99.00); helpers in types/planPricing.ts convert to/from cents on save.

How the feature was built

1

Data in addins

Prices are not columns on plans. Each amount is a row in plan_prices linking planId + priceSchemeId.

addins.plan_prices, price_schemes, currencies

2

Checkout resolution

PHP Pricing picks a price scheme from the customer country, then loads the matching plan_prices row.

api/common/components/pricing/Pricing.php

3

ST-admin API

FastAPI reads/writes addins directly. GET returns a full matrix; PUT upserts one or more cells.

st-admin/backend/app/services/plan_prices.py

4

Pricing tab UI

Plans as rows, price schemes as columns. Edit unlocks a row; blur on a cell saves that cell.

st-admin/frontend/components/plans-pricing/PlanPricingMatrix.vue

Edit flow & safeguards

Pricing reuses the same Edit / Done editing pattern as the Features tab:

  1. Draft plans — one confirmation step; no plan-name typing. API allows PUT without acknowledgeLiveImpact.
  2. Active / legacy plans — two-step modal (ConfirmPlanEditModal with subject="pricing"); user must type the plan name. Frontend sends acknowledgeLiveImpact: true; backend returns 403 otherwise.
  3. Unlocked plan IDs live in session state (useState('st-admin-unlocked-pricing-plan-ids')).
  4. Each cell saves on blur via upsertCell — optimistic local patch, rollback on error.
  5. Clearing both Y and M deletes the plan_prices row for that scheme.

Catalog pills (Active / Legacy / Draft / All) share useState('st-admin-plan-catalog') with the Features tab so filters stay in sync when switching tabs.

API contract

Both routes require bearer token + superadmin role (see get_superadmin_user).

Load matrix

GET /api/plan-prices
Authorization: Bearer <superadmin token>

{
  "plans": [{ "id": 1, "name": "Pro", "isEvent": false, "isDraft": false, ... }],
  "priceSchemes": [
    { "id": 1, "description": "Europe", "currencyIso": "EUR", "currencySymbol": "€", "isDefault": true }
  ],
  "planPrices": {
    "1": {
      "1": { "id": 42, "price": 9900, "priceMonth": 12900 }
    }
  }
}

planPrices keys are stringified plan and scheme IDs. Missing combinations are null (shown as — in the UI).

Upsert cell(s)

PUT /api/plans/1/prices
{
  "cells": [
    { "priceSchemeId": 1, "price": 9900, "priceMonth": 12900 }
  ],
  "acknowledgeLiveImpact": true
}

Service logic (summary)

async def set_plan_prices(db, plan_id, cells, *, acknowledge_live_impact=False):
    # Draft: always allowed. Active/legacy: requires acknowledge_live_impact.
    # Event plans: only price (one-time); priceMonth must be null.
    # Both null → DELETE row for that scheme.

Duplicate plan

POST /api/plans/{id}/duplicate clones plan metadata, all plan_features, and all plan_prices via copy_plan_prices() into the new draft. Edit prices on the draft before going live.

File map

AreaPath
Backend routerbackend/app/routers/plan_prices.py
Backend servicebackend/app/services/plan_prices.py
Pydantic schemasbackend/app/schemas/plan_prices.py
SQLAlchemy modelsbackend/app/models/entities.py (PlanPriceRow, PriceSchemeRow)
Duplicate planbackend/app/services/plans.py → copy_plan_prices()
Fetch matrixfrontend/composables/usePlanPricing.ts
Save / unlockfrontend/composables/usePlanPricingAdmin.ts
Types & cents helpersfrontend/types/planPricing.ts
Matrix UIfrontend/components/plans-pricing/PlanPricingMatrix.vue
Page wiringfrontend/pages/plans-features.vue (Pricing tab)
Shared catalog filterfrontend/composables/usePlanCatalogFilter.ts