diff --git a/PROGRESS-billing-pricing-20260830.md b/PROGRESS-billing-pricing-20260830.md
index 9f60fd09..32b3e43a 100644
--- a/PROGRESS-billing-pricing-20260830.md
+++ b/PROGRESS-billing-pricing-20260830.md
@@ -27,3 +27,23 @@
## 尚未执行
任务 1–6 依赖任务 0 的真实成本口径;在取得可审计的 staging/production 聚合数据前,不接入会导致线上默认失败的空定价配置,也不修改商品售价或公平使用参数。
+
+## 任务 3 · 功能级定价服务端配置
+
+已完成结构与后台管理接线:
+
+- 新增 `public.feature_pricing`,按 `feature_key × model_tier × version` 保存草稿、发布、退休状态;迁移事务化、幂等、RLS 与 service/admin runtime 权限均已配置。
+- 新增 `resolve_feature_pricing(feature_key, model_id)`:只解析 published/enabled 模型与价格;模型或价格缺失时分别 fail-closed 为 `feature_pricing_model_unavailable` / `feature_pricing_missing`。
+- 新增 security-definer 管理函数 `admin_save_feature_pricing_draft` 与 `admin_publish_feature_pricing`,均要求既有权限、原因与 request id,并写入 admin 审计日志。
+- 新增 `/admin/feature-pricing` 与 `/api/admin/feature-pricing`,前端不携带默认价格数字;价格只来自服务端配置。
+- 已为 `profile.extra` / `report.export` 预留 feature key,未接入业务路径。
+
+本轮必要地更新了 `database-local-business.test.ts` 的 schema 表清单:原断言锁定基线表集合,本迁移有意新增 `feature_pricing`,因此仅补入该表并在断言旁注明原因。
+
+验证:
+
+- `./node_modules/.bin/tsc --noEmit`:通过
+- `./node_modules/.bin/tsx --test tests/feature-pricing-contract.test.ts`:2 通过
+- `npm run test:db`:34 通过,0 失败;Docker 中 migration apply 与业务 schema 检查通过
+
+价格门控仍生效:没有 staging/production 非零真实成本前,migration 未写入任何价格 seed。
diff --git a/frontend/src/app/admin/feature-pricing/page.tsx b/frontend/src/app/admin/feature-pricing/page.tsx
new file mode 100644
index 00000000..c67aa32e
--- /dev/null
+++ b/frontend/src/app/admin/feature-pricing/page.tsx
@@ -0,0 +1,5 @@
+import { FeaturePricingManagement } from "@/components/admin/feature-pricing-management";
+
+export default function FeaturePricingPage() {
+ return ;
+}
diff --git a/frontend/src/app/api/admin/feature-pricing/route.ts b/frontend/src/app/api/admin/feature-pricing/route.ts
new file mode 100644
index 00000000..bdef2adc
--- /dev/null
+++ b/frontend/src/app/api/admin/feature-pricing/route.ts
@@ -0,0 +1,102 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { requirePermission } from "@/lib/admin/auth";
+import { adminErrorResponse, requestId, requireAdminMutation } from "@/lib/admin/http";
+import { queryAdminRows } from "@/lib/admin/database";
+
+export const runtime = "nodejs";
+
+const featureKey = z.enum([
+ "chat.standard",
+ "chat.premium",
+ "rectification",
+ "report.full",
+ "report.export",
+ "profile.extra",
+]);
+const modelTier = z.enum(["standard", "premium", "internal"]);
+const mutationSchema = z.discriminatedUnion("action", [
+ z.object({
+ action: z.literal("saveDraft"),
+ id: z.string().uuid().nullable().optional(),
+ featureKey,
+ modelTier,
+ creditCost: z.number().int().positive(),
+ reason: z.string().trim().min(1).max(500),
+ }).strict(),
+ z.object({
+ action: z.literal("publish"),
+ id: z.string().uuid(),
+ reason: z.string().trim().min(1).max(500),
+ }).strict(),
+]);
+
+type FeaturePricingRow = {
+ id: string;
+ feature_key: string;
+ model_tier: string;
+ credit_cost: number;
+ version: number;
+ status: string;
+ enabled: boolean;
+ effective_from: Date | null;
+ effective_to: Date | null;
+ updated_at: Date;
+};
+
+function output(row: FeaturePricingRow) {
+ return {
+ id: row.id,
+ featureKey: row.feature_key,
+ modelTier: row.model_tier,
+ creditCost: row.credit_cost,
+ version: row.version,
+ status: row.status,
+ enabled: row.enabled,
+ effectiveFrom: row.effective_from?.toISOString() ?? null,
+ effectiveTo: row.effective_to?.toISOString() ?? null,
+ updatedAt: row.updated_at.toISOString(),
+ };
+}
+
+export async function GET() {
+ try {
+ await requirePermission("billing.products.read");
+ const rows = await queryAdminRows(`
+ select id, feature_key, model_tier, credit_cost, version, status, enabled,
+ effective_from, effective_to, updated_at
+ from public.feature_pricing
+ order by feature_key, model_tier, version desc
+ `);
+ return NextResponse.json({ data: rows.map(output), total: rows.length });
+ } catch (error) {
+ return adminErrorResponse(error);
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const body = mutationSchema.safeParse(await request.json().catch(() => null));
+ if (!body.success) {
+ return NextResponse.json({ error: "请求格式不正确", details: body.error.flatten() }, { status: 400 });
+ }
+ const permission = body.data.action === "publish" ? "billing.products.publish" : "billing.products.write";
+ const session = await requireAdminMutation(request, permission);
+ const rid = requestId(request);
+ if (body.data.action === "publish") {
+ const rows = await queryAdminRows<{ id: string }>(
+ "select public.admin_publish_feature_pricing($1,$2,$3,$4) id",
+ [session.user.id, body.data.id, body.data.reason, rid],
+ );
+ return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
+ }
+ const rows = await queryAdminRows<{ id: string }>(
+ "select public.admin_save_feature_pricing_draft($1,$2,$3,$4,$5,$6,$7) id",
+ [session.user.id, body.data.id ?? null, body.data.featureKey, body.data.modelTier, body.data.creditCost, body.data.reason, rid],
+ );
+ return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
+ } catch (error) {
+ return adminErrorResponse(error);
+ }
+}
diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx
index 255018ef..7833928b 100644
--- a/frontend/src/components/admin/admin-app.tsx
+++ b/frontend/src/components/admin/admin-app.tsx
@@ -111,6 +111,8 @@ export function AdminApp({ children }: { children: ReactNode }) {
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } },
{ name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } },
{ name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } },
+ { name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: } },
+ { name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: } },
{ name: "model-releases", list: "/admin/model-releases", meta: { label: "模型发布", icon: } },
{ name: "feature-flags", list: "/admin/feature-flags", meta: { label: "功能开关", icon: } },
{ name: "security", list: "/admin/security", meta: { label: "安全验证", icon: } },
diff --git a/frontend/src/components/admin/feature-pricing-management.tsx b/frontend/src/components/admin/feature-pricing-management.tsx
new file mode 100644
index 00000000..c86bb33d
--- /dev/null
+++ b/frontend/src/components/admin/feature-pricing-management.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { EditOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons";
+import { useGetIdentity } from "@refinedev/core";
+import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography } from "antd";
+import { useEffect, useState } from "react";
+
+import { adminRequestJson } from "@/lib/admin/providers";
+
+type Row = {
+ id: string;
+ featureKey: string;
+ modelTier: string;
+ creditCost: number;
+ version: number;
+ status: string;
+ enabled: boolean;
+ effectiveFrom: string | null;
+ effectiveTo: string | null;
+};
+
+const featureOptions = ["chat.standard", "chat.premium", "rectification", "report.full", "report.export", "profile.extra"];
+const tierOptions = ["standard", "premium", "internal"];
+
+export function FeaturePricingManagement() {
+ const { data: identity } = useGetIdentity<{ permissions?: string[] }>();
+ const [rows, setRows] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [refresh, setRefresh] = useState(0);
+ const [editing, setEditing] = useState();
+ const [form] = Form.useForm();
+ const canWrite = Boolean(identity?.permissions?.includes("billing.products.write"));
+ const canPublish = Boolean(identity?.permissions?.includes("billing.products.publish"));
+
+ useEffect(() => {
+ let active = true;
+ setIsLoading(true);
+ adminRequestJson<{ data: Row[] }>("/api/admin/feature-pricing")
+ .then((payload) => { if (active) setRows(payload.data); })
+ .finally(() => { if (active) setIsLoading(false); });
+ return () => { active = false; };
+ }, [refresh]);
+
+ const refetch = async () => setRefresh((value) => value + 1);
+
+ async function save() {
+ const values = await form.validateFields();
+ await adminRequestJson("/api/admin/feature-pricing", {
+ method: "POST",
+ body: JSON.stringify({ action: "saveDraft", ...values, id: editing?.id ?? null, reason: values.reason }),
+ });
+ setEditing(undefined);
+ form.resetFields();
+ await refetch();
+ }
+
+ async function publish(row: Row) {
+ await adminRequestJson("/api/admin/feature-pricing", {
+ method: "POST",
+ body: JSON.stringify({ action: "publish", id: row.id, reason: "后台发布功能定价草稿" }),
+ });
+ await refetch();
+ }
+
+ return (
+ } onClick={() => setEditing(null)}>新建草稿 : null}
+ >
+
+ 价格只在服务端计费解析和本页后台接口可见;没有发布配置的功能会 fail-closed,不会回退到模型默认点数。
+
+
+ rowKey="id"
+ loading={isLoading}
+ dataSource={rows}
+ columns={[
+ { title: "功能", dataIndex: "featureKey" },
+ { title: "模型档位", dataIndex: "modelTier" },
+ { title: "积分", dataIndex: "creditCost" },
+ { title: "版本", dataIndex: "version" },
+ { title: "状态", dataIndex: "status", render: (value: string) => {value} },
+ {
+ title: "操作",
+ render: (_: unknown, row: Row) => (
+
+ {row.status === "draft" && canWrite ? } onClick={() => {
+ form.setFieldsValue({ featureKey: row.featureKey, modelTier: row.modelTier, creditCost: row.creditCost });
+ setEditing(row);
+ }}>编辑 : null}
+ {row.status === "draft" && canPublish ? } onClick={() => publish(row)}>发布 : null}
+
+ ),
+ },
+ ]}
+ />
+ { setEditing(undefined); form.resetFields(); }} onOk={save} okButtonProps={{ disabled: !canWrite }}>
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/lib/consultation-billing.ts b/frontend/src/lib/consultation-billing.ts
index ae5a409f..4b850e0f 100644
--- a/frontend/src/lib/consultation-billing.ts
+++ b/frontend/src/lib/consultation-billing.ts
@@ -97,7 +97,7 @@ export async function authorizeUsage(
input: {
userId: string;
requestId: string;
- featureKey: "chat.standard" | "chat.premium" | "rectification" | "report.full" | "report.export";
+ featureKey: "chat.standard" | "chat.premium" | "rectification" | "report.full" | "report.export" | "profile.extra";
requestedModelId: string;
creditCost: number;
},
diff --git a/frontend/src/lib/feature-pricing.ts b/frontend/src/lib/feature-pricing.ts
new file mode 100644
index 00000000..b0a74d18
--- /dev/null
+++ b/frontend/src/lib/feature-pricing.ts
@@ -0,0 +1,50 @@
+import "server-only";
+
+import { z } from "zod";
+
+const featurePricingSchema = z.object({
+ feature_key: z.string(),
+ model_tier: z.enum(["standard", "premium", "internal"]),
+ credit_cost: z.number().int().positive(),
+ version: z.number().int().positive(),
+});
+
+type PricingClient = {
+ rpc(rpcName: string, args: Record): PromiseLike<{
+ data: unknown;
+ error: { message: string } | null;
+ }>;
+};
+
+export class FeaturePricingError extends Error {
+ readonly code: string;
+
+ constructor(code: string) {
+ super(`Feature pricing failed: ${code}`);
+ this.name = "FeaturePricingError";
+ this.code = code;
+ }
+}
+
+export type FeaturePricing = z.infer;
+
+export async function resolveFeaturePricing(
+ accounting: PricingClient,
+ featureKey: FeaturePricing["feature_key"],
+ modelId: string,
+): Promise {
+ try {
+ const { data, error } = await accounting.rpc("resolve_feature_pricing", {
+ p_feature_key: featureKey,
+ p_model_id: modelId,
+ });
+ const row = Array.isArray(data) ? data[0] : data;
+ const parsed = featurePricingSchema.safeParse(row);
+ if (error) throw new FeaturePricingError(error.message);
+ if (!parsed.success) throw new FeaturePricingError("feature_pricing_invalid_response");
+ return parsed.data;
+ } catch (error) {
+ if (error instanceof FeaturePricingError) throw error;
+ throw new FeaturePricingError(error instanceof Error ? error.message : "feature_pricing_unavailable");
+ }
+}
diff --git a/frontend/supabase/migrations/20260831010000_feature_pricing.sql b/frontend/supabase/migrations/20260831010000_feature_pricing.sql
new file mode 100644
index 00000000..2c70439c
--- /dev/null
+++ b/frontend/supabase/migrations/20260831010000_feature_pricing.sql
@@ -0,0 +1,207 @@
+begin;
+
+create table if not exists public.feature_pricing (
+ id uuid primary key default gen_random_uuid(),
+ feature_key text not null check (feature_key in ('chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra')),
+ model_tier text not null check (model_tier in ('standard', 'premium', 'internal')),
+ credit_cost integer not null check (credit_cost > 0),
+ version integer not null check (version > 0),
+ status text not null default 'draft' check (status in ('draft', 'published', 'retired')),
+ enabled boolean not null default false,
+ effective_from timestamptz,
+ effective_to timestamptz,
+ created_by uuid references auth.users(id) on delete set null,
+ updated_by uuid references auth.users(id) on delete set null,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (feature_key, model_tier, version),
+ check (effective_to is null or effective_from is null or effective_to > effective_from)
+);
+
+create unique index if not exists feature_pricing_one_draft_idx
+ on public.feature_pricing(feature_key, model_tier) where status = 'draft';
+create index if not exists feature_pricing_active_idx
+ on public.feature_pricing(feature_key, model_tier, effective_from)
+ where status = 'published' and enabled;
+
+create or replace function public.resolve_feature_pricing(
+ p_feature_key text,
+ p_model_id text
+)
+returns table(feature_key text, model_tier text, credit_cost integer, version integer)
+language plpgsql security definer set search_path = ''
+as $$
+declare
+ v_model_tier text;
+begin
+ if p_feature_key is null or p_feature_key not in ('chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra') then
+ raise exception 'feature_pricing_invalid_feature' using errcode = '22023';
+ end if;
+ if p_model_id is null or btrim(p_model_id) = '' then
+ raise exception 'feature_pricing_model_required' using errcode = '22023';
+ end if;
+
+ select v.model_tier into v_model_tier
+ from public.model_configs c
+ join public.model_config_versions v on v.config_id = c.id
+ where c.model_id = btrim(p_model_id)
+ and v.status = 'published'
+ and v.enabled
+ order by v.version desc
+ limit 1;
+
+ if v_model_tier is null then
+ raise exception 'feature_pricing_model_unavailable' using errcode = '22023';
+ end if;
+
+ return query
+ select p.feature_key, p.model_tier, p.credit_cost, p.version
+ from public.feature_pricing p
+ where p.feature_key = p_feature_key
+ and p.model_tier = v_model_tier
+ and p.status = 'published'
+ and p.enabled
+ and (p.effective_from is null or p.effective_from <= clock_timestamp())
+ and (p.effective_to is null or p.effective_to > clock_timestamp())
+ order by p.version desc
+ limit 1;
+
+ if not found then
+ raise exception 'feature_pricing_missing' using errcode = '22023';
+ end if;
+end; $$;
+
+create or replace function public.admin_save_feature_pricing_draft(
+ p_actor_user_id uuid,
+ p_pricing_id uuid,
+ p_feature_key text,
+ p_model_tier text,
+ p_credit_cost integer,
+ p_reason text,
+ p_request_id text
+)
+returns uuid
+language plpgsql security definer set search_path = ''
+as $$
+declare
+ v_id uuid;
+ v_version integer;
+begin
+ if not public.admin_has_permission(p_actor_user_id, 'billing.products.write') then
+ raise exception 'admin_permission_denied' using errcode = '42501';
+ end if;
+ if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then
+ raise exception 'admin_reason_required' using errcode = '22023';
+ end if;
+ if p_feature_key not in ('chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra')
+ or p_model_tier not in ('standard', 'premium', 'internal')
+ or p_credit_cost is null or p_credit_cost <= 0 then
+ raise exception 'feature_pricing_invalid' using errcode = '22023';
+ end if;
+
+ if p_pricing_id is null then
+ select coalesce(max(version), 0) + 1 into v_version
+ from public.feature_pricing
+ where feature_key = p_feature_key and model_tier = p_model_tier;
+ insert into public.feature_pricing(feature_key, model_tier, credit_cost, version, status, enabled, updated_by)
+ values (p_feature_key, p_model_tier, p_credit_cost, v_version, 'draft', false, p_actor_user_id)
+ returning id into v_id;
+ else
+ update public.feature_pricing
+ set credit_cost = p_credit_cost, updated_by = p_actor_user_id, updated_at = clock_timestamp()
+ where id = p_pricing_id and status = 'draft'
+ returning id into v_id;
+ if v_id is null then
+ raise exception 'feature_pricing_draft_not_found' using errcode = '22023';
+ end if;
+ end if;
+
+ insert into audit.admin_audit_logs(
+ actor_user_id, actor_email, actor_role, action, target_type, target_id,
+ after_value, request_id, permission_used, reason
+ )
+ select p_actor_user_id, lower(btrim(u.email)), 'admin', 'billing.feature_pricing.draft.save',
+ 'feature_pricing', v_id,
+ jsonb_build_object('featureKey', p_feature_key, 'modelTier', p_model_tier, 'creditCost', p_credit_cost),
+ btrim(p_request_id), 'billing.products.write', btrim(p_reason)
+ from identity.users u where u.id = p_actor_user_id
+ on conflict do nothing;
+ return v_id;
+end; $$;
+
+create or replace function public.admin_publish_feature_pricing(
+ p_actor_user_id uuid,
+ p_pricing_id uuid,
+ p_reason text,
+ p_request_id text
+)
+returns uuid
+language plpgsql security definer set search_path = ''
+as $$
+declare
+ v_draft public.feature_pricing%rowtype;
+ v_old public.feature_pricing%rowtype;
+begin
+ if not public.admin_has_permission(p_actor_user_id, 'billing.products.publish') then
+ raise exception 'admin_permission_denied' using errcode = '42501';
+ end if;
+ if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then
+ raise exception 'admin_reason_required' using errcode = '22023';
+ end if;
+
+ select * into v_draft from public.feature_pricing where id = p_pricing_id and status = 'draft' for update;
+ if not found then raise exception 'feature_pricing_draft_not_found' using errcode = '22023'; end if;
+
+ select * into v_old
+ from public.feature_pricing
+ where feature_key = v_draft.feature_key and model_tier = v_draft.model_tier
+ and status = 'published'
+ order by version desc limit 1 for update;
+
+ if v_old.id is not null then
+ update public.feature_pricing
+ set status = 'retired', enabled = false, effective_to = clock_timestamp(), updated_by = p_actor_user_id, updated_at = clock_timestamp()
+ where id = v_old.id;
+ end if;
+ update public.feature_pricing
+ set status = 'published', enabled = true, effective_from = clock_timestamp(), effective_to = null,
+ updated_by = p_actor_user_id, updated_at = clock_timestamp()
+ where id = v_draft.id;
+
+ insert into audit.admin_audit_logs(
+ actor_user_id, actor_email, actor_role, action, target_type, target_id,
+ before_value, after_value, request_id, permission_used, reason
+ )
+ select p_actor_user_id, lower(btrim(u.email)), 'admin', 'billing.feature_pricing.publish',
+ 'feature_pricing', v_draft.id,
+ case when v_old.id is null then '{}'::jsonb else jsonb_build_object('id', v_old.id, 'version', v_old.version, 'status', v_old.status) end,
+ jsonb_build_object('featureKey', v_draft.feature_key, 'modelTier', v_draft.model_tier, 'version', v_draft.version, 'creditCost', v_draft.credit_cost),
+ btrim(p_request_id), 'billing.products.publish', btrim(p_reason)
+ from identity.users u where u.id = p_actor_user_id
+ on conflict do nothing;
+ return v_draft.id;
+end; $$;
+
+alter table public.feature_pricing enable row level security;
+revoke all on table public.feature_pricing from public, anon, authenticated;
+grant all on table public.feature_pricing to service_role;
+
+drop policy if exists feature_pricing_public_select on public.feature_pricing;
+
+revoke all on function public.resolve_feature_pricing(text, text),
+ public.admin_save_feature_pricing_draft(uuid, uuid, text, text, integer, text, text),
+ public.admin_publish_feature_pricing(uuid, uuid, text, text)
+ from public, anon, authenticated;
+grant execute on function public.resolve_feature_pricing(text, text) to service_role;
+grant execute on function public.admin_save_feature_pricing_draft(uuid, uuid, text, text, integer, text, text),
+ public.admin_publish_feature_pricing(uuid, uuid, text, text) to service_role;
+
+do $$ begin
+ if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
+ grant select on table public.feature_pricing to admin_runtime;
+ grant execute on function public.admin_save_feature_pricing_draft(uuid, uuid, text, text, integer, text, text),
+ public.admin_publish_feature_pricing(uuid, uuid, text, text) to admin_runtime;
+ end if;
+end $$;
+
+commit;
diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts
index e2667029..d01636b2 100644
--- a/frontend/tests/database-local-business.test.ts
+++ b/frontend/tests/database-local-business.test.ts
@@ -197,6 +197,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"credit_transactions",
"epay_settings",
"feature_flags",
+ // This migration intentionally adds the server-owned feature pricing table.
+ "feature_pricing",
"model_config_versions",
"model_configs",
"model_connection_test_evidence",
diff --git a/frontend/tests/feature-pricing-contract.test.ts b/frontend/tests/feature-pricing-contract.test.ts
new file mode 100644
index 00000000..dfabc586
--- /dev/null
+++ b/frontend/tests/feature-pricing-contract.test.ts
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+const source = readFileSync(
+ fileURLToPath(new URL("../src/lib/feature-pricing.ts", import.meta.url)),
+ "utf8",
+);
+
+test("feature pricing resolver calls the server-side RPC and validates its row", () => {
+ assert.match(source, /rpc\("resolve_feature_pricing"/);
+ assert.match(source, /p_feature_key: featureKey/);
+ assert.match(source, /p_model_id: modelId/);
+ assert.match(source, /feature_pricing_invalid_response/);
+});
+
+test("feature pricing resolver has explicit fail-closed errors", () => {
+ assert.match(source, /error\.message/);
+ assert.match(source, /feature_pricing_unavailable/);
+ assert.match(source, /class FeaturePricingError/);
+});