From de392e0828f03b012c89b5d445adf688d77091c3 Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Fri, 22 May 2026 17:10:48 +0100 Subject: [PATCH] feat: expand product fields on prices --- apps/api/src/modules/Product.ts | 24 ++ apps/api/src/routes/prices.routes.ts | 17 +- .../src/app/data/services/price.service.ts | 6 +- .../services/product-actions.service.ts | 3 +- .../price-detail/price-detail.component.html | 12 +- .../price-detail/price-detail.component.ts | 26 +- libs/shared-schemas/src/lib/PriceSchema.ts | 318 +++++++++--------- libs/shared-types/src/lib/Price.ts | 4 +- 8 files changed, 246 insertions(+), 164 deletions(-) diff --git a/apps/api/src/modules/Product.ts b/apps/api/src/modules/Product.ts index 29543e8..bcfc95c 100644 --- a/apps/api/src/modules/Product.ts +++ b/apps/api/src/modules/Product.ts @@ -127,6 +127,30 @@ export class ProductModule { return this.db.Get('Products', id); } + /** + * Batch-load products by id, scoped to a single platform account. + * Used by the expansion engine. + */ + async BatchGet( + ids: string[], + platformAccount: string + ): Promise> { + if (ids.length === 0) return new Map(); + const products = await this.db.Query({ + collection: 'Products', + method: 'READ', + parameters: [ + { key: 'id', operator: QueryOperators['in'], value: ids }, + { + key: 'platform_account', + operator: QueryOperators['=='], + value: platformAccount, + }, + ], + }); + return new Map(products.map((product) => [product.id, product])); + } + /** * Update a product. * Emits an 'product.updated' event if EventService is configured. diff --git a/apps/api/src/routes/prices.routes.ts b/apps/api/src/routes/prices.routes.ts index 2811c5a..98a8b53 100644 --- a/apps/api/src/routes/prices.routes.ts +++ b/apps/api/src/routes/prices.routes.ts @@ -17,6 +17,7 @@ import { ParseCreatedFilter, ParseOptionalQueryBoolean, } from '../utils/ListHelper'; +import { ApplyExpand, RegisterExpansions } from '../utils/Expand'; const router = express.Router(); @@ -24,6 +25,14 @@ const eventService = new EventService(db); const productModule = new ProductModule(db, eventService); //Pass into price module to enable creating products. const priceModule = new PriceModule(db, eventService, productModule); +RegisterExpansions('price', { + product: { + sourcePath: 'product', + targetObject: 'product', + BatchLoad: (ids, ctx) => productModule.BatchGet(ids, ctx.platformAccount), + }, +}); + /** * POST /v1/prices * Create a new price. @@ -46,7 +55,7 @@ router.post( priceId: price.id, }); - res.status(201).json(price); + res.status(201).json(await ApplyExpand(req, price)); }) ); export default router; @@ -95,7 +104,7 @@ router.post( priceId: updatedPrice.id, }); - res.json(updatedPrice); + res.json(await ApplyExpand(req, updatedPrice)); }) ); @@ -129,7 +138,7 @@ router.get( ); } - res.json(price); + res.json(await ApplyExpand(req, price)); }) ); @@ -189,6 +198,6 @@ router.get( hasMore: result.has_more, }); - res.json(result); + res.json(await ApplyExpand(req, result)); }) ); diff --git a/apps/web/src/app/data/services/price.service.ts b/apps/web/src/app/data/services/price.service.ts index f72b418..410873b 100644 --- a/apps/web/src/app/data/services/price.service.ts +++ b/apps/web/src/app/data/services/price.service.ts @@ -23,6 +23,7 @@ export class PriceService { async UpdatePrice(priceId: string, data: UpdatePriceInput): Promise { this.loading.set(true); + data.expand = ['default_price']; try { const price = await this.api.Call( 'POST', @@ -47,7 +48,10 @@ export class PriceService { async GetPrice(priceId: string): Promise { this.loading.set(true); try { - const price = await this.api.Call('GET', `prices/${priceId}`); + const price = await this.api.Call( + 'GET', + `prices/${priceId}?expand=product` + ); return price; } finally { this.loading.set(false); diff --git a/apps/web/src/app/features/account/products/services/product-actions.service.ts b/apps/web/src/app/features/account/products/services/product-actions.service.ts index d887ffd..30ae991 100644 --- a/apps/web/src/app/features/account/products/services/product-actions.service.ts +++ b/apps/web/src/app/features/account/products/services/product-actions.service.ts @@ -165,7 +165,8 @@ export class ProductActionsService { } async SetDefaultPrice(price: Price): Promise { - const product = await this.productService.UpdateProduct(price.product, { + const productId = price.product as string; + const product = await this.productService.UpdateProduct(productId, { default_price: price.id, }); this.CreateEvent({ type: 'updated', product: product }); diff --git a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html index 44d7ef8..91bc72a 100644 --- a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html +++ b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html @@ -44,7 +44,7 @@

This price has been archived

@if(price.nickname){

{{ price.nickname }}

} @else { -

Price for {{ price.product }}

+

Price for {{ productName() }}

} @if(!price.active){ Archived } @@ -81,7 +81,7 @@

Price for {{ price.product }}

(keydown.enter)="GoToProduct()" (keydown.space)="GoToProduct()" tabindex="0" - >{{ price.product }}{{ productName() }}
@@ -214,7 +214,13 @@

Pricing

Default price - - + + @if(isDefaultPrice()){ + Yes + } @else { + No + } + diff --git a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts index 3c5c49e..3c467f9 100644 --- a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts +++ b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts @@ -6,7 +6,7 @@ import { WritableSignal, } from '@angular/core'; import { DecimalPipe, UpperCasePipe } from '@angular/common'; -import type { Price } from '@zoneless/shared-types'; +import type { Price, Product } from '@zoneless/shared-types'; import { PriceService } from '../../../../../data'; import { PriceActionsService } from '../../services/price-actions.service'; import { PriceActionsHostComponent } from '../../components/price-actions-host/price-actions-host.component'; @@ -49,6 +49,10 @@ export class PriceDetailComponent { archivedBannedOpen: WritableSignal = signal(true); price: WritableSignal = signal(null); + relatedProduct: WritableSignal = signal(null); + productName: WritableSignal = signal(null); + isDefaultPrice: WritableSignal = signal(false); + private sub?: Subscription; popupMenuActions: PopupMenuAction[] = [ @@ -68,6 +72,7 @@ export class PriceDetailComponent { const id = this.route.snapshot.paramMap.get('priceId'); if (!id) return; await this.LoadPrice(id); + this.ConfigureProductDetails(this.price() as Price); this.metaService.SetMetaTitle( this.price()?.nickname || this.price()?.id || 'Price' ); @@ -85,6 +90,23 @@ export class PriceDetailComponent { }); } + ConfigureProductDetails(price: Price): void { + if ( + price.product && + typeof price.product === 'object' && + 'name' in price.product + ) { + const product = price.product as Product; + this.relatedProduct.set(product); + this.productName.set(product.name); + this.isDefaultPrice.set(product.default_price === price.id); + } else if (typeof price.product === 'string') { + this.productName.set(price.product); + } else { + this.productName.set(null); + } + } + private async LoadPrice(id: string): Promise { this.loading.set(true); try { @@ -100,7 +122,7 @@ export class PriceDetailComponent { } GoToProduct(): void { - this.router.navigate(['/account/products', this.price()?.product]); + this.router.navigate(['/account/products', this.relatedProduct()?.id]); } OnEditMetadata(): void { diff --git a/libs/shared-schemas/src/lib/PriceSchema.ts b/libs/shared-schemas/src/lib/PriceSchema.ts index 67d1205..5eb0a74 100644 --- a/libs/shared-schemas/src/lib/PriceSchema.ts +++ b/libs/shared-schemas/src/lib/PriceSchema.ts @@ -1,179 +1,193 @@ import { z } from 'zod'; +import { ExpandableSchema } from './ExpandableSchema'; + +/** + * Schema for retrieving a price. + */ +export const RetrievePriceSchema = ExpandableSchema; +export type RetrievePriceInput = z.infer; /** * Schema for creating a price. */ -export const CreatePriceSchema = z.object({ - currency: z.string().min(1).max(4), - active: z.boolean().default(true).optional(), - metadata: z.record(z.string(), z.string()).optional(), - nickname: z.string().max(22).optional(), - product: z.string().min(1).max(32).optional(), - recurring: z - .object({ - interval: z.enum(['day', 'week', 'month', 'year']), - interval_count: z.number().int().positive().optional(), - trial_period_days: z.number().int().positive().optional(), - usage_type: z.enum(['metered', 'licensed']).optional(), - meter: z.string().optional(), - }) - .optional(), - tax_behavior: z.enum(['exclusive', 'inclusive', 'unspecified']).optional(), - unit_amount: z.number().int().positive(), - billing_scheme: z.enum(['per_unit', 'tiered']).optional(), - currency_options: z - .record( - z.string(), - z.object({ - custom_unit_amount: z +export const CreatePriceSchema = z + .object({ + currency: z.string().min(1).max(4), + active: z.boolean().default(true).optional(), + metadata: z.record(z.string(), z.string()).optional(), + nickname: z.string().max(22).optional(), + product: z.string().min(1).max(32).optional(), + recurring: z + .object({ + interval: z.enum(['day', 'week', 'month', 'year']), + interval_count: z.number().int().positive().optional(), + trial_period_days: z.number().int().positive().optional(), + usage_type: z.enum(['metered', 'licensed']).optional(), + meter: z.string().optional(), + }) + .optional(), + tax_behavior: z.enum(['exclusive', 'inclusive', 'unspecified']).optional(), + unit_amount: z.number().int().positive(), + billing_scheme: z.enum(['per_unit', 'tiered']).optional(), + currency_options: z + .record( + z.string(), + z.object({ + custom_unit_amount: z + .object({ + enabled: z.boolean().default(true), + maximum: z.number().int().positive(), + minimum: z.number().int().positive(), + preset: z.number().int().positive(), + }) + .optional(), + tax_behavior: z + .enum(['exclusive', 'inclusive', 'unspecified']) + .optional(), + tiers: z + .array( + z.object({ + flat_amount: z.number().int().positive(), + flat_amount_decimal: z.string().optional(), + unit_amount: z.number().int().positive().optional(), + unit_amount_decimal: z.string().optional(), + up_to: z.number().int().positive(), + }) + ) + .optional(), + unit_amount: z.number().int().positive().optional(), + unit_amount_decimal: z.string().optional(), + }) + ) + .optional(), + custom_unit_amount: z + .object({ + enabled: z.boolean().default(true), + maximum: z.number().int().positive(), + minimum: z.number().int().positive(), + preset: z.number().int().positive(), + }) + .optional(), + lookup_key: z.string().max(200).optional(), + product_data: z + .object({ + name: z.string().min(1).max(200), + active: z.boolean().default(true).optional(), + metadata: z.record(z.string(), z.string()).optional(), + statement_descriptor: z.string().max(22).optional(), + tax_code: z.string().optional(), + tax_details: z .object({ - enabled: z.boolean().default(true), - maximum: z.number().int().positive(), - minimum: z.number().int().positive(), - preset: z.number().int().positive(), + performance_locations: z.string().optional(), + tax_code: z.string().optional(), }) .optional(), - tax_behavior: z - .enum(['exclusive', 'inclusive', 'unspecified']) - .optional(), - tiers: z - .array( - z.object({ - flat_amount: z.number().int().positive(), - flat_amount_decimal: z.string().optional(), - unit_amount: z.number().int().positive().optional(), - unit_amount_decimal: z.string().optional(), - up_to: z.number().int().positive(), - }) - ) - .optional(), - unit_amount: z.number().int().positive().optional(), - unit_amount_decimal: z.string().optional(), + unit_label: z.string().max(12).optional(), }) - ) - .optional(), - custom_unit_amount: z - .object({ - enabled: z.boolean().default(true), - maximum: z.number().int().positive(), - minimum: z.number().int().positive(), - preset: z.number().int().positive(), - }) - .optional(), - lookup_key: z.string().max(200).optional(), - product_data: z - .object({ - name: z.string().min(1).max(200), - active: z.boolean().default(true).optional(), - metadata: z.record(z.string(), z.string()).optional(), - statement_descriptor: z.string().max(22).optional(), - tax_code: z.string().optional(), - tax_details: z - .object({ - performance_locations: z.string().optional(), - tax_code: z.string().optional(), + .optional(), + tiers: z + .array( + z.object({ + flat_amount: z.number().int().positive(), + flat_amount_decimal: z.string().optional(), + unit_amount: z.number().int().positive().optional(), + unit_amount_decimal: z.string().optional(), + up_to: z.number().int().positive(), }) - .optional(), - unit_label: z.string().max(12).optional(), - }) - .optional(), - tiers: z - .array( - z.object({ - flat_amount: z.number().int().positive(), - flat_amount_decimal: z.string().optional(), - unit_amount: z.number().int().positive().optional(), - unit_amount_decimal: z.string().optional(), - up_to: z.number().int().positive(), + ) + .optional(), + tiers_mode: z.enum(['graduated', 'volume']).optional(), + transfer_lookup_key: z.boolean().optional(), + transform_quantity: z + .object({ + divide_by: z.number().int().positive(), + round: z.enum(['up', 'down']), }) - ) - .optional(), - tiers_mode: z.enum(['graduated', 'volume']).optional(), - transfer_lookup_key: z.boolean().optional(), - transform_quantity: z - .object({ - divide_by: z.number().int().positive(), - round: z.enum(['up', 'down']), - }) - .optional(), - unit_amount_decimal: z.string().optional(), -}); + .optional(), + unit_amount_decimal: z.string().optional(), + }) + .merge(ExpandableSchema); export type CreatePriceInput = z.infer; /** * Schema for updating a price. */ -export const UpdatePriceSchema = z.object({ - active: z.boolean().default(true).optional(), - metadata: z.record(z.string(), z.string()).optional(), - nickname: z.string().max(22).optional(), - tax_behavior: z.enum(['exclusive', 'inclusive', 'unspecified']).optional(), - currency_options: z - .record( - z.string(), - z.object({ - custom_unit_amount: z - .object({ - enabled: z.boolean().default(true), - maximum: z.number().int().positive(), - minimum: z.number().int().positive(), - preset: z.number().int().positive(), - }) - .optional(), - tax_behavior: z - .enum(['exclusive', 'inclusive', 'unspecified']) - .optional(), - tiers: z - .array( - z.object({ - flat_amount: z.number().int().positive(), - flat_amount_decimal: z.string().optional(), - unit_amount: z.number().int().positive().optional(), - unit_amount_decimal: z.string().optional(), - up_to: z.number().int().positive(), +export const UpdatePriceSchema = z + .object({ + active: z.boolean().default(true).optional(), + metadata: z.record(z.string(), z.string()).optional(), + nickname: z.string().max(22).optional(), + tax_behavior: z.enum(['exclusive', 'inclusive', 'unspecified']).optional(), + currency_options: z + .record( + z.string(), + z.object({ + custom_unit_amount: z + .object({ + enabled: z.boolean().default(true), + maximum: z.number().int().positive(), + minimum: z.number().int().positive(), + preset: z.number().int().positive(), }) - ) - .optional(), - unit_amount: z.number().int().positive().optional(), - unit_amount_decimal: z.string().optional(), - }) - ) - .optional(), - lookup_key: z.string().max(200).optional(), - transfer_lookup_key: z.boolean().optional(), -}); + .optional(), + tax_behavior: z + .enum(['exclusive', 'inclusive', 'unspecified']) + .optional(), + tiers: z + .array( + z.object({ + flat_amount: z.number().int().positive(), + flat_amount_decimal: z.string().optional(), + unit_amount: z.number().int().positive().optional(), + unit_amount_decimal: z.string().optional(), + up_to: z.number().int().positive(), + }) + ) + .optional(), + unit_amount: z.number().int().positive().optional(), + unit_amount_decimal: z.string().optional(), + }) + ) + .optional(), + lookup_key: z.string().max(200).optional(), + transfer_lookup_key: z.boolean().optional(), + }) + .merge(ExpandableSchema); export type UpdatePriceInput = z.infer; /** * Schema for listing prices */ -export const ListPricesSchema = z.object({ - active: z.boolean().optional(), - currency: z.string().min(1).max(3).optional(), - product: z.string().min(1).max(32).optional(), - type: z.enum(['one_time', 'recurring']).optional(), - created: z - .object({ - gt: z.number().int().optional(), - gte: z.number().int().optional(), - lt: z.number().int().optional(), - lte: z.number().int().optional(), - }) - .optional(), - ending_before: z.string().optional(), - limit: z.number().int().min(1).max(100).optional(), - lookup_keys: z.array(z.string()).optional(), - recurring: z - .object({ - interval: z.enum(['day', 'week', 'month', 'year']).optional(), - meter: z.string().optional(), - usage_type: z.enum(['metered', 'licensed']).optional(), - }) - .optional(), - starting_after: z.string().optional(), -}); +export const ListPricesSchema = z + .object({ + active: z.boolean().optional(), + currency: z.string().min(1).max(3).optional(), + product: z.string().min(1).max(32).optional(), + type: z.enum(['one_time', 'recurring']).optional(), + created: z + .object({ + gt: z.number().int().optional(), + gte: z.number().int().optional(), + lt: z.number().int().optional(), + lte: z.number().int().optional(), + }) + .optional(), + ending_before: z.string().optional(), + limit: z.number().int().min(1).max(100).optional(), + lookup_keys: z.array(z.string()).optional(), + recurring: z + .object({ + interval: z.enum(['day', 'week', 'month', 'year']).optional(), + meter: z.string().optional(), + usage_type: z.enum(['metered', 'licensed']).optional(), + }) + .optional(), + starting_after: z.string().optional(), + }) + .merge(ExpandableSchema); + export type ListPricesInput = z.infer; export const ListPricesFiltersSchema = z.object({ diff --git a/libs/shared-types/src/lib/Price.ts b/libs/shared-types/src/lib/Price.ts index bca2b4d..da800ee 100644 --- a/libs/shared-types/src/lib/Price.ts +++ b/libs/shared-types/src/lib/Price.ts @@ -1,3 +1,5 @@ +import { Product } from './Product'; + /** * Stripe-compatible Price object for Zoneless. * Represents the price of a product. @@ -16,7 +18,7 @@ export interface Price { /** A brief description of the price, hidden from customers. */ nickname: string | null; /** The ID of the product this price is associated with. */ - product: string; + product: string | Product | null; /** The recurring components of a price such as interval and usage_type. */ recurring: { /** The frequency at which a subscription is billed. One of day, week, month or year. */