From 4c984ee21a22c895ac0b59f8393aa8265a53da56 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Sat, 19 Sep 2026 02:28:33 +0530 Subject: [PATCH 1/2] feat: Add Subscription Items API endpoints --- .../src/__tests__/SubscriptionItem.spec.ts | 273 ++++++++++++++++++ apps/api/src/modules/Subscription.ts | 171 ++++++++++- apps/api/src/routes/index.ts | 2 + .../src/routes/subscriptionItems.routes.ts | 249 ++++++++++++++++ 4 files changed, 688 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/__tests__/SubscriptionItem.spec.ts create mode 100644 apps/api/src/routes/subscriptionItems.routes.ts diff --git a/apps/api/src/__tests__/SubscriptionItem.spec.ts b/apps/api/src/__tests__/SubscriptionItem.spec.ts new file mode 100644 index 0000000..43cdac4 --- /dev/null +++ b/apps/api/src/__tests__/SubscriptionItem.spec.ts @@ -0,0 +1,273 @@ +import { SubscriptionModule } from '../modules/Subscription'; +import { Database } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { CustomerModule } from '../modules/Customer'; +import { PriceModule } from '../modules/Price'; +import { InvoiceModule } from '../modules/Invoice'; +import { ListHelper } from '../utils/ListHelper'; +import { + CreateMockDatabase, + DeterministicId, + ResetIdCounter, + GetFixedTimestamp, +} from './Setup'; +import { AppError } from '../utils/AppError'; + +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', + })), +})); + +const PLATFORM = 'acct_z_platform'; +const CUSTOMER_ID = 'cus_z_1'; +const PRICE_ID = 'price_z_1'; +const SUBSCRIPTION_ID = 'sub_z_1'; + +describe('SubscriptionModule - Subscription Items', () => { + let module: SubscriptionModule; + let mockDb: jest.Mocked; + let eventService: jest.Mocked; + let customerModule: jest.Mocked; + let priceModule: jest.Mocked; + let invoiceModule: jest.Mocked; + let store: Map>; + + function StoreKey(collection: string, id: string): string { + return `${collection}:${id}`; + } + + beforeEach(() => { + jest.clearAllMocks(); + ResetIdCounter(); + store = new Map(); + + mockDb = CreateMockDatabase(); + mockDb.Set = jest.fn(async (_collection, id, doc) => { + store.set(StoreKey(_collection, id), { + ...(doc as Record), + }); + return doc; + }) as typeof mockDb.Set; + mockDb.Get = jest.fn(async (_collection, id) => { + return (store.get(StoreKey(_collection, id)) ?? null) as never; + }) as typeof mockDb.Get; + mockDb.Update = jest.fn(async (_collection, id, patch) => { + const existing = store.get(StoreKey(_collection, id)) ?? {}; + const updated = { ...existing, ...(patch as Record) }; + store.set(StoreKey(_collection, id), updated); + return updated; + }) as typeof mockDb.Update; + mockDb.Delete = jest.fn(async (_collection, id) => { + store.delete(StoreKey(_collection, id)); + return { deletedCount: 1 }; + }) as typeof mockDb.Delete; + mockDb.Query = jest.fn(async ({ collection, parameters }) => { + const items = Array.from(store.entries()) + .filter(([key]) => key.startsWith(`${collection}:`)) + .map(([, value]) => value); + + // Simple mock filter for subscription items query + if (parameters && parameters[0] && parameters[0].key === 'subscription') { + return items.filter( + (i) => i.subscription === parameters[0].value + ) as never; + } + return items as never; + }) as typeof mockDb.Query; + + eventService = { + Emit: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + customerModule = { + GetCustomer: jest.fn().mockResolvedValue({ + id: CUSTOMER_ID, + platform_account: PLATFORM, + }), + } as unknown as jest.Mocked; + + priceModule = { + GetPrice: jest.fn().mockResolvedValue({ + id: PRICE_ID, + platform_account: PLATFORM, + product: 'prod_z_1', + unit_amount: 1099, + unit_amount_decimal: '1099', + currency: 'usdc', + type: 'recurring', + recurring: { + interval: 'month', + interval_count: 1, + }, + }), + } as unknown as jest.Mocked; + + invoiceModule = {} as unknown as jest.Mocked; + + module = new SubscriptionModule( + mockDb, + eventService, + customerModule, + priceModule, + invoiceModule + ); + + // Setup initial subscription in store + store.set(`Subscriptions:${SUBSCRIPTION_ID}`, { + id: SUBSCRIPTION_ID, + object: 'subscription', + platform_account: PLATFORM, + customer: CUSTOMER_ID, + status: 'active', + current_period_start: GetFixedTimestamp(), + current_period_end: GetFixedTimestamp() + 30 * 24 * 60 * 60, + }); + }); + + describe('CreateItem', () => { + it('should create an item and emit customer.subscription.updated', async () => { + const item = await module.CreateItem(PLATFORM, { + subscription: SUBSCRIPTION_ID, + price: PRICE_ID, + quantity: 2, + }); + + expect(item.object).toBe('subscription_item'); + expect(item.subscription).toBe(SUBSCRIPTION_ID); + expect(item.price).toBe(PRICE_ID); + expect(item.quantity).toBe(2); + + const savedItem = store.get(StoreKey('SubscriptionItems', item.id)); + expect(savedItem).toBeDefined(); + + expect(eventService.Emit).toHaveBeenCalledWith( + 'customer.subscription.updated', + PLATFORM, + expect.objectContaining({ id: SUBSCRIPTION_ID }) + ); + }); + }); + + describe('GetItem', () => { + it('should retrieve an item by id', async () => { + store.set(StoreKey('SubscriptionItems', 'si_123'), { + id: 'si_123', + object: 'subscription_item', + platform_account: PLATFORM, + subscription: SUBSCRIPTION_ID, + }); + + const item = await module.GetItem('si_123', PLATFORM); + expect(item).toBeDefined(); + expect(item?.id).toBe('si_123'); + }); + + it('should return null if item belongs to different platform', async () => { + store.set(StoreKey('SubscriptionItems', 'si_123'), { + id: 'si_123', + object: 'subscription_item', + platform_account: 'different_platform', + subscription: SUBSCRIPTION_ID, + }); + + const item = await module.GetItem('si_123', PLATFORM); + expect(item).toBeNull(); + }); + }); + + describe('UpdateItem', () => { + it('should update item attributes and emit customer.subscription.updated', async () => { + store.set(StoreKey('SubscriptionItems', 'si_123'), { + id: 'si_123', + object: 'subscription_item', + platform_account: PLATFORM, + subscription: SUBSCRIPTION_ID, + price: PRICE_ID, + quantity: 1, + }); + + const updated = await module.UpdateItem( + 'si_123', + { quantity: 5 }, + PLATFORM + ); + + expect(updated.quantity).toBe(5); + const savedItem = store.get(StoreKey('SubscriptionItems', 'si_123')); + expect(savedItem?.quantity).toBe(5); + + expect(eventService.Emit).toHaveBeenCalledWith( + 'customer.subscription.updated', + PLATFORM, + expect.objectContaining({ id: SUBSCRIPTION_ID }) + ); + }); + + it('should throw error if item does not exist', async () => { + await expect( + module.UpdateItem('si_missing', { quantity: 5 }, PLATFORM) + ).rejects.toThrow(AppError); + }); + }); + + describe('DeleteItem', () => { + it('should delete the item and emit customer.subscription.updated', async () => { + store.set(StoreKey('SubscriptionItems', 'si_123'), { + id: 'si_123', + object: 'subscription_item', + platform_account: PLATFORM, + subscription: SUBSCRIPTION_ID, + }); + + const result = await module.DeleteItem('si_123', {}, PLATFORM); + + expect(result.deleted).toBe(true); + expect(store.has(StoreKey('SubscriptionItems', 'si_123'))).toBe(false); + + expect(eventService.Emit).toHaveBeenCalledWith( + 'customer.subscription.updated', + PLATFORM, + expect.objectContaining({ id: SUBSCRIPTION_ID }) + ); + }); + }); + + describe('ListItems', () => { + it('should pass options to ListHelper', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/subscription_items', + }); + + await module.ListItems({ + account: PLATFORM, + limit: 10, + subscription: SUBSCRIPTION_ID, + }); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + account: PLATFORM, + limit: 10, + filters: expect.objectContaining({ + subscription: SUBSCRIPTION_ID, + }), + }) + ); + }); + }); +}); diff --git a/apps/api/src/modules/Subscription.ts b/apps/api/src/modules/Subscription.ts index e8c3255..0806961 100644 --- a/apps/api/src/modules/Subscription.ts +++ b/apps/api/src/modules/Subscription.ts @@ -42,10 +42,10 @@ import { MigrateSubscriptionSchema, ResumeSubscriptionInput, ResumeSubscriptionSchema, - SubscriptionCreateItemSchema, - SubscriptionUpdateItemSchema, - UpdateSubscriptionInput, UpdateSubscriptionSchema, + UpdateSubscriptionInput, + CreateSubscriptionItemSchema, + UpdateSubscriptionItemSchema, } from '@zoneless/shared-schemas'; import { z } from 'zod'; import { @@ -53,8 +53,12 @@ import { SECONDS_PER_DAY, } from '../utils/RecurringInterval'; -type CreateItemInput = z.infer; -type UpdateItemInput = z.infer; +type CreateItemInput = z.infer< + typeof import('@zoneless/shared-schemas').SubscriptionCreateItemSchema +>; +type UpdateItemInput = z.infer< + typeof import('@zoneless/shared-schemas').SubscriptionUpdateItemSchema +>; const THREE_DAYS_SECONDS = 3 * SECONDS_PER_DAY; @@ -65,6 +69,7 @@ export class SubscriptionModule { private readonly priceModule: PriceModule | null; private readonly invoiceModule: InvoiceModule | null; private readonly listHelper: ListHelper; + private readonly itemsListHelper: ListHelper; constructor( db: Database, @@ -85,6 +90,13 @@ export class SubscriptionModule { urlPath: '/v1/subscriptions', accountField: 'platform_account', }); + this.itemsListHelper = new ListHelper(db, { + collection: 'SubscriptionItems', + orderByField: 'created', + orderDirection: 'asc', + urlPath: '/v1/subscription_items', + accountField: 'platform_account', + }); } /** @@ -761,6 +773,145 @@ export class SubscriptionModule { return subscription; } + // ─────────────────────────────────────────────────────────────────────────── + // Public Subscription Item Methods + // ─────────────────────────────────────────────────────────────────────────── + + async CreateItem( + platformAccountId: string, + input: z.infer + ): Promise { + const validatedInput = ValidateUpdate(CreateSubscriptionItemSchema, input); + const subscription = await this.RequireSubscription( + validatedInput.subscription, + platformAccountId + ); + + const item = await this.CreateSubscriptionItem( + platformAccountId, + subscription.id, + validatedInput, + subscription.billing_cycle_anchor + ); + + if (this.eventService) { + const updatedSubscription = await this.GetSubscription(subscription.id); + await this.eventService.Emit( + 'customer.subscription.updated', + platformAccountId, + updatedSubscription! + ); + } + + return item; + } + + async GetItem( + id: string, + platformAccountId: string + ): Promise { + const item = await this.db.Get( + 'SubscriptionItems', + id + ); + if (!item || item.platform_account !== platformAccountId) { + return null; + } + return item; + } + + async UpdateItem( + id: string, + input: z.infer, + platformAccountId: string + ): Promise { + const validatedInput = ValidateUpdate(UpdateSubscriptionItemSchema, input); + const existing = await this.GetItem(id, platformAccountId); + + if (!existing) { + throw new AppError( + 'Subscription item not found', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + const subscription = await this.RequireSubscription( + existing.subscription, + platformAccountId + ); + + await this.ApplyItemUpdates( + platformAccountId, + subscription.id, + [{ id, ...validatedInput }], + subscription.billing_cycle_anchor + ); + + const updated = await this.GetItem(id, platformAccountId); + + if (this.eventService) { + const updatedSubscription = await this.GetSubscription(subscription.id); + await this.eventService.Emit( + 'customer.subscription.updated', + platformAccountId, + updatedSubscription! + ); + } + + return updated!; + } + + async DeleteItem( + id: string, + input: z.infer< + typeof import('@zoneless/shared-schemas').DeleteSubscriptionItemSchema + > = {}, + platformAccountId: string + ) { + const existing = await this.GetItem(id, platformAccountId); + if (!existing) { + throw new AppError( + 'Subscription item not found', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + const subscription = await this.RequireSubscription( + existing.subscription, + platformAccountId + ); + void input.clear_usage; + + await this.db.Delete('SubscriptionItems', id); + + if (this.eventService) { + const updatedSubscription = await this.GetSubscription(subscription.id); + await this.eventService.Emit( + 'customer.subscription.updated', + platformAccountId, + updatedSubscription! + ); + } + + return { id, object: 'subscription_item' as const, deleted: true }; + } + + async ListItems( + options: ListOptions & { subscription: string } + ): Promise> { + const { subscription, ...listOptions } = options; + + return this.itemsListHelper.List({ + ...listOptions, + filters: { + ...listOptions.filters, + subscription, + }, + }); + } + // ─────────────────────────────────────────────────────────────────────────── // Object builders // ─────────────────────────────────────────────────────────────────────────── @@ -1740,9 +1891,15 @@ export class SubscriptionModule { return Math.min(...ends); } - private async RequireSubscription(id: string): Promise { + private async RequireSubscription( + id: string, + platformAccountId?: string + ): Promise { const subscription = await this.GetSubscription(id); - if (!subscription) { + if ( + !subscription || + (platformAccountId && subscription.platform_account !== platformAccountId) + ) { throw new AppError( ERRORS.SUBSCRIPTION_NOT_FOUND.message, ERRORS.SUBSCRIPTION_NOT_FOUND.status, diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index f66ccbe..7e0dc56 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -21,6 +21,7 @@ import configRouter from './config.routes'; import setupRouter from './setup.routes'; import operatorRouter from './operator.routes'; import subscriptionsRouter from './subscriptions.routes'; +import subscriptionItemsRouter from './subscriptionItems.routes'; import productsRouter from './products.routes'; import pricesRouter from './prices.routes'; import customersRouter from './customers.routes'; @@ -83,6 +84,7 @@ router.use('/webhook_endpoints', webhookEndpointsRouter); router.use('/api_keys', apiKeysRouter); router.use('/events', eventsRouter); router.use('/subscriptions', subscriptionsRouter); +router.use('/subscription_items', subscriptionItemsRouter); router.use('/products', productsRouter); router.use('/prices', pricesRouter); router.use('/customers', customersRouter); diff --git a/apps/api/src/routes/subscriptionItems.routes.ts b/apps/api/src/routes/subscriptionItems.routes.ts new file mode 100644 index 0000000..2308de4 --- /dev/null +++ b/apps/api/src/routes/subscriptionItems.routes.ts @@ -0,0 +1,249 @@ +/** + * @fileOverview Subscription Item routes + * + * Handles creating, retrieving, updating, listing, and deleting subscription items. + * + * @see https://docs.stripe.com/api/subscription_items + */ + +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 { db } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { CustomerModule } from '../modules/Customer'; +import { PriceModule } from '../modules/Price'; +import { ProductModule } from '../modules/Product'; +import { InvoiceItemModule } from '../modules/InvoiceItem'; +import { InvoiceModule } from '../modules/Invoice'; +import { SubscriptionModule } from '../modules/Subscription'; +import { PaymentIntentModule } from '../modules/PaymentIntent'; +import { ChargeModule } from '../modules/Charge'; + +import { ValidateRequest } from '../middleware/ValidateRequest'; +import { RequirePlatform } from '../middleware/Authorization'; + +import { + CreateSubscriptionItemSchema, + UpdateSubscriptionItemSchema, + DeleteSubscriptionItemSchema, +} from '@zoneless/shared-schemas'; +import { SubscriptionItem } from '@zoneless/shared-types'; +import { ApplyExpand } from '../utils/Expand'; + +const router = express.Router(); + +const eventService = new EventService(db); +const customerModule = new CustomerModule(db, eventService); +const productModule = new ProductModule(db, eventService); +const priceModule = new PriceModule(db, eventService, productModule); +const paymentIntentModule = new PaymentIntentModule( + db, + eventService, + customerModule +); +const chargeModule = new ChargeModule(db, eventService, customerModule); +const invoiceItemModule = new InvoiceItemModule( + db, + eventService, + customerModule, + priceModule +); +const invoiceModule = new InvoiceModule( + db, + eventService, + customerModule, + invoiceItemModule, + paymentIntentModule, + chargeModule, + priceModule +); +const subscriptionModule = new SubscriptionModule( + db, + eventService, + customerModule, + priceModule, + invoiceModule +); + +/** + * Fetch a Subscription Item and verify it belongs to the requesting platform. + */ +async function GetOwnedSubscriptionItem( + id: string, + platformAccountId: string +): Promise { + const subscriptionItem = await subscriptionModule.GetItem( + id, + platformAccountId + ); + + if (!subscriptionItem) { + throw new AppError( + 'Subscription item not found', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + return subscriptionItem; +} + +/** + * POST /v1/subscription_items + * Create a new subscription item. + */ +router.post( + '/', + RequirePlatform(), + ValidateRequest(CreateSubscriptionItemSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + + Logger.info('Creating Subscription Item', { + platformAccountId, + fields: Object.keys(req.body), + }); + + const subscriptionItem = await subscriptionModule.CreateItem( + platformAccountId, + req.body + ); + + Logger.info('Subscription Item created successfully', { + subscriptionItemId: subscriptionItem.id, + }); + + res.status(201).json(await ApplyExpand(req, subscriptionItem)); + }) +); + +/** + * POST /v1/subscription_items/:id + * Update a subscription item. + */ +router.post( + '/:id', + RequirePlatform(), + ValidateRequest(UpdateSubscriptionItemSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + Logger.info('Updating Subscription Item', { + subscriptionItemId: id, + fields: Object.keys(req.body), + }); + + const updated = await subscriptionModule.UpdateItem( + id, + req.body, + platformAccountId + ); + + Logger.info('Subscription Item updated successfully', { + subscriptionItemId: updated.id, + }); + + res.json(await ApplyExpand(req, updated)); + }) +); + +/** + * GET /v1/subscription_items/:id + * Retrieve a subscription item. + */ +router.get( + '/:id', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + const subscriptionItem = await GetOwnedSubscriptionItem( + id, + platformAccountId + ); + + res.json(await ApplyExpand(req, subscriptionItem)); + }) +); + +/** + * DELETE /v1/subscription_items/:id + * Delete a subscription item. + */ +router.delete( + '/:id', + RequirePlatform(), + ValidateRequest(DeleteSubscriptionItemSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + Logger.info('Deleting Subscription Item', { subscriptionItemId: id }); + + const result = await subscriptionModule.DeleteItem( + id, + req.body, + platformAccountId + ); + + Logger.info('Subscription Item deleted successfully', { + subscriptionItemId: id, + }); + + res.json(result); + }) +); + +/** + * GET /v1/subscription_items + * Returns a list of subscription items. + */ +router.get( + '/', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + + Logger.info('Listing Subscription Items', { platformAccountId }); + + 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 subscription = req.query.subscription as string | undefined; + + if (!subscription) { + throw new AppError( + 'Missing required parameter: subscription', + ERRORS.VALIDATION_ERROR.status, + ERRORS.VALIDATION_ERROR.type + ); + } + + const result = await subscriptionModule.ListItems({ + account: platformAccountId, + limit, + startingAfter, + endingBefore, + subscription, + }); + + Logger.info('Subscription Items listed successfully', { + platformAccountId, + count: result.data.length, + hasMore: result.has_more, + }); + + res.json(await ApplyExpand(req, result)); + }) +); + +export default router; From 93ce02a4c0b39ac7a55e3966e5f2083c874b6807 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Sun, 20 Sep 2026 15:48:47 +0530 Subject: [PATCH 2/2] fix: 404s and naming conventions --- .../src/__tests__/SubscriptionItem.spec.ts | 18 ++++---- apps/api/src/modules/Subscription.ts | 42 +++++++++---------- .../src/routes/subscriptionItems.routes.ts | 16 +++---- apps/api/src/utils/Errors.ts | 5 +++ 4 files changed, 44 insertions(+), 37 deletions(-) diff --git a/apps/api/src/__tests__/SubscriptionItem.spec.ts b/apps/api/src/__tests__/SubscriptionItem.spec.ts index 43cdac4..53b6db5 100644 --- a/apps/api/src/__tests__/SubscriptionItem.spec.ts +++ b/apps/api/src/__tests__/SubscriptionItem.spec.ts @@ -136,7 +136,7 @@ describe('SubscriptionModule - Subscription Items', () => { describe('CreateItem', () => { it('should create an item and emit customer.subscription.updated', async () => { - const item = await module.CreateItem(PLATFORM, { + const item = await module.CreateSubscriptionItem(PLATFORM, { subscription: SUBSCRIPTION_ID, price: PRICE_ID, quantity: 2, @@ -167,7 +167,7 @@ describe('SubscriptionModule - Subscription Items', () => { subscription: SUBSCRIPTION_ID, }); - const item = await module.GetItem('si_123', PLATFORM); + const item = await module.GetSubscriptionItem('si_123', PLATFORM); expect(item).toBeDefined(); expect(item?.id).toBe('si_123'); }); @@ -180,7 +180,7 @@ describe('SubscriptionModule - Subscription Items', () => { subscription: SUBSCRIPTION_ID, }); - const item = await module.GetItem('si_123', PLATFORM); + const item = await module.GetSubscriptionItem('si_123', PLATFORM); expect(item).toBeNull(); }); }); @@ -196,7 +196,7 @@ describe('SubscriptionModule - Subscription Items', () => { quantity: 1, }); - const updated = await module.UpdateItem( + const updated = await module.UpdateSubscriptionItem( 'si_123', { quantity: 5 }, PLATFORM @@ -215,7 +215,7 @@ describe('SubscriptionModule - Subscription Items', () => { it('should throw error if item does not exist', async () => { await expect( - module.UpdateItem('si_missing', { quantity: 5 }, PLATFORM) + module.UpdateSubscriptionItem('si_missing', { quantity: 5 }, PLATFORM) ).rejects.toThrow(AppError); }); }); @@ -229,7 +229,11 @@ describe('SubscriptionModule - Subscription Items', () => { subscription: SUBSCRIPTION_ID, }); - const result = await module.DeleteItem('si_123', {}, PLATFORM); + const result = await module.DeleteSubscriptionItem( + 'si_123', + {}, + PLATFORM + ); expect(result.deleted).toBe(true); expect(store.has(StoreKey('SubscriptionItems', 'si_123'))).toBe(false); @@ -253,7 +257,7 @@ describe('SubscriptionModule - Subscription Items', () => { url: '/v1/subscription_items', }); - await module.ListItems({ + await module.ListSubscriptionItems({ account: PLATFORM, limit: 10, subscription: SUBSCRIPTION_ID, diff --git a/apps/api/src/modules/Subscription.ts b/apps/api/src/modules/Subscription.ts index 0806961..c4f5b36 100644 --- a/apps/api/src/modules/Subscription.ts +++ b/apps/api/src/modules/Subscription.ts @@ -46,6 +46,7 @@ import { UpdateSubscriptionInput, CreateSubscriptionItemSchema, UpdateSubscriptionItemSchema, + DeleteSubscriptionItemSchema, } from '@zoneless/shared-schemas'; import { z } from 'zod'; import { @@ -777,7 +778,7 @@ export class SubscriptionModule { // Public Subscription Item Methods // ─────────────────────────────────────────────────────────────────────────── - async CreateItem( + async CreateSubscriptionItem( platformAccountId: string, input: z.infer ): Promise { @@ -787,7 +788,7 @@ export class SubscriptionModule { platformAccountId ); - const item = await this.CreateSubscriptionItem( + const item = await this.InsertSubscriptionItem( platformAccountId, subscription.id, validatedInput, @@ -806,7 +807,7 @@ export class SubscriptionModule { return item; } - async GetItem( + async GetSubscriptionItem( id: string, platformAccountId: string ): Promise { @@ -820,22 +821,21 @@ export class SubscriptionModule { return item; } - async UpdateItem( + async UpdateSubscriptionItem( id: string, input: z.infer, platformAccountId: string ): Promise { const validatedInput = ValidateUpdate(UpdateSubscriptionItemSchema, input); - const existing = await this.GetItem(id, platformAccountId); + const existing = await this.GetSubscriptionItem(id, platformAccountId); if (!existing) { throw new AppError( - 'Subscription item not found', - ERRORS.INVALID_REQUEST.status, - ERRORS.INVALID_REQUEST.type + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.message, + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.status, + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.type ); } - const subscription = await this.RequireSubscription( existing.subscription, platformAccountId @@ -848,7 +848,7 @@ export class SubscriptionModule { subscription.billing_cycle_anchor ); - const updated = await this.GetItem(id, platformAccountId); + const updated = await this.GetSubscriptionItem(id, platformAccountId); if (this.eventService) { const updatedSubscription = await this.GetSubscription(subscription.id); @@ -862,19 +862,17 @@ export class SubscriptionModule { return updated!; } - async DeleteItem( + async DeleteSubscriptionItem( id: string, - input: z.infer< - typeof import('@zoneless/shared-schemas').DeleteSubscriptionItemSchema - > = {}, + input: z.infer = {}, platformAccountId: string ) { - const existing = await this.GetItem(id, platformAccountId); + const existing = await this.GetSubscriptionItem(id, platformAccountId); if (!existing) { throw new AppError( - 'Subscription item not found', - ERRORS.INVALID_REQUEST.status, - ERRORS.INVALID_REQUEST.type + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.message, + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.status, + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.type ); } @@ -898,7 +896,7 @@ export class SubscriptionModule { return { id, object: 'subscription_item' as const, deleted: true }; } - async ListItems( + async ListSubscriptionItems( options: ListOptions & { subscription: string } ): Promise> { const { subscription, ...listOptions } = options; @@ -1290,7 +1288,7 @@ export class SubscriptionModule { ): Promise { const items: SubscriptionItemType[] = []; for (const itemInput of itemsInput) { - const item = await this.CreateSubscriptionItem( + const item = await this.InsertSubscriptionItem( platformAccountId, subscriptionId, itemInput, @@ -1301,7 +1299,7 @@ export class SubscriptionModule { return items; } - private async CreateSubscriptionItem( + private async InsertSubscriptionItem( platformAccountId: string, subscriptionId: string, itemInput: CreateItemInput | UpdateItemInput, @@ -1380,7 +1378,7 @@ export class SubscriptionModule { } if (!itemInput.id) { - await this.CreateSubscriptionItem( + await this.InsertSubscriptionItem( platformAccountId, subscriptionId, itemInput, diff --git a/apps/api/src/routes/subscriptionItems.routes.ts b/apps/api/src/routes/subscriptionItems.routes.ts index 2308de4..715f4a3 100644 --- a/apps/api/src/routes/subscriptionItems.routes.ts +++ b/apps/api/src/routes/subscriptionItems.routes.ts @@ -76,16 +76,16 @@ async function GetOwnedSubscriptionItem( id: string, platformAccountId: string ): Promise { - const subscriptionItem = await subscriptionModule.GetItem( + const subscriptionItem = await subscriptionModule.GetSubscriptionItem( id, platformAccountId ); if (!subscriptionItem) { throw new AppError( - 'Subscription item not found', - ERRORS.INVALID_REQUEST.status, - ERRORS.INVALID_REQUEST.type + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.message, + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.status, + ERRORS.SUBSCRIPTION_ITEM_NOT_FOUND.type ); } @@ -108,7 +108,7 @@ router.post( fields: Object.keys(req.body), }); - const subscriptionItem = await subscriptionModule.CreateItem( + const subscriptionItem = await subscriptionModule.CreateSubscriptionItem( platformAccountId, req.body ); @@ -138,7 +138,7 @@ router.post( fields: Object.keys(req.body), }); - const updated = await subscriptionModule.UpdateItem( + const updated = await subscriptionModule.UpdateSubscriptionItem( id, req.body, platformAccountId @@ -186,7 +186,7 @@ router.delete( Logger.info('Deleting Subscription Item', { subscriptionItemId: id }); - const result = await subscriptionModule.DeleteItem( + const result = await subscriptionModule.DeleteSubscriptionItem( id, req.body, platformAccountId @@ -228,7 +228,7 @@ router.get( ); } - const result = await subscriptionModule.ListItems({ + const result = await subscriptionModule.ListSubscriptionItems({ account: platformAccountId, limit, startingAfter, diff --git a/apps/api/src/utils/Errors.ts b/apps/api/src/utils/Errors.ts index 94d3194..b466c1f 100644 --- a/apps/api/src/utils/Errors.ts +++ b/apps/api/src/utils/Errors.ts @@ -174,6 +174,11 @@ export const ERRORS = { status: 404, type: 'resource_missing', }, + SUBSCRIPTION_ITEM_NOT_FOUND: { + message: 'Subscription item not found', + status: 404, + type: 'resource_missing', + }, VERIFICATION_SESSION_NOT_FOUND: { message: 'Verification session not found', status: 404,