diff --git a/src/modules/billing-usage/entities/coupon-redemption.entity.ts b/src/modules/billing-usage/entities/coupon-redemption.entity.ts new file mode 100644 index 0000000..6c96f1a --- /dev/null +++ b/src/modules/billing-usage/entities/coupon-redemption.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Unique, +} from 'typeorm'; +import { Coupon } from './coupon.entity.js'; +import { TenantSubscription } from './tenant-subscription.entity.js'; + +@Entity({ name: 'coupon_redemptions', schema: 'billing' }) +@Unique(['couponId', 'tenantId']) +export class CouponRedemption { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'coupon_id', type: 'uuid' }) + couponId!: string; + + @ManyToOne(() => Coupon, (coupon) => coupon.redemptions) + @JoinColumn({ name: 'coupon_id' }) + coupon!: Coupon; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + @Column({ name: 'subscription_id', type: 'uuid', nullable: true }) + subscriptionId?: string; + + @ManyToOne(() => TenantSubscription, { nullable: true }) + @JoinColumn({ name: 'subscription_id' }) + subscription?: TenantSubscription; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2 }) + discountAmount!: number; + + @CreateDateColumn({ name: 'redeemed_at', type: 'timestamptz' }) + redeemedAt!: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date; +} diff --git a/src/modules/billing-usage/entities/coupon.entity.ts b/src/modules/billing-usage/entities/coupon.entity.ts new file mode 100644 index 0000000..6c8d0bb --- /dev/null +++ b/src/modules/billing-usage/entities/coupon.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { CouponRedemption } from './coupon-redemption.entity.js'; + +export type DiscountType = 'percentage' | 'fixed'; +export type DurationPeriod = 'once' | 'forever' | 'months'; + +@Entity({ name: 'coupons', schema: 'billing' }) +export class Coupon { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + code!: string; + + @Column({ type: 'varchar', length: 255 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'discount_type', type: 'varchar', length: 20 }) + discountType!: DiscountType; + + @Column({ name: 'discount_value', type: 'decimal', precision: 10, scale: 2 }) + discountValue!: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency!: string; + + @Column({ name: 'applicable_plans', type: 'uuid', array: true, default: [] }) + applicablePlans!: string[]; + + @Column({ name: 'min_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + minAmount!: number; + + @Column({ name: 'duration_period', type: 'varchar', length: 20, default: 'once' }) + durationPeriod!: DurationPeriod; + + @Column({ name: 'duration_months', type: 'integer', nullable: true }) + durationMonths?: number; + + @Column({ name: 'max_redemptions', type: 'integer', nullable: true }) + maxRedemptions?: number; + + @Column({ name: 'current_redemptions', type: 'integer', default: 0 }) + currentRedemptions!: number; + + @Column({ name: 'valid_from', type: 'timestamptz', nullable: true }) + validFrom?: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil?: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @OneToMany(() => CouponRedemption, (redemption) => redemption.coupon) + redemptions!: CouponRedemption[]; +} diff --git a/src/modules/billing-usage/entities/index.ts b/src/modules/billing-usage/entities/index.ts index 39d73c3..5d72394 100644 --- a/src/modules/billing-usage/entities/index.ts +++ b/src/modules/billing-usage/entities/index.ts @@ -1,9 +1,13 @@ -export { SubscriptionPlan, PlanType } from './subscription-plan.entity'; -export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity'; -export { UsageTracking } from './usage-tracking.entity'; -export { UsageEvent, EventCategory } from './usage-event.entity'; -export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity'; -export { InvoiceItemType } from './invoice-item.entity'; -export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity'; -export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity'; -export { PlanFeature } from './plan-feature.entity'; +export { SubscriptionPlan, PlanType } from './subscription-plan.entity.js'; +export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity.js'; +export { UsageTracking } from './usage-tracking.entity.js'; +export { UsageEvent, EventCategory } from './usage-event.entity.js'; +export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity.js'; +export { InvoiceItemType } from './invoice-item.entity.js'; +export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity.js'; +export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity.js'; +export { PlanFeature } from './plan-feature.entity.js'; +export { PlanLimit, LimitType } from './plan-limit.entity.js'; +export { Coupon, DiscountType, DurationPeriod } from './coupon.entity.js'; +export { CouponRedemption } from './coupon-redemption.entity.js'; +export { StripeEvent } from './stripe-event.entity.js'; diff --git a/src/modules/billing-usage/entities/plan-limit.entity.ts b/src/modules/billing-usage/entities/plan-limit.entity.ts new file mode 100644 index 0000000..1cc2fe2 --- /dev/null +++ b/src/modules/billing-usage/entities/plan-limit.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SubscriptionPlan } from './subscription-plan.entity.js'; + +export type LimitType = 'monthly' | 'daily' | 'total' | 'per_user'; + +@Entity({ name: 'plan_limits', schema: 'billing' }) +export class PlanLimit { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'plan_id', type: 'uuid' }) + planId!: string; + + @ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'plan_id' }) + plan!: SubscriptionPlan; + + @Column({ name: 'limit_key', type: 'varchar', length: 100 }) + limitKey!: string; + + @Column({ name: 'limit_name', type: 'varchar', length: 255 }) + limitName!: string; + + @Column({ name: 'limit_value', type: 'integer' }) + limitValue!: number; + + @Column({ name: 'limit_type', type: 'varchar', length: 50, default: 'monthly' }) + limitType!: LimitType; + + @Column({ name: 'allow_overage', type: 'boolean', default: false }) + allowOverage!: boolean; + + @Column({ name: 'overage_unit_price', type: 'decimal', precision: 10, scale: 4, default: 0 }) + overageUnitPrice!: number; + + @Column({ name: 'overage_currency', type: 'varchar', length: 3, default: 'MXN' }) + overageCurrency!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/billing-usage/entities/stripe-event.entity.ts b/src/modules/billing-usage/entities/stripe-event.entity.ts new file mode 100644 index 0000000..d11eb11 --- /dev/null +++ b/src/modules/billing-usage/entities/stripe-event.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'stripe_events', schema: 'billing' }) +export class StripeEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'stripe_event_id', type: 'varchar', length: 255, unique: true }) + @Index() + stripeEventId!: string; + + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + @Index() + eventType!: string; + + @Column({ name: 'api_version', type: 'varchar', length: 20, nullable: true }) + apiVersion?: string; + + @Column({ type: 'jsonb' }) + data!: Record; + + @Column({ type: 'boolean', default: false }) + @Index() + processed!: boolean; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt?: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/src/modules/partners/__tests__/partners.controller.test.ts b/src/modules/partners/__tests__/partners.controller.test.ts new file mode 100644 index 0000000..d2cd0ec --- /dev/null +++ b/src/modules/partners/__tests__/partners.controller.test.ts @@ -0,0 +1,292 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Response, NextFunction } from 'express'; +import { createMockPartner } from '../../../__tests__/helpers.js'; +import { AuthenticatedRequest } from '../../../shared/types/index.js'; + +// Mock the service +const mockFindAll = jest.fn(); +const mockFindById = jest.fn(); +const mockCreate = jest.fn(); +const mockUpdate = jest.fn(); +const mockDelete = jest.fn(); +const mockFindCustomers = jest.fn(); +const mockFindSuppliers = jest.fn(); + +jest.mock('../partners.service.js', () => ({ + partnersService: { + findAll: (...args: any[]) => mockFindAll(...args), + findById: (...args: any[]) => mockFindById(...args), + create: (...args: any[]) => mockCreate(...args), + update: (...args: any[]) => mockUpdate(...args), + delete: (...args: any[]) => mockDelete(...args), + findCustomers: (...args: any[]) => mockFindCustomers(...args), + findSuppliers: (...args: any[]) => mockFindSuppliers(...args), + }, +})); + +// Import after mocking +import { partnersController } from '../partners.controller.js'; + +describe('PartnersController', () => { + let mockReq: Partial; + let mockRes: Partial; + let mockNext: NextFunction; + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + mockReq = { + user: { + id: userId, + userId, + tenantId, + email: 'test@test.com', + role: 'admin', + } as any, + params: {}, + query: {}, + body: {}, + }; + mockRes = { + status: jest.fn().mockReturnThis() as any, + json: jest.fn() as any, + }; + mockNext = jest.fn(); + }); + + describe('findAll', () => { + it('should return paginated partners', async () => { + const mockPartners = { + data: [createMockPartner()], + total: 1, + }; + mockFindAll.mockResolvedValue(mockPartners); + mockReq.query = { page: '1', limit: '20' }; + + await partnersController.findAll( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockFindAll).toHaveBeenCalledWith(tenantId, expect.objectContaining({ + page: 1, + limit: 20, + })); + expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + data: mockPartners.data, + })); + }); + + it('should apply filters from query params', async () => { + mockFindAll.mockResolvedValue({ data: [], total: 0 }); + mockReq.query = { search: 'test', partnerType: 'customer', isActive: 'true' }; + + await partnersController.findAll( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockFindAll).toHaveBeenCalledWith(tenantId, expect.objectContaining({ + search: 'test', + partnerType: 'customer', + isActive: true, + })); + }); + + it('should call next with error on invalid query', async () => { + mockReq.query = { page: 'invalid' }; + + await partnersController.findAll( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('findById', () => { + it('should return partner when found', async () => { + const mockPartner = createMockPartner(); + mockFindById.mockResolvedValue(mockPartner); + mockReq.params = { id: 'partner-uuid-1' }; + + await partnersController.findById( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockFindById).toHaveBeenCalledWith('partner-uuid-1', tenantId); + expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + data: mockPartner, + })); + }); + + it('should call next with error when partner not found', async () => { + mockFindById.mockRejectedValue(new Error('Not found')); + mockReq.params = { id: 'nonexistent' }; + + await partnersController.findById( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('should create partner successfully', async () => { + const mockPartner = createMockPartner(); + mockCreate.mockResolvedValue(mockPartner); + mockReq.body = { + code: 'PART-001', + displayName: 'New Partner', + email: 'new@partner.com', + partnerType: 'customer', + }; + + await partnersController.create( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PART-001', + displayName: 'New Partner', + email: 'new@partner.com', + partnerType: 'customer', + }), + tenantId, + userId + ); + expect(mockRes.status).toHaveBeenCalledWith(201); + }); + + it('should validate required fields', async () => { + mockReq.body = {}; // Missing required code + + await partnersController.create( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('should accept snake_case fields', async () => { + const mockPartner = createMockPartner(); + mockCreate.mockResolvedValue(mockPartner); + mockReq.body = { + code: 'PART-002', + display_name: 'Snake Case Partner', + partner_type: 'supplier', + }; + + await partnersController.create( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockCreate).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update partner successfully', async () => { + const mockPartner = createMockPartner({ displayName: 'Updated Name' }); + mockUpdate.mockResolvedValue(mockPartner); + mockReq.params = { id: 'partner-uuid-1' }; + mockReq.body = { displayName: 'Updated Name' }; + + await partnersController.update( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockUpdate).toHaveBeenCalledWith( + 'partner-uuid-1', + expect.objectContaining({ displayName: 'Updated Name' }), + tenantId, + userId + ); + }); + + it('should call next with error on invalid data', async () => { + mockReq.params = { id: 'partner-uuid-1' }; + mockReq.body = { email: 'not-an-email' }; + + await partnersController.update( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete partner successfully', async () => { + mockDelete.mockResolvedValue(undefined); + mockReq.params = { id: 'partner-uuid-1' }; + + await partnersController.delete( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockDelete).toHaveBeenCalledWith('partner-uuid-1', tenantId, userId); + expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + })); + }); + }); + + describe('findCustomers', () => { + it('should return only customers', async () => { + const mockCustomers = { data: [createMockPartner({ partnerType: 'customer' })], total: 1 }; + mockFindCustomers.mockResolvedValue(mockCustomers); + mockReq.query = {}; + + await partnersController.findCustomers( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockFindCustomers).toHaveBeenCalledWith(tenantId, expect.any(Object)); + }); + }); + + describe('findSuppliers', () => { + it('should return only suppliers', async () => { + const mockSuppliers = { data: [createMockPartner({ partnerType: 'supplier' })], total: 1 }; + mockFindSuppliers.mockResolvedValue(mockSuppliers); + mockReq.query = {}; + + await partnersController.findSuppliers( + mockReq as AuthenticatedRequest, + mockRes as Response, + mockNext + ); + + expect(mockFindSuppliers).toHaveBeenCalledWith(tenantId, expect.any(Object)); + }); + }); +}); diff --git a/src/modules/purchases/__tests__/purchases.service.test.ts b/src/modules/purchases/__tests__/purchases.service.test.ts index 4963ede..0284d82 100644 --- a/src/modules/purchases/__tests__/purchases.service.test.ts +++ b/src/modules/purchases/__tests__/purchases.service.test.ts @@ -12,6 +12,17 @@ jest.mock('../../../config/database.js', () => ({ getClient: () => mockGetClient(), })); +// Mock sequences service +const mockGetNextNumber = jest.fn(); +jest.mock('../../core/sequences.service.js', () => ({ + sequencesService: { + getNextNumber: (...args: any[]) => mockGetNextNumber(...args), + }, + SEQUENCE_CODES: { + PURCHASE_ORDER: 'PO', + }, +})); + // Import after mocking import { purchasesService } from '../purchases.service.js'; import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js'; @@ -28,6 +39,7 @@ describe('PurchasesService', () => { beforeEach(() => { jest.clearAllMocks(); mockGetClient.mockResolvedValue(mockClient); + mockGetNextNumber.mockResolvedValue('PO-00001'); }); describe('findAll', () => { @@ -278,18 +290,29 @@ describe('PurchasesService', () => { it('should confirm draft order with lines', async () => { const draftOrder = createMockPurchaseOrder({ status: 'draft', + company_id: 'company-uuid', lines: [createMockPurchaseOrderLine()], }); mockQueryOne.mockResolvedValue(draftOrder); - mockQuery.mockResolvedValue([createMockPurchaseOrderLine()]); + mockQuery + .mockResolvedValueOnce([createMockPurchaseOrderLine()]) // findById lines + .mockResolvedValueOnce(undefined); // UPDATE status + + // Mock client.query calls for confirm flow + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN returns empty + .mockResolvedValueOnce({ rows: [{ id: 'supplier-loc-uuid' }] }) // supplier location + .mockResolvedValueOnce({ rows: [{ location_id: 'internal-loc-uuid', warehouse_id: 'wh-uuid' }] }) // internal location + .mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking + .mockResolvedValueOnce({ rows: [] }) // INSERT stock_move + .mockResolvedValueOnce({ rows: [] }) // UPDATE status + .mockResolvedValueOnce({ rows: [] }); // COMMIT await purchasesService.confirm('po-uuid-1', tenantId, userId); - expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("status = 'confirmed'"), - expect.any(Array) - ); + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); }); it('should throw ConflictError when order is not draft', async () => { diff --git a/src/modules/sales/__tests__/orders.service.test.ts b/src/modules/sales/__tests__/orders.service.test.ts index 3f16e1c..c96c6d2 100644 --- a/src/modules/sales/__tests__/orders.service.test.ts +++ b/src/modules/sales/__tests__/orders.service.test.ts @@ -30,6 +30,16 @@ jest.mock('../../core/sequences.service.js', () => ({ }, SEQUENCE_CODES: { SALES_ORDER: 'SO', + PICKING_OUT: 'WH/OUT', + }, +})); + +// Mock stockReservationService +jest.mock('../../inventory/stock-reservation.service.js', () => ({ + stockReservationService: { + reserveWithClient: jest.fn(() => Promise.resolve({ success: true, errors: [] })), + releaseWithClient: jest.fn(() => Promise.resolve()), + checkAvailability: jest.fn(() => Promise.resolve({ available: true, lines: [] })), }, })); @@ -364,15 +374,24 @@ describe('OrdersService', () => { it('should confirm draft order with lines', async () => { const order = createMockSalesOrder({ status: 'draft', + company_id: 'company-uuid', lines: [createMockSalesOrderLine()], }); mockQueryOne.mockResolvedValue(order); - mockQuery.mockResolvedValue([createMockSalesOrderLine()]); + mockQuery + .mockResolvedValueOnce([createMockSalesOrderLine()]) // findById lines + .mockResolvedValueOnce(undefined); // UPDATE status + + // Mock client.query calls for confirm flow mockClient.query - .mockResolvedValueOnce(undefined) // BEGIN - .mockResolvedValueOnce(undefined) // UPDATE status - .mockResolvedValueOnce(undefined); // COMMIT + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'stock-loc-uuid', warehouse_id: 'wh-uuid' }] }) // stock location + .mockResolvedValueOnce({ rows: [{ id: 'customer-loc-uuid' }] }) // customer location + .mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking + .mockResolvedValueOnce({ rows: [] }) // INSERT stock_move + .mockResolvedValueOnce({ rows: [] }) // UPDATE status + .mockResolvedValueOnce({ rows: [] }); // COMMIT const result = await ordersService.confirm('order-uuid-1', tenantId, userId); @@ -403,14 +422,17 @@ describe('OrdersService', () => { it('should rollback on error', async () => { const order = createMockSalesOrder({ status: 'draft', + company_id: 'company-uuid', lines: [createMockSalesOrderLine()], }); mockQueryOne.mockResolvedValue(order); mockQuery.mockResolvedValue([createMockSalesOrderLine()]); mockClient.query - .mockResolvedValueOnce(undefined) // BEGIN - .mockRejectedValueOnce(new Error('DB Error')); // UPDATE fails + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'stock-loc-uuid', warehouse_id: 'wh-uuid' }] }) // stock location + .mockResolvedValueOnce({ rows: [{ id: 'customer-loc-uuid' }] }) // customer location + .mockRejectedValueOnce(new Error('DB Error')); // INSERT picking fails await expect( ordersService.confirm('order-uuid-1', tenantId, userId) @@ -422,17 +444,36 @@ describe('OrdersService', () => { }); describe('cancel', () => { + const mockClientCancel = { + query: jest.fn(), + release: jest.fn(), + }; + + beforeEach(() => { + mockGetClient.mockResolvedValue(mockClientCancel); + }); + it('should cancel draft order', async () => { - const draftOrder = createMockSalesOrder({ status: 'draft' }); - mockQueryOne.mockResolvedValue(draftOrder); + const draftOrder = createMockSalesOrder({ + status: 'draft', + delivery_status: 'pending', + invoice_status: 'pending', + }); + mockQueryOne + .mockResolvedValueOnce(draftOrder) // findById + .mockResolvedValueOnce({ ...draftOrder, status: 'cancelled' }); // findById after cancel mockQuery.mockResolvedValue([]); + // Mock client.query for cancel flow (draft orders don't need stock release) + mockClientCancel.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // UPDATE status + .mockResolvedValueOnce({ rows: [] }); // COMMIT + await ordersService.cancel('order-uuid-1', tenantId, userId); - expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("status = 'cancelled'"), - expect.any(Array) - ); + expect(mockClientCancel.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClientCancel.query).toHaveBeenCalledWith('COMMIT'); }); it('should throw ValidationError when order is done', async () => {