feat(billing): implement billing module with Stripe and Mercado Pago integration
- Add Stripe and Mercado Pago providers for handling subscriptions. - Create billing repository and access services for managing billing records. - Implement webhook handlers for processing events from Stripe and Mercado Pago. - Add routes for checkout, canceling subscriptions, and retrieving billing status. - Introduce billing status management with appropriate state transitions. - Create tests for billing functionalities including webhook idempotency and provider resolution. - Document the billing module architecture, environment variables, and API endpoints.
This commit is contained in:
66
apps/backend/test/billing/duplicate-subscription.test.ts
Normal file
66
apps/backend/test/billing/duplicate-subscription.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { prismaMock } from '../support/prisma.mock';
|
||||
|
||||
describe('duplicate-subscription', () => {
|
||||
it('returns true when status is ACTIVE', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue({
|
||||
status: 'ACTIVE',
|
||||
} as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-active');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true when status is TRIAL', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue({
|
||||
status: 'TRIAL',
|
||||
} as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-trial');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns false when status is CANCELED', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue({
|
||||
status: 'CANCELED',
|
||||
} as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-canceled');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns false when no billing record exists', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue(null as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-no-billing');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
31
apps/backend/test/billing/event-mapping.test.ts
Normal file
31
apps/backend/test/billing/event-mapping.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mapWebhookTypeToStatus } from './helpers';
|
||||
|
||||
describe('event-mapping', () => {
|
||||
it('maps all webhook event types correctly', () => {
|
||||
expect(mapWebhookTypeToStatus('activated')).toBe('ACTIVE');
|
||||
expect(mapWebhookTypeToStatus('updated')).toBe('ACTIVE');
|
||||
expect(mapWebhookTypeToStatus('payment_failed')).toBe('PAST_DUE');
|
||||
expect(mapWebhookTypeToStatus('cancelled')).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('maps Stripe checkout.session.completed -> activated -> ACTIVE', () => {
|
||||
const status = mapWebhookTypeToStatus('activated');
|
||||
expect(status).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('maps Stripe customer.subscription.deleted -> cancelled -> CANCELED', () => {
|
||||
const status = mapWebhookTypeToStatus('cancelled');
|
||||
expect(status).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('maps Mercado Pago subscription_authorized_payment -> activated -> ACTIVE', () => {
|
||||
const status = mapWebhookTypeToStatus('activated');
|
||||
expect(status).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('maps Mercado Pago subscription_cancelled -> cancelled -> CANCELED', () => {
|
||||
const status = mapWebhookTypeToStatus('cancelled');
|
||||
expect(status).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
22
apps/backend/test/billing/helpers.ts
Normal file
22
apps/backend/test/billing/helpers.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Helper that replicates the mapWebhookTypeToStatus logic
|
||||
* used in webhook business files for testability.
|
||||
*/
|
||||
export function mapWebhookTypeToStatus(
|
||||
eventType: 'activated' | 'updated' | 'payment_failed' | 'cancelled'
|
||||
): 'ACTIVE' | 'PAST_DUE' | 'CANCELED' | null {
|
||||
switch (eventType) {
|
||||
case 'activated':
|
||||
return 'ACTIVE';
|
||||
case 'updated':
|
||||
return 'ACTIVE';
|
||||
case 'payment_failed':
|
||||
return 'PAST_DUE';
|
||||
case 'cancelled':
|
||||
return 'CANCELED';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
83
apps/backend/test/billing/idempotency.test.ts
Normal file
83
apps/backend/test/billing/idempotency.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { prismaMock } from '../support/prisma.mock';
|
||||
|
||||
describe('webhook-idempotency', () => {
|
||||
it('returns conflict when providerEventId already exists', async () => {
|
||||
const existingEvent = { id: 'event-1' };
|
||||
|
||||
prismaMock.billingEvent.findUnique.mockResolvedValue(existingEvent as never);
|
||||
|
||||
const { findEventByProviderId } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await findEventByProviderId('mp-duplicate-id');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual({ id: 'event-1' });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when providerEventId does not exist', async () => {
|
||||
prismaMock.billingEvent.findUnique.mockResolvedValue(null as never);
|
||||
|
||||
const { findEventByProviderId } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await findEventByProviderId('new-event-id');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('records a billing event successfully', async () => {
|
||||
const createdEvent = { id: 'new-uuid' };
|
||||
prismaMock.billingEvent.create.mockResolvedValue(createdEvent as never);
|
||||
|
||||
const { recordBillingEvent } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await recordBillingEvent({
|
||||
complexId: 'complex-1',
|
||||
eventType: 'activated',
|
||||
provider: 'STRIPE',
|
||||
providerEventId: 'evt_unique',
|
||||
providerData: { some: 'data' },
|
||||
previousStatus: 'TRIAL',
|
||||
newStatus: 'ACTIVE',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.id).toBe('new-uuid');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns conflict when billing event create has P2002 (duplicate)', async () => {
|
||||
const prismaError = new Error('Unique constraint') as Error & { code: string };
|
||||
prismaError.code = 'P2002';
|
||||
prismaMock.billingEvent.create.mockRejectedValue(prismaError as never);
|
||||
|
||||
const { recordBillingEvent } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await recordBillingEvent({
|
||||
complexId: 'complex-1',
|
||||
eventType: 'activated',
|
||||
provider: 'STRIPE',
|
||||
providerEventId: 'evt_duplicate',
|
||||
providerData: null,
|
||||
previousStatus: null,
|
||||
newStatus: null,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe('conflict');
|
||||
}
|
||||
});
|
||||
});
|
||||
36
apps/backend/test/billing/provider-resolver.test.ts
Normal file
36
apps/backend/test/billing/provider-resolver.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mercadopagoBillingProvider } from '@/modules/billing/providers/mercadopago.provider';
|
||||
import { stripeBillingProvider } from '@/modules/billing/providers/stripe.provider';
|
||||
import { resolveProviderByCountry } from '@/modules/billing/services/provider-resolver.service';
|
||||
|
||||
describe('provider-resolver', () => {
|
||||
it('returns MERCADOPAGO for country AR', () => {
|
||||
const provider = resolveProviderByCountry('AR');
|
||||
expect(provider).toBe(mercadopagoBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for country BR', () => {
|
||||
const provider = resolveProviderByCountry('BR');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for country US', () => {
|
||||
const provider = resolveProviderByCountry('US');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for null country', () => {
|
||||
const provider = resolveProviderByCountry(null);
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for empty country', () => {
|
||||
const provider = resolveProviderByCountry('');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for any other ISO code', () => {
|
||||
const provider = resolveProviderByCountry('CL');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
});
|
||||
20
apps/backend/test/billing/status-transitions.test.ts
Normal file
20
apps/backend/test/billing/status-transitions.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mapWebhookTypeToStatus } from './helpers';
|
||||
|
||||
describe('status-transitions', () => {
|
||||
it('activated maps to ACTIVE', () => {
|
||||
expect(mapWebhookTypeToStatus('activated')).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('updated maps to ACTIVE', () => {
|
||||
expect(mapWebhookTypeToStatus('updated')).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('payment_failed maps to PAST_DUE', () => {
|
||||
expect(mapWebhookTypeToStatus('payment_failed')).toBe('PAST_DUE');
|
||||
});
|
||||
|
||||
it('cancelled maps to CANCELED', () => {
|
||||
expect(mapWebhookTypeToStatus('cancelled')).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ export const dbMock = {
|
||||
courtBookingLog: prismaMock.courtBookingLog,
|
||||
courtAvailability: prismaMock.courtAvailability,
|
||||
courtPriceRule: prismaMock.courtPriceRule,
|
||||
complexBilling: prismaMock.complexBilling,
|
||||
billingEvent: prismaMock.billingEvent,
|
||||
$transaction: transactionMock,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user