[FASE 4] feat: Add SaaS billing entities and fix unit tests

- Add PlanLimit entity for plan limits tracking
- Add Coupon entity for discount coupons
- Add CouponRedemption entity for coupon usage
- Add StripeEvent entity for webhook processing
- Fix purchases.service tests (mock sequencesService)
- Fix orders.service tests (mock stockReservation)
- Add partners.controller tests (13 new tests)

Test Results: 361 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 07:47:24 -06:00
parent edadaf3180
commit 0bdb2eed65
8 changed files with 597 additions and 26 deletions

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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<string, any>;
@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;
}

View File

@ -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<AuthenticatedRequest>;
let mockRes: Partial<Response>;
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));
});
});
});

View File

@ -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 () => {

View File

@ -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 () => {