diff --git a/apps/api/src/__tests__/Product.spec.ts b/apps/api/src/__tests__/Product.spec.ts new file mode 100644 index 0000000..b14f5c4 --- /dev/null +++ b/apps/api/src/__tests__/Product.spec.ts @@ -0,0 +1,243 @@ +import { ProductModule } from '../modules/Product'; +import { Database } from '../modules/Database'; +import { Product, QueryOperators } from '@zoneless/shared-types'; +import { ListHelper } from '../utils/ListHelper'; +import { + CreateMockDatabase, + DeterministicId, + ResetIdCounter, + GetFixedTimestamp, +} from './Setup'; + +jest.mock('../modules/Database'); +jest.mock('../utils/IdGenerator', () => ({ + GenerateId: jest.fn((prefix: string) => DeterministicId(prefix)), +})); +jest.mock('../utils/Timestamp', () => ({ + Now: jest.fn(() => GetFixedTimestamp()), +})); +jest.mock('../modules/AppConfig', () => ({ + GetAppConfig: jest.fn(() => ({ + dashboardUrl: 'http://localhost:4200', + livemode: false, + appSecret: 'test-secret', + })), +})); + +describe('ProductModule', () => { + let module: ProductModule; + let mockDb: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + ResetIdCounter(); + mockDb = CreateMockDatabase(); + module = new ProductModule(mockDb); + }); + + describe('ProductObject', () => { + it('should create a product with sensible defaults', () => { + const product = module.ProductObject('acct_z_platform', { + name: 'Test Product', + }); + + expect(product.object).toBe('product'); + expect(product.name).toBe('Test Product'); + expect(product.platform_account).toBe('acct_z_platform'); + expect(product.description).toBeNull(); + expect(product.images).toEqual([]); + expect(product.marketing_features).toEqual([]); + expect(product.livemode).toBe(false); + expect(product.metadata).toEqual({}); + expect(product.tax_code).toBeNull(); + expect(product.default_price).toBeNull(); + expect(product.unit_label).toBeNull(); + expect(product.url).toBeNull(); + }); + + it('should accept provided input fields', () => { + const product = module.ProductObject('acct_z_platform', { + name: 'Test Product', + description: 'Test Description', + images: ['https://example.com/image.jpg'], + marketing_features: [{ name: 'Feature 1' }], + package_dimensions: { height: 10, length: 10, weight: 10, width: 10 }, + shippable: true, + statement_descriptor: 'Test Statement Descriptor', + tax_code: 'Test Tax Code', + unit_label: 'Test Unit Label', + url: 'https://example.com/product', + }); + + expect(product.name).toBe('Test Product'); + expect(product.description).toBe('Test Description'); + expect(product.images).toEqual(['https://example.com/image.jpg']); + expect(product.marketing_features).toEqual([{ name: 'Feature 1' }]); + expect(product.package_dimensions).toEqual({ + height: 10, + length: 10, + weight: 10, + width: 10, + }); + expect(product.shippable).toBe(true); + expect(product.statement_descriptor).toBe('Test Statement Descriptor'); + expect(product.tax_code).toBe('Test Tax Code'); + expect(product.unit_label).toBe('Test Unit Label'); + expect(product.url).toBe('https://example.com/product'); + }); + }); + + describe('CreateProduct', () => { + it('should persist the product to the database', async () => { + const product = await module.CreateProduct('acct_z_platform', { + name: 'Test Product', + }); + expect(mockDb.Set).toHaveBeenCalledTimes(1); + expect(mockDb.Set).toHaveBeenCalledWith('Products', product.id, product); + }); + }); + + describe('UpdateProduct', () => { + it('should update product metadata', async () => { + const existingProduct = { + id: 'prod_z_1', + object: 'product', + platform_account: 'acct_z_1', + metadata: {}, + } as Product; + mockDb.Get = jest.fn().mockResolvedValue(existingProduct); + + const result = await module.UpdateProduct('prod_z_1', { + metadata: { tracking_number: '12345' }, + }); + + expect(mockDb.Update).toHaveBeenCalledWith( + 'Products', + 'prod_z_1', + expect.objectContaining({ metadata: { tracking_number: '12345' } }) + ); + expect(result).toEqual(existingProduct); + }); + + it('should throw when product not found', async () => { + await expect( + module.UpdateProduct('nonexistent', { metadata: {} }) + ).rejects.toThrow('Product not found'); + }); + }); + + describe('GetProduct', () => { + it('should return the product when found', async () => { + const mockProduct = { id: 'prod_z_1', object: 'product' } as Product; + mockDb.Get = jest.fn().mockResolvedValue(mockProduct); + + const result = await module.GetProduct('prod_z_1'); + expect(result).toEqual(mockProduct); + }); + + it('should return null when not found', async () => { + const result = await module.GetProduct('nonexistent'); + expect(result).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // DeleteProduct + // ----------------------------------------------------------------------- + describe('DeleteProduct', () => { + it('should delete the product and return confirmation', async () => { + mockDb.Get = jest + .fn() + .mockResolvedValue({ id: 'prod_z_1', object: 'product' }); + + const result = await module.DeleteProduct('prod_z_1'); + + expect(mockDb.Delete).toHaveBeenCalledWith('Products', 'prod_z_1'); + expect(result).toEqual({ + id: 'prod_z_1', + object: 'product', + deleted: true, + }); + }); + + it('should throw when product not found', async () => { + await expect(module.DeleteProduct('nonexistent')).rejects.toThrow( + 'Product not found' + ); + }); + }); + + // ----------------------------------------------------------------------- + // ListProducts + // ----------------------------------------------------------------------- + describe('ListProducts', () => { + it('should pass account and product filters to ListHelper', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/products', + }); + await module.ListProducts({ + account: 'acct_z_platform', + limit: 25, + active: true, + shippable: false, + ids: ['prod_z_1', 'prod_z_2'], + url: 'https://example.com/p', + }); + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + account: 'acct_z_platform', + limit: 25, + filters: expect.objectContaining({ + active: true, + shippable: false, + url: 'https://example.com/p', + id: { + operator: QueryOperators['in'], + value: ['prod_z_1', 'prod_z_2'], + }, + }), + }) + ); + listSpy.mockRestore(); + }); + it('should omit optional filters when not provided', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/products', + }); + await module.ListProducts({ account: 'acct_z_platform' }); + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + filters: {}, + }) + ); + listSpy.mockRestore(); + }); + it('should not add ids filter when ids is empty', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/products', + }); + await module.ListProducts({ account: 'acct_z_platform', ids: [] }); + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.not.objectContaining({ id: expect.anything() }), + }) + ); + listSpy.mockRestore(); + }); + }); +}); diff --git a/apps/api/src/modules/Product.ts b/apps/api/src/modules/Product.ts new file mode 100644 index 0000000..8938c1c --- /dev/null +++ b/apps/api/src/modules/Product.ts @@ -0,0 +1,220 @@ +/** + * @fileOverview Methods for Products + * + * + * @module Product + */ + +import { Database } from './Database'; +import { EventService } from './EventService'; +import { GenerateId } from '../utils/IdGenerator'; +import { ExtractChangedFields } from './Event'; +import { + Product as ProductType, + ProductDeleted, + QueryOperators, +} from '@zoneless/shared-types'; +import { ValidateUpdate } from './Util'; +import { + CreateProductSchema, + CreateProductInput, + UpdateProductSchema, + UpdateProductInput, + ListProductsFiltersInput, +} from '../schemas/ProductSchema'; +import { ListHelper, ListOptions, ListResult } from '../utils/ListHelper'; +import { Now } from '../utils/Timestamp'; +import { GetAppConfig } from './AppConfig'; +import { AppError } from '../utils/AppError'; +import { ERRORS } from '../utils/Errors'; + +export class ProductModule { + private readonly db: Database; + private readonly eventService: EventService | null; + private readonly listHelper: ListHelper; + + constructor(db: Database, eventService?: EventService) { + this.db = db; + this.eventService = eventService || null; + this.listHelper = new ListHelper(db, { + collection: 'Products', + orderByField: 'created', + orderDirection: 'desc', + urlPath: '/v1/products', + accountField: 'platform_account', + }); + } + + /** + * Create a new product. + * + * @param platformAccountId - The platform account ID + * @param input - The input data for the product + * @returns The created product + */ + async CreateProduct( + platformAccountId: string, + input: CreateProductInput + ): Promise { + const validatedInput = ValidateUpdate(CreateProductSchema, input); + + const product = this.ProductObject(platformAccountId, validatedInput); + await this.db.Set('Products', product.id, product); + + if (this.eventService) { + await this.eventService.Emit( + 'product.created', + product.platform_account, + product + ); + } + return product; + } + + ProductObject( + platformAccountId: string, + input: CreateProductInput + ): ProductType { + const product: ProductType = { + id: GenerateId('prod_z'), + object: 'product', + active: input.active ?? true, + created: Now(), + default_price: input.default_price ?? null, + description: input.description ?? null, + images: input.images ?? [], + marketing_features: input.marketing_features ?? [], + livemode: GetAppConfig().livemode, + metadata: input.metadata ?? {}, + name: input.name, + package_dimensions: input.package_dimensions ?? null, + shippable: input.shippable ?? null, + statement_descriptor: input.statement_descriptor ?? null, + tax_code: input.tax_code ?? null, + unit_label: input.unit_label ?? null, + updated: Now(), + url: input.url ?? null, + platform_account: platformAccountId, + }; + return product; + } + + /** + * Get a product by its ID. + * + * @param id - The product ID + * @returns The Product if found, null otherwise + */ + async GetProduct(id: string): Promise { + return this.db.Get('Products', id); + } + + /** + * Update a product. + * Emits an 'product.updated' event if EventService is configured. + * + * @param id - The Product ID + * @param input - The fields to update + * @returns The updated Product + */ + async UpdateProduct( + id: string, + input: UpdateProductInput + ): Promise { + const validatedUpdate = ValidateUpdate(UpdateProductSchema, input); + + // Get previous state for the event (before update) + const previousProduct = this.eventService + ? await this.GetProduct(id) + : null; + + await this.db.Update('Products', id, validatedUpdate); + + const product = await this.GetProduct(id); + if (!product) { + throw new Error('Product not found after update'); + } + + // Emit product.updated event + if (this.eventService && previousProduct) { + const previousAttributes = ExtractChangedFields( + previousProduct as unknown as Record, + validatedUpdate as Record + ); + + await this.eventService.Emit( + 'product.updated', + product.platform_account, + product, + { + previousAttributes, + } + ); + } + + return product; + } + + /** + * Delete a product. + * Emits an 'product.deleted' event if EventService is configured. + * + * @param id - The product ID + * @returns Deletion confirmation object + */ + async DeleteProduct(id: string): Promise { + // Get the product before deletion for the event + const product = await this.GetProduct(id); + + if (!product) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + await this.db.Delete('Products', id); + + // Emit product.deleted event + if (this.eventService && product) { + await this.eventService.Emit( + 'product.deleted', + product.platform_account, + product + ); + } + + return { + id, + object: 'product', + deleted: true, + }; + } + + /** + * List products + */ + async ListProducts( + options: ListOptions & ListProductsFiltersInput + ): Promise> { + const { active, shippable, ids, url, ...listOptions } = options; + + // Build filters + const filters: Record = {}; + if (active !== undefined) filters.active = active; + if (shippable !== undefined) filters.shippable = shippable; + if (ids?.length) { + filters.id = { + operator: QueryOperators['in'], + value: ids, + }; + } + if (url) filters.url = url; + + return this.listHelper.List({ + ...listOptions, + filters: { ...listOptions.filters, ...filters }, + }); + } +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 1ec52b7..6dad79a 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -19,6 +19,7 @@ import authExchangeRouter from './exchange.routes'; import configRouter from './config.routes'; import setupRouter from './setup.routes'; import subscriptionsRouter from './subscriptions.routes'; +import productsRouter from './products.routes'; const router = express.Router(); @@ -51,4 +52,5 @@ router.use('/webhook_endpoints', webhookEndpointsRouter); router.use('/api_keys', apiKeysRouter); router.use('/events', eventsRouter); router.use('/subscriptions', subscriptionsRouter); +router.use('/products', productsRouter); export default router; diff --git a/apps/api/src/routes/products.routes.ts b/apps/api/src/routes/products.routes.ts new file mode 100644 index 0000000..e5c198b --- /dev/null +++ b/apps/api/src/routes/products.routes.ts @@ -0,0 +1,229 @@ +import * as express from 'express'; +import { AsyncHandler } from '../utils/AsyncHandler'; +import { AppError } from '../utils/AppError'; +import { ERRORS } from '../utils/Errors'; +import { Logger } from '../utils/Logger'; +import { + ParseCreatedFilter, + ParseOptionalQueryBoolean, +} from '../utils/ListHelper'; + +import { db } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { ProductModule } from '../modules/Product'; + +import { ValidateRequest } from '../middleware/ValidateRequest'; +import { RequirePlatform } from '../middleware/Authorization'; + +import { + CreateProductSchema, + UpdateProductSchema, +} from '../schemas/ProductSchema'; + +const router = express.Router(); + +const eventService = new EventService(db); +const productModule = new ProductModule(db, eventService); + +/** + * POST /v1/products + * Create a new product. + */ +router.post( + '/', + RequirePlatform(), + ValidateRequest(CreateProductSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + + Logger.info('Creating product', { + platformAccountId, + fields: Object.keys(req.body), + }); + + const product = await productModule.CreateProduct( + platformAccountId, + req.body + ); + + Logger.info('Product created successfully', { + productId: product.id, + }); + + res.status(201).json(product); + }) +); + +/** + * POST /v1/products/:id + * Update a product. + */ +router.post( + '/:id', + RequirePlatform(), + ValidateRequest(UpdateProductSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + // Verify the API key exists and belongs to this platform + const existingProduct = await productModule.GetProduct(id); + + // Product exists + if (!existingProduct) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + // Product belongs to this platform + if (existingProduct.platform_account !== platformAccountId) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + Logger.info('Updating Product', { + productId: id, + fields: Object.keys(req.body), + }); + + const updatedProduct = await productModule.UpdateProduct(id, req.body); + + Logger.info('Product updated successfully', { + productId: updatedProduct.id, + }); + + res.json(updatedProduct); + }) +); + +/** + * GET /v1/products/:id + * Retrieve a product. + */ +router.get( + '/:id', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + const product = await productModule.GetProduct(id); + + if (!product) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + // Verify the API key belongs to this platform + if (product.platform_account !== platformAccountId) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + res.json(product); + }) +); + +/** + * DELETE /v1/products/:id + * Delete a product. + */ +router.delete( + '/:id', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + // Verify the API key exists and belongs to this platform + const existingProduct = await productModule.GetProduct(id); + + if (!existingProduct) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + if (existingProduct.platform_account !== platformAccountId) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + Logger.info('Deleting Product', { productId: id }); + + const result = await productModule.DeleteProduct(id); + + Logger.info('Product deleted successfully', { productId: id }); + + res.json(result); + }) +); + +/** + * GET /v1/products + * Returns a list of products + */ +router.get( + '/', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + + Logger.info('Listing products', { platformAccountId }); + + //Parse query parameters + const limit = req.query.limit + ? parseInt(req.query.limit as string, 10) + : 10; + const startingAfter = req.query.starting_after as string | undefined; + const endingBefore = req.query.ending_before as string | undefined; + const created = ParseCreatedFilter(req.query as Record); + + const active = ParseOptionalQueryBoolean(req.query.active); + const shippable = ParseOptionalQueryBoolean(req.query.shippable); + const url = req.query.url as string | undefined; + let ids: string[] | undefined = undefined; + if (req.query.ids) { + ids = (req.query.ids as string).split(','); + } + + const result = await productModule.ListProducts({ + account: platformAccountId, + limit, + startingAfter, + endingBefore, + created, + active, + shippable, + ids, + url, + }); + + Logger.info('Products listed successfully', { + platformAccountId, + count: result.data.length, + hasMore: result.has_more, + }); + + res.json(result); + }) +); + +export default router; diff --git a/apps/api/src/schemas/ProductSchema.ts b/apps/api/src/schemas/ProductSchema.ts new file mode 100644 index 0000000..c1488db --- /dev/null +++ b/apps/api/src/schemas/ProductSchema.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; + +const PackageDimensionsSchema = z.object({ + height: z.number().min(0).max(100000), + length: z.number().min(0).max(100000), + weight: z.number().min(0).max(100000), + width: z.number().min(0).max(100000), +}); + +const MarketingFeatureSchema = z.object({ + name: z.string().min(1).max(80), +}); + +/** + * Schema for creating a product. Only name is required. + */ +export const CreateProductSchema = z.object({ + name: z.string().min(1).max(200), + active: z.boolean().default(true).optional(), + description: z.string().max(40000).optional(), + id: z.string().min(1).max(32).optional(), + metadata: z.record(z.string(), z.string()).optional(), + tax_code: z.string().optional(), + default_price: z.any().optional(), //TODO: add price schema + images: z.array(z.string()).max(8).optional(), + marketing_features: z.array(MarketingFeatureSchema).max(15).optional(), + package_dimensions: PackageDimensionsSchema.optional(), + shippable: z.boolean().optional(), + statement_descriptor: z.string().max(22).optional(), + unit_label: z.string().optional(), + url: z.string().url().optional(), +}); + +export type CreateProductInput = z.infer; + +/** + * Schema for updating a product. + */ +export const UpdateProductSchema = z.object({ + active: z.boolean().optional(), + default_price: z.string().optional(), //TODO: add price schema + description: z.string().max(40000).optional(), + metadata: z.record(z.string(), z.string()).optional(), + name: z.string().min(1).max(200).optional(), + tax_code: z.string().optional(), + images: z.array(z.string()).max(8).optional(), + marketing_features: z.array(MarketingFeatureSchema).max(15).optional(), + package_dimensions: PackageDimensionsSchema.optional(), + shippable: z.boolean().optional(), + statement_descriptor: z.string().max(22).optional(), + unit_label: z.string().optional(), + url: z.string().url().optional(), +}); + +export type UpdateProductInput = z.infer; + +/** + * Schema for listing products + */ +export const ListProductsSchema = z.object({ + active: z.boolean().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(), + ids: z.array(z.string()).optional(), + limit: z.number().int().min(1).max(100).optional(), + shippable: z.boolean().optional(), + starting_after: z.string().optional(), + url: z.string().url().optional(), +}); +export type ListProductsInput = z.infer; + +export const ListProductsFiltersSchema = z.object({ + active: z.boolean().optional(), + shippable: z.boolean().optional(), + ids: z.array(z.string()).optional(), + url: z.string().url().optional(), +}); +export type ListProductsFiltersInput = z.infer< + typeof ListProductsFiltersSchema +>; diff --git a/apps/api/src/utils/Errors.ts b/apps/api/src/utils/Errors.ts index ba2f9f9..06c8c3a 100644 --- a/apps/api/src/utils/Errors.ts +++ b/apps/api/src/utils/Errors.ts @@ -119,6 +119,11 @@ export const ERRORS = { status: 404, type: 'invalid_request_error', }, + PRODUCT_NOT_FOUND: { + message: 'Product not found', + status: 404, + type: 'resource_missing', + }, // Validation errors VALIDATION_ERROR: { diff --git a/apps/api/src/utils/ListHelper.ts b/apps/api/src/utils/ListHelper.ts index 94761d2..0e52e57 100644 --- a/apps/api/src/utils/ListHelper.ts +++ b/apps/api/src/utils/ListHelper.ts @@ -245,6 +245,7 @@ export type TimestampFilter = * Parse a generic timestamp filter from request query parameters. * Supports simple timestamp format: ?field=123 * And object notation: ?field[gt]=123&field[gte]=456 + * And nested objects from Express/qs: field: { gt, gte, lt, lte } * * @param query - The query parameters object * @param fieldName - The name of the timestamp field (e.g., 'created', 'arrival_date') @@ -287,9 +288,44 @@ export function ParseTimestampFilter( filter.lte = parseInt(String(query[`${fieldName}[lte]`]), 10); } + MergeNestedTimestampBounds(query[fieldName], filter); + return Object.keys(filter).length > 0 ? filter : undefined; } +/** + * Reads gt/gte/lt/lte from a nested query object (Express qs shape for field[gt]=…). + */ +function MergeNestedTimestampBounds( + raw: unknown, + filter: { + gt?: number; + gte?: number; + lt?: number; + lte?: number; + } +): void { + const timestampBoundKeys = ['gt', 'gte', 'lt', 'lte'] as const; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return; + } + const o = raw as Record; + for (const key of timestampBoundKeys) { + const v = o[key]; + if (v === undefined || v === null || v === '') { + continue; + } + const first = Array.isArray(v) && v.length > 0 ? v[0] : v; + if (first === undefined || first === '') { + continue; + } + const n = parseInt(String(first), 10); + if (Number.isFinite(n)) { + filter[key] = n; + } + } +} + /** * Check if query has bracket notation for a field */ @@ -317,3 +353,29 @@ export function ParseCreatedFilter( ): ListOptions['created'] | undefined { return ParseTimestampFilter(query, 'created'); } + +/** + * Parse optional boolean from Express query values (strings or string[]). + * Supports ?key=true and ?key=false. Unknown or empty values yield undefined. + */ +export function ParseOptionalQueryBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null) { + return undefined; + } + const first = + typeof value === 'string' + ? value + : Array.isArray(value) && typeof value[0] === 'string' + ? value[0] + : undefined; + if (first === undefined || first === '') { + return undefined; + } + if (first === 'true') { + return true; + } + if (first === 'false') { + return false; + } + return undefined; +} diff --git a/apps/web/src/app/data/services/webhook-endpoint.service.ts b/apps/web/src/app/data/services/webhook-endpoint.service.ts index f91b36e..4754c0a 100644 --- a/apps/web/src/app/data/services/webhook-endpoint.service.ts +++ b/apps/web/src/app/data/services/webhook-endpoint.service.ts @@ -48,6 +48,9 @@ const EVENT_TYPE_LABELS: Record<(typeof EVENT_TYPES)[number], string> = { 'person.created': 'Person created', 'person.updated': 'Person updated', 'person.deleted': 'Person deleted', + 'product.created': 'Product created', + 'product.updated': 'Product updated', + 'product.deleted': 'Product deleted', 'topup.created': 'Top-up created', 'topup.canceled': 'Top-up canceled', 'topup.failed': 'Top-up failed', diff --git a/libs/shared-types/src/lib/Event.ts b/libs/shared-types/src/lib/Event.ts index c1d5a28..3f68f0e 100644 --- a/libs/shared-types/src/lib/Event.ts +++ b/libs/shared-types/src/lib/Event.ts @@ -7,6 +7,7 @@ import { ExternalWallet } from './ExternalWallet'; import { IdempotencyKey } from './IdempotencyKey'; import { Payout } from './Payout'; import { Person } from './Person'; +import { Product } from './Product'; import { TopUp } from './TopUp'; import { Transfer, TransferReversal } from './Transfer'; @@ -52,6 +53,11 @@ export const EVENT_TYPES = [ 'person.updated', 'person.deleted', + // Product events + 'product.created', + 'product.updated', + 'product.deleted', + // Top-up events 'topup.created', 'topup.canceled', @@ -81,6 +87,7 @@ export type EventDataObject = | IdempotencyKey | Payout | Person + | Product | TopUp | Transfer | TransferReversal diff --git a/libs/shared-types/src/lib/Product.ts b/libs/shared-types/src/lib/Product.ts new file mode 100644 index 0000000..c0b86fc --- /dev/null +++ b/libs/shared-types/src/lib/Product.ts @@ -0,0 +1,78 @@ +/** + * Stripe-compatible Balance Product object for Zoneless. + * Represents a product that can be sold. + * + * @see https://docs.stripe.com/api/products/object + */ +export interface Product { + /** Unique identifier for the object. */ + id: string; + /** String representing the object's type. Objects of the same type share the same value. */ + object: string; + /** Whether the product is currently available for purchase. */ + active: boolean; + /** Time at which the object was created. Measured in seconds since the Unix epoch.*/ + created: number; + /** The ID of the Price object that is the default price for this product.*/ + default_price: string | null; + /** The product’s description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.*/ + description: string | null; + /** A list of up to 8 URLs of images for this product, meant to be displayable to the customer.*/ + images: string[]; + /** A list of up to 15 marketing features for this product. These are displayed in pricing tables.*/ + marketing_features: MarketingFeature[]; + /** If the object exists in live mode, the value is true. If the object exists in test mode, the value is false.*/ + livemode: boolean; + /** Set of key-value pairs that you can attach to an object. This can be useful for storing additional information about the object in a structured format.*/ + metadata: Record; + /** The product’s name, meant to be displayable to the customer.*/ + name: string; + /** The dimensions of this product for shipping purposes.*/ + package_dimensions: PackageDimensions | null; + /** Whether this product is shipped (i.e., physical goods).*/ + shippable: boolean | null; + /** Extra information about a product which will appear on your customer’s statement. In the case that multiple products are billed at once, the first statement descriptor will be used. Only used for subscription payments.*/ + statement_descriptor: string | null; + /** A tax code ID.*/ + tax_code: string | null; + /** A label that represents units of this product. When set, this will be included in customers’ receipts, invoices, Checkout, and the customer portal.*/ + unit_label: string | null; + /** Time at which the object was last updated. Measured in seconds since the Unix epoch.*/ + updated: number; + /** A URL of a publicly-accessible webpage for this product.*/ + url: string | null; + /** + * The platform account that owns this resource. + * @zoneless_extension + */ + platform_account: string; +} + +export interface PackageDimensions { + /** */ + /** Height, in inches.*/ + height: number; + /** Length, in inches.*/ + length: number; + /** Weight, in ounces.*/ + weight: number; + /** Width, in inches.*/ + width: number; +} + +export interface MarketingFeature { + /** The marketing feature name. Up to 80 characters long.*/ + name: string | null; +} + +/** + * Deleted product response object. + */ +export interface ProductDeleted { + /** Unique identifier for the object */ + id: string; + /** String representing the object's type */ + object: 'product'; + /** Always true for a deleted object */ + deleted: true; +} diff --git a/libs/shared-types/src/lib/index.ts b/libs/shared-types/src/lib/index.ts index e0ae8b9..b8a1e54 100644 --- a/libs/shared-types/src/lib/index.ts +++ b/libs/shared-types/src/lib/index.ts @@ -13,6 +13,7 @@ export * from './ExternalWallet'; export * from './IdempotencyKey'; export * from './Payout'; export * from './Person'; +export * from './Product'; export * from './QueryParameters'; export * from './TopUp'; export * from './Transfer';