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:
Jose Selesan
2026-06-29 15:55:20 -03:00
parent f490eecd11
commit e06bc12097
45 changed files with 2553 additions and 30 deletions

View File

@@ -16,3 +16,23 @@ SMTP_FROM=Playzer <no-reply@example.com>
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# Billing / Subscriptions
BILLING_ENV=sandbox
FRONTEND_BASE_URL=http://localhost:5173
# Stripe
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret
# Price IDs por plan y moneda (ajustar según tus products/prices en Stripe)
STRIPE_PRICE_ID_USD_BASIC=price_basic_usd
STRIPE_PRICE_ID_USD_ADVANCED=price_advanced_usd
STRIPE_PRICE_ID_USD_ENTERPRISE=price_enterprise_usd
# Mercado Pago (Argentina)
MERCADOPAGO_ACCESS_TOKEN=TEST-your-access-token
MERCADOPAGO_WEBHOOK_SECRET=your-webhook-secret
# Plan IDs por plan (ajustar según tus planes de suscripción en MP)
MERCADOPAGO_PLAN_ID_ARS_BASIC=2c938084...
MERCADOPAGO_PLAN_ID_ARS_ADVANCED=2c938084...
MERCADOPAGO_PLAN_ID_ARS_ENTERPRISE=2c938084...

View File

