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:
@@ -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...
|
||||
|
||||
@@ -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": {
|
||||
|
||||
52
apps/backend/prisma/billing.prisma
Normal file
52
apps/backend/prisma/billing.prisma
Normal 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")
|
||||
}
|
||||
@@ -15,6 +15,7 @@ model Complex {
|
||||
invitations ComplexInvitation[]
|
||||
courts Court[]
|
||||
recurringGroups RecurringBookingGroup[]
|
||||
billing ComplexBilling?
|
||||
|
||||
@@index([planCode])
|
||||
@@index([complexSlug])
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
24
apps/backend/src/modules/billing/billing.routes.ts
Normal file
24
apps/backend/src/modules/billing/billing.routes.ts
Normal 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);
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
};
|
||||
165
apps/backend/src/modules/billing/providers/stripe.provider.ts
Normal file
165
apps/backend/src/modules/billing/providers/stripe.provider.ts
Normal 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'));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
66
apps/backend/test/billing/duplicate-subscription.test.ts
Normal file
66
apps/backend/test/billing/duplicate-subscription.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { prismaMock } from '../support/prisma.mock';
|
||||
|
||||
describe('duplicate-subscription', () => {
|
||||
it('returns true when status is ACTIVE', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue({
|
||||
status: 'ACTIVE',
|
||||
} as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-active');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true when status is TRIAL', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue({
|
||||
status: 'TRIAL',
|
||||
} as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-trial');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns false when status is CANCELED', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue({
|
||||
status: 'CANCELED',
|
||||
} as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-canceled');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns false when no billing record exists', async () => {
|
||||
prismaMock.complexBilling.findUnique.mockResolvedValue(null as never);
|
||||
|
||||
const { hasActiveSubscription } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await hasActiveSubscription('complex-no-billing');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
31
apps/backend/test/billing/event-mapping.test.ts
Normal file
31
apps/backend/test/billing/event-mapping.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mapWebhookTypeToStatus } from './helpers';
|
||||
|
||||
describe('event-mapping', () => {
|
||||
it('maps all webhook event types correctly', () => {
|
||||
expect(mapWebhookTypeToStatus('activated')).toBe('ACTIVE');
|
||||
expect(mapWebhookTypeToStatus('updated')).toBe('ACTIVE');
|
||||
expect(mapWebhookTypeToStatus('payment_failed')).toBe('PAST_DUE');
|
||||
expect(mapWebhookTypeToStatus('cancelled')).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('maps Stripe checkout.session.completed -> activated -> ACTIVE', () => {
|
||||
const status = mapWebhookTypeToStatus('activated');
|
||||
expect(status).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('maps Stripe customer.subscription.deleted -> cancelled -> CANCELED', () => {
|
||||
const status = mapWebhookTypeToStatus('cancelled');
|
||||
expect(status).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('maps Mercado Pago subscription_authorized_payment -> activated -> ACTIVE', () => {
|
||||
const status = mapWebhookTypeToStatus('activated');
|
||||
expect(status).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('maps Mercado Pago subscription_cancelled -> cancelled -> CANCELED', () => {
|
||||
const status = mapWebhookTypeToStatus('cancelled');
|
||||
expect(status).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
22
apps/backend/test/billing/helpers.ts
Normal file
22
apps/backend/test/billing/helpers.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Helper that replicates the mapWebhookTypeToStatus logic
|
||||
* used in webhook business files for testability.
|
||||
*/
|
||||
export function mapWebhookTypeToStatus(
|
||||
eventType: 'activated' | 'updated' | 'payment_failed' | 'cancelled'
|
||||
): 'ACTIVE' | 'PAST_DUE' | 'CANCELED' | null {
|
||||
switch (eventType) {
|
||||
case 'activated':
|
||||
return 'ACTIVE';
|
||||
case 'updated':
|
||||
return 'ACTIVE';
|
||||
case 'payment_failed':
|
||||
return 'PAST_DUE';
|
||||
case 'cancelled':
|
||||
return 'CANCELED';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
83
apps/backend/test/billing/idempotency.test.ts
Normal file
83
apps/backend/test/billing/idempotency.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { prismaMock } from '../support/prisma.mock';
|
||||
|
||||
describe('webhook-idempotency', () => {
|
||||
it('returns conflict when providerEventId already exists', async () => {
|
||||
const existingEvent = { id: 'event-1' };
|
||||
|
||||
prismaMock.billingEvent.findUnique.mockResolvedValue(existingEvent as never);
|
||||
|
||||
const { findEventByProviderId } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await findEventByProviderId('mp-duplicate-id');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual({ id: 'event-1' });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when providerEventId does not exist', async () => {
|
||||
prismaMock.billingEvent.findUnique.mockResolvedValue(null as never);
|
||||
|
||||
const { findEventByProviderId } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await findEventByProviderId('new-event-id');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('records a billing event successfully', async () => {
|
||||
const createdEvent = { id: 'new-uuid' };
|
||||
prismaMock.billingEvent.create.mockResolvedValue(createdEvent as never);
|
||||
|
||||
const { recordBillingEvent } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await recordBillingEvent({
|
||||
complexId: 'complex-1',
|
||||
eventType: 'activated',
|
||||
provider: 'STRIPE',
|
||||
providerEventId: 'evt_unique',
|
||||
providerData: { some: 'data' },
|
||||
previousStatus: 'TRIAL',
|
||||
newStatus: 'ACTIVE',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.id).toBe('new-uuid');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns conflict when billing event create has P2002 (duplicate)', async () => {
|
||||
const prismaError = new Error('Unique constraint') as Error & { code: string };
|
||||
prismaError.code = 'P2002';
|
||||
prismaMock.billingEvent.create.mockRejectedValue(prismaError as never);
|
||||
|
||||
const { recordBillingEvent } = await import(
|
||||
'@/modules/billing/services/billing-repository.service'
|
||||
);
|
||||
|
||||
const result = await recordBillingEvent({
|
||||
complexId: 'complex-1',
|
||||
eventType: 'activated',
|
||||
provider: 'STRIPE',
|
||||
providerEventId: 'evt_duplicate',
|
||||
providerData: null,
|
||||
previousStatus: null,
|
||||
newStatus: null,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe('conflict');
|
||||
}
|
||||
});
|
||||
});
|
||||
36
apps/backend/test/billing/provider-resolver.test.ts
Normal file
36
apps/backend/test/billing/provider-resolver.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mercadopagoBillingProvider } from '@/modules/billing/providers/mercadopago.provider';
|
||||
import { stripeBillingProvider } from '@/modules/billing/providers/stripe.provider';
|
||||
import { resolveProviderByCountry } from '@/modules/billing/services/provider-resolver.service';
|
||||
|
||||
describe('provider-resolver', () => {
|
||||
it('returns MERCADOPAGO for country AR', () => {
|
||||
const provider = resolveProviderByCountry('AR');
|
||||
expect(provider).toBe(mercadopagoBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for country BR', () => {
|
||||
const provider = resolveProviderByCountry('BR');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for country US', () => {
|
||||
const provider = resolveProviderByCountry('US');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for null country', () => {
|
||||
const provider = resolveProviderByCountry(null);
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for empty country', () => {
|
||||
const provider = resolveProviderByCountry('');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
|
||||
it('returns STRIPE for any other ISO code', () => {
|
||||
const provider = resolveProviderByCountry('CL');
|
||||
expect(provider).toBe(stripeBillingProvider);
|
||||
});
|
||||
});
|
||||
20
apps/backend/test/billing/status-transitions.test.ts
Normal file
20
apps/backend/test/billing/status-transitions.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mapWebhookTypeToStatus } from './helpers';
|
||||
|
||||
describe('status-transitions', () => {
|
||||
it('activated maps to ACTIVE', () => {
|
||||
expect(mapWebhookTypeToStatus('activated')).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('updated maps to ACTIVE', () => {
|
||||
expect(mapWebhookTypeToStatus('updated')).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('payment_failed maps to PAST_DUE', () => {
|
||||
expect(mapWebhookTypeToStatus('payment_failed')).toBe('PAST_DUE');
|
||||
});
|
||||
|
||||
it('cancelled maps to CANCELED', () => {
|
||||
expect(mapWebhookTypeToStatus('cancelled')).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ export const dbMock = {
|
||||
courtBookingLog: prismaMock.courtBookingLog,
|
||||
courtAvailability: prismaMock.courtAvailability,
|
||||
courtPriceRule: prismaMock.courtPriceRule,
|
||||
complexBilling: prismaMock.complexBilling,
|
||||
billingEvent: prismaMock.billingEvent,
|
||||
$transaction: transactionMock,
|
||||
};
|
||||
|
||||
|
||||
10
bun.lock
10
bun.lock
@@ -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
283
docs/billing.md
Normal 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
|
||||
```
|
||||
34
packages/api-contract/src/billing.ts
Normal file
34
packages/api-contract/src/billing.ts
Normal 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>;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user