@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/astro.config.mjs +59 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
- package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
- package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
- package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
- package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
- package/apps/storefront/src/config/devTenantSeed.ts +65 -16
- package/apps/storefront/src/config/resolver.ts +48 -1
- package/apps/storefront/src/layouts/Layout.astro +14 -12
- package/apps/storefront/src/lib/admin/README.md +2 -2
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
- package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
- package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
- package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
- package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
- package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
- package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
- package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
- package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
- package/apps/storefront/src/lib/edgeCache.ts +7 -6
- package/apps/storefront/src/lib/health.ts +53 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
- package/apps/storefront/src/lib/sections.ts +19 -23
- package/apps/storefront/src/lib/securityHeaders.ts +1 -1
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
- package/apps/storefront/src/middleware/index.ts +29 -12
- package/apps/storefront/src/pages/[...slug].astro +5 -6
- package/apps/storefront/src/pages/admin.astro +10 -3
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
- package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
- package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
- package/apps/storefront/src/pages/auth/login.astro +4 -1
- package/apps/storefront/src/pages/auth/magic.astro +263 -43
- package/apps/storefront/src/pages/capabilities.astro +1 -1
- package/apps/storefront/src/pages/cockpit.astro +2365 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
- package/apps/storefront/src/pages/index.astro +23 -29
- package/apps/storefront/src/pages/products/[handle].astro +50 -20
- package/package.json +6 -2
- package/packages/public-runtime/src/capabilities.ts +97 -9
- package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
- package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
- package/packages/public-runtime/src/checkout-style.ts +13 -13
- package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
- package/packages/public-runtime/src/csp.ts +11 -11
- package/packages/public-runtime/src/customization-scripts.ts +28 -0
- package/packages/public-runtime/src/extension-contract.ts +73 -0
- package/packages/public-runtime/src/index.ts +4 -2
- package/packages/public-runtime/src/product.ts +57 -0
- package/packages/public-runtime/src/raw-html-policy.ts +35 -0
- package/packages/public-runtime/src/tenant.ts +14 -11
- package/pnpm-workspace.yaml +1 -0
- package/scripts/dev/checkout-watch.mjs +9 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/scripts/dev/transient-files.mjs +34 -0
- package/scripts/tot-dev.mjs +16 -3
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
- package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
- package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
- package/apps/storefront/src/pages/api/dev/file.ts +0 -57
- package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
- package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
- package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
- package/apps/storefront/src/pages/api/dev/write.ts +0 -116
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-side client for
|
|
2
|
+
* Server-side client for the checkout order READ API (card 8585 / U2).
|
|
3
3
|
*
|
|
4
4
|
* Calls `GET /commerce/orders[/:orderId]` with the tenant's per-tenant bearer key
|
|
5
5
|
* (the ToT `partnerApi.apiKey`, held as a Worker secret — NEVER shipped to the
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* `fetchImpl` is injectable so the client is unit-testable offline.
|
|
15
15
|
*/
|
|
16
16
|
import type {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
CheckoutOrderDetail,
|
|
18
|
+
CheckoutOrderDetailResponse,
|
|
19
|
+
CheckoutOrderListFilters,
|
|
20
|
+
CheckoutOrderListResponse,
|
|
21
21
|
} from "@tot/public-runtime";
|
|
22
22
|
import type { CustomizationEnv } from "@tot/public-runtime";
|
|
23
23
|
|
|
@@ -50,13 +50,13 @@ export class OrderApiError extends Error {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export interface
|
|
54
|
-
listOrders(filters?:
|
|
55
|
-
getOrder(orderId: string): Promise<
|
|
53
|
+
export interface CheckoutOrderClient {
|
|
54
|
+
listOrders(filters?: CheckoutOrderListFilters): Promise<CheckoutOrderListResponse>;
|
|
55
|
+
getOrder(orderId: string): Promise<CheckoutOrderDetail>;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export interface
|
|
59
|
-
/**
|
|
58
|
+
export interface CheckoutOrderClientConfig {
|
|
59
|
+
/** Checkout order API base URL for the checkout proxy. */
|
|
60
60
|
baseUrl: string;
|
|
61
61
|
/** The tenant, sent as `?appDomain=` (e.g. `sample-store.example`). */
|
|
62
62
|
appDomain: string;
|
|
@@ -66,10 +66,10 @@ export interface FoxyOrderClientConfig {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Resolve the
|
|
70
|
-
* qa
|
|
71
|
-
*
|
|
72
|
-
* the
|
|
69
|
+
* Resolve the checkout order API base URL for an environment (contract §2).
|
|
70
|
+
* qa/test use the non-prod proxy; live uses prod. Kept private so it does not
|
|
71
|
+
* collide with U11b's exported checkout theme base URL before the units fold;
|
|
72
|
+
* the integrator can dedup into one shared export.
|
|
73
73
|
*/
|
|
74
74
|
export function orderApiBaseUrl(env: CustomizationEnv): string {
|
|
75
75
|
return env === "live"
|
|
@@ -98,7 +98,7 @@ function toError(status: number, body: string): OrderApiError {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/** Build the list querystring: appDomain (required) + any provided filters. */
|
|
101
|
-
function listQuery(appDomain: string, filters?:
|
|
101
|
+
function listQuery(appDomain: string, filters?: CheckoutOrderListFilters): string {
|
|
102
102
|
const p = new URLSearchParams({ appDomain });
|
|
103
103
|
if (filters) {
|
|
104
104
|
if (filters.queue) p.set("queue", filters.queue);
|
|
@@ -112,9 +112,9 @@ function listQuery(appDomain: string, filters?: FoxyOrderListFilters): string {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** Create a per-request order client bound to one tenant + env. */
|
|
115
|
-
export function
|
|
116
|
-
config:
|
|
117
|
-
):
|
|
115
|
+
export function createCheckoutOrderClient(
|
|
116
|
+
config: CheckoutOrderClientConfig,
|
|
117
|
+
): CheckoutOrderClient {
|
|
118
118
|
const doFetch = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
|
119
119
|
const base = config.baseUrl.replace(/\/+$/, "");
|
|
120
120
|
const headers = { authorization: `Bearer ${config.bearer}` };
|
|
@@ -133,11 +133,11 @@ export function createFoxyOrderClient(
|
|
|
133
133
|
return {
|
|
134
134
|
async listOrders(filters) {
|
|
135
135
|
const qs = listQuery(config.appDomain, filters);
|
|
136
|
-
return getJson<
|
|
136
|
+
return getJson<CheckoutOrderListResponse>(`${base}/commerce/orders?${qs}`);
|
|
137
137
|
},
|
|
138
138
|
async getOrder(orderId) {
|
|
139
139
|
const qs = new URLSearchParams({ appDomain: config.appDomain }).toString();
|
|
140
|
-
const { order } = await getJson<
|
|
140
|
+
const { order } = await getJson<CheckoutOrderDetailResponse>(
|
|
141
141
|
`${base}/commerce/orders/${encodeURIComponent(orderId)}?${qs}`,
|
|
142
142
|
);
|
|
143
143
|
return order;
|
package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts}
RENAMED
|
@@ -8,30 +8,29 @@
|
|
|
8
8
|
* the `TenantResolver` seam via `config/adminTargetTenant`), never looked up from
|
|
9
9
|
* the static tenant map here: `lib/**` stays behind the `tenancy-resolver-fence`
|
|
10
10
|
* so a single-tenant extraction stays a drop-in. A null tenant (or one with no
|
|
11
|
-
*
|
|
11
|
+
* checkout store) renders the panel's inert "no store" state.
|
|
12
12
|
*
|
|
13
13
|
* Overrides are read from a mock store here (no live persistence yet — the save
|
|
14
|
-
* hop is the
|
|
15
|
-
*
|
|
16
|
-
* states without a backend.
|
|
14
|
+
* hop is the commerce service's theme PUT/refresh seam). One example override is
|
|
15
|
+
* seeded so the panel demonstrates the theme/override/reset states without a backend.
|
|
17
16
|
*/
|
|
18
|
-
import {
|
|
17
|
+
import { resolveManagedCheckout } from "@tot/public-runtime";
|
|
19
18
|
import type { TenantConfig } from "@tot/public-runtime";
|
|
20
19
|
import { referenceTheme } from "@/themes/reference";
|
|
21
20
|
import { mergeTheme } from "@/themes/schema";
|
|
22
21
|
import type { AdminTenantEnvelope } from "../../envelope";
|
|
23
22
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
type
|
|
27
|
-
type
|
|
28
|
-
} from "@/lib/commerce/
|
|
23
|
+
buildCheckoutSettingsFields,
|
|
24
|
+
deriveCheckoutSettings,
|
|
25
|
+
type CheckoutSettingsOverrides,
|
|
26
|
+
type CheckoutSettingsField,
|
|
27
|
+
} from "@/lib/commerce/checkoutSettings";
|
|
29
28
|
|
|
30
|
-
export interface
|
|
29
|
+
export interface CheckoutSettingsViewModel {
|
|
31
30
|
targetTenant: string;
|
|
32
|
-
/** True when the tenant has a
|
|
31
|
+
/** True when the tenant has a checkout store (else the section is inert). */
|
|
33
32
|
hasStore: boolean;
|
|
34
|
-
fields:
|
|
33
|
+
fields: CheckoutSettingsField[];
|
|
35
34
|
overrideCount: number;
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -40,7 +39,7 @@ export interface FoxyCartSettingsViewModel {
|
|
|
40
39
|
* generic sample tenant so the panel shows a mixed theme/override state out of
|
|
41
40
|
* the box. Real overrides would come from the tenant's saved cart settings.
|
|
42
41
|
*/
|
|
43
|
-
const MOCK_OVERRIDES: Record<string,
|
|
42
|
+
const MOCK_OVERRIDES: Record<string, CheckoutSettingsOverrides> = {
|
|
44
43
|
"sample-store.example": {
|
|
45
44
|
// Example: the merchant pinned a custom tertiary accent, overriding the
|
|
46
45
|
// theme's --color-sale default — the panel renders this row as "Overridden"
|
|
@@ -50,28 +49,28 @@ const MOCK_OVERRIDES: Record<string, FoxyCartSettingsOverrides> = {
|
|
|
50
49
|
};
|
|
51
50
|
|
|
52
51
|
/**
|
|
53
|
-
* Build the
|
|
52
|
+
* Build the checkout appearance view model from the resolved TARGET tenant
|
|
54
53
|
* config. `tenant` is the target's resolved `TenantConfig` (or null when no
|
|
55
54
|
* tenant claims the id) — resolved by the caller through the resolver seam.
|
|
56
55
|
*/
|
|
57
|
-
export function
|
|
56
|
+
export function getCheckoutSettingsViewModel(
|
|
58
57
|
envelope: AdminTenantEnvelope,
|
|
59
58
|
tenant: TenantConfig | null,
|
|
60
|
-
):
|
|
59
|
+
): CheckoutSettingsViewModel {
|
|
61
60
|
const { targetTenant } = envelope;
|
|
62
|
-
const
|
|
61
|
+
const checkout = tenant?.commerce?.checkout ?? null;
|
|
63
62
|
|
|
64
|
-
if (!tenant || !
|
|
63
|
+
if (!tenant || !checkout) {
|
|
65
64
|
return { targetTenant, hasStore: false, fields: [], overrideCount: 0 };
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
const theme = mergeTheme(referenceTheme, tenant.theme_tokens);
|
|
69
|
-
const commerce =
|
|
70
|
-
const defaults =
|
|
68
|
+
const commerce = resolveManagedCheckout(checkout);
|
|
69
|
+
const defaults = deriveCheckoutSettings(theme, commerce, {
|
|
71
70
|
originBase: `https://${targetTenant}`,
|
|
72
71
|
});
|
|
73
72
|
const overrides = MOCK_OVERRIDES[targetTenant] ?? {};
|
|
74
|
-
const fields =
|
|
73
|
+
const fields = buildCheckoutSettingsFields(defaults, overrides);
|
|
75
74
|
const overrideCount = fields.filter((f) => f.source === "override").length;
|
|
76
75
|
|
|
77
76
|
return { targetTenant, hasStore: true, fields, overrideCount };
|
|
@@ -4,8 +4,9 @@ export { getCustomersViewModel } from "./customers";
|
|
|
4
4
|
export { getFulfillmentViewModel } from "./fulfillment";
|
|
5
5
|
export { getReportingViewModel } from "./reporting";
|
|
6
6
|
export { getSettingsViewModel } from "./settings";
|
|
7
|
-
export {
|
|
8
|
-
export type {
|
|
7
|
+
export { getCheckoutSettingsViewModel } from "./checkoutSettings";
|
|
8
|
+
export type { CheckoutSettingsViewModel } from "./checkoutSettings";
|
|
9
|
+
export { getSubscriptionsViewModel } from "./subscriptions";
|
|
9
10
|
export { getAiWorkflowsViewModel } from "./aiWorkflows";
|
|
10
11
|
export { getEvidenceViewModel } from "./evidence";
|
|
11
12
|
export { SEEDED_TENANTS, tenantProfile } from "./tenants";
|
|
@@ -7,7 +7,7 @@ import type { SettingsAuditEvent, SettingsSection, SettingsViewModel } from "../
|
|
|
7
7
|
const SECTION_DEFS: Omit<SettingsSection, "riskyChangeReview">[] = [
|
|
8
8
|
{ key: "store", label: "Store", summary: "Store profile, branding, and white-label copy." },
|
|
9
9
|
{ key: "checkout", label: "Checkout", summary: "Checkout assurance and identity/age verification flow." },
|
|
10
|
-
{ key: "cart_appearance", label: "Cart & checkout appearance", summary: "
|
|
10
|
+
{ key: "cart_appearance", label: "Cart & checkout appearance", summary: "Hosted checkout colors and templates, prepopulated from the theme." },
|
|
11
11
|
{ key: "payments", label: "Payments", summary: "Payment capture timing and gateway configuration." },
|
|
12
12
|
{ key: "shipping", label: "Shipping", summary: "Carriers, rates, and shipping restrictions by region." },
|
|
13
13
|
{ key: "taxes", label: "Taxes", summary: "Excise and sales tax configuration by jurisdiction." },
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProductPurchaseOption,
|
|
3
|
+
SubscriptionPurchaseOption,
|
|
4
|
+
} from "@tot/public-runtime";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_ONE_TIME_PURCHASE_OPTION,
|
|
7
|
+
isSubscriptionFrequency,
|
|
8
|
+
purchaseOptionPrice,
|
|
9
|
+
} from "@tot/public-runtime";
|
|
10
|
+
import type { AdminTenantEnvelope } from "../../envelope";
|
|
11
|
+
import { memoizeByEnvelope } from "../../memoizeByEnvelope";
|
|
12
|
+
import {
|
|
13
|
+
MOCK_ANCHOR_ISO,
|
|
14
|
+
offsetIso,
|
|
15
|
+
randomInt,
|
|
16
|
+
seededRandom,
|
|
17
|
+
} from "../../seed";
|
|
18
|
+
import type { CustomerRow } from "../../viewmodels/customers";
|
|
19
|
+
import type { ProductDetail, ProductRow } from "../../viewmodels/products";
|
|
20
|
+
import type {
|
|
21
|
+
SubscriptionAttentionRow,
|
|
22
|
+
SubscriptionCheckoutField,
|
|
23
|
+
SubscriptionEvidenceEvent,
|
|
24
|
+
SubscriptionProgramDetail,
|
|
25
|
+
SubscriptionProgramReadiness,
|
|
26
|
+
SubscriptionProgramRow,
|
|
27
|
+
SubscriptionRenewalEvent,
|
|
28
|
+
SubscriptionSubscriberRow,
|
|
29
|
+
SubscriptionsViewModel,
|
|
30
|
+
} from "../../viewmodels/subscriptions";
|
|
31
|
+
import { getCustomersViewModel } from "./customers";
|
|
32
|
+
import { getProductsViewModel } from "./products";
|
|
33
|
+
|
|
34
|
+
export interface SubscriptionProductSource {
|
|
35
|
+
productId: string;
|
|
36
|
+
productName: string;
|
|
37
|
+
sellable: boolean;
|
|
38
|
+
blocker: string | null;
|
|
39
|
+
basePrice: number;
|
|
40
|
+
purchaseOptions: ProductPurchaseOption[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SUBSCRIPTION_TEMPLATES: SubscriptionPurchaseOption[] = [
|
|
44
|
+
{
|
|
45
|
+
id: "monthly",
|
|
46
|
+
kind: "subscription",
|
|
47
|
+
label: "Subscribe monthly",
|
|
48
|
+
description: "Ships every month.",
|
|
49
|
+
savingsLabel: "Save 15%",
|
|
50
|
+
priceAdjustment: { type: "percent_off", value: 15 },
|
|
51
|
+
frequency: "1m",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "every-60-days",
|
|
55
|
+
kind: "subscription",
|
|
56
|
+
label: "Every 60 days",
|
|
57
|
+
description: "A slower refill cadence.",
|
|
58
|
+
savingsLabel: "Save $5",
|
|
59
|
+
priceAdjustment: { type: "amount_off", value: 5 },
|
|
60
|
+
frequency: "60d",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "setup-review",
|
|
64
|
+
kind: "subscription",
|
|
65
|
+
label: "Autopilot setup review",
|
|
66
|
+
description: "Needs a compact checkout interval before launch.",
|
|
67
|
+
savingsLabel: "Save 10%",
|
|
68
|
+
priceAdjustment: { type: "percent_off", value: 10 },
|
|
69
|
+
frequency: "every month",
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
function frequencyIsValid(option: SubscriptionPurchaseOption): boolean {
|
|
74
|
+
return typeof option.frequency === "string" && isSubscriptionFrequency(option.frequency);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function priceAdjustmentLabel(option: SubscriptionPurchaseOption): string | null {
|
|
78
|
+
const adjustment = option.priceAdjustment;
|
|
79
|
+
if (!adjustment) return null;
|
|
80
|
+
if (adjustment.type === "percent_off") return `${adjustment.value}% off`;
|
|
81
|
+
return `$${adjustment.value.toFixed(2)} off`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readinessFor(
|
|
85
|
+
source: SubscriptionProductSource,
|
|
86
|
+
option: SubscriptionPurchaseOption,
|
|
87
|
+
): { readiness: SubscriptionProgramReadiness; detail: string } {
|
|
88
|
+
if (!frequencyIsValid(option)) {
|
|
89
|
+
return {
|
|
90
|
+
readiness: "needs_setup",
|
|
91
|
+
detail: "Subscription interval must use a compact checkout frequency.",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!source.sellable) {
|
|
95
|
+
return {
|
|
96
|
+
readiness: "blocked",
|
|
97
|
+
detail: source.blocker ?? "Product readiness is blocking subscription launch.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { readiness: "ready", detail: "Ready for checkout." };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildSubscriptionProgramRows(
|
|
104
|
+
sources: SubscriptionProductSource[],
|
|
105
|
+
): SubscriptionProgramRow[] {
|
|
106
|
+
return sources.flatMap((source) =>
|
|
107
|
+
source.purchaseOptions
|
|
108
|
+
.filter((option): option is SubscriptionPurchaseOption => option.kind === "subscription")
|
|
109
|
+
.map((option) => {
|
|
110
|
+
const readiness = readinessFor(source, option);
|
|
111
|
+
const renewalPrice = frequencyIsValid(option)
|
|
112
|
+
? purchaseOptionPrice(source.basePrice, option)
|
|
113
|
+
: source.basePrice;
|
|
114
|
+
return {
|
|
115
|
+
id: `sub:${source.productId}:${option.id}`,
|
|
116
|
+
productId: source.productId,
|
|
117
|
+
productName: source.productName,
|
|
118
|
+
optionId: option.id,
|
|
119
|
+
optionLabel: option.label,
|
|
120
|
+
frequency: option.frequency,
|
|
121
|
+
savingsLabel: option.savingsLabel ?? null,
|
|
122
|
+
priceAdjustmentLabel: priceAdjustmentLabel(option),
|
|
123
|
+
readiness: readiness.readiness,
|
|
124
|
+
readinessDetail: readiness.detail,
|
|
125
|
+
subscriberCount: 0,
|
|
126
|
+
upcomingRenewalValue: renewalPrice,
|
|
127
|
+
needsAttentionCount: readiness.readiness === "ready" ? 0 : 1,
|
|
128
|
+
};
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function purchaseOptionsForProduct(
|
|
134
|
+
index: number,
|
|
135
|
+
firstSellableIndex: number,
|
|
136
|
+
): ProductPurchaseOption[] {
|
|
137
|
+
const options: ProductPurchaseOption[] = [DEFAULT_ONE_TIME_PURCHASE_OPTION];
|
|
138
|
+
const template = SUBSCRIPTION_TEMPLATES[index];
|
|
139
|
+
if (template) options.push(template);
|
|
140
|
+
if (index === firstSellableIndex && template?.id !== "monthly") {
|
|
141
|
+
options.push({ ...SUBSCRIPTION_TEMPLATES[0]!, id: "monthly-ready" });
|
|
142
|
+
}
|
|
143
|
+
return options;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sourceFromProduct(
|
|
147
|
+
row: ProductRow,
|
|
148
|
+
detail: ProductDetail,
|
|
149
|
+
index: number,
|
|
150
|
+
firstSellableIndex: number,
|
|
151
|
+
): SubscriptionProductSource {
|
|
152
|
+
return {
|
|
153
|
+
productId: row.id,
|
|
154
|
+
productName: row.name,
|
|
155
|
+
sellable: row.sellable,
|
|
156
|
+
blocker: row.sellable ? null : `${row.missingExciseCount} catalog readiness gap(s)`,
|
|
157
|
+
basePrice: detail.variants[0]?.price ?? 0,
|
|
158
|
+
purchaseOptions: purchaseOptionsForProduct(index, firstSellableIndex),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function checkoutFieldsFor(
|
|
163
|
+
option: SubscriptionPurchaseOption,
|
|
164
|
+
): SubscriptionCheckoutField[] {
|
|
165
|
+
const fields: SubscriptionCheckoutField[] = [
|
|
166
|
+
{ name: "Purchase option", value: option.label },
|
|
167
|
+
{ name: "sub_frequency", value: option.frequency },
|
|
168
|
+
];
|
|
169
|
+
if (option.startDate) fields.push({ name: "sub_startdate", value: option.startDate });
|
|
170
|
+
if (option.endDate) fields.push({ name: "sub_enddate", value: option.endDate });
|
|
171
|
+
if (option.savingsLabel) fields.push({ name: "Savings", value: option.savingsLabel });
|
|
172
|
+
return fields;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setupAttentionFor(
|
|
176
|
+
row: SubscriptionProgramRow,
|
|
177
|
+
source: SubscriptionProductSource,
|
|
178
|
+
): SubscriptionAttentionRow[] {
|
|
179
|
+
const attention: SubscriptionAttentionRow[] = [];
|
|
180
|
+
if (!isSubscriptionFrequency(row.frequency)) {
|
|
181
|
+
attention.push({
|
|
182
|
+
id: `${row.id}:setup-frequency`,
|
|
183
|
+
kind: "setup_gap",
|
|
184
|
+
programId: row.id,
|
|
185
|
+
productId: row.productId,
|
|
186
|
+
customerId: null,
|
|
187
|
+
label: "Subscription interval needs setup",
|
|
188
|
+
detail: `${row.optionLabel} uses "${row.frequency}". Use a compact interval such as 1m, 60d, or 2w.`,
|
|
189
|
+
action: "Review purchase option",
|
|
190
|
+
tone: "amber",
|
|
191
|
+
blocking: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (!source.sellable) {
|
|
195
|
+
attention.push({
|
|
196
|
+
id: `${row.id}:product-readiness`,
|
|
197
|
+
kind: "product_blocker",
|
|
198
|
+
programId: row.id,
|
|
199
|
+
productId: row.productId,
|
|
200
|
+
customerId: null,
|
|
201
|
+
label: "Product readiness blocks subscription",
|
|
202
|
+
detail: source.blocker ?? "Resolve product readiness before this can sell.",
|
|
203
|
+
action: "Open product readiness",
|
|
204
|
+
tone: "red",
|
|
205
|
+
blocking: true,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return attention;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function statusForSubscriber(
|
|
212
|
+
programIndex: number,
|
|
213
|
+
subscriberIndex: number,
|
|
214
|
+
): SubscriptionSubscriberRow["status"] {
|
|
215
|
+
if (programIndex === 0 && subscriberIndex === 0) return "payment_attention";
|
|
216
|
+
if (programIndex === 1 && subscriberIndex === 0) return "verification_hold";
|
|
217
|
+
return "active";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function subscribersForProgram(
|
|
221
|
+
targetTenant: string,
|
|
222
|
+
row: SubscriptionProgramRow,
|
|
223
|
+
programIndex: number,
|
|
224
|
+
customers: CustomerRow[],
|
|
225
|
+
rng: () => number,
|
|
226
|
+
): SubscriptionSubscriberRow[] {
|
|
227
|
+
if (row.readiness === "needs_setup") return [];
|
|
228
|
+
const count = Math.min(customers.length, row.readiness === "ready" ? randomInt(rng, 2, 4) : randomInt(rng, 1, 2));
|
|
229
|
+
return Array.from({ length: count }, (_, index) => {
|
|
230
|
+
const customer = customers[(programIndex + index) % customers.length]!;
|
|
231
|
+
const status = statusForSubscriber(programIndex, index);
|
|
232
|
+
return {
|
|
233
|
+
id: `sub-instance:${targetTenant}:${row.optionId}:${customer.id}`,
|
|
234
|
+
programId: row.id,
|
|
235
|
+
customerId: customer.id,
|
|
236
|
+
customerName: customer.name,
|
|
237
|
+
email: customer.email,
|
|
238
|
+
status,
|
|
239
|
+
verificationState:
|
|
240
|
+
status === "verification_hold" ? "pending" : customer.verificationState,
|
|
241
|
+
paymentState: status === "payment_attention" ? "attention" : "current",
|
|
242
|
+
nextRenewalAt: offsetIso(MOCK_ANCHOR_ISO, randomInt(rng, 1_440, 35_000)),
|
|
243
|
+
renewalValue: row.upcomingRenewalValue,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function subscriberAttentionFor(
|
|
249
|
+
row: SubscriptionProgramRow,
|
|
250
|
+
subscriber: SubscriptionSubscriberRow,
|
|
251
|
+
): SubscriptionAttentionRow | null {
|
|
252
|
+
if (subscriber.status === "payment_attention") {
|
|
253
|
+
return {
|
|
254
|
+
id: `${subscriber.id}:payment`,
|
|
255
|
+
kind: "payment_attention",
|
|
256
|
+
programId: row.id,
|
|
257
|
+
productId: row.productId,
|
|
258
|
+
customerId: subscriber.customerId,
|
|
259
|
+
label: "Payment attention",
|
|
260
|
+
detail: `${subscriber.customerName} needs payment follow-up before the next renewal.`,
|
|
261
|
+
action: "Open customer",
|
|
262
|
+
tone: "amber",
|
|
263
|
+
blocking: true,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (subscriber.status === "verification_hold") {
|
|
267
|
+
return {
|
|
268
|
+
id: `${subscriber.id}:verification`,
|
|
269
|
+
kind: "verification_hold",
|
|
270
|
+
programId: row.id,
|
|
271
|
+
productId: row.productId,
|
|
272
|
+
customerId: subscriber.customerId,
|
|
273
|
+
label: "Verification hold",
|
|
274
|
+
detail: `${subscriber.customerName} needs age/identity verification before renewal.`,
|
|
275
|
+
action: "Open customer",
|
|
276
|
+
tone: "red",
|
|
277
|
+
blocking: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renewalEventsFor(subscribers: SubscriptionSubscriberRow[]): SubscriptionRenewalEvent[] {
|
|
284
|
+
return subscribers.map((subscriber) => ({
|
|
285
|
+
id: `renewal:${subscriber.id}`,
|
|
286
|
+
customerId: subscriber.customerId,
|
|
287
|
+
customerName: subscriber.customerName,
|
|
288
|
+
at: subscriber.nextRenewalAt,
|
|
289
|
+
amount: subscriber.renewalValue,
|
|
290
|
+
state:
|
|
291
|
+
subscriber.status === "active"
|
|
292
|
+
? "scheduled"
|
|
293
|
+
: subscriber.status === "payment_attention"
|
|
294
|
+
? "attention"
|
|
295
|
+
: "held",
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function evidenceFor(
|
|
300
|
+
row: SubscriptionProgramRow,
|
|
301
|
+
subscribers: SubscriptionSubscriberRow[],
|
|
302
|
+
): SubscriptionEvidenceEvent[] {
|
|
303
|
+
const events: SubscriptionEvidenceEvent[] = [
|
|
304
|
+
{
|
|
305
|
+
title: "Catalog purchase option detected",
|
|
306
|
+
detail: `${row.optionLabel} is available on ${row.productName}.`,
|
|
307
|
+
at: offsetIso(MOCK_ANCHOR_ISO, -18_000),
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
title: "Checkout payload reviewed",
|
|
311
|
+
detail: `Subscription frequency ${row.frequency} is included in the signed cart payload.`,
|
|
312
|
+
at: offsetIso(MOCK_ANCHOR_ISO, -9_000),
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
if (subscribers.length > 0) {
|
|
316
|
+
events.push({
|
|
317
|
+
title: "Subscriber activity imported",
|
|
318
|
+
detail: `${subscribers.length} customer subscription record(s) linked to this program.`,
|
|
319
|
+
at: offsetIso(MOCK_ANCHOR_ISO, -1_800),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return events;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function computeSubscriptionsViewModel(envelope: AdminTenantEnvelope): SubscriptionsViewModel {
|
|
326
|
+
const { targetTenant } = envelope;
|
|
327
|
+
const rng = seededRandom(`subscriptions:${targetTenant}`);
|
|
328
|
+
const products = getProductsViewModel(envelope);
|
|
329
|
+
const customers = getCustomersViewModel(envelope);
|
|
330
|
+
const firstSellableIndex = products.rows.findIndex((row) => row.sellable);
|
|
331
|
+
const sources = products.rows.map((row, index) =>
|
|
332
|
+
sourceFromProduct(row, products.detailsById[row.id]!, index, firstSellableIndex),
|
|
333
|
+
);
|
|
334
|
+
const baseRows = buildSubscriptionProgramRows(sources);
|
|
335
|
+
const sourceByProgramId = new Map(
|
|
336
|
+
baseRows.map((row) => [
|
|
337
|
+
row.id,
|
|
338
|
+
sources.find((source) => source.productId === row.productId)!,
|
|
339
|
+
]),
|
|
340
|
+
);
|
|
341
|
+
const optionByProgramId = new Map<string, SubscriptionPurchaseOption>(
|
|
342
|
+
sources.flatMap((source) =>
|
|
343
|
+
source.purchaseOptions
|
|
344
|
+
.filter((option): option is SubscriptionPurchaseOption => option.kind === "subscription")
|
|
345
|
+
.map((option) => [`sub:${source.productId}:${option.id}`, option] as const),
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const programs: SubscriptionProgramRow[] = [];
|
|
350
|
+
const detailsById: Record<string, SubscriptionProgramDetail> = {};
|
|
351
|
+
const allAttention: SubscriptionAttentionRow[] = [];
|
|
352
|
+
|
|
353
|
+
baseRows.forEach((baseRow, index) => {
|
|
354
|
+
const source = sourceByProgramId.get(baseRow.id)!;
|
|
355
|
+
const option = optionByProgramId.get(baseRow.id)!;
|
|
356
|
+
const subscribers = subscribersForProgram(targetTenant, baseRow, index, customers.directory, rng);
|
|
357
|
+
const setupAttention = setupAttentionFor(baseRow, source);
|
|
358
|
+
const subscriberAttention = subscribers
|
|
359
|
+
.map((subscriber) => subscriberAttentionFor(baseRow, subscriber))
|
|
360
|
+
.filter((row): row is SubscriptionAttentionRow => row !== null);
|
|
361
|
+
const attention = [...setupAttention, ...subscriberAttention];
|
|
362
|
+
const upcomingRenewals = renewalEventsFor(subscribers);
|
|
363
|
+
const upcomingRenewalValue = upcomingRenewals
|
|
364
|
+
.filter((event) => event.state === "scheduled")
|
|
365
|
+
.reduce((sum, event) => sum + event.amount, 0);
|
|
366
|
+
const row: SubscriptionProgramRow = {
|
|
367
|
+
...baseRow,
|
|
368
|
+
subscriberCount: subscribers.length,
|
|
369
|
+
upcomingRenewalValue,
|
|
370
|
+
needsAttentionCount: attention.length,
|
|
371
|
+
};
|
|
372
|
+
programs.push(row);
|
|
373
|
+
allAttention.push(...attention);
|
|
374
|
+
detailsById[row.id] = {
|
|
375
|
+
...row,
|
|
376
|
+
checkoutFields: checkoutFieldsFor(option),
|
|
377
|
+
subscribers,
|
|
378
|
+
upcomingRenewals,
|
|
379
|
+
evidenceTimeline: evidenceFor(row, subscribers),
|
|
380
|
+
attention,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
targetTenant,
|
|
386
|
+
summary: {
|
|
387
|
+
subscriptionProducts: programs.length,
|
|
388
|
+
activeSubscribers: programs.reduce((sum, row) => sum + row.subscriberCount, 0),
|
|
389
|
+
upcomingRenewalValue: programs.reduce((sum, row) => sum + row.upcomingRenewalValue, 0),
|
|
390
|
+
needsAttention: allAttention.length,
|
|
391
|
+
},
|
|
392
|
+
programs,
|
|
393
|
+
detailsById,
|
|
394
|
+
attention: allAttention.sort((a, b) => Number(b.blocking) - Number(a.blocking)),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export const getSubscriptionsViewModel = memoizeByEnvelope(computeSubscriptionsViewModel);
|