@@ -24,12 +24,14 @@
"dotenv": "^17.4.1",
"hono": "4.12.10",
"mailtrap": "^4.5.1",
"mercadopago": "^3.1.0",
"nodemailer": "^8.0.5",
"pg": "^8.20.0",
"pino": "10.3.1",
"pino-pretty": "13.1.3",
"pino-std-serializers": "7.1.0",
"prisma": "^7",
"stripe": "^22.2.2",
"uuid": "^13.0.0"
},
"devDependencies": {

View File

@@ -0,0 +1,52 @@
enum BillingProvider {
STRIPE
MERCADOPAGO
PAYPAL
}
enum BillingStatus {
TRIAL
ACTIVE
PAST_DUE
CANCELED
SUSPENDED
}
model ComplexBilling {
complexId String @id @map("complex_id") @db.Uuid
status BillingStatus @default(TRIAL)
planCode String @map("plan_code") @db.VarChar(10)
currency String @db.VarChar(3)
provider BillingProvider?
providerCustomerId String? @map("provider_customer_id")
providerSubscriptionId String? @map("provider_subscription_id")
providerPreapprovalId String? @map("provider_preapproval_id")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
trialEndsAt DateTime? @map("trial_ends_at")
canceledAt DateTime? @map("canceled_at")
suspendedAt DateTime? @map("suspended_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)
events BillingEvent[]
@@map("complex_billings")
}
model BillingEvent {
id String @id @db.Uuid
complexId String @map("complex_id") @db.Uuid
eventType String @map("event_type") @db.VarChar(100)
provider BillingProvider
providerEventId String? @unique @map("provider_event_id")
providerData Json? @map("provider_data")
previousStatus BillingStatus? @map("previous_status")
newStatus BillingStatus? @map("new_status")
processedAt DateTime @default(now()) @map("processed_at")
billing ComplexBilling @relation(fields: [complexId], references: [complexId])
@@index([provider, providerEventId])
@@index([complexId])
@@map("billing_events")
}

View File

@@ -15,6 +15,7 @@ model Complex {
invitations ComplexInvitation[]
courts Court[]
recurringGroups RecurringBookingGroup[]
billing ComplexBilling?
@@index([planCode])
@@index([complexSlug])

View File

@@ -0,0 +1,56 @@
-- CreateEnum
CREATE TYPE "BillingProvider" AS ENUM ('STRIPE', 'MERCADOPAGO', 'PAYPAL');
-- CreateEnum
CREATE TYPE "BillingStatus" AS ENUM ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELED', 'SUSPENDED');
-- CreateTable
CREATE TABLE "complex_billings" (
"complex_id" UUID NOT NULL,
"status" "BillingStatus" NOT NULL DEFAULT 'TRIAL',
"plan_code" VARCHAR(10) NOT NULL,
"currency" VARCHAR(3) NOT NULL,
"provider" "BillingProvider",
"provider_customer_id" TEXT,
"provider_subscription_id" TEXT,
"provider_preapproval_id" TEXT,
"current_period_start" TIMESTAMP(3),
"current_period_end" TIMESTAMP(3),
"trial_ends_at" TIMESTAMP(3),
"canceled_at" TIMESTAMP(3),
"suspended_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "complex_billings_pkey" PRIMARY KEY ("complex_id")
);
-- CreateTable
CREATE TABLE "billing_events" (
"id" UUID NOT NULL,
"complex_id" UUID NOT NULL,
"event_type" VARCHAR(100) NOT NULL,
"provider" "BillingProvider" NOT NULL,
"provider_event_id" TEXT,
"provider_data" JSONB,
"previous_status" "BillingStatus",
"new_status" "BillingStatus",
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "billing_events_provider_event_id_key" ON "billing_events"("provider_event_id");
-- CreateIndex
CREATE INDEX "billing_events_provider_provider_event_id_idx" ON "billing_events"("provider", "provider_event_id");
-- CreateIndex
CREATE INDEX "billing_events_complex_id_idx" ON "billing_events"("complex_id");
-- AddForeignKey
ALTER TABLE "complex_billings" ADD CONSTRAINT "complex_billings_complex_id_fkey" FOREIGN KEY ("complex_id") REFERENCES "complexes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_events" ADD CONSTRAINT "billing_events_complex_id_fkey" FOREIGN KEY ("complex_id") REFERENCES "complex_billings"("complex_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -37,6 +37,16 @@ export type Account = Prisma.AccountModel
*
*/
export type Verification = Prisma.VerificationModel
/**
* Model ComplexBilling
*
*/
export type ComplexBilling = Prisma.ComplexBillingModel
/**
* Model BillingEvent
*
*/
export type BillingEvent = Prisma.BillingEventModel
/**
* Model Complex
*

View File

@@ -61,6 +61,16 @@ export type Account = Prisma.AccountModel
*
*/
export type Verification = Prisma.VerificationModel
/**
* Model ComplexBilling
*
*/
export type ComplexBilling = Prisma.ComplexBillingModel
/**
* Model BillingEvent
*
*/
export type BillingEvent = Prisma.BillingEventModel
/**
* Model Complex
*

View File

@@ -187,6 +187,20 @@ export type UuidFilter<$PrismaModel = never> = {
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
}
export type EnumBillingStatusFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel>
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingStatusFilter<$PrismaModel> | $Enums.BillingStatus
}
export type EnumBillingProviderNullableFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingProviderNullableFilter<$PrismaModel> | $Enums.BillingProvider | null
}
export type UuidWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -202,6 +216,111 @@ export type UuidWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type EnumBillingStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel>
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingStatusWithAggregatesFilter<$PrismaModel> | $Enums.BillingStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingStatusFilter<$PrismaModel>
}
export type EnumBillingProviderNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingProviderNullableWithAggregatesFilter<$PrismaModel> | $Enums.BillingProvider | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingProviderNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingProviderNullableFilter<$PrismaModel>
}
export type EnumBillingProviderFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel>
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingProviderFilter<$PrismaModel> | $Enums.BillingProvider
}
export type JsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type EnumBillingStatusNullableFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingStatusNullableFilter<$PrismaModel> | $Enums.BillingStatus | null
}
export type EnumBillingProviderWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel>
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingProviderWithAggregatesFilter<$PrismaModel> | $Enums.BillingProvider
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingProviderFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingProviderFilter<$PrismaModel>
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
}
export type EnumBillingStatusNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingStatusNullableWithAggregatesFilter<$PrismaModel> | $Enums.BillingStatus | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingStatusNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingStatusNullableFilter<$PrismaModel>
}
export type EnumComplexUserRoleFilter<$PrismaModel = never> = {
equals?: $Enums.ComplexUserRole | Prisma.EnumComplexUserRoleFieldRefInput<$PrismaModel>
in?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>
@@ -604,6 +723,20 @@ export type NestedUuidFilter<$PrismaModel = never> = {
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
}
export type NestedEnumBillingStatusFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel>
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingStatusFilter<$PrismaModel> | $Enums.BillingStatus
}
export type NestedEnumBillingProviderNullableFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingProviderNullableFilter<$PrismaModel> | $Enums.BillingProvider | null
}
export type NestedUuidWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -618,6 +751,84 @@ export type NestedUuidWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedEnumBillingStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel>
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingStatusWithAggregatesFilter<$PrismaModel> | $Enums.BillingStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingStatusFilter<$PrismaModel>
}
export type NestedEnumBillingProviderNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingProviderNullableWithAggregatesFilter<$PrismaModel> | $Enums.BillingProvider | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingProviderNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingProviderNullableFilter<$PrismaModel>
}
export type NestedEnumBillingProviderFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel>
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingProviderFilter<$PrismaModel> | $Enums.BillingProvider
}
export type NestedEnumBillingStatusNullableFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingStatusNullableFilter<$PrismaModel> | $Enums.BillingStatus | null
}
export type NestedEnumBillingProviderWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingProvider | Prisma.EnumBillingProviderFieldRefInput<$PrismaModel>
in?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingProvider[] | Prisma.ListEnumBillingProviderFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingProviderWithAggregatesFilter<$PrismaModel> | $Enums.BillingProvider
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingProviderFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingProviderFilter<$PrismaModel>
}
export type NestedJsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedEnumBillingStatusNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingStatus | Prisma.EnumBillingStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
notIn?: $Enums.BillingStatus[] | Prisma.ListEnumBillingStatusFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumBillingStatusNullableWithAggregatesFilter<$PrismaModel> | $Enums.BillingStatus | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingStatusNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingStatusNullableFilter<$PrismaModel>
}
export type NestedEnumComplexUserRoleFilter<$PrismaModel = never> = {
equals?: $Enums.ComplexUserRole | Prisma.EnumComplexUserRoleFieldRefInput<$PrismaModel>
in?: $Enums.ComplexUserRole[] | Prisma.ListEnumComplexUserRoleFieldRefInput<$PrismaModel>

View File

@@ -9,6 +9,26 @@
* 🟢 You can import this file directly.
*/
export const BillingProvider = {
STRIPE: 'STRIPE',
MERCADOPAGO: 'MERCADOPAGO',
PAYPAL: 'PAYPAL'
} as const
export type BillingProvider = (typeof BillingProvider)[keyof typeof BillingProvider]
export const BillingStatus = {
TRIAL: 'TRIAL',
ACTIVE: 'ACTIVE',
PAST_DUE: 'PAST_DUE',
CANCELED: 'CANCELED',
SUSPENDED: 'SUSPENDED'
} as const
export type BillingStatus = (typeof BillingStatus)[keyof typeof BillingStatus]
export const ComplexUserRole = {
ADMIN: 'ADMIN',
EMPLOYEE: 'EMPLOYEE'

File diff suppressed because one or more lines are too long

View File

@@ -388,6 +388,8 @@ export const ModelName = {
Session: 'Session',
Account: 'Account',
Verification: 'Verification',
ComplexBilling: 'ComplexBilling',
BillingEvent: 'BillingEvent',
Complex: 'Complex',
ComplexUser: 'ComplexUser',
ComplexInvitation: 'ComplexInvitation',
@@ -416,7 +418,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtMaintenance" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "recurringBookingGroup" | "passwordResetRequest" | "plan"
modelProps: "user" | "session" | "account" | "verification" | "complexBilling" | "billingEvent" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtMaintenance" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "recurringBookingGroup" | "passwordResetRequest" | "plan"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -716,6 +718,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ComplexBilling: {
payload: Prisma.$ComplexBillingPayload<ExtArgs>
fields: Prisma.ComplexBillingFieldRefs
operations: {
findUnique: {
args: Prisma.ComplexBillingFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ComplexBillingFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>
}
findFirst: {
args: Prisma.ComplexBillingFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload> | null
}
findFirstOrThrow: {
args: Prisma.ComplexBillingFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>
}
findMany: {
args: Prisma.ComplexBillingFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>[]
}
create: {
args: Prisma.ComplexBillingCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>
}
createMany: {
args: Prisma.ComplexBillingCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ComplexBillingCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>[]
}
delete: {
args: Prisma.ComplexBillingDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>
}
update: {
args: Prisma.ComplexBillingUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>
}
deleteMany: {
args: Prisma.ComplexBillingDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ComplexBillingUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ComplexBillingUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>[]
}
upsert: {
args: Prisma.ComplexBillingUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexBillingPayload>
}
aggregate: {
args: Prisma.ComplexBillingAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateComplexBilling>
}
groupBy: {
args: Prisma.ComplexBillingGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ComplexBillingGroupByOutputType>[]
}
count: {
args: Prisma.ComplexBillingCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ComplexBillingCountAggregateOutputType> | number
}
}
}
BillingEvent: {
payload: Prisma.$BillingEventPayload<ExtArgs>
fields: Prisma.BillingEventFieldRefs
operations: {
findUnique: {
args: Prisma.BillingEventFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BillingEventFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>
}
findFirst: {
args: Prisma.BillingEventFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload> | null
}
findFirstOrThrow: {
args: Prisma.BillingEventFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>
}
findMany: {
args: Prisma.BillingEventFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>[]
}
create: {
args: Prisma.BillingEventCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>
}
createMany: {
args: Prisma.BillingEventCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BillingEventCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>[]
}
delete: {
args: Prisma.BillingEventDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>
}
update: {
args: Prisma.BillingEventUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>
}
deleteMany: {
args: Prisma.BillingEventDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BillingEventUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BillingEventUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>[]
}
upsert: {
args: Prisma.BillingEventUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BillingEventPayload>
}
aggregate: {
args: Prisma.BillingEventAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBillingEvent>
}
groupBy: {
args: Prisma.BillingEventGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BillingEventGroupByOutputType>[]
}
count: {
args: Prisma.BillingEventCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BillingEventCountAggregateOutputType> | number
}
}
}
Complex: {
payload: Prisma.$ComplexPayload<ExtArgs>
fields: Prisma.ComplexFieldRefs
@@ -1785,6 +1935,42 @@ export const VerificationScalarFieldEnum = {
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
export const ComplexBillingScalarFieldEnum = {
complexId: 'complexId',
status: 'status',
planCode: 'planCode',
currency: 'currency',
provider: 'provider',
providerCustomerId: 'providerCustomerId',
providerSubscriptionId: 'providerSubscriptionId',
providerPreapprovalId: 'providerPreapprovalId',
currentPeriodStart: 'currentPeriodStart',
currentPeriodEnd: 'currentPeriodEnd',
trialEndsAt: 'trialEndsAt',
canceledAt: 'canceledAt',
suspendedAt: 'suspendedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ComplexBillingScalarFieldEnum = (typeof ComplexBillingScalarFieldEnum)[keyof typeof ComplexBillingScalarFieldEnum]
export const BillingEventScalarFieldEnum = {
id: 'id',
complexId: 'complexId',
eventType: 'eventType',
provider: 'provider',
providerEventId: 'providerEventId',
providerData: 'providerData',
previousStatus: 'previousStatus',
newStatus: 'newStatus',
processedAt: 'processedAt'
} as const
export type BillingEventScalarFieldEnum = (typeof BillingEventScalarFieldEnum)[keyof typeof BillingEventScalarFieldEnum]
export const ComplexScalarFieldEnum = {
id: 'id',
complexName: 'complexName',
@@ -1991,6 +2177,14 @@ export const SortOrder = {
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullableJsonNullValueInput = {
DbNull: DbNull,
JsonNull: JsonNull
} as const
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
export const JsonNullValueInput = {
JsonNull: JsonNull
} as const
@@ -2078,6 +2272,48 @@ export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaMode
/**
* Reference to a field of type 'BillingStatus'
*/
export type EnumBillingStatusFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BillingStatus'>
/**
* Reference to a field of type 'BillingStatus[]'
*/
export type ListEnumBillingStatusFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BillingStatus[]'>
/**
* Reference to a field of type 'BillingProvider'
*/
export type EnumBillingProviderFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BillingProvider'>
/**
* Reference to a field of type 'BillingProvider[]'
*/
export type ListEnumBillingProviderFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BillingProvider[]'>
/**
* Reference to a field of type 'Json'
*/
export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Json'>
/**
* Reference to a field of type 'QueryMode'
*/
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
/**
* Reference to a field of type 'ComplexUserRole'
*/
@@ -2161,20 +2397,6 @@ export type EnumRecurringBookingGroupStatusFieldRefInput<$PrismaModel> = FieldRe
export type ListEnumRecurringBookingGroupStatusFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RecurringBookingGroupStatus[]'>
/**
* Reference to a field of type 'Json'
*/
export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Json'>
/**
* Reference to a field of type 'QueryMode'
*/
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
/**
* Batch Payload for updateMany & deleteMany & createMany
*/
@@ -2274,6 +2496,8 @@ export type GlobalOmitConfig = {
session?: Prisma.SessionOmit
account?: Prisma.AccountOmit
verification?: Prisma.VerificationOmit
complexBilling?: Prisma.ComplexBillingOmit
billingEvent?: Prisma.BillingEventOmit
complex?: Prisma.ComplexOmit
complexUser?: Prisma.ComplexUserOmit
complexInvitation?: Prisma.ComplexInvitationOmit

View File

@@ -55,6 +55,8 @@ export const ModelName = {
Session: 'Session',
Account: 'Account',
Verification: 'Verification',
ComplexBilling: 'ComplexBilling',
BillingEvent: 'BillingEvent',
Complex: 'Complex',
ComplexUser: 'ComplexUser',
ComplexInvitation: 'ComplexInvitation',
@@ -154,6 +156,42 @@ export const VerificationScalarFieldEnum = {
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
export const ComplexBillingScalarFieldEnum = {
complexId: 'complexId',
status: 'status',
planCode: 'planCode',
currency: 'currency',
provider: 'provider',
providerCustomerId: 'providerCustomerId',
providerSubscriptionId: 'providerSubscriptionId',
providerPreapprovalId: 'providerPreapprovalId',
currentPeriodStart: 'currentPeriodStart',
currentPeriodEnd: 'currentPeriodEnd',
trialEndsAt: 'trialEndsAt',
canceledAt: 'canceledAt',
suspendedAt: 'suspendedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ComplexBillingScalarFieldEnum = (typeof ComplexBillingScalarFieldEnum)[keyof typeof ComplexBillingScalarFieldEnum]
export const BillingEventScalarFieldEnum = {
id: 'id',
complexId: 'complexId',
eventType: 'eventType',
provider: 'provider',
providerEventId: 'providerEventId',
providerData: 'providerData',
previousStatus: 'previousStatus',
newStatus: 'newStatus',
processedAt: 'processedAt'
} as const
export type BillingEventScalarFieldEnum = (typeof BillingEventScalarFieldEnum)[keyof typeof BillingEventScalarFieldEnum]
export const ComplexScalarFieldEnum = {
id: 'id',
complexName: 'complexName',
@@ -360,6 +398,14 @@ export const SortOrder = {
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullableJsonNullValueInput = {
DbNull: DbNull,
JsonNull: JsonNull
} as const
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
export const JsonNullValueInput = {
JsonNull: JsonNull
} as const

View File

@@ -12,6 +12,8 @@ export type * from './models/User'
export type * from './models/Session'
export type * from './models/Account'
export type * from './models/Verification'
export type * from './models/ComplexBilling'
export type * from './models/BillingEvent'
export type * from './models/Complex'
export type * from './models/ComplexUser'
export type * from './models/ComplexInvitation'

View File

@@ -235,6 +235,7 @@ export type ComplexWhereInput = {
invitations?: Prisma.ComplexInvitationListRelationFilter
courts?: Prisma.CourtListRelationFilter
recurringGroups?: Prisma.RecurringBookingGroupListRelationFilter
billing?: Prisma.XOR<Prisma.ComplexBillingNullableScalarRelationFilter, Prisma.ComplexBillingWhereInput> | null
}
export type ComplexOrderByWithRelationInput = {
@@ -254,6 +255,7 @@ export type ComplexOrderByWithRelationInput = {
invitations?: Prisma.ComplexInvitationOrderByRelationAggregateInput
courts?: Prisma.CourtOrderByRelationAggregateInput
recurringGroups?: Prisma.RecurringBookingGroupOrderByRelationAggregateInput
billing?: Prisma.ComplexBillingOrderByWithRelationInput
}
export type ComplexWhereUniqueInput = Prisma.AtLeast<{
@@ -276,6 +278,7 @@ export type ComplexWhereUniqueInput = Prisma.AtLeast<{
invitations?: Prisma.ComplexInvitationListRelationFilter
courts?: Prisma.CourtListRelationFilter
recurringGroups?: Prisma.RecurringBookingGroupListRelationFilter
billing?: Prisma.XOR<Prisma.ComplexBillingNullableScalarRelationFilter, Prisma.ComplexBillingWhereInput> | null
}, "id" | "complexSlug">
export type ComplexOrderByWithAggregationInput = {
@@ -328,6 +331,7 @@ export type ComplexCreateInput = {
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingCreateNestedOneWithoutComplexInput
}
export type ComplexUncheckedCreateInput = {
@@ -346,6 +350,7 @@ export type ComplexUncheckedCreateInput = {
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingUncheckedCreateNestedOneWithoutComplexInput
}
export type ComplexUpdateInput = {
@@ -364,6 +369,7 @@ export type ComplexUpdateInput = {
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateInput = {
@@ -382,6 +388,7 @@ export type ComplexUncheckedUpdateInput = {
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUncheckedUpdateOneWithoutComplexNestedInput
}
export type ComplexCreateManyInput = {
@@ -425,6 +432,11 @@ export type ComplexUncheckedUpdateManyInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type ComplexScalarRelationFilter = {
is?: Prisma.ComplexWhereInput
isNot?: Prisma.ComplexWhereInput
}
export type ComplexCountOrderByAggregateInput = {
id?: Prisma.SortOrder
complexName?: Prisma.SortOrder
@@ -467,11 +479,6 @@ export type ComplexMinOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
}
export type ComplexScalarRelationFilter = {
is?: Prisma.ComplexWhereInput
isNot?: Prisma.ComplexWhereInput
}
export type ComplexListRelationFilter = {
every?: Prisma.ComplexWhereInput
some?: Prisma.ComplexWhereInput
@@ -482,6 +489,20 @@ export type ComplexOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type ComplexCreateNestedOneWithoutBillingInput = {
create?: Prisma.XOR<Prisma.ComplexCreateWithoutBillingInput, Prisma.ComplexUncheckedCreateWithoutBillingInput>
connectOrCreate?: Prisma.ComplexCreateOrConnectWithoutBillingInput
connect?: Prisma.ComplexWhereUniqueInput
}
export type ComplexUpdateOneRequiredWithoutBillingNestedInput = {
create?: Prisma.XOR<Prisma.ComplexCreateWithoutBillingInput, Prisma.ComplexUncheckedCreateWithoutBillingInput>
connectOrCreate?: Prisma.ComplexCreateOrConnectWithoutBillingInput
upsert?: Prisma.ComplexUpsertWithoutBillingInput
connect?: Prisma.ComplexWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ComplexUpdateToOneWithWhereWithoutBillingInput, Prisma.ComplexUpdateWithoutBillingInput>, Prisma.ComplexUncheckedUpdateWithoutBillingInput>
}
export type ComplexCreateNestedOneWithoutUsersInput = {
create?: Prisma.XOR<Prisma.ComplexCreateWithoutUsersInput, Prisma.ComplexUncheckedCreateWithoutUsersInput>
connectOrCreate?: Prisma.ComplexCreateOrConnectWithoutUsersInput
@@ -580,6 +601,94 @@ export type ComplexUncheckedUpdateManyWithoutPlanNestedInput = {
deleteMany?: Prisma.ComplexScalarWhereInput | Prisma.ComplexScalarWhereInput[]
}
export type ComplexCreateWithoutBillingInput = {
id: string
complexName: string
physicalAddress?: string | null
city?: string | null
state?: string | null
country?: string | null
complexSlug: string
adminEmail: string
createdAt?: Date | string
updatedAt?: Date | string
plan?: Prisma.PlanCreateNestedOneWithoutComplexesInput
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupCreateNestedManyWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutBillingInput = {
id: string
complexName: string
physicalAddress?: string | null
city?: string | null
state?: string | null
country?: string | null
complexSlug: string
adminEmail: string
planCode?: string | null
createdAt?: Date | string
updatedAt?: Date | string
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedCreateNestedManyWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutBillingInput = {
where: Prisma.ComplexWhereUniqueInput
create: Prisma.XOR<Prisma.ComplexCreateWithoutBillingInput, Prisma.ComplexUncheckedCreateWithoutBillingInput>
}
export type ComplexUpsertWithoutBillingInput = {
update: Prisma.XOR<Prisma.ComplexUpdateWithoutBillingInput, Prisma.ComplexUncheckedUpdateWithoutBillingInput>
create: Prisma.XOR<Prisma.ComplexCreateWithoutBillingInput, Prisma.ComplexUncheckedCreateWithoutBillingInput>
where?: Prisma.ComplexWhereInput
}
export type ComplexUpdateToOneWithWhereWithoutBillingInput = {
where?: Prisma.ComplexWhereInput
data: Prisma.XOR<Prisma.ComplexUpdateWithoutBillingInput, Prisma.ComplexUncheckedUpdateWithoutBillingInput>
}
export type ComplexUpdateWithoutBillingInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
complexName?: Prisma.StringFieldUpdateOperationsInput | string
physicalAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
state?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
country?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
complexSlug?: Prisma.StringFieldUpdateOperationsInput | string
adminEmail?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
plan?: Prisma.PlanUpdateOneWithoutComplexesNestedInput
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUpdateManyWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutBillingInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
complexName?: Prisma.StringFieldUpdateOperationsInput | string
physicalAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
state?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
country?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
complexSlug?: Prisma.StringFieldUpdateOperationsInput | string
adminEmail?: Prisma.StringFieldUpdateOperationsInput | string
planCode?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedUpdateManyWithoutComplexNestedInput
}
export type ComplexCreateWithoutUsersInput = {
id: string
complexName: string
@@ -595,6 +704,7 @@ export type ComplexCreateWithoutUsersInput = {
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingCreateNestedOneWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutUsersInput = {
@@ -612,6 +722,7 @@ export type ComplexUncheckedCreateWithoutUsersInput = {
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingUncheckedCreateNestedOneWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutUsersInput = {
@@ -645,6 +756,7 @@ export type ComplexUpdateWithoutUsersInput = {
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutUsersInput = {
@@ -662,6 +774,7 @@ export type ComplexUncheckedUpdateWithoutUsersInput = {
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUncheckedUpdateOneWithoutComplexNestedInput
}
export type ComplexCreateWithoutInvitationsInput = {
@@ -679,6 +792,7 @@ export type ComplexCreateWithoutInvitationsInput = {
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingCreateNestedOneWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutInvitationsInput = {
@@ -696,6 +810,7 @@ export type ComplexUncheckedCreateWithoutInvitationsInput = {
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingUncheckedCreateNestedOneWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutInvitationsInput = {
@@ -729,6 +844,7 @@ export type ComplexUpdateWithoutInvitationsInput = {
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutInvitationsInput = {
@@ -746,6 +862,7 @@ export type ComplexUncheckedUpdateWithoutInvitationsInput = {
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUncheckedUpdateOneWithoutComplexNestedInput
}
export type ComplexCreateWithoutCourtsInput = {
@@ -763,6 +880,7 @@ export type ComplexCreateWithoutCourtsInput = {
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingCreateNestedOneWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutCourtsInput = {
@@ -780,6 +898,7 @@ export type ComplexUncheckedCreateWithoutCourtsInput = {
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingUncheckedCreateNestedOneWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutCourtsInput = {
@@ -813,6 +932,7 @@ export type ComplexUpdateWithoutCourtsInput = {
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutCourtsInput = {
@@ -830,6 +950,7 @@ export type ComplexUncheckedUpdateWithoutCourtsInput = {
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUncheckedUpdateOneWithoutComplexNestedInput
}
export type ComplexCreateWithoutRecurringGroupsInput = {
@@ -847,6 +968,7 @@ export type ComplexCreateWithoutRecurringGroupsInput = {
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingCreateNestedOneWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutRecurringGroupsInput = {
@@ -864,6 +986,7 @@ export type ComplexUncheckedCreateWithoutRecurringGroupsInput = {
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingUncheckedCreateNestedOneWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutRecurringGroupsInput = {
@@ -897,6 +1020,7 @@ export type ComplexUpdateWithoutRecurringGroupsInput = {
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutRecurringGroupsInput = {
@@ -914,6 +1038,7 @@ export type ComplexUncheckedUpdateWithoutRecurringGroupsInput = {
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUncheckedUpdateOneWithoutComplexNestedInput
}
export type ComplexCreateWithoutPlanInput = {
@@ -931,6 +1056,7 @@ export type ComplexCreateWithoutPlanInput = {
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingCreateNestedOneWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutPlanInput = {
@@ -948,6 +1074,7 @@ export type ComplexUncheckedCreateWithoutPlanInput = {
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedCreateNestedManyWithoutComplexInput
billing?: Prisma.ComplexBillingUncheckedCreateNestedOneWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutPlanInput = {
@@ -1021,6 +1148,7 @@ export type ComplexUpdateWithoutPlanInput = {
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutPlanInput = {
@@ -1038,6 +1166,7 @@ export type ComplexUncheckedUpdateWithoutPlanInput = {
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
recurringGroups?: Prisma.RecurringBookingGroupUncheckedUpdateManyWithoutComplexNestedInput
billing?: Prisma.ComplexBillingUncheckedUpdateOneWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateManyWithoutPlanInput = {
@@ -1128,6 +1257,7 @@ export type ComplexSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
invitations?: boolean | Prisma.Complex$invitationsArgs<ExtArgs>
courts?: boolean | Prisma.Complex$courtsArgs<ExtArgs>
recurringGroups?: boolean | Prisma.Complex$recurringGroupsArgs<ExtArgs>
billing?: boolean | Prisma.Complex$billingArgs<ExtArgs>
_count?: boolean | Prisma.ComplexCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["complex"]>
@@ -1182,6 +1312,7 @@ export type ComplexInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs
invitations?: boolean | Prisma.Complex$invitationsArgs<ExtArgs>
courts?: boolean | Prisma.Complex$courtsArgs<ExtArgs>
recurringGroups?: boolean | Prisma.Complex$recurringGroupsArgs<ExtArgs>
billing?: boolean | Prisma.Complex$billingArgs<ExtArgs>
_count?: boolean | Prisma.ComplexCountOutputTypeDefaultArgs<ExtArgs>
}
export type ComplexIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1199,6 +1330,7 @@ export type $ComplexPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
invitations: Prisma.$ComplexInvitationPayload<ExtArgs>[]
courts: Prisma.$CourtPayload<ExtArgs>[]
recurringGroups: Prisma.$RecurringBookingGroupPayload<ExtArgs>[]
billing: Prisma.$ComplexBillingPayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1611,6 +1743,7 @@ export interface Prisma__ComplexClient<T, Null = never, ExtArgs extends runtime.
invitations<T extends Prisma.Complex$invitationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$invitationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ComplexInvitationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
courts<T extends Prisma.Complex$courtsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$courtsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
recurringGroups<T extends Prisma.Complex$recurringGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$recurringGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RecurringBookingGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
billing<T extends Prisma.Complex$billingArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$billingArgs<ExtArgs>>): Prisma.Prisma__ComplexBillingClient<runtime.Types.Result.GetResult<Prisma.$ComplexBillingPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -2166,6 +2299,25 @@ export type Complex$recurringGroupsArgs<ExtArgs extends runtime.Types.Extensions
distinct?: Prisma.RecurringBookingGroupScalarFieldEnum | Prisma.RecurringBookingGroupScalarFieldEnum[]
}
/**
* Complex.billing
*/
export type Complex$billingArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ComplexBilling
*/
select?: Prisma.ComplexBillingSelect<ExtArgs> | null
/**
* Omit specific fields from the ComplexBilling
*/
omit?: Prisma.ComplexBillingOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ComplexBillingInclude<ExtArgs> | null
where?: Prisma.ComplexBillingWhereInput
}
/**
* Complex without action
*/

View File

@@ -249,7 +249,7 @@ export async function createAdminRecurringBooking(
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail?.trim() ?? '',
status: 'CONFIRMED',
recurringGroupId: groupId,
recurringGroupId: groupId,
},
include: {
court: {

View File

@@ -0,0 +1,24 @@
import { validate } from '@/lib/http/validate';
import { requireAuth } from '@/middlewares/require-auth.middleware';
import { cancelSubscriptionHandler } from '@/modules/billing/features/cancel-subscription/cancel-subscription.handler';
import { createCheckoutHandler } from '@/modules/billing/features/create-checkout/create-checkout.handler';
import { getBillingStatusHandler } from '@/modules/billing/features/get-billing-status/get-billing-status.handler';
import { mercadopagoWebhookHandler } from '@/modules/billing/features/mercadopago-webhook/mercadopago-webhook.handler';
import { stripeWebhookHandler } from '@/modules/billing/features/stripe-webhook/stripe-webhook.handler';
import type { AppEnv } from '@/types/hono';
import { createCheckoutSchema } from '@repo/api-contract';
import { Hono } from 'hono';
export const billingRoutes = new Hono<AppEnv>();
billingRoutes.post(
'/checkout',
requireAuth,
validate.json(createCheckoutSchema),
createCheckoutHandler
);
billingRoutes.post('/cancel', requireAuth, cancelSubscriptionHandler);
billingRoutes.get('/status', requireAuth, getBillingStatusHandler);
billingRoutes.post('/webhooks/stripe', stripeWebhookHandler);
billingRoutes.post('/webhooks/mercadopago', mercadopagoWebhookHandler);

View File

@@ -0,0 +1,47 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import { getBilling } from '@/modules/billing/services/billing-repository.service';
import { getProviderForComplex } from '@/modules/billing/services/provider-resolver.service';
export async function cancelSubscription(
user: { id: string },
complexId: string
): Promise<Result<{ status: string }>> {
const userAccess = await db.complexUser.findUnique({
where: { complexId_userId: { complexId, userId: user.id } },
select: { role: true },
});
if (!userAccess || userAccess.role !== 'ADMIN') {
return err(Errors.forbidden('Only admins can cancel the subscription'));
}
const billingResult = await getBilling(complexId);
if (!billingResult.ok) return err(billingResult.error);
const billing = billingResult.value;
if (billing.status === 'CANCELED' || billing.status === 'SUSPENDED') {
return err(Errors.conflict('La suscripción ya está cancelada o suspendida'));
}
const providerResult = await getProviderForComplex(complexId);
if (!providerResult.ok) return err(providerResult.error);
const { provider } = providerResult.value;
const subId = billing.providerSubscriptionId;
if (subId) {
const cancelResult = await provider.cancelSubscription(subId);
if (!cancelResult.ok) return err(cancelResult.error);
}
await db.complexBilling.update({
where: { complexId },
data: {
status: 'CANCELED',
canceledAt: new Date(),
},
});
return ok({ status: 'CANCELED' });
}

View File

@@ -0,0 +1,17 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { getCookie } from 'hono/cookie';
import { cancelSubscription } from './cancel-subscription.business';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
export async function cancelSubscriptionHandler(c: AppContext) {
const user = c.get('user');
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
if (!complexId) {
return c.json({ message: 'No complex selected' }, 400);
}
return handleResult(c, await cancelSubscription(user, complexId));
}

View File

@@ -0,0 +1,80 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import { hasActiveSubscription } from '@/modules/billing/services/billing-repository.service';
import { getProviderForComplex } from '@/modules/billing/services/provider-resolver.service';
import type { CheckoutResponse, CreateCheckoutInput } from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
export async function createCheckout(
user: { id: string; email: string },
complexId: string,
input: CreateCheckoutInput
): Promise<Result<CheckoutResponse>> {
const complex = await db.complex.findUnique({
where: { id: complexId },
select: { id: true, country: true },
});
if (!complex) {
return err(Errors.notFound('Complex not found'));
}
const userAccess = await db.complexUser.findUnique({
where: { complexId_userId: { complexId, userId: user.id } },
});
if (!userAccess) {
return err(Errors.forbidden('You do not have access to this complex'));
}
const activeResult = await hasActiveSubscription(complexId);
if (!activeResult.ok) return err(activeResult.error);
if (activeResult.value) {
return err(Errors.conflict('Ya existe una suscripción activa para este complejo'));
}
const providerResult = await getProviderForComplex(complexId);
if (!providerResult.ok) return err(providerResult.error);
const { provider, country } = providerResult.value;
const currency = country === 'AR' ? 'ARS' : 'USD';
const baseUrl = Bun.env.FRONTEND_BASE_URL || Bun.env.APP_BASE_URL || 'http://localhost:5173';
const successUrl = `${baseUrl}/billing/success`;
const cancelUrl = `${baseUrl}/billing/cancel`;
const checkoutResult = await provider.createCheckout({
planCode: input.planCode,
complexId,
customerEmail: user.email,
successUrl,
cancelUrl,
});
if (!checkoutResult.ok) return err(checkoutResult.error);
await db.complexBilling.upsert({
where: { complexId },
create: {
complexId,
planCode: input.planCode,
currency,
status: 'TRIAL',
provider: country === 'AR' ? 'MERCADOPAGO' : 'STRIPE',
providerSubscriptionId: checkoutResult.value.providerSubscriptionId,
},
update: {
planCode: input.planCode,
currency,
status: 'TRIAL',
provider: country === 'AR' ? 'MERCADOPAGO' : 'STRIPE',
providerSubscriptionId: checkoutResult.value.providerSubscriptionId,
},
});
return ok({
checkoutUrl: checkoutResult.value.checkoutUrl,
subscriptionId: checkoutResult.value.providerSubscriptionId,
});
}

View File

@@ -0,0 +1,19 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import type { CreateCheckoutInput } from '@repo/api-contract';
import { getCookie } from 'hono/cookie';
import { createCheckout } from './create-checkout.business';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
export async function createCheckoutHandler(c: AppContext) {
const user = c.get('user');
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
const payload = c.req.valid('json' as never) as CreateCheckoutInput;
if (!complexId) {
return c.json({ message: 'No complex selected' }, 400);
}
return handleResult(c, await createCheckout(user, complexId, payload), 201);
}

View File

@@ -0,0 +1,26 @@
import { Errors } from '@/lib/errors';
import { type Result, err, ok } from '@/lib/result';
import { getBilling } from '@/modules/billing/services/billing-repository.service';
import type { BillingStatusResponse } from '@repo/api-contract';
export async function getBillingStatus(
_user: { id: string },
complexId: string
): Promise<Result<BillingStatusResponse>> {
const billingResult = await getBilling(complexId);
if (!billingResult.ok) return err(billingResult.error);
const billing = billingResult.value;
return ok({
complexId: billing.complexId,
status: billing.status,
planCode: billing.planCode,
currency: billing.currency,
provider: billing.provider,
currentPeriodEnd: billing.currentPeriodEnd?.toISOString() ?? null,
currentPeriodStart: billing.currentPeriodStart?.toISOString() ?? null,
trialEndsAt: billing.trialEndsAt?.toISOString() ?? null,
canceledAt: billing.canceledAt?.toISOString() ?? null,
suspendedAt: billing.suspendedAt?.toISOString() ?? null,
});
}

View File

@@ -0,0 +1,17 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { getCookie } from 'hono/cookie';
import { getBillingStatus } from './get-billing-status.business';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
export async function getBillingStatusHandler(c: AppContext) {
const user = c.get('user');
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
if (!complexId) {
return c.json({ message: 'No complex selected' }, 400);
}
return handleResult(c, await getBillingStatus(user, complexId));
}

View File

@@ -0,0 +1,133 @@
import { Errors } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type { WebhookEvent } from '@/modules/billing/providers/billing-provider.interface';
import { mercadopagoBillingProvider } from '@/modules/billing/providers/mercadopago.provider';
import {
findEventByProviderId,
recordBillingEvent,
updateBillingStatus,
} from '@/modules/billing/services/billing-repository.service';
import type { BillingStatusEnum } from '@/modules/billing/services/billing-repository.service';
const MP_PROVIDER = 'MERCADOPAGO';
function mapWebhookTypeToStatus(event: WebhookEvent): BillingStatusEnum | null {
switch (event.type) {
case 'activated':
return 'ACTIVE';
case 'updated':
return 'ACTIVE';
case 'payment_failed':
return 'PAST_DUE';
case 'cancelled':
return 'CANCELED';
default:
return null;
}
}
async function getBillingByPreapprovalId(preapprovalId: string) {
return db.complexBilling.findFirst({
where: { providerPreapprovalId: preapprovalId },
}) as Promise<{ complexId: string; status: BillingStatusEnum } | null>;
}
async function getBillingByExternalRef(externalRef: string) {
return db.complexBilling.findUnique({
where: { complexId: externalRef },
}) as Promise<{ complexId: string; status: BillingStatusEnum } | null>;
}
export async function processMercadoPagoWebhook(
body: Record<string, unknown>,
signature: string | undefined,
requestId: string | undefined
): Promise<Result<{ received: boolean }>> {
const parsedResult = await mercadopagoBillingProvider.parseWebhook(body, signature, requestId);
if (!parsedResult.ok) {
if (parsedResult.error.type === 'validation') {
return err(parsedResult.error);
}
logger.error({ error: parsedResult.error }, 'MP webhook parse failed');
return ok({ received: true });
}
const event = parsedResult.value;
const existingResult = await findEventByProviderId(event.providerEventId);
if (!existingResult.ok) return err(existingResult.error);
if (existingResult.value) {
logger.info(
{ providerEventId: event.providerEventId },
'MP webhook: already processed, skipping'
);
return ok({ received: true });
}
const newStatus = mapWebhookTypeToStatus(event);
try {
const billing =
(await getBillingByPreapprovalId(event.providerSubscriptionId)) ??
(await getBillingByExternalRef(event.providerSubscriptionId));
if (!billing) {
logger.info(
{ providerSubscriptionId: event.providerSubscriptionId, eventType: event.type },
'MP webhook: no billing record found'
);
await recordBillingEvent({
complexId: 'unknown',
eventType: `unlinked:${event.type}`,
provider: MP_PROVIDER,
providerEventId: event.providerEventId,
providerData: event.data ?? null,
previousStatus: null,
newStatus: null,
});
return ok({ received: true });
}
const previousStatus = billing.status;
if (newStatus) {
await updateBillingStatus(billing.complexId, newStatus, {
provider: MP_PROVIDER,
providerPreapprovalId: event.providerSubscriptionId,
...(event.type === 'activated' || event.type === 'updated'
? {
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}
: {}),
});
}
await recordBillingEvent({
complexId: billing.complexId,
eventType: event.type,
provider: MP_PROVIDER,
providerEventId: event.providerEventId,
providerData: event.data ?? null,
previousStatus,
newStatus,
});
return ok({ received: true });
} catch (error) {
logger.error({ error, providerEventId: event.providerEventId }, 'MP webhook processing failed');
await recordBillingEvent({
complexId: 'unknown',
eventType: `error:${event.type}`,
provider: MP_PROVIDER,
providerEventId: event.providerEventId,
providerData: { error: String(error) },
previousStatus: null,
newStatus: null,
}).catch((e) => logger.error({ error: e }, 'Failed to record error event'));
return ok({ received: true });
}
}

View File

@@ -0,0 +1,15 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { processMercadoPagoWebhook } from './mercadopago-webhook.business';
export async function mercadopagoWebhookHandler(c: AppContext) {
const body = await c.req.json();
const signature = c.req.header('x-signature');
const requestId = c.req.header('x-request-id');
return handleResult(
c,
await processMercadoPagoWebhook(body, signature ?? undefined, requestId ?? undefined),
200
);
}

View File

@@ -0,0 +1,128 @@
import { Errors } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type { WebhookEvent } from '@/modules/billing/providers/billing-provider.interface';
import { stripeBillingProvider } from '@/modules/billing/providers/stripe.provider';
import {
findEventByProviderId,
recordBillingEvent,
updateBillingStatus,
} from '@/modules/billing/services/billing-repository.service';
import type { BillingStatusEnum } from '@/modules/billing/services/billing-repository.service';
const STRIPE_PROVIDER = 'STRIPE';
function mapWebhookTypeToStatus(event: WebhookEvent): BillingStatusEnum | null {
switch (event.type) {
case 'activated':
return 'ACTIVE';
case 'updated':
return 'ACTIVE';
case 'payment_failed':
return 'PAST_DUE';
case 'cancelled':
return 'CANCELED';
default:
return null;
}
}
async function getBillingBySubscription(providerSubscriptionId: string) {
return db.complexBilling.findFirst({
where: { providerSubscriptionId },
}) as Promise<{ complexId: string; status: BillingStatusEnum } | null>;
}
export async function processStripeWebhook(
rawBody: string,
signature: string | undefined
): Promise<Result<{ received: boolean }>> {
const parsedResult = await stripeBillingProvider.parseWebhook(rawBody, signature);
if (!parsedResult.ok) {
if (parsedResult.error.type === 'validation') {
return err(parsedResult.error);
}
logger.error({ error: parsedResult.error }, 'Stripe webhook parse failed');
return ok({ received: true });
}
const event = parsedResult.value;
const existingResult = await findEventByProviderId(event.providerEventId);
if (!existingResult.ok) return err(existingResult.error);
if (existingResult.value) {
logger.info(
{ providerEventId: event.providerEventId },
'Stripe webhook: already processed, skipping'
);
return ok({ received: true });
}
const newStatus = mapWebhookTypeToStatus(event);
try {
const billing = await getBillingBySubscription(event.providerSubscriptionId);
if (!billing) {
logger.info(
{ providerSubscriptionId: event.providerSubscriptionId, eventType: event.type },
'Stripe webhook: no billing record found for subscription'
);
await recordBillingEvent({
complexId: 'unknown',
eventType: `unlinked:${event.type}`,
provider: STRIPE_PROVIDER,
providerEventId: event.providerEventId,
providerData: event.data ?? null,
previousStatus: null,
newStatus: null,
});
return ok({ received: true });
}
const previousStatus = billing.status;
if (newStatus) {
await updateBillingStatus(billing.complexId, newStatus, {
provider: STRIPE_PROVIDER,
providerCustomerId: event.providerCustomerId,
providerSubscriptionId: event.providerSubscriptionId,
...(event.type === 'activated' || event.type === 'updated'
? {
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}
: {}),
});
}
await recordBillingEvent({
complexId: billing.complexId,
eventType: event.type,
provider: STRIPE_PROVIDER,
providerEventId: event.providerEventId,
providerData: event.data ?? null,
previousStatus,
newStatus,
});
return ok({ received: true });
} catch (error) {
logger.error(
{ error, providerEventId: event.providerEventId },
'Stripe webhook processing failed'
);
await recordBillingEvent({
complexId: 'unknown',
eventType: `error:${event.type}`,
provider: STRIPE_PROVIDER,
providerEventId: event.providerEventId,
providerData: { error: String(error) },
previousStatus: null,
newStatus: null,
}).catch((e) => logger.error({ error: e }, 'Failed to record error event'));
return ok({ received: true });
}
}

View File

@@ -0,0 +1,10 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { processStripeWebhook } from './stripe-webhook.business';
export async function stripeWebhookHandler(c: AppContext) {
const body = await c.req.raw.text();
const signature = c.req.header('stripe-signature');
return handleResult(c, await processStripeWebhook(body, signature ?? undefined), 200);
}

View File

@@ -0,0 +1,34 @@
import type { Result } from '@/lib/result';
export type WebhookEventType = 'activated' | 'updated' | 'cancelled' | 'payment_failed';
export type WebhookEvent = {
providerEventId: string;
type: WebhookEventType;
providerSubscriptionId: string;
providerCustomerId?: string;
data?: Record<string, unknown>;
};
export type CheckoutParams = {
planCode: string;
complexId: string;
customerEmail: string;
successUrl: string;
cancelUrl: string;
};
export type CheckoutResult = {
checkoutUrl: string;
providerSubscriptionId: string;
};
export interface BillingProvider {
createCheckout(params: CheckoutParams): Promise<Result<CheckoutResult>>;
cancelSubscription(providerSubscriptionId: string): Promise<Result<void>>;
parseWebhook(
payload: unknown,
signature?: string,
requestId?: string
): Promise<Result<WebhookEvent>>;
}

View File

@@ -0,0 +1,164 @@
import { Errors } from '@/lib/errors';
import { logger } from '@/lib/logger';
import type { Result } from '@/lib/result';
import { err, ok } from '@/lib/result';
import MercadoPagoConfig, { PreApproval } from 'mercadopago';
import { WebhookSignatureValidator } from 'mercadopago';
import type {
BillingProvider,
CheckoutParams,
CheckoutResult,
WebhookEvent,
} from './billing-provider.interface';
function getMpConfig(): MercadoPagoConfig {
const accessToken = Bun.env.MERCADOPAGO_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('Missing MERCADOPAGO_ACCESS_TOKEN env var');
}
return new MercadoPagoConfig({ accessToken });
}
let _mpConfig: MercadoPagoConfig | null = null;
let _preApproval: PreApproval | null = null;
function preapproval(): PreApproval {
if (!_preApproval) {
_mpConfig = getMpConfig();
_preApproval = new PreApproval(_mpConfig);
}
return _preApproval;
}
function getMpPlanId(planCode: string, currency: string): string {
const key = `MERCADOPAGO_PLAN_ID_${currency}_${planCode}` as const;
const planId = Bun.env[key];
if (!planId) {
throw new Error(`Missing env var ${key} for Mercado Pago plan ID`);
}
return planId;
}
function isSandbox(): boolean {
return Bun.env.BILLING_ENV !== 'production';
}
export const mercadopagoBillingProvider: BillingProvider = {
async createCheckout(params: CheckoutParams): Promise<Result<CheckoutResult>> {
try {
const { planCode, complexId, customerEmail, successUrl } = params;
const currency = 'ARS';
const planId = getMpPlanId(planCode, currency);
const response = await preapproval().create({
body: {
preapproval_plan_id: planId,
reason: `Suscripción ${planCode} - Playzer`,
payer_email: customerEmail,
external_reference: complexId,
back_url: successUrl,
status: isSandbox() ? 'pending' : undefined,
auto_recurring: undefined,
},
});
if (!response.id || !response.init_point) {
return err(Errors.unexpected('Mercado Pago did not return a preapproval ID or init_point'));
}
return ok({
checkoutUrl: response.init_point,
providerSubscriptionId: response.id,
});
} catch (error) {
logger.error(
{ error, params: { planCode: params.planCode } },
'MercadoPago createCheckout failed'
);
return err(Errors.unexpected('Failed to create Mercado Pago preapproval'));
}
},
async cancelSubscription(providerSubscriptionId: string): Promise<Result<void>> {
try {
await preapproval().update({
id: providerSubscriptionId,
body: { status: 'cancelled' },
});
return ok(undefined);
} catch (error) {
logger.error({ error, providerSubscriptionId }, 'MercadoPago cancelSubscription failed');
return err(Errors.unexpected('Failed to cancel Mercado Pago preapproval'));
}
},
async parseWebhook(
payload: unknown,
signature?: string,
requestId?: string
): Promise<Result<WebhookEvent>> {
try {
const body = payload as Record<string, unknown>;
const action = body?.action as string | undefined;
const dataId = (body?.data as Record<string, unknown>)?.id as string | undefined;
if (!action || !dataId) {
return err(
Errors.validation('Invalid Mercado Pago webhook payload: missing action or data.id')
);
}
if (signature && requestId) {
const secret = Bun.env.MERCADOPAGO_WEBHOOK_SECRET;
if (secret) {
try {
WebhookSignatureValidator.validate({
xSignature: signature,
xRequestId: requestId,
dataId,
secret,
toleranceSeconds: 300,
});
} catch {
return err(Errors.validation('Invalid Mercado Pago webhook signature'));
}
}
}
let webhookType: WebhookEvent['type'];
switch (action) {
case 'subscription_authorized_payment':
webhookType = 'activated';
break;
case 'subscription_cancelled':
webhookType = 'cancelled';
break;
case 'subscription_updated':
webhookType = 'updated';
break;
case 'subscription_charge_payment':
webhookType = 'payment_failed';
break;
default:
logger.info(
{ action },
'Mercado Pago webhook: unhandled action type, treating as updated'
);
webhookType = 'updated';
}
return ok({
providerEventId: `mp-${dataId}-${action}`,
type: webhookType,
providerSubscriptionId: dataId,
providerCustomerId: undefined,
data: body as Record<string, unknown>,
});
} catch (error) {
logger.error({ error }, 'MercadoPago webhook parsing failed');
return err(Errors.validation('Invalid Mercado Pago webhook payload'));
}
},
};

View File

@@ -0,0 +1,165 @@
import { Errors } from '@/lib/errors';
import { logger } from '@/lib/logger';
import type { Result } from '@/lib/result';
import { err, ok } from '@/lib/result';
import Stripe from 'stripe';
import type {
BillingProvider,
CheckoutParams,
CheckoutResult,
WebhookEvent,
} from './billing-provider.interface';
function getStripeClient(): Stripe {
const secretKey = Bun.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('Missing STRIPE_SECRET_KEY env var');
}
return new Stripe(secretKey);
}
let _stripeClient: Stripe | null = null;
function stripe(): Stripe {
if (!_stripeClient) {
_stripeClient = getStripeClient();
}
return _stripeClient;
}
function getStripePriceId(planCode: string, currency: string): string {
const key = `STRIPE_PRICE_ID_${currency}_${planCode}` as const;
const priceId = Bun.env[key];
if (!priceId) {
throw new Error(`Missing env var ${key} for Stripe price ID`);
}
return priceId;
}
export const stripeBillingProvider: BillingProvider = {
async createCheckout(params: CheckoutParams): Promise<Result<CheckoutResult>> {
try {
const { planCode, complexId, customerEmail, successUrl, cancelUrl } = params;
const currency = 'USD';
const priceId = getStripePriceId(planCode, currency);
const session = await stripe().checkout.sessions.create({
mode: 'subscription',
customer_email: customerEmail,
line_items: [
{
price: priceId,
quantity: 1,
},
],
metadata: {
complexId,
planCode,
},
subscription_data: {
metadata: {
complexId,
planCode,
},
},
success_url: successUrl,
cancel_url: cancelUrl,
});
if (!session.url || !session.subscription) {
return err(Errors.unexpected('Stripe did not return a checkout URL or subscription ID'));
}
const subscriptionId =
typeof session.subscription === 'string' ? session.subscription : session.subscription.id;
return ok({
checkoutUrl: session.url,
providerSubscriptionId: subscriptionId,
});
} catch (error) {
logger.error(
{ error, params: { planCode: params.planCode } },
'Stripe createCheckout failed'
);
return err(Errors.unexpected('Failed to create Stripe checkout session'));
}
},
async cancelSubscription(providerSubscriptionId: string): Promise<Result<void>> {
try {
await stripe().subscriptions.cancel(providerSubscriptionId);
return ok(undefined);
} catch (error) {
logger.error({ error, providerSubscriptionId }, 'Stripe cancelSubscription failed');
return err(Errors.unexpected('Failed to cancel Stripe subscription'));
}
},
async parseWebhook(payload: unknown, signature?: string): Promise<Result<WebhookEvent>> {
if (!signature) {
return err(Errors.validation('Missing Stripe signature'));
}
const secret = Bun.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
return err(Errors.unexpected('Missing STRIPE_WEBHOOK_SECRET env var'));
}
try {
const rawBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
const event = stripe().webhooks.constructEvent(rawBody, signature, secret);
const eventType = event.type;
const data = event.data.object as unknown as Record<string, unknown>;
let webhookType: WebhookEvent['type'];
let providerSubscriptionId: string | undefined;
let providerCustomerId: string | undefined;
if (eventType === 'checkout.session.completed') {
webhookType = 'activated';
providerSubscriptionId = data.subscription as string | undefined;
providerCustomerId = data.customer as string | undefined;
} else if (eventType === 'customer.subscription.updated') {
const status = data.status as string | undefined;
if (status === 'past_due') {
webhookType = 'payment_failed';
} else if (status === 'active' || status === 'trialing') {
webhookType = 'updated';
} else if (status === 'canceled' || status === 'unpaid') {
webhookType = 'cancelled';
} else {
webhookType = 'updated';
}
providerSubscriptionId = data.id as string | undefined;
providerCustomerId = data.customer as string | undefined;
} else if (eventType === 'customer.subscription.deleted') {
webhookType = 'cancelled';
providerSubscriptionId = data.id as string | undefined;
providerCustomerId = data.customer as string | undefined;
} else if (eventType === 'invoice.payment_failed') {
webhookType = 'payment_failed';
providerSubscriptionId = data.subscription as string | undefined;
providerCustomerId = data.customer as string | undefined;
} else {
logger.info({ eventType }, 'Stripe webhook: unhandled event type, skipping');
webhookType = 'updated';
providerSubscriptionId =
(data as { subscription?: string })?.subscription ?? `unknown-${event.id}`;
}
return ok({
providerEventId: event.id,
type: webhookType,
providerSubscriptionId: providerSubscriptionId ?? `unknown-${event.id}`,
providerCustomerId,
data,
});
} catch (error) {
logger.error({ error }, 'Stripe webhook parsing failed');
return err(Errors.validation('Invalid Stripe webhook signature or payload'));
}
},
};

View File

@@ -0,0 +1,38 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
export async function canUseApp(complexId: string): Promise<Result<true>> {
const billing = await db.complexBilling.findUnique({
where: { complexId },
select: { status: true },
});
if (!billing) {
return ok(true);
}
if (billing.status === 'TRIAL' || billing.status === 'ACTIVE') {
return ok(true);
}
if (billing.status === 'CANCELED') {
return err(
Errors.forbidden('Suscripción cancelada. Renueva tu plan para seguir usando la app.')
);
}
if (billing.status === 'PAST_DUE') {
return err(
Errors.forbidden(
'Suscripción con pago pendiente. Regularizá tu deuda para seguir usando la app.'
)
);
}
if (billing.status === 'SUSPENDED') {
return err(Errors.forbidden('Suscripción suspendida. Contactanos para reactivarla.'));
}
return ok(true);
}

View File

@@ -0,0 +1,150 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import { v7 as uuidv7 } from 'uuid';
export type BillingProviderEnum = 'STRIPE' | 'MERCADOPAGO' | 'PAYPAL';
export type BillingStatusEnum = 'TRIAL' | 'ACTIVE' | 'PAST_DUE' | 'CANCELED' | 'SUSPENDED';
export type ComplexBillingRow = {
complexId: string;
status: BillingStatusEnum;
planCode: string;
currency: string;
provider: BillingProviderEnum | null;
providerCustomerId: string | null;
providerSubscriptionId: string | null;
providerPreapprovalId: string | null;
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
trialEndsAt: Date | null;
canceledAt: Date | null;
suspendedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export async function getBilling(complexId: string): Promise<Result<ComplexBillingRow>> {
const billing = await db.complexBilling.findUnique({
where: { complexId },
});
if (!billing) {
return err(Errors.notFound('Billing record not found for this complex'));
}
return ok(billing as unknown as ComplexBillingRow);
}
export async function ensureBilling(
complexId: string,
currency: string
): Promise<Result<ComplexBillingRow>> {
const existing = await db.complexBilling.findUnique({
where: { complexId },
});
if (existing) {
return ok(existing as unknown as ComplexBillingRow);
}
const created = await db.complexBilling.create({
data: {
complexId,
status: 'TRIAL',
planCode: 'BASIC',
currency,
},
});
return ok(created as unknown as ComplexBillingRow);
}
export async function updateBillingStatus(
complexId: string,
status: BillingStatusEnum,
extra?: {
provider?: BillingProviderEnum;
providerCustomerId?: string;
providerSubscriptionId?: string;
providerPreapprovalId?: string;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
}
): Promise<Result<ComplexBillingRow>> {
try {
const data: Record<string, unknown> = { status };
if (extra?.provider) data.provider = extra.provider;
if (extra?.providerCustomerId) data.providerCustomerId = extra.providerCustomerId;
if (extra?.providerSubscriptionId) data.providerSubscriptionId = extra.providerSubscriptionId;
if (extra?.providerPreapprovalId) data.providerPreapprovalId = extra.providerPreapprovalId;
if (extra?.currentPeriodStart) data.currentPeriodStart = extra.currentPeriodStart;
if (extra?.currentPeriodEnd) data.currentPeriodEnd = extra.currentPeriodEnd;
const updated = await db.complexBilling.update({
where: { complexId },
data: data as never,
});
return ok(updated as unknown as ComplexBillingRow);
} catch {
return err(Errors.unexpected('Failed to update billing status'));
}
}
export async function recordBillingEvent(params: {
complexId: string;
eventType: string;
provider: BillingProviderEnum;
providerEventId: string | null;
providerData: unknown;
previousStatus: BillingStatusEnum | null;
newStatus: BillingStatusEnum | null;
}): Promise<Result<{ id: string }>> {
try {
const event = await db.billingEvent.create({
data: {
id: uuidv7(),
complexId: params.complexId,
eventType: params.eventType,
provider: params.provider,
providerEventId: params.providerEventId,
providerData: params.providerData as never,
previousStatus: params.previousStatus,
newStatus: params.newStatus,
},
select: { id: true },
});
return ok({ id: event.id });
} catch (error: unknown) {
const prismaError = error as { code?: string };
if (prismaError.code === 'P2002') {
return err(Errors.conflict('Webhook event already processed (idempotency)'));
}
return err(Errors.unexpected('Failed to record billing event'));
}
}
export async function findEventByProviderId(
providerEventId: string
): Promise<Result<{ id: string } | null>> {
const event = await db.billingEvent.findUnique({
where: { providerEventId },
select: { id: true },
});
return ok(event ?? null);
}
export async function hasActiveSubscription(complexId: string): Promise<Result<boolean>> {
const billing = await db.complexBilling.findUnique({
where: { complexId },
select: { status: true },
});
if (!billing) return ok(false);
return ok(billing.status === 'ACTIVE' || billing.status === 'TRIAL');
}

View File

@@ -0,0 +1,33 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import type { Result } from '@/lib/result';
import { err, ok } from '@/lib/result';
import type { BillingProvider } from '@/modules/billing/providers/billing-provider.interface';
import { mercadopagoBillingProvider } from '@/modules/billing/providers/mercadopago.provider';
import { stripeBillingProvider } from '@/modules/billing/providers/stripe.provider';
export const STRIPE = stripeBillingProvider;
export const MERCADOPAGO = mercadopagoBillingProvider;
export function resolveProviderByCountry(country: string | null): BillingProvider {
if (country === 'AR' || country?.toLowerCase() === 'argentina') {
return MERCADOPAGO;
}
return STRIPE;
}
export async function getProviderForComplex(
complexId: string
): Promise<Result<{ provider: BillingProvider; country: string | null }>> {
const complex = await db.complex.findUnique({
where: { id: complexId },
select: { country: true },
});
if (!complex) {
return err(Errors.notFound('Complex not found'));
}
const provider = resolveProviderByCountry(complex.country);
return ok({ provider, country: complex.country });
}

View File

@@ -173,9 +173,7 @@ export function validateSportSelection(
});
}
export async function getComplexWithBookingData(
complexSlug: string
) {
export async function getComplexWithBookingData(complexSlug: string) {
const complex = await db.complex.findUnique({
where: { complexSlug },
select: {

View File

@@ -2,6 +2,7 @@ import path from 'node:path';
import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes';
import { adminRoutes } from '@/modules/admin/admin.routes';
import { authRoutes } from '@/modules/auth/auth.routes';
import { billingRoutes } from '@/modules/billing/billing.routes';
import { complexRoutes } from '@/modules/complex/complex.routes';
import { courtRoutes } from '@/modules/court/court.routes';
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
@@ -27,6 +28,7 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('/api/courts', courtRoutes)
.route('/api/public-bookings', publicBookingRoutes)
.route('/api/admin-bookings', adminBookingRoutes)
.route('/api/billing', billingRoutes)
.route('/api/health', healthCheckRoutes);
registerEventsRoutes(app);

View 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);
}
});
});

View 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');
});
});

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

View 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');
}
});
});

View 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);
});
});

View 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');
});
});

View File

@@ -27,6 +27,8 @@ export const dbMock = {
courtBookingLog: prismaMock.courtBookingLog,
courtAvailability: prismaMock.courtAvailability,
courtPriceRule: prismaMock.courtPriceRule,
complexBilling: prismaMock.complexBilling,
billingEvent: prismaMock.billingEvent,
$transaction: transactionMock,
};

View File

@@ -26,12 +26,14 @@
"dotenv": "^17.4.1",
"hono": "4.12.10",
"mailtrap": "^4.5.1",
"mercadopago": "^3.1.0",
"nodemailer": "^8.0.5",
"pg": "^8.20.0",
"pino": "10.3.1",
"pino-pretty": "13.1.3",
"pino-std-serializers": "7.1.0",
"prisma": "^7",
"stripe": "^22.2.2",
"uuid": "^13.0.0",
},
"devDependencies": {
@@ -625,7 +627,7 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
@@ -731,7 +733,7 @@
"bun-mock-prisma": ["bun-mock-prisma@1.2.1", "", { "peerDependencies": { "bun-types": ">=1.0.0" } }, "sha512-1c6CByUMk9acH7R87lcVoJqPpUWoqHRu6VwipXzU94s03/FXotACOt2DMgGhfz2xw5218BVoPJiH0QQXUwgmWg=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1197,6 +1199,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"mercadopago": ["mercadopago@3.1.0", "", {}, "sha512-3VB8AFpE8eZgm67e22kVbR19k9JC/1j66voLSh7m9tPvkASuRKNH2Y7yb793rO+fIpMtvl+wBj27UY4jIItWYA=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
@@ -1569,6 +1573,8 @@
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"stripe": ["stripe@22.2.2", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lRXLiarjW/I2QVa6QuFfz1Ma7Dc2yBQ7vlYH6NEmp5htMJNQGfiWExmOv0McfqY5wMCGV8DLuCvsyVRIPJlUUQ=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],

283
docs/billing.md Normal file
View File

@@ -0,0 +1,283 @@
# Billing / Subscriptions Module
## Architecture
```
modules/billing/
├── billing.routes.ts # Rutas Hono
├── features/
│ ├── create-checkout/ # POST /api/billing/checkout
│ │ ├── create-checkout.handler.ts
│ │ └── create-checkout.business.ts
│ ├── cancel-subscription/ # POST /api/billing/cancel
│ │ ├── cancel-subscription.handler.ts
│ │ └── cancel-subscription.business.ts
│ ├── get-billing-status/ # GET /api/billing/status
│ │ ├── get-billing-status.handler.ts
│ │ └── get-billing-status.business.ts
│ ├── stripe-webhook/ # POST /api/billing/webhooks/stripe
│ │ ├── stripe-webhook.handler.ts
│ │ └── stripe-webhook.business.ts
│ └── mercadopago-webhook/ # POST /api/billing/webhooks/mercadopago
│ ├── mercadopago-webhook.handler.ts
│ └── mercadopago-webhook.business.ts
├── providers/
│ ├── billing-provider.interface.ts # BillingProvider abstraction
│ ├── stripe.provider.ts # Stripe implementation
│ └── mercadopago.provider.ts # Mercado Pago implementation
└── services/
├── provider-resolver.service.ts # Resuelve Stripe/MP por país
├── billing-repository.service.ts # DB access (Prisma)
└── billing-access.service.ts # canUseApp() helper
```
### Resolución de provider
| Country | Provider |
|---------|----------|
| `AR` | Mercado Pago |
| Otro / null | Stripe |
La resolución usa el campo `country` del `Complex` (ISO 3166-1 alpha-2).
---
## Variables de entorno
```
# Billing
BILLING_ENV=sandbox|production
FRONTEND_BASE_URL=http://localhost:5173
# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRICE_ID_USD_BASIC=price_basic_usd
STRIPE_PRICE_ID_USD_ADVANCED=price_advanced_usd
STRIPE_PRICE_ID_USD_ENTERPRISE=price_enterprise_usd
# Mercado Pago (Argentina)
MERCADOPAGO_ACCESS_TOKEN=TEST-xxx
MERCADOPAGO_WEBHOOK_SECRET=xxx
MERCADOPAGO_PLAN_ID_ARS_BASIC=2c938084...
MERCADOPAGO_PLAN_ID_ARS_ADVANCED=2c938084...
MERCADOPAGO_PLAN_ID_ARS_ENTERPRISE=2c938084...
```
### Naming de plans
Los IDs de prices/plans en Stripe y Mercado Pago se nombran con el código del Plan interno (`BASIC`, `ADVANCED`, `ENTERPRISE`) y la moneda (`USD`, `ARS`):
```
STRIPE_PRICE_ID_{CURRENCY}_{PLAN_CODE}
MERCADOPAGO_PLAN_ID_{CURRENCY}_{PLAN_CODE}
```
---
## Endpoints
| Método | Ruta | Auth | Descripción |
|--------|------|------|-------------|
| POST | `/api/billing/checkout` | `requireAuth` + cookie | Inicia checkout de suscripción |
| POST | `/api/billing/cancel` | `requireAuth` + cookie | Cancela suscripción activa |
| GET | `/api/billing/status` | `requireAuth` + cookie | Estado actual del billing |
| POST | `/api/billing/webhooks/stripe` | none (firma) | Webhook de Stripe |
| POST | `/api/billing/webhooks/mercadopago` | none (firma) | Webhook de Mercado Pago |
El `complexId` se obtiene de la cookie `selected-complex-id`.
---
## Estados internos
```
TRIAL ──(checkout completed)──► ACTIVE
TRIAL ──(user cancels)────────► CANCELED
ACTIVE ──(payment fails)──────► PAST_DUE
PAST_DUE ──(retry ok)─────────► ACTIVE
PAST_DUE ──(user cancels)─────► CANCELED
ACTIVE ──(user cancels)───────► CANCELED
CANCELED ──(grace ends)───────► SUSPENDED
```
| Estado | Significado |
|--------|-------------|
| `TRIAL` | Período de prueba, puede usar la app |
| `ACTIVE` | Suscripción activa, puede usar la app |
| `PAST_DUE` | Pago fallido, acceso restringido |
| `CANCELED` | Cancelado por usuario, acceso restringido |
| `SUSPENDED` | Suspendido, acceso restringido |
### Helper: `canUseApp(complexId)`
```typescript
import { canUseApp } from '@/modules/billing/services/billing-access.service';
const result = await canUseApp(complexId);
if (!result.ok) {
// Rechazar acceso con mensaje según el estado
}
```
Retorna `ok(true)` si el estado es `TRIAL` o `ACTIVE`. Caso contrario retorna un `AppError` tipo `forbidden` con un mensaje específico según el estado.
---
## Flujo de Checkout
1. **Frontend**`POST /api/billing/checkout` con `{ planCode: "BASIC" }`
2. **Backend** verifica:
- Usuario tiene acceso al complex (cookie)
- No existe suscripción activa
3. **Backend** resuelve provider según `Complex.country`
4. **Provider** genera URL de checkout (Stripe Checkout Session / Mercado Pago Preapproval)
5. **Backend** guarda/actualiza `ComplexBilling` con status `TRIAL`
6. **Frontend** redirige al usuario a `checkoutUrl`
7. **Usuario** completa pago en el provider
8. **Provider** envía webhook → backend procesa y pasa a `ACTIVE`
---
## Flujo de Webhooks
### Idempotencia
Cada webhook tiene un `providerEventId` único (Stripe: `event.id`, MP: `mp-{data.id}-{action}`). El campo `providerEventId` en `BillingEvent` tiene `@unique`.
**Antes de procesar:**
1. Buscar `BillingEvent` por `providerEventId`
2. Si existe → responder 200 (ya procesado)
3. Si no existe → procesar y crear `BillingEvent` dentro de una transacción
### Stripe
```sh
stripe listen --forward-to localhost:3000/api/billing/webhooks/stripe
```
Stripe envía los eventos al endpoint con la firma en el header `stripe-signature`.
**Eventos manejados:**
| Evento | Acción |
|--------|--------|
| `checkout.session.completed` | Activar suscripción (`ACTIVE`) |
| `customer.subscription.updated` (active) | Actualizar período |
| `customer.subscription.updated` (past_due) | Marcar como `PAST_DUE` |
| `customer.subscription.deleted` | Cancelar (`CANCELED`) |
| `invoice.payment_failed` | Marcar como `PAST_DUE` |
### Mercado Pago
Usar ngrok para exponer el servidor local:
```sh
ngrok http 3000
# Configurar la URL del webhook en el dashboard de MP:
# https://www.mercadopago.com.ar/developers/panel/webhooks
# Apuntar a: https://{ngrok-id}.ngrok.app/api/billing/webhooks/mercadopago
```
**Eventos manejados:**
| Acción | Tipo |
|--------|------|
| `subscription_authorized_payment` | `activated``ACTIVE` |
| `subscription_cancelled` | `cancelled``CANCELED` |
| `subscription_updated` | `updated``ACTIVE` |
| `subscription_charge_payment` | `payment_failed``PAST_DUE` |
> **Nota:** Los nombres exactos de eventos de Mercado Pago pueden variar. Verificar en el dashboard de desarrolladores de MP durante integración.
---
## Modelo de datos (Prisma)
### `ComplexBilling`
| Columna | Tipo | Descripción |
|---------|------|-------------|
| `complexId` | `String @id` | FK a Complex |
| `status` | `BillingStatus` | `TRIAL`, `ACTIVE`, `PAST_DUE`, `CANCELED`, `SUSPENDED` |
| `planCode` | `String` | Código del Plan (BASIC, ADVANCED, ENTERPRISE) |
| `currency` | `String` | `ARS` o `USD` |
| `provider` | `BillingProvider?` | `STRIPE`, `MERCADOPAGO`, `PAYPAL` |
| `providerCustomerId` | `String?` | ID del customer en el provider |
| `providerSubscriptionId` | `String?` | ID de la suscripción en Stripe |
| `providerPreapprovalId` | `String?` | ID del preapproval en MP |
| `currentPeriodStart` | `DateTime?` | Inicio del período actual |
| `currentPeriodEnd` | `DateTime?` | Fin del período actual |
| `trialEndsAt` | `DateTime?` | Fin del trial |
| `canceledAt` | `DateTime?` | Fecha de cancelación |
| `suspendedAt` | `DateTime?` | Fecha de suspensión |
### `BillingEvent`
| Columna | Tipo | Descripción |
|---------|------|-------------|
| `id` | `String @id` | UUID |
| `complexId` | `String` | FK a ComplexBilling |
| `eventType` | `String` | Tipo de evento del provider |
| `provider` | `BillingProvider` | Provider que originó el evento |
| `providerEventId` | `String? @unique` | ID único del evento en el provider (idempotencia) |
| `providerData` | `Json?` | Payload completo del webhook |
| `previousStatus` | `BillingStatus?` | Estado anterior |
| `newStatus` | `BillingStatus?` | Nuevo estado |
---
## Agregar un nuevo provider (ej: PayPal)
1. Crear `providers/paypal.provider.ts` implementando `BillingProvider`
2. Agregar `PAYPAL` al enum `BillingProvider` en Prisma
3. Agregar case en `provider-resolver.service.ts` si aplica
4. Agregar webhook handler en `features/paypal-webhook/`
5. Agregar ruta en `billing.routes.ts`
---
## Pruebas en local
### Sin cobrar dinero
- Stripe: Usar claves `sk_test_...`. Stripe automáticamente usa el entorno de prueba con tarjetas de prueba (`4242 4242 4242 4242`).
- Mercado Pago: Usar `BILLING_ENV=sandbox` con `MERCADOPAGO_ACCESS_TOKEN=TEST-...`.
### Stripe CLI (webhooks locales)
```sh
# Instalar: https://stripe.com/docs/stripe-cli
stripe login
stripe listen --forward-to localhost:3000/api/billing/webhooks/stripe
# Copiar el webhook secret que muestra y ponerlo en STRIPE_WEBHOOK_SECRET
```
### Mercado Pago + ngrok (webhooks locales)
```sh
ngrok http 3000
# Configurar webhook en: https://www.mercadopago.com.ar/developers/panel/webhooks
# URL: https://{ngrok-id}.ngrok.app/api/billing/webhooks/mercadopago
```
---
## Comandos
```sh
# Instalar dependencias (ya instalado: stripe, mercadopago)
bun add stripe mercadopago
# Generar cliente Prisma
bun --filter backend prisma:generate
# Crear migración
bun --filter backend prisma:migrate
# Tests
bun run test
# Dev server
bun run dev
```

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
export const billingProviderSchema = z.enum(['STRIPE', 'MERCADOPAGO', 'PAYPAL']);
export const billingStatusSchema = z.enum(['TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELED', 'SUSPENDED']);
export const createCheckoutSchema = z.object({
planCode: z.string().trim().min(1).max(10),
});
export const checkoutResponseSchema = z.object({
checkoutUrl: z.string(),
subscriptionId: z.string(),
});
export const cancelSubscriptionSchema = z.object({});
export const billingStatusResponseSchema = z.object({
complexId: z.string(),
status: billingStatusSchema,
planCode: z.string(),
currency: z.string(),
provider: billingProviderSchema.nullable(),
currentPeriodEnd: z.string().nullable(),
currentPeriodStart: z.string().nullable(),
trialEndsAt: z.string().nullable(),
canceledAt: z.string().nullable(),
suspendedAt: z.string().nullable(),
});
export type BillingProvider = z.infer<typeof billingProviderSchema>;
export type BillingStatus = z.infer<typeof billingStatusSchema>;
export type CreateCheckoutInput = z.infer<typeof createCheckoutSchema>;
export type CheckoutResponse = z.infer<typeof checkoutResponseSchema>;
export type BillingStatusResponse = z.infer<typeof billingStatusResponseSchema>;

View File

@@ -155,6 +155,20 @@ export {
adminGeoCountrySchema,
adminGeoStatsSchema,
} from './admin';
export {
billingProviderSchema,
billingStatusSchema,
billingStatusResponseSchema,
createCheckoutSchema,
checkoutResponseSchema,
} from './billing';
export type {
BillingProvider,
BillingStatus,
BillingStatusResponse,
CreateCheckoutInput,
CheckoutResponse,
} from './billing';
export type {
AdminComplexListItem,
AdminComplexStats,