diff --git a/.env.example b/.env.example index 7045523..787a95b 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,16 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?sc # Example: postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=members MEMBER_DB_URL="" +# Optional lookup stores used to resolve billing-account line-item names. +# If unset, line items are still returned with externalName omitted. +CHALLENGE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=challenges" +ENGAGEMENTS_DB_URL="postgresql://postgres:postgres@localhost:5432/engagements" + +# Historical engagement-payment backfill sources. +# FINANCE_DB_URL points to tc-finance-api, PROJECTS_DB_URL points to projects-api-v6. +FINANCE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=finance" +PROJECTS_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=projects" + # Auth (tc-core-library-js) AUTH_SECRET="" AUTH0_URL="" # e.g. https://topcoder-dev.auth0.com/ (optional) diff --git a/README.md b/README.md index 292d2c9..3dc95c1 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ - Endpoints: - `GET /billing-accounts` - `POST /billing-accounts` - - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals) + - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility) - `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object) - `PATCH /billing-accounts/:billingAccountId` - - `PATCH /billing-accounts/:billingAccountId/lock-amount` (0 amount = unlock) - - `PATCH /billing-accounts/:billingAccountId/consume-amount` (deletes locks for challenge, then upserts consumed) + - `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds) + - `PATCH /billing-accounts/:billingAccountId/consume-amount` (positive amount; challenge typed references delete the matching lock and overwrite consumed; engagement typed references append a consumed row; rejects insufficient remaining funds) - `GET /billing-accounts/:billingAccountId/users` (list users with access) - `POST /billing-accounts/:billingAccountId/users` (grant access; accepts `{ param: { userId } }` or `{ userId }`) - `DELETE /billing-accounts/:billingAccountId/users/:userId` (revoke access) @@ -74,6 +74,38 @@ pnpm run build && pnpm start - Lookup the matching `user_account` by `user_account_id`, then query the Members DB by `user_name` (handle) - Upsert `BillingAccountAccess` for the resolved `userId` +- Engagement payment consumed backfill: + - Required env: + - `DATABASE_URL` for billing-accounts-api-v6. + - `FINANCE_DB_URL` for tc-finance-api (`winnings` and `payment`). + - `ENGAGEMENTS_DB_URL` for engagements-api-v6 (`EngagementAssignment` and `Engagement`). + - `PROJECTS_DB_URL` for projects-api-v6 (`projects.billingAccountId`, matching the trusted project lookup used by engagements-api-v6). + - Optional `TGBillingAccounts` for finance-compatible TopGear exemptions. If unset, the script uses finance's default exempt accounts `80000062,80002800`. + - Dry run: + - `pnpm run backfill:engagementPayments -- --dry-run` + - Dry run is the default. It reads finance engagement payments, resolves assignment/project/billing account context, plans inserts/updates, and writes a JSON audit report under `scripts/output/`. + - Apply: + - `pnpm run backfill:engagementPayments -- --apply` + - Optional filters: `--assignmentId=[,]`, `--winningId=[,]`, `--since=`, `--until=`, `--limit=`, `--report=`. + - Behavior: + - Uses `payment.challenge_markup` first as a markup rate and computes `total_amount + (total_amount * challenge_markup)`. + - If `payment.challenge_markup` is absent and `payment.challenge_fee` is present, treats `challenge_fee` as an absolute fee amount, computes `total_amount + challenge_fee`, and records the row in the `absoluteFee` audit bucket. + - If neither finance value is present, falls back to the current `BillingAccount.markup` and records that fallback in the audit report. + - Skips automated consumed-row planning for TopGear-exempt billing accounts and records those payments in the `exemptBillingAccounts` audit bucket. + - Apply mode runs billing mutations, post-apply total calculation, and report writing inside one billing database transaction. If a write, verification read, or report write fails before commit, the billing mutations are rolled back. + - Reconciles one assignment-level aggregate row for historical idempotency. Existing correct rows are left alone; a single incorrect row is updated; multiple existing rows are only moved when their total already matches the legacy expected amount. + - Exceptions such as missing assignments, missing project billing accounts, missing billing accounts, and ambiguous consumed duplicates are reported without blocking resolvable assignments. + - Validation: + - The dry-run/apply report includes expected legacy totals and current/projected billing totals. + - `verify:engagementPayments` invokes `psql` directly, so export `DATABASE_URL` in the shell before running it. + - If the source schemas are visible in one Postgres session, run: + - `pnpm run verify:engagementPayments -- -v finance_schema=finance -v engagements_schema=public -v projects_schema=projects -v billing_schema=billing-accounts -v exempt_billing_account_ids=80000062,80002800` + - The verification SQL lists mismatches between expected legacy engagement-payment consumes and `ConsumedAmount` rows with `externalType = ENGAGEMENT`. TopGear-exempt accounts are reported separately as `topgear_exempt_*` statuses. + - Rollback: + - Use the JSON report from the apply run. A successful apply only commits after that report is written. Delete rows listed with `action: "insert"` and `createdId`; restore rows listed with `action: "update_single"` from `previous`; restore rows listed with `action: "move_existing_rows"` from their `existingRows` billing account ids. + - If apply mode exits with an error before writing the report, the transaction rolled back and there should be no partial billing-ledger changes from that run. + - If apply mode exits with an error after a report file exists, run the verifier or a dry run before rollback; the billing transaction should have committed all planned actions or none. + ## Downstream Usage - This service is consumed (directly or indirectly) by multiple Topcoder apps. The pointers below help with debugging. diff --git a/package.json b/package.json index b638581..b4f5ea1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "import:access": "ts-node scripts/import-ba-access.ts", "import:markup": "ts-node scripts/import-markup.ts", "backfill:clientIds": "ts-node scripts/backfill-client-ids.ts", + "backfill:engagementPayments": "ts-node scripts/backfill-engagement-payments.ts", + "verify:engagementPayments": "psql \"$DATABASE_URL\" -f scripts/verify-engagement-payment-backfill.sql", "generate:subcontractor-sql": "ts-node scripts/generate-subcontractor-sql.ts" }, "dependencies": { diff --git a/packages/ba-prisma-client/edge.js b/packages/ba-prisma-client/edge.js index 10a602a..0fa888c 100644 --- a/packages/ba-prisma-client/edge.js +++ b/packages/ba-prisma-client/edge.js @@ -130,7 +130,8 @@ exports.Prisma.BillingAccountScalarFieldEnum = { exports.Prisma.LockedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -139,7 +140,8 @@ exports.Prisma.LockedAmountScalarFieldEnum = { exports.Prisma.ConsumedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -176,6 +178,11 @@ exports.BAStatus = exports.$Enums.BAStatus = { INACTIVE: 'INACTIVE' }; +exports.BudgetEntryExternalType = exports.$Enums.BudgetEntryExternalType = { + CHALLENGE: 'CHALLENGE', + ENGAGEMENT: 'ENGAGEMENT' +}; + exports.Prisma.ModelName = { Client: 'Client', BillingAccount: 'BillingAccount', @@ -194,7 +201,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "/home/vasea/work/topcoder/billing-accounts-api-v6/packages/ba-prisma-client", + "value": "/home/jmgasper/Documents/Git/v6/billing-accounts-api-v6/packages/ba-prisma-client", "fromEnvVar": null }, "config": { @@ -212,12 +219,11 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "/home/vasea/work/topcoder/billing-accounts-api-v6/prisma/schema.prisma", + "sourceFilePath": "/home/jmgasper/Documents/Git/v6/billing-accounts-api-v6/prisma/schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { - "rootEnvPath": null, - "schemaEnvPath": "../../.env" + "rootEnvPath": null }, "relativePath": "../../prisma", "clientVersion": "6.19.0", @@ -235,13 +241,13 @@ const config = { } } }, - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Ensure Alpine uses the OpenSSL 3-compatible binary\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/ba-prisma-client\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Client {\n id String @id @default(uuid())\n name String\n codeName String? @db.VarChar(255) // customer number / internal code\n status ClientStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccounts BillingAccount[]\n\n @@index([name])\n @@index([codeName])\n @@index([status])\n @@index([startDate, endDate])\n}\n\nenum ClientStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel BillingAccount {\n id Int @id @default(autoincrement())\n projectId String?\n name String\n description String?\n subcontractingEndCustomer String? @db.VarChar(255)\n status BAStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n budget Decimal @db.Decimal(20, 4)\n markup Decimal @db.Decimal(10, 4) // 0..1 typical, can exceed 1\n clientId String\n poNumber String? @db.VarChar(255)\n subscriptionNumber String? @db.VarChar(255)\n isManualPrize Boolean @default(false)\n paymentTerms String? @db.VarChar(255)\n salesTax Decimal? @db.Decimal(10, 4)\n billable Boolean @default(true)\n createdBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n client Client @relation(fields: [clientId], references: [id])\n lockedAmounts LockedAmount[]\n consumedAmounts ConsumedAmount[]\n accessGrants BillingAccountAccess[]\n\n @@index([clientId])\n @@index([status])\n @@index([startDate, endDate])\n @@index([createdBy])\n}\n\nenum BAStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel LockedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n challengeId String // UUID or legacy int-as-string\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, challengeId], name: \"locked_unique_challenge\")\n @@index([billingAccountId])\n}\n\nmodel ConsumedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n challengeId String\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, challengeId], name: \"consumed_unique_challenge\")\n @@index([billingAccountId])\n}\n\n// Optional: whitelist which users can access which BAs for userId filtering\nmodel BillingAccountAccess {\n id String @id @default(uuid())\n billingAccountId Int\n userId String\n createdAt DateTime @default(now())\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, userId], name: \"ba_access_unique\")\n @@index([userId])\n}\n", - "inlineSchemaHash": "4cc39769d8f9701b9e1adf54dbd047a5ed6e19c66cc27a7a9afe2d11b996bf48", + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Ensure Alpine uses the OpenSSL 3-compatible binary\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/ba-prisma-client\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Client {\n id String @id @default(uuid())\n name String\n codeName String? @db.VarChar(255) // customer number / internal code\n status ClientStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccounts BillingAccount[]\n\n @@index([name])\n @@index([codeName])\n @@index([status])\n @@index([startDate, endDate])\n}\n\nenum ClientStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel BillingAccount {\n id Int @id @default(autoincrement())\n projectId String?\n name String\n description String?\n subcontractingEndCustomer String? @db.VarChar(255)\n status BAStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n budget Decimal @db.Decimal(20, 4)\n markup Decimal @db.Decimal(10, 4) // 0..1 typical, can exceed 1\n clientId String\n poNumber String? @db.VarChar(255)\n subscriptionNumber String? @db.VarChar(255)\n isManualPrize Boolean @default(false)\n paymentTerms String? @db.VarChar(255)\n salesTax Decimal? @db.Decimal(10, 4)\n billable Boolean @default(true)\n createdBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n client Client @relation(fields: [clientId], references: [id])\n lockedAmounts LockedAmount[]\n consumedAmounts ConsumedAmount[]\n accessGrants BillingAccountAccess[]\n\n @@index([clientId])\n @@index([status])\n @@index([startDate, endDate])\n @@index([createdBy])\n}\n\nenum BAStatus {\n ACTIVE\n INACTIVE\n}\n\nenum BudgetEntryExternalType {\n CHALLENGE\n ENGAGEMENT\n}\n\nmodel LockedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n externalId String // UUID or legacy int-as-string for challenges; assignment UUID for engagements\n externalType BudgetEntryExternalType @default(CHALLENGE)\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, externalType, externalId], name: \"locked_unique_external\")\n @@index([billingAccountId])\n @@index([externalType, externalId])\n}\n\nmodel ConsumedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n externalId String\n externalType BudgetEntryExternalType @default(CHALLENGE)\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@index([billingAccountId])\n @@index([billingAccountId, externalType, externalId])\n}\n\n// Optional: whitelist which users can access which BAs for userId filtering\nmodel BillingAccountAccess {\n id String @id @default(uuid())\n billingAccountId Int\n userId String\n createdAt DateTime @default(now())\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, userId], name: \"ba_access_unique\")\n @@index([userId])\n}\n", + "inlineSchemaHash": "c4822ef86f1376bc8be47a6b059062e90921e71d7a633cf8cc2b3e159752e0f6", "copyEngine": true } config.dirname = '/' -config.runtimeDataModel = JSON.parse("{\"models\":{\"Client\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"codeName\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"ClientStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"BillingAccount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Int\",\"nativeType\":null,\"default\":{\"name\":\"autoincrement\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"projectId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"description\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subcontractingEndCustomer\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BAStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"budget\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"markup\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"clientId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"poNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subscriptionNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"isManualPrize\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":false,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"paymentTerms\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"salesTax\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billable\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":true,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"client\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Client\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[\"clientId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"lockedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"LockedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"consumedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"ConsumedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"accessGrants\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccountAccess\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"LockedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"challengeId\"]],\"uniqueIndexes\":[{\"name\":\"locked_unique_challenge\",\"fields\":[\"billingAccountId\",\"challengeId\"]}],\"isGenerated\":false},\"ConsumedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"challengeId\"]],\"uniqueIndexes\":[{\"name\":\"consumed_unique_challenge\",\"fields\":[\"billingAccountId\",\"challengeId\"]}],\"isGenerated\":false},\"BillingAccountAccess\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"userId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"userId\"]],\"uniqueIndexes\":[{\"name\":\"ba_access_unique\",\"fields\":[\"billingAccountId\",\"userId\"]}],\"isGenerated\":false}},\"enums\":{\"ClientStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null},\"BAStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null}},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Client\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"codeName\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"ClientStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"BillingAccount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Int\",\"nativeType\":null,\"default\":{\"name\":\"autoincrement\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"projectId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"description\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subcontractingEndCustomer\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BAStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"budget\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"markup\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"clientId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"poNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subscriptionNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"isManualPrize\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":false,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"paymentTerms\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"salesTax\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billable\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":true,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"client\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Client\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[\"clientId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"lockedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"LockedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"consumedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"ConsumedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"accessGrants\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccountAccess\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"LockedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalType\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BudgetEntryExternalType\",\"nativeType\":null,\"default\":\"CHALLENGE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"externalType\",\"externalId\"]],\"uniqueIndexes\":[{\"name\":\"locked_unique_external\",\"fields\":[\"billingAccountId\",\"externalType\",\"externalId\"]}],\"isGenerated\":false},\"ConsumedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalType\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BudgetEntryExternalType\",\"nativeType\":null,\"default\":\"CHALLENGE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"BillingAccountAccess\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"userId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"userId\"]],\"uniqueIndexes\":[{\"name\":\"ba_access_unique\",\"fields\":[\"billingAccountId\",\"userId\"]}],\"isGenerated\":false}},\"enums\":{\"ClientStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null},\"BAStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null},\"BudgetEntryExternalType\":{\"values\":[{\"name\":\"CHALLENGE\",\"dbName\":null},{\"name\":\"ENGAGEMENT\",\"dbName\":null}],\"dbName\":null}},\"types\":{}}") defineDmmfProperty(exports.Prisma, config.runtimeDataModel) config.engineWasm = undefined config.compilerWasm = undefined diff --git a/packages/ba-prisma-client/index-browser.js b/packages/ba-prisma-client/index-browser.js index 4f2bf64..1280b42 100644 --- a/packages/ba-prisma-client/index-browser.js +++ b/packages/ba-prisma-client/index-browser.js @@ -158,7 +158,8 @@ exports.Prisma.BillingAccountScalarFieldEnum = { exports.Prisma.LockedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -167,7 +168,8 @@ exports.Prisma.LockedAmountScalarFieldEnum = { exports.Prisma.ConsumedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -204,6 +206,11 @@ exports.BAStatus = exports.$Enums.BAStatus = { INACTIVE: 'INACTIVE' }; +exports.BudgetEntryExternalType = exports.$Enums.BudgetEntryExternalType = { + CHALLENGE: 'CHALLENGE', + ENGAGEMENT: 'ENGAGEMENT' +}; + exports.Prisma.ModelName = { Client: 'Client', BillingAccount: 'BillingAccount', diff --git a/packages/ba-prisma-client/index.d.ts b/packages/ba-prisma-client/index.d.ts index 61f9fcb..286dc79 100644 --- a/packages/ba-prisma-client/index.d.ts +++ b/packages/ba-prisma-client/index.d.ts @@ -58,6 +58,14 @@ export const BAStatus: { export type BAStatus = (typeof BAStatus)[keyof typeof BAStatus] + +export const BudgetEntryExternalType: { + CHALLENGE: 'CHALLENGE', + ENGAGEMENT: 'ENGAGEMENT' +}; + +export type BudgetEntryExternalType = (typeof BudgetEntryExternalType)[keyof typeof BudgetEntryExternalType] + } export type ClientStatus = $Enums.ClientStatus @@ -68,6 +76,10 @@ export type BAStatus = $Enums.BAStatus export const BAStatus: typeof $Enums.BAStatus +export type BudgetEntryExternalType = $Enums.BudgetEntryExternalType + +export const BudgetEntryExternalType: typeof $Enums.BudgetEntryExternalType + /** * ## Prisma Client ʲˢ * @@ -3852,7 +3864,8 @@ export namespace Prisma { export type LockedAmountMinAggregateOutputType = { id: string | null billingAccountId: number | null - challengeId: string | null + externalId: string | null + externalType: $Enums.BudgetEntryExternalType | null amount: Decimal | null createdAt: Date | null updatedAt: Date | null @@ -3861,7 +3874,8 @@ export namespace Prisma { export type LockedAmountMaxAggregateOutputType = { id: string | null billingAccountId: number | null - challengeId: string | null + externalId: string | null + externalType: $Enums.BudgetEntryExternalType | null amount: Decimal | null createdAt: Date | null updatedAt: Date | null @@ -3870,7 +3884,8 @@ export namespace Prisma { export type LockedAmountCountAggregateOutputType = { id: number billingAccountId: number - challengeId: number + externalId: number + externalType: number amount: number createdAt: number updatedAt: number @@ -3891,7 +3906,8 @@ export namespace Prisma { export type LockedAmountMinAggregateInputType = { id?: true billingAccountId?: true - challengeId?: true + externalId?: true + externalType?: true amount?: true createdAt?: true updatedAt?: true @@ -3900,7 +3916,8 @@ export namespace Prisma { export type LockedAmountMaxAggregateInputType = { id?: true billingAccountId?: true - challengeId?: true + externalId?: true + externalType?: true amount?: true createdAt?: true updatedAt?: true @@ -3909,7 +3926,8 @@ export namespace Prisma { export type LockedAmountCountAggregateInputType = { id?: true billingAccountId?: true - challengeId?: true + externalId?: true + externalType?: true amount?: true createdAt?: true updatedAt?: true @@ -4005,7 +4023,8 @@ export namespace Prisma { export type LockedAmountGroupByOutputType = { id: string billingAccountId: number - challengeId: string + externalId: string + externalType: $Enums.BudgetEntryExternalType amount: Decimal createdAt: Date updatedAt: Date @@ -4033,7 +4052,8 @@ export namespace Prisma { export type LockedAmountSelect = $Extensions.GetSelect<{ id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean @@ -4043,7 +4063,8 @@ export namespace Prisma { export type LockedAmountSelectCreateManyAndReturn = $Extensions.GetSelect<{ id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean @@ -4053,7 +4074,8 @@ export namespace Prisma { export type LockedAmountSelectUpdateManyAndReturn = $Extensions.GetSelect<{ id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean @@ -4063,13 +4085,14 @@ export namespace Prisma { export type LockedAmountSelectScalar = { id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean } - export type LockedAmountOmit = $Extensions.GetOmit<"id" | "billingAccountId" | "challengeId" | "amount" | "createdAt" | "updatedAt", ExtArgs["result"]["lockedAmount"]> + export type LockedAmountOmit = $Extensions.GetOmit<"id" | "billingAccountId" | "externalId" | "externalType" | "amount" | "createdAt" | "updatedAt", ExtArgs["result"]["lockedAmount"]> export type LockedAmountInclude = { billingAccount?: boolean | BillingAccountDefaultArgs } @@ -4088,7 +4111,8 @@ export namespace Prisma { scalars: $Extensions.GetPayloadResult<{ id: string billingAccountId: number - challengeId: string + externalId: string + externalType: $Enums.BudgetEntryExternalType amount: Prisma.Decimal createdAt: Date updatedAt: Date @@ -4518,7 +4542,8 @@ export namespace Prisma { interface LockedAmountFieldRefs { readonly id: FieldRef<"LockedAmount", 'String'> readonly billingAccountId: FieldRef<"LockedAmount", 'Int'> - readonly challengeId: FieldRef<"LockedAmount", 'String'> + readonly externalId: FieldRef<"LockedAmount", 'String'> + readonly externalType: FieldRef<"LockedAmount", 'BudgetEntryExternalType'> readonly amount: FieldRef<"LockedAmount", 'Decimal'> readonly createdAt: FieldRef<"LockedAmount", 'DateTime'> readonly updatedAt: FieldRef<"LockedAmount", 'DateTime'> @@ -4961,7 +4986,8 @@ export namespace Prisma { export type ConsumedAmountMinAggregateOutputType = { id: string | null billingAccountId: number | null - challengeId: string | null + externalId: string | null + externalType: $Enums.BudgetEntryExternalType | null amount: Decimal | null createdAt: Date | null updatedAt: Date | null @@ -4970,7 +4996,8 @@ export namespace Prisma { export type ConsumedAmountMaxAggregateOutputType = { id: string | null billingAccountId: number | null - challengeId: string | null + externalId: string | null + externalType: $Enums.BudgetEntryExternalType | null amount: Decimal | null createdAt: Date | null updatedAt: Date | null @@ -4979,7 +5006,8 @@ export namespace Prisma { export type ConsumedAmountCountAggregateOutputType = { id: number billingAccountId: number - challengeId: number + externalId: number + externalType: number amount: number createdAt: number updatedAt: number @@ -5000,7 +5028,8 @@ export namespace Prisma { export type ConsumedAmountMinAggregateInputType = { id?: true billingAccountId?: true - challengeId?: true + externalId?: true + externalType?: true amount?: true createdAt?: true updatedAt?: true @@ -5009,7 +5038,8 @@ export namespace Prisma { export type ConsumedAmountMaxAggregateInputType = { id?: true billingAccountId?: true - challengeId?: true + externalId?: true + externalType?: true amount?: true createdAt?: true updatedAt?: true @@ -5018,7 +5048,8 @@ export namespace Prisma { export type ConsumedAmountCountAggregateInputType = { id?: true billingAccountId?: true - challengeId?: true + externalId?: true + externalType?: true amount?: true createdAt?: true updatedAt?: true @@ -5114,7 +5145,8 @@ export namespace Prisma { export type ConsumedAmountGroupByOutputType = { id: string billingAccountId: number - challengeId: string + externalId: string + externalType: $Enums.BudgetEntryExternalType amount: Decimal createdAt: Date updatedAt: Date @@ -5142,7 +5174,8 @@ export namespace Prisma { export type ConsumedAmountSelect = $Extensions.GetSelect<{ id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean @@ -5152,7 +5185,8 @@ export namespace Prisma { export type ConsumedAmountSelectCreateManyAndReturn = $Extensions.GetSelect<{ id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean @@ -5162,7 +5196,8 @@ export namespace Prisma { export type ConsumedAmountSelectUpdateManyAndReturn = $Extensions.GetSelect<{ id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean @@ -5172,13 +5207,14 @@ export namespace Prisma { export type ConsumedAmountSelectScalar = { id?: boolean billingAccountId?: boolean - challengeId?: boolean + externalId?: boolean + externalType?: boolean amount?: boolean createdAt?: boolean updatedAt?: boolean } - export type ConsumedAmountOmit = $Extensions.GetOmit<"id" | "billingAccountId" | "challengeId" | "amount" | "createdAt" | "updatedAt", ExtArgs["result"]["consumedAmount"]> + export type ConsumedAmountOmit = $Extensions.GetOmit<"id" | "billingAccountId" | "externalId" | "externalType" | "amount" | "createdAt" | "updatedAt", ExtArgs["result"]["consumedAmount"]> export type ConsumedAmountInclude = { billingAccount?: boolean | BillingAccountDefaultArgs } @@ -5197,7 +5233,8 @@ export namespace Prisma { scalars: $Extensions.GetPayloadResult<{ id: string billingAccountId: number - challengeId: string + externalId: string + externalType: $Enums.BudgetEntryExternalType amount: Prisma.Decimal createdAt: Date updatedAt: Date @@ -5627,7 +5664,8 @@ export namespace Prisma { interface ConsumedAmountFieldRefs { readonly id: FieldRef<"ConsumedAmount", 'String'> readonly billingAccountId: FieldRef<"ConsumedAmount", 'Int'> - readonly challengeId: FieldRef<"ConsumedAmount", 'String'> + readonly externalId: FieldRef<"ConsumedAmount", 'String'> + readonly externalType: FieldRef<"ConsumedAmount", 'BudgetEntryExternalType'> readonly amount: FieldRef<"ConsumedAmount", 'Decimal'> readonly createdAt: FieldRef<"ConsumedAmount", 'DateTime'> readonly updatedAt: FieldRef<"ConsumedAmount", 'DateTime'> @@ -7181,7 +7219,8 @@ export namespace Prisma { export const LockedAmountScalarFieldEnum: { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -7193,7 +7232,8 @@ export namespace Prisma { export const ConsumedAmountScalarFieldEnum: { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -7332,6 +7372,20 @@ export namespace Prisma { + /** + * Reference to a field of type 'BudgetEntryExternalType' + */ + export type EnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BudgetEntryExternalType'> + + + + /** + * Reference to a field of type 'BudgetEntryExternalType[]' + */ + export type ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BudgetEntryExternalType[]'> + + + /** * Reference to a field of type 'Float' */ @@ -7566,7 +7620,8 @@ export namespace Prisma { NOT?: LockedAmountWhereInput | LockedAmountWhereInput[] id?: StringFilter<"LockedAmount"> | string billingAccountId?: IntFilter<"LockedAmount"> | number - challengeId?: StringFilter<"LockedAmount"> | string + externalId?: StringFilter<"LockedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeFilter<"LockedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalFilter<"LockedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFilter<"LockedAmount"> | Date | string updatedAt?: DateTimeFilter<"LockedAmount"> | Date | string @@ -7576,7 +7631,8 @@ export namespace Prisma { export type LockedAmountOrderByWithRelationInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -7585,22 +7641,24 @@ export namespace Prisma { export type LockedAmountWhereUniqueInput = Prisma.AtLeast<{ id?: string - locked_unique_challenge?: LockedAmountLocked_unique_challengeCompoundUniqueInput + locked_unique_external?: LockedAmountLocked_unique_externalCompoundUniqueInput AND?: LockedAmountWhereInput | LockedAmountWhereInput[] OR?: LockedAmountWhereInput[] NOT?: LockedAmountWhereInput | LockedAmountWhereInput[] billingAccountId?: IntFilter<"LockedAmount"> | number - challengeId?: StringFilter<"LockedAmount"> | string + externalId?: StringFilter<"LockedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeFilter<"LockedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalFilter<"LockedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFilter<"LockedAmount"> | Date | string updatedAt?: DateTimeFilter<"LockedAmount"> | Date | string billingAccount?: XOR - }, "id" | "locked_unique_challenge"> + }, "id" | "locked_unique_external"> export type LockedAmountOrderByWithAggregationInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -7617,7 +7675,8 @@ export namespace Prisma { NOT?: LockedAmountScalarWhereWithAggregatesInput | LockedAmountScalarWhereWithAggregatesInput[] id?: StringWithAggregatesFilter<"LockedAmount"> | string billingAccountId?: IntWithAggregatesFilter<"LockedAmount"> | number - challengeId?: StringWithAggregatesFilter<"LockedAmount"> | string + externalId?: StringWithAggregatesFilter<"LockedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeWithAggregatesFilter<"LockedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalWithAggregatesFilter<"LockedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeWithAggregatesFilter<"LockedAmount"> | Date | string updatedAt?: DateTimeWithAggregatesFilter<"LockedAmount"> | Date | string @@ -7629,7 +7688,8 @@ export namespace Prisma { NOT?: ConsumedAmountWhereInput | ConsumedAmountWhereInput[] id?: StringFilter<"ConsumedAmount"> | string billingAccountId?: IntFilter<"ConsumedAmount"> | number - challengeId?: StringFilter<"ConsumedAmount"> | string + externalId?: StringFilter<"ConsumedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeFilter<"ConsumedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalFilter<"ConsumedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFilter<"ConsumedAmount"> | Date | string updatedAt?: DateTimeFilter<"ConsumedAmount"> | Date | string @@ -7639,7 +7699,8 @@ export namespace Prisma { export type ConsumedAmountOrderByWithRelationInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -7648,22 +7709,23 @@ export namespace Prisma { export type ConsumedAmountWhereUniqueInput = Prisma.AtLeast<{ id?: string - consumed_unique_challenge?: ConsumedAmountConsumed_unique_challengeCompoundUniqueInput AND?: ConsumedAmountWhereInput | ConsumedAmountWhereInput[] OR?: ConsumedAmountWhereInput[] NOT?: ConsumedAmountWhereInput | ConsumedAmountWhereInput[] billingAccountId?: IntFilter<"ConsumedAmount"> | number - challengeId?: StringFilter<"ConsumedAmount"> | string + externalId?: StringFilter<"ConsumedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeFilter<"ConsumedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalFilter<"ConsumedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFilter<"ConsumedAmount"> | Date | string updatedAt?: DateTimeFilter<"ConsumedAmount"> | Date | string billingAccount?: XOR - }, "id" | "consumed_unique_challenge"> + }, "id"> export type ConsumedAmountOrderByWithAggregationInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -7680,7 +7742,8 @@ export namespace Prisma { NOT?: ConsumedAmountScalarWhereWithAggregatesInput | ConsumedAmountScalarWhereWithAggregatesInput[] id?: StringWithAggregatesFilter<"ConsumedAmount"> | string billingAccountId?: IntWithAggregatesFilter<"ConsumedAmount"> | number - challengeId?: StringWithAggregatesFilter<"ConsumedAmount"> | string + externalId?: StringWithAggregatesFilter<"ConsumedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeWithAggregatesFilter<"ConsumedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalWithAggregatesFilter<"ConsumedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeWithAggregatesFilter<"ConsumedAmount"> | Date | string updatedAt?: DateTimeWithAggregatesFilter<"ConsumedAmount"> | Date | string @@ -7991,7 +8054,8 @@ export namespace Prisma { export type LockedAmountCreateInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -8001,7 +8065,8 @@ export namespace Prisma { export type LockedAmountUncheckedCreateInput = { id?: string billingAccountId: number - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -8009,7 +8074,8 @@ export namespace Prisma { export type LockedAmountUpdateInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8019,7 +8085,8 @@ export namespace Prisma { export type LockedAmountUncheckedUpdateInput = { id?: StringFieldUpdateOperationsInput | string billingAccountId?: IntFieldUpdateOperationsInput | number - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8028,7 +8095,8 @@ export namespace Prisma { export type LockedAmountCreateManyInput = { id?: string billingAccountId: number - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -8036,7 +8104,8 @@ export namespace Prisma { export type LockedAmountUpdateManyMutationInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8045,7 +8114,8 @@ export namespace Prisma { export type LockedAmountUncheckedUpdateManyInput = { id?: StringFieldUpdateOperationsInput | string billingAccountId?: IntFieldUpdateOperationsInput | number - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8053,7 +8123,8 @@ export namespace Prisma { export type ConsumedAmountCreateInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -8063,7 +8134,8 @@ export namespace Prisma { export type ConsumedAmountUncheckedCreateInput = { id?: string billingAccountId: number - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -8071,7 +8143,8 @@ export namespace Prisma { export type ConsumedAmountUpdateInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8081,7 +8154,8 @@ export namespace Prisma { export type ConsumedAmountUncheckedUpdateInput = { id?: StringFieldUpdateOperationsInput | string billingAccountId?: IntFieldUpdateOperationsInput | number - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8090,7 +8164,8 @@ export namespace Prisma { export type ConsumedAmountCreateManyInput = { id?: string billingAccountId: number - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -8098,7 +8173,8 @@ export namespace Prisma { export type ConsumedAmountUpdateManyMutationInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8107,7 +8183,8 @@ export namespace Prisma { export type ConsumedAmountUncheckedUpdateManyInput = { id?: StringFieldUpdateOperationsInput | string billingAccountId?: IntFieldUpdateOperationsInput | number - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -8571,20 +8648,29 @@ export namespace Prisma { _max?: NestedDecimalNullableFilter<$PrismaModel> } + export type EnumBudgetEntryExternalTypeFilter<$PrismaModel = never> = { + equals?: $Enums.BudgetEntryExternalType | EnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + in?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + not?: NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel> | $Enums.BudgetEntryExternalType + } + export type BillingAccountScalarRelationFilter = { is?: BillingAccountWhereInput isNot?: BillingAccountWhereInput } - export type LockedAmountLocked_unique_challengeCompoundUniqueInput = { + export type LockedAmountLocked_unique_externalCompoundUniqueInput = { billingAccountId: number - challengeId: string + externalType: $Enums.BudgetEntryExternalType + externalId: string } export type LockedAmountCountOrderByAggregateInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -8598,7 +8684,8 @@ export namespace Prisma { export type LockedAmountMaxOrderByAggregateInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -8607,7 +8694,8 @@ export namespace Prisma { export type LockedAmountMinOrderByAggregateInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -8618,15 +8706,21 @@ export namespace Prisma { amount?: SortOrder } - export type ConsumedAmountConsumed_unique_challengeCompoundUniqueInput = { - billingAccountId: number - challengeId: string + export type EnumBudgetEntryExternalTypeWithAggregatesFilter<$PrismaModel = never> = { + equals?: $Enums.BudgetEntryExternalType | EnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + in?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + not?: NestedEnumBudgetEntryExternalTypeWithAggregatesFilter<$PrismaModel> | $Enums.BudgetEntryExternalType + _count?: NestedIntFilter<$PrismaModel> + _min?: NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel> + _max?: NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel> } export type ConsumedAmountCountOrderByAggregateInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -8640,7 +8734,8 @@ export namespace Prisma { export type ConsumedAmountMaxOrderByAggregateInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -8649,7 +8744,8 @@ export namespace Prisma { export type ConsumedAmountMinOrderByAggregateInput = { id?: SortOrder billingAccountId?: SortOrder - challengeId?: SortOrder + externalId?: SortOrder + externalType?: SortOrder amount?: SortOrder createdAt?: SortOrder updatedAt?: SortOrder @@ -8934,6 +9030,10 @@ export namespace Prisma { connect?: BillingAccountWhereUniqueInput } + export type EnumBudgetEntryExternalTypeFieldUpdateOperationsInput = { + set?: $Enums.BudgetEntryExternalType + } + export type BillingAccountUpdateOneRequiredWithoutLockedAmountsNestedInput = { create?: XOR connectOrCreate?: BillingAccountCreateOrConnectWithoutLockedAmountsInput @@ -9232,6 +9332,23 @@ export namespace Prisma { _max?: NestedDecimalNullableFilter<$PrismaModel> } + export type NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel = never> = { + equals?: $Enums.BudgetEntryExternalType | EnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + in?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + not?: NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel> | $Enums.BudgetEntryExternalType + } + + export type NestedEnumBudgetEntryExternalTypeWithAggregatesFilter<$PrismaModel = never> = { + equals?: $Enums.BudgetEntryExternalType | EnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + in?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.BudgetEntryExternalType[] | ListEnumBudgetEntryExternalTypeFieldRefInput<$PrismaModel> + not?: NestedEnumBudgetEntryExternalTypeWithAggregatesFilter<$PrismaModel> | $Enums.BudgetEntryExternalType + _count?: NestedIntFilter<$PrismaModel> + _min?: NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel> + _max?: NestedEnumBudgetEntryExternalTypeFilter<$PrismaModel> + } + export type BillingAccountCreateWithoutClientInput = { projectId?: string | null name: string @@ -9362,7 +9479,8 @@ export namespace Prisma { export type LockedAmountCreateWithoutBillingAccountInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -9370,7 +9488,8 @@ export namespace Prisma { export type LockedAmountUncheckedCreateWithoutBillingAccountInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -9388,7 +9507,8 @@ export namespace Prisma { export type ConsumedAmountCreateWithoutBillingAccountInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -9396,7 +9516,8 @@ export namespace Prisma { export type ConsumedAmountUncheckedCreateWithoutBillingAccountInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -9489,7 +9610,8 @@ export namespace Prisma { NOT?: LockedAmountScalarWhereInput | LockedAmountScalarWhereInput[] id?: StringFilter<"LockedAmount"> | string billingAccountId?: IntFilter<"LockedAmount"> | number - challengeId?: StringFilter<"LockedAmount"> | string + externalId?: StringFilter<"LockedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeFilter<"LockedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalFilter<"LockedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFilter<"LockedAmount"> | Date | string updatedAt?: DateTimeFilter<"LockedAmount"> | Date | string @@ -9517,7 +9639,8 @@ export namespace Prisma { NOT?: ConsumedAmountScalarWhereInput | ConsumedAmountScalarWhereInput[] id?: StringFilter<"ConsumedAmount"> | string billingAccountId?: IntFilter<"ConsumedAmount"> | number - challengeId?: StringFilter<"ConsumedAmount"> | string + externalId?: StringFilter<"ConsumedAmount"> | string + externalType?: EnumBudgetEntryExternalTypeFilter<"ConsumedAmount"> | $Enums.BudgetEntryExternalType amount?: DecimalFilter<"ConsumedAmount"> | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFilter<"ConsumedAmount"> | Date | string updatedAt?: DateTimeFilter<"ConsumedAmount"> | Date | string @@ -9986,7 +10109,8 @@ export namespace Prisma { export type LockedAmountCreateManyBillingAccountInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -9994,7 +10118,8 @@ export namespace Prisma { export type ConsumedAmountCreateManyBillingAccountInput = { id?: string - challengeId: string + externalId: string + externalType?: $Enums.BudgetEntryExternalType amount: Decimal | DecimalJsLike | number | string createdAt?: Date | string updatedAt?: Date | string @@ -10008,7 +10133,8 @@ export namespace Prisma { export type LockedAmountUpdateWithoutBillingAccountInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -10016,7 +10142,8 @@ export namespace Prisma { export type LockedAmountUncheckedUpdateWithoutBillingAccountInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -10024,7 +10151,8 @@ export namespace Prisma { export type LockedAmountUncheckedUpdateManyWithoutBillingAccountInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -10032,7 +10160,8 @@ export namespace Prisma { export type ConsumedAmountUpdateWithoutBillingAccountInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -10040,7 +10169,8 @@ export namespace Prisma { export type ConsumedAmountUncheckedUpdateWithoutBillingAccountInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string @@ -10048,7 +10178,8 @@ export namespace Prisma { export type ConsumedAmountUncheckedUpdateManyWithoutBillingAccountInput = { id?: StringFieldUpdateOperationsInput | string - challengeId?: StringFieldUpdateOperationsInput | string + externalId?: StringFieldUpdateOperationsInput | string + externalType?: EnumBudgetEntryExternalTypeFieldUpdateOperationsInput | $Enums.BudgetEntryExternalType amount?: DecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string createdAt?: DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string diff --git a/packages/ba-prisma-client/index.js b/packages/ba-prisma-client/index.js index de93664..14dc63d 100644 --- a/packages/ba-prisma-client/index.js +++ b/packages/ba-prisma-client/index.js @@ -131,7 +131,8 @@ exports.Prisma.BillingAccountScalarFieldEnum = { exports.Prisma.LockedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -140,7 +141,8 @@ exports.Prisma.LockedAmountScalarFieldEnum = { exports.Prisma.ConsumedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -177,6 +179,11 @@ exports.BAStatus = exports.$Enums.BAStatus = { INACTIVE: 'INACTIVE' }; +exports.BudgetEntryExternalType = exports.$Enums.BudgetEntryExternalType = { + CHALLENGE: 'CHALLENGE', + ENGAGEMENT: 'ENGAGEMENT' +}; + exports.Prisma.ModelName = { Client: 'Client', BillingAccount: 'BillingAccount', @@ -195,7 +202,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "/home/vasea/work/topcoder/billing-accounts-api-v6/packages/ba-prisma-client", + "value": "/home/jmgasper/Documents/Git/v6/billing-accounts-api-v6/packages/ba-prisma-client", "fromEnvVar": null }, "config": { @@ -213,12 +220,11 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "/home/vasea/work/topcoder/billing-accounts-api-v6/prisma/schema.prisma", + "sourceFilePath": "/home/jmgasper/Documents/Git/v6/billing-accounts-api-v6/prisma/schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { - "rootEnvPath": null, - "schemaEnvPath": "../../.env" + "rootEnvPath": null }, "relativePath": "../../prisma", "clientVersion": "6.19.0", @@ -236,8 +242,8 @@ const config = { } } }, - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Ensure Alpine uses the OpenSSL 3-compatible binary\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/ba-prisma-client\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Client {\n id String @id @default(uuid())\n name String\n codeName String? @db.VarChar(255) // customer number / internal code\n status ClientStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccounts BillingAccount[]\n\n @@index([name])\n @@index([codeName])\n @@index([status])\n @@index([startDate, endDate])\n}\n\nenum ClientStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel BillingAccount {\n id Int @id @default(autoincrement())\n projectId String?\n name String\n description String?\n subcontractingEndCustomer String? @db.VarChar(255)\n status BAStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n budget Decimal @db.Decimal(20, 4)\n markup Decimal @db.Decimal(10, 4) // 0..1 typical, can exceed 1\n clientId String\n poNumber String? @db.VarChar(255)\n subscriptionNumber String? @db.VarChar(255)\n isManualPrize Boolean @default(false)\n paymentTerms String? @db.VarChar(255)\n salesTax Decimal? @db.Decimal(10, 4)\n billable Boolean @default(true)\n createdBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n client Client @relation(fields: [clientId], references: [id])\n lockedAmounts LockedAmount[]\n consumedAmounts ConsumedAmount[]\n accessGrants BillingAccountAccess[]\n\n @@index([clientId])\n @@index([status])\n @@index([startDate, endDate])\n @@index([createdBy])\n}\n\nenum BAStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel LockedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n challengeId String // UUID or legacy int-as-string\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, challengeId], name: \"locked_unique_challenge\")\n @@index([billingAccountId])\n}\n\nmodel ConsumedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n challengeId String\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, challengeId], name: \"consumed_unique_challenge\")\n @@index([billingAccountId])\n}\n\n// Optional: whitelist which users can access which BAs for userId filtering\nmodel BillingAccountAccess {\n id String @id @default(uuid())\n billingAccountId Int\n userId String\n createdAt DateTime @default(now())\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, userId], name: \"ba_access_unique\")\n @@index([userId])\n}\n", - "inlineSchemaHash": "4cc39769d8f9701b9e1adf54dbd047a5ed6e19c66cc27a7a9afe2d11b996bf48", + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Ensure Alpine uses the OpenSSL 3-compatible binary\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/ba-prisma-client\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Client {\n id String @id @default(uuid())\n name String\n codeName String? @db.VarChar(255) // customer number / internal code\n status ClientStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccounts BillingAccount[]\n\n @@index([name])\n @@index([codeName])\n @@index([status])\n @@index([startDate, endDate])\n}\n\nenum ClientStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel BillingAccount {\n id Int @id @default(autoincrement())\n projectId String?\n name String\n description String?\n subcontractingEndCustomer String? @db.VarChar(255)\n status BAStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n budget Decimal @db.Decimal(20, 4)\n markup Decimal @db.Decimal(10, 4) // 0..1 typical, can exceed 1\n clientId String\n poNumber String? @db.VarChar(255)\n subscriptionNumber String? @db.VarChar(255)\n isManualPrize Boolean @default(false)\n paymentTerms String? @db.VarChar(255)\n salesTax Decimal? @db.Decimal(10, 4)\n billable Boolean @default(true)\n createdBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n client Client @relation(fields: [clientId], references: [id])\n lockedAmounts LockedAmount[]\n consumedAmounts ConsumedAmount[]\n accessGrants BillingAccountAccess[]\n\n @@index([clientId])\n @@index([status])\n @@index([startDate, endDate])\n @@index([createdBy])\n}\n\nenum BAStatus {\n ACTIVE\n INACTIVE\n}\n\nenum BudgetEntryExternalType {\n CHALLENGE\n ENGAGEMENT\n}\n\nmodel LockedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n externalId String // UUID or legacy int-as-string for challenges; assignment UUID for engagements\n externalType BudgetEntryExternalType @default(CHALLENGE)\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, externalType, externalId], name: \"locked_unique_external\")\n @@index([billingAccountId])\n @@index([externalType, externalId])\n}\n\nmodel ConsumedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n externalId String\n externalType BudgetEntryExternalType @default(CHALLENGE)\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@index([billingAccountId])\n @@index([billingAccountId, externalType, externalId])\n}\n\n// Optional: whitelist which users can access which BAs for userId filtering\nmodel BillingAccountAccess {\n id String @id @default(uuid())\n billingAccountId Int\n userId String\n createdAt DateTime @default(now())\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, userId], name: \"ba_access_unique\")\n @@index([userId])\n}\n", + "inlineSchemaHash": "c4822ef86f1376bc8be47a6b059062e90921e71d7a633cf8cc2b3e159752e0f6", "copyEngine": true } @@ -258,7 +264,7 @@ if (!fs.existsSync(path.join(__dirname, 'schema.prisma'))) { config.isBundled = true } -config.runtimeDataModel = JSON.parse("{\"models\":{\"Client\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"codeName\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"ClientStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"BillingAccount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Int\",\"nativeType\":null,\"default\":{\"name\":\"autoincrement\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"projectId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"description\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subcontractingEndCustomer\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BAStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"budget\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"markup\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"clientId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"poNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subscriptionNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"isManualPrize\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":false,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"paymentTerms\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"salesTax\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billable\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":true,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"client\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Client\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[\"clientId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"lockedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"LockedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"consumedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"ConsumedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"accessGrants\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccountAccess\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"LockedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"challengeId\"]],\"uniqueIndexes\":[{\"name\":\"locked_unique_challenge\",\"fields\":[\"billingAccountId\",\"challengeId\"]}],\"isGenerated\":false},\"ConsumedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"challengeId\"]],\"uniqueIndexes\":[{\"name\":\"consumed_unique_challenge\",\"fields\":[\"billingAccountId\",\"challengeId\"]}],\"isGenerated\":false},\"BillingAccountAccess\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"userId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"userId\"]],\"uniqueIndexes\":[{\"name\":\"ba_access_unique\",\"fields\":[\"billingAccountId\",\"userId\"]}],\"isGenerated\":false}},\"enums\":{\"ClientStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null},\"BAStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null}},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Client\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"codeName\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"ClientStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"BillingAccount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Int\",\"nativeType\":null,\"default\":{\"name\":\"autoincrement\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"projectId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"name\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"description\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subcontractingEndCustomer\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"status\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BAStatus\",\"nativeType\":null,\"default\":\"ACTIVE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"startDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"endDate\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"budget\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"markup\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"clientId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"poNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"subscriptionNumber\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"isManualPrize\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":false,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"paymentTerms\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":[\"VarChar\",[\"255\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"salesTax\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"10\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billable\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"Boolean\",\"nativeType\":null,\"default\":true,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":false,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"client\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Client\",\"nativeType\":null,\"relationName\":\"BillingAccountToClient\",\"relationFromFields\":[\"clientId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"lockedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"LockedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"consumedAmounts\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"ConsumedAmount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"accessGrants\",\"kind\":\"object\",\"isList\":true,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccountAccess\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[],\"relationToFields\":[],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"LockedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalType\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BudgetEntryExternalType\",\"nativeType\":null,\"default\":\"CHALLENGE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToLockedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"externalType\",\"externalId\"]],\"uniqueIndexes\":[{\"name\":\"locked_unique_external\",\"fields\":[\"billingAccountId\",\"externalType\",\"externalId\"]}],\"isGenerated\":false},\"ConsumedAmount\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"externalType\",\"kind\":\"enum\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"BudgetEntryExternalType\",\"nativeType\":null,\"default\":\"CHALLENGE\",\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"amount\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"Decimal\",\"nativeType\":[\"Decimal\",[\"20\",\"4\"]],\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"DateTime\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":true},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToConsumedAmount\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[],\"uniqueIndexes\":[],\"isGenerated\":false},\"BillingAccountAccess\":{\"dbName\":null,\"schema\":null,\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":true,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"String\",\"nativeType\":null,\"default\":{\"name\":\"uuid\",\"args\":[4]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":true,\"hasDefaultValue\":false,\"type\":\"Int\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"userId\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"String\",\"nativeType\":null,\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":true,\"type\":\"DateTime\",\"nativeType\":null,\"default\":{\"name\":\"now\",\"args\":[]},\"isGenerated\":false,\"isUpdatedAt\":false},{\"name\":\"billingAccount\",\"kind\":\"object\",\"isList\":false,\"isRequired\":true,\"isUnique\":false,\"isId\":false,\"isReadOnly\":false,\"hasDefaultValue\":false,\"type\":\"BillingAccount\",\"nativeType\":null,\"relationName\":\"BillingAccountToBillingAccountAccess\",\"relationFromFields\":[\"billingAccountId\"],\"relationToFields\":[\"id\"],\"isGenerated\":false,\"isUpdatedAt\":false}],\"primaryKey\":null,\"uniqueFields\":[[\"billingAccountId\",\"userId\"]],\"uniqueIndexes\":[{\"name\":\"ba_access_unique\",\"fields\":[\"billingAccountId\",\"userId\"]}],\"isGenerated\":false}},\"enums\":{\"ClientStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null},\"BAStatus\":{\"values\":[{\"name\":\"ACTIVE\",\"dbName\":null},{\"name\":\"INACTIVE\",\"dbName\":null}],\"dbName\":null},\"BudgetEntryExternalType\":{\"values\":[{\"name\":\"CHALLENGE\",\"dbName\":null},{\"name\":\"ENGAGEMENT\",\"dbName\":null}],\"dbName\":null}},\"types\":{}}") defineDmmfProperty(exports.Prisma, config.runtimeDataModel) config.engineWasm = undefined config.compilerWasm = undefined diff --git a/packages/ba-prisma-client/package.json b/packages/ba-prisma-client/package.json index c6beee5..3e82cba 100644 --- a/packages/ba-prisma-client/package.json +++ b/packages/ba-prisma-client/package.json @@ -1,5 +1,5 @@ { - "name": "prisma-client-951b303acc920864f35dc83c7bbf10cc17c146068c18955dd7c0404dd9f58b1d", + "name": "prisma-client-41adb4fd9328fd3c36ad32be244e282ad3f75373a9ddd56026a132ea681d3da5", "main": "index.js", "types": "index.d.ts", "browser": "default.js", diff --git a/packages/ba-prisma-client/schema.prisma b/packages/ba-prisma-client/schema.prisma index b073cc9..1ae2797 100644 --- a/packages/ba-prisma-client/schema.prisma +++ b/packages/ba-prisma-client/schema.prisma @@ -76,32 +76,40 @@ enum BAStatus { INACTIVE } +enum BudgetEntryExternalType { + CHALLENGE + ENGAGEMENT +} + model LockedAmount { - id String @id @default(uuid()) + id String @id @default(uuid()) billingAccountId Int - challengeId String // UUID or legacy int-as-string - amount Decimal @db.Decimal(20, 4) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + externalId String // UUID or legacy int-as-string for challenges; assignment UUID for engagements + externalType BudgetEntryExternalType @default(CHALLENGE) + amount Decimal @db.Decimal(20, 4) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id]) - @@unique([billingAccountId, challengeId], name: "locked_unique_challenge") + @@unique([billingAccountId, externalType, externalId], name: "locked_unique_external") @@index([billingAccountId]) + @@index([externalType, externalId]) } model ConsumedAmount { - id String @id @default(uuid()) + id String @id @default(uuid()) billingAccountId Int - challengeId String - amount Decimal @db.Decimal(20, 4) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + externalId String + externalType BudgetEntryExternalType @default(CHALLENGE) + amount Decimal @db.Decimal(20, 4) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id]) - @@unique([billingAccountId, challengeId], name: "consumed_unique_challenge") @@index([billingAccountId]) + @@index([billingAccountId, externalType, externalId]) } // Optional: whitelist which users can access which BAs for userId filtering diff --git a/packages/ba-prisma-client/wasm.js b/packages/ba-prisma-client/wasm.js index dff0343..50179b9 100644 --- a/packages/ba-prisma-client/wasm.js +++ b/packages/ba-prisma-client/wasm.js @@ -130,7 +130,8 @@ exports.Prisma.BillingAccountScalarFieldEnum = { exports.Prisma.LockedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -139,7 +140,8 @@ exports.Prisma.LockedAmountScalarFieldEnum = { exports.Prisma.ConsumedAmountScalarFieldEnum = { id: 'id', billingAccountId: 'billingAccountId', - challengeId: 'challengeId', + externalId: 'externalId', + externalType: 'externalType', amount: 'amount', createdAt: 'createdAt', updatedAt: 'updatedAt' @@ -176,6 +178,11 @@ exports.BAStatus = exports.$Enums.BAStatus = { INACTIVE: 'INACTIVE' }; +exports.BudgetEntryExternalType = exports.$Enums.BudgetEntryExternalType = { + CHALLENGE: 'CHALLENGE', + ENGAGEMENT: 'ENGAGEMENT' +}; + exports.Prisma.ModelName = { Client: 'Client', BillingAccount: 'BillingAccount', @@ -194,7 +201,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "/home/vasea/work/topcoder/billing-accounts-api-v6/packages/ba-prisma-client", + "value": "/home/jmgasper/Documents/Git/v6/billing-accounts-api-v6/packages/ba-prisma-client", "fromEnvVar": null }, "config": { @@ -212,12 +219,11 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "/home/vasea/work/topcoder/billing-accounts-api-v6/prisma/schema.prisma", + "sourceFilePath": "/home/jmgasper/Documents/Git/v6/billing-accounts-api-v6/prisma/schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { - "rootEnvPath": null, - "schemaEnvPath": "../../.env" + "rootEnvPath": null }, "relativePath": "../../prisma", "clientVersion": "6.19.0", @@ -235,13 +241,13 @@ const config = { } } }, - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Ensure Alpine uses the OpenSSL 3-compatible binary\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/ba-prisma-client\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Client {\n id String @id @default(uuid())\n name String\n codeName String? @db.VarChar(255) // customer number / internal code\n status ClientStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccounts BillingAccount[]\n\n @@index([name])\n @@index([codeName])\n @@index([status])\n @@index([startDate, endDate])\n}\n\nenum ClientStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel BillingAccount {\n id Int @id @default(autoincrement())\n projectId String?\n name String\n description String?\n subcontractingEndCustomer String? @db.VarChar(255)\n status BAStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n budget Decimal @db.Decimal(20, 4)\n markup Decimal @db.Decimal(10, 4) // 0..1 typical, can exceed 1\n clientId String\n poNumber String? @db.VarChar(255)\n subscriptionNumber String? @db.VarChar(255)\n isManualPrize Boolean @default(false)\n paymentTerms String? @db.VarChar(255)\n salesTax Decimal? @db.Decimal(10, 4)\n billable Boolean @default(true)\n createdBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n client Client @relation(fields: [clientId], references: [id])\n lockedAmounts LockedAmount[]\n consumedAmounts ConsumedAmount[]\n accessGrants BillingAccountAccess[]\n\n @@index([clientId])\n @@index([status])\n @@index([startDate, endDate])\n @@index([createdBy])\n}\n\nenum BAStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel LockedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n challengeId String // UUID or legacy int-as-string\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, challengeId], name: \"locked_unique_challenge\")\n @@index([billingAccountId])\n}\n\nmodel ConsumedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n challengeId String\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, challengeId], name: \"consumed_unique_challenge\")\n @@index([billingAccountId])\n}\n\n// Optional: whitelist which users can access which BAs for userId filtering\nmodel BillingAccountAccess {\n id String @id @default(uuid())\n billingAccountId Int\n userId String\n createdAt DateTime @default(now())\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, userId], name: \"ba_access_unique\")\n @@index([userId])\n}\n", - "inlineSchemaHash": "4cc39769d8f9701b9e1adf54dbd047a5ed6e19c66cc27a7a9afe2d11b996bf48", + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Ensure Alpine uses the OpenSSL 3-compatible binary\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/ba-prisma-client\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Client {\n id String @id @default(uuid())\n name String\n codeName String? @db.VarChar(255) // customer number / internal code\n status ClientStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccounts BillingAccount[]\n\n @@index([name])\n @@index([codeName])\n @@index([status])\n @@index([startDate, endDate])\n}\n\nenum ClientStatus {\n ACTIVE\n INACTIVE\n}\n\nmodel BillingAccount {\n id Int @id @default(autoincrement())\n projectId String?\n name String\n description String?\n subcontractingEndCustomer String? @db.VarChar(255)\n status BAStatus @default(ACTIVE)\n startDate DateTime?\n endDate DateTime?\n budget Decimal @db.Decimal(20, 4)\n markup Decimal @db.Decimal(10, 4) // 0..1 typical, can exceed 1\n clientId String\n poNumber String? @db.VarChar(255)\n subscriptionNumber String? @db.VarChar(255)\n isManualPrize Boolean @default(false)\n paymentTerms String? @db.VarChar(255)\n salesTax Decimal? @db.Decimal(10, 4)\n billable Boolean @default(true)\n createdBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n client Client @relation(fields: [clientId], references: [id])\n lockedAmounts LockedAmount[]\n consumedAmounts ConsumedAmount[]\n accessGrants BillingAccountAccess[]\n\n @@index([clientId])\n @@index([status])\n @@index([startDate, endDate])\n @@index([createdBy])\n}\n\nenum BAStatus {\n ACTIVE\n INACTIVE\n}\n\nenum BudgetEntryExternalType {\n CHALLENGE\n ENGAGEMENT\n}\n\nmodel LockedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n externalId String // UUID or legacy int-as-string for challenges; assignment UUID for engagements\n externalType BudgetEntryExternalType @default(CHALLENGE)\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, externalType, externalId], name: \"locked_unique_external\")\n @@index([billingAccountId])\n @@index([externalType, externalId])\n}\n\nmodel ConsumedAmount {\n id String @id @default(uuid())\n billingAccountId Int\n externalId String\n externalType BudgetEntryExternalType @default(CHALLENGE)\n amount Decimal @db.Decimal(20, 4)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@index([billingAccountId])\n @@index([billingAccountId, externalType, externalId])\n}\n\n// Optional: whitelist which users can access which BAs for userId filtering\nmodel BillingAccountAccess {\n id String @id @default(uuid())\n billingAccountId Int\n userId String\n createdAt DateTime @default(now())\n\n billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id])\n\n @@unique([billingAccountId, userId], name: \"ba_access_unique\")\n @@index([userId])\n}\n", + "inlineSchemaHash": "c4822ef86f1376bc8be47a6b059062e90921e71d7a633cf8cc2b3e159752e0f6", "copyEngine": true } config.dirname = '/' -config.runtimeDataModel = JSON.parse("{\"models\":{\"Client\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"codeName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"ClientStatus\"},{\"name\":\"startDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"endDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccounts\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToClient\"}],\"dbName\":null},\"BillingAccount\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subcontractingEndCustomer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"BAStatus\"},{\"name\":\"startDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"endDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"budget\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"markup\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"clientId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"poNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subscriptionNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isManualPrize\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"paymentTerms\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"salesTax\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"billable\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"client\",\"kind\":\"object\",\"type\":\"Client\",\"relationName\":\"BillingAccountToClient\"},{\"name\":\"lockedAmounts\",\"kind\":\"object\",\"type\":\"LockedAmount\",\"relationName\":\"BillingAccountToLockedAmount\"},{\"name\":\"consumedAmounts\",\"kind\":\"object\",\"type\":\"ConsumedAmount\",\"relationName\":\"BillingAccountToConsumedAmount\"},{\"name\":\"accessGrants\",\"kind\":\"object\",\"type\":\"BillingAccountAccess\",\"relationName\":\"BillingAccountToBillingAccountAccess\"}],\"dbName\":null},\"LockedAmount\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"amount\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccount\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToLockedAmount\"}],\"dbName\":null},\"ConsumedAmount\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"amount\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccount\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToConsumedAmount\"}],\"dbName\":null},\"BillingAccountAccess\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccount\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToBillingAccountAccess\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Client\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"codeName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"ClientStatus\"},{\"name\":\"startDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"endDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccounts\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToClient\"}],\"dbName\":null},\"BillingAccount\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subcontractingEndCustomer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"BAStatus\"},{\"name\":\"startDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"endDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"budget\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"markup\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"clientId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"poNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subscriptionNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isManualPrize\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"paymentTerms\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"salesTax\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"billable\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"client\",\"kind\":\"object\",\"type\":\"Client\",\"relationName\":\"BillingAccountToClient\"},{\"name\":\"lockedAmounts\",\"kind\":\"object\",\"type\":\"LockedAmount\",\"relationName\":\"BillingAccountToLockedAmount\"},{\"name\":\"consumedAmounts\",\"kind\":\"object\",\"type\":\"ConsumedAmount\",\"relationName\":\"BillingAccountToConsumedAmount\"},{\"name\":\"accessGrants\",\"kind\":\"object\",\"type\":\"BillingAccountAccess\",\"relationName\":\"BillingAccountToBillingAccountAccess\"}],\"dbName\":null},\"LockedAmount\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"externalId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"externalType\",\"kind\":\"enum\",\"type\":\"BudgetEntryExternalType\"},{\"name\":\"amount\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccount\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToLockedAmount\"}],\"dbName\":null},\"ConsumedAmount\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"externalId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"externalType\",\"kind\":\"enum\",\"type\":\"BudgetEntryExternalType\"},{\"name\":\"amount\",\"kind\":\"scalar\",\"type\":\"Decimal\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccount\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToConsumedAmount\"}],\"dbName\":null},\"BillingAccountAccess\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"billingAccountId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"billingAccount\",\"kind\":\"object\",\"type\":\"BillingAccount\",\"relationName\":\"BillingAccountToBillingAccountAccess\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") defineDmmfProperty(exports.Prisma, config.runtimeDataModel) config.engineWasm = { getRuntime: async () => require('./query_engine_bg.js'), diff --git a/prisma/migrations/20260422000000_generalize_budget_entry_externals/migration.sql b/prisma/migrations/20260422000000_generalize_budget_entry_externals/migration.sql new file mode 100644 index 0000000..89f7ff0 --- /dev/null +++ b/prisma/migrations/20260422000000_generalize_budget_entry_externals/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "BudgetEntryExternalType" AS ENUM ('CHALLENGE', 'ENGAGEMENT'); + +-- Remove legacy challenge-only unique indexes before renaming the reference columns. +DROP INDEX IF EXISTS "LockedAmount_billingAccountId_challengeId_key"; +DROP INDEX IF EXISTS "ConsumedAmount_billingAccountId_challengeId_key"; +DROP INDEX IF EXISTS "locked_unique_challenge"; +DROP INDEX IF EXISTS "consumed_unique_challenge"; + +-- Rename challenge-specific references to the generic external reference contract. +ALTER TABLE "LockedAmount" RENAME COLUMN "challengeId" TO "externalId"; +ALTER TABLE "ConsumedAmount" RENAME COLUMN "challengeId" TO "externalId"; + +-- Backfill existing budget rows as CHALLENGE entries while keeping future inserts explicit. +ALTER TABLE "LockedAmount" + ADD COLUMN "externalType" "BudgetEntryExternalType" NOT NULL DEFAULT 'CHALLENGE'; +ALTER TABLE "ConsumedAmount" + ADD COLUMN "externalType" "BudgetEntryExternalType" NOT NULL DEFAULT 'CHALLENGE'; + +-- Locked entries remain unique per billing account and typed external reference. +CREATE UNIQUE INDEX "LockedAmount_billingAccountId_externalType_externalId_key" + ON "LockedAmount"("billingAccountId", "externalType", "externalId"); +CREATE INDEX "LockedAmount_externalType_externalId_idx" + ON "LockedAmount"("externalType", "externalId"); + +-- Consumed CHALLENGE entries keep overwrite semantics, while ENGAGEMENT entries are append-only. +CREATE INDEX "ConsumedAmount_billingAccountId_externalType_externalId_idx" + ON "ConsumedAmount"("billingAccountId", "externalType", "externalId"); +CREATE UNIQUE INDEX "ConsumedAmount_billingAccountId_externalType_externalId_challenge_key" + ON "ConsumedAmount"("billingAccountId", "externalType", "externalId") + WHERE "externalType" = 'CHALLENGE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d672bb..6814464 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,32 +76,40 @@ enum BAStatus { INACTIVE } +enum BudgetEntryExternalType { + CHALLENGE + ENGAGEMENT +} + model LockedAmount { id String @id @default(uuid()) billingAccountId Int - challengeId String // UUID or legacy int-as-string + externalId String // UUID or legacy int-as-string for challenges; assignment UUID for engagements + externalType BudgetEntryExternalType @default(CHALLENGE) amount Decimal @db.Decimal(20,4) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id]) - @@unique([billingAccountId, challengeId], name: "locked_unique_challenge") + @@unique([billingAccountId, externalType, externalId], name: "locked_unique_external") @@index([billingAccountId]) + @@index([externalType, externalId]) } model ConsumedAmount { id String @id @default(uuid()) billingAccountId Int - challengeId String + externalId String + externalType BudgetEntryExternalType @default(CHALLENGE) amount Decimal @db.Decimal(20,4) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id]) - @@unique([billingAccountId, challengeId], name: "consumed_unique_challenge") @@index([billingAccountId]) + @@index([billingAccountId, externalType, externalId]) } // Optional: whitelist which users can access which BAs for userId filtering diff --git a/scripts/backfill-engagement-payments.ts b/scripts/backfill-engagement-payments.ts new file mode 100644 index 0000000..6510e89 --- /dev/null +++ b/scripts/backfill-engagement-payments.ts @@ -0,0 +1,1881 @@ +/* eslint-disable no-console */ +import 'dotenv/config'; +import { Prisma, PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; + +const CHUNK_SIZE = 500; +const LEDGER_DECIMAL_PLACES = 4; +const ENGAGEMENT_EXTERNAL_TYPE = 'ENGAGEMENT'; +const DEFAULT_EXEMPT_BILLING_ACCOUNT_IDS = [80000062, 80002800] as const; +const APPLY_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000; + +type ParsedArgs = { + apply: boolean; + assignmentIds: Set; + limit?: number; + reportPath: string; + since?: Date; + until?: Date; + winningIds: Set; +}; + +type FinancePaymentRow = { + winningId: string; + winnerId: string; + externalId: string | null; + attributes: unknown; + winningCreatedAt: Date | null; + paymentId: string; + totalAmount: Prisma.Decimal | string | number | null; + billingAccount: string | null; + challengeMarkup: Prisma.Decimal | string | number | null; + challengeFee: Prisma.Decimal | string | number | null; + paymentStatus: string | null; + currency: string | null; + installmentNumber: number | null; + paymentCreatedAt: Date | null; +}; + +type AssignmentLookupRow = { + assignmentId: string; + engagementId: string; + projectId: string; + memberId: string; + memberHandle: string; + engagementTitle: string; + externalEngagementId?: string; +}; + +type ResolvedAssignment = AssignmentLookupRow & { + resolutionSource: + | 'attributes.assignmentId' + | 'external_id.assignment' + | 'external_id.engagement_member' + | 'external_id.engagement_single'; +}; + +type BillingAccountMetadata = { + id: number; + markup: Prisma.Decimal; +}; + +type ConsumeAmountSource = + | 'finance.challenge_markup_rate' + | 'finance.challenge_fee_absolute' + | 'billing_account_markup_fallback'; + +type ConsumeAmountResolution = + | { + adjustmentType: 'markup_rate'; + challengeFee: null; + markupRate: Prisma.Decimal; + source: + | 'finance.challenge_markup_rate' + | 'billing_account_markup_fallback'; + } + | { + adjustmentType: 'absolute_fee'; + challengeFee: Prisma.Decimal; + markupRate: null; + source: 'finance.challenge_fee_absolute'; + }; + +type ConsumeAmountSourceCounts = Partial>; + +type BillingLedgerClient = PrismaClient | Prisma.TransactionClient; + +type PaymentConsumeContext = { + assignment: ResolvedAssignment; + billingAccountId: number; + challengeFee: Prisma.Decimal | null; + consumedAmount: Prisma.Decimal; + consumeAmountSource: ConsumeAmountSource; + consumeAmountType: ConsumeAmountResolution['adjustmentType']; + markupRate: Prisma.Decimal | null; + payment: FinancePaymentRow; + totalAmount: Prisma.Decimal; +}; + +type AssignmentAggregate = { + assignmentId: string; + billingAccountId: number; + consumedAmount: Prisma.Decimal; + consumeAmountSourceCounts: ConsumeAmountSourceCounts; + engagementId: string; + fallbackMarkupCount: number; + paymentCount: number; + paymentIds: string[]; + paymentTotal: Prisma.Decimal; + projectId: string; + resolutionSources: string[]; + winningIds: string[]; +}; + +type ExistingConsumedRow = { + id: string; + billingAccountId: number; + externalId: string; + amount: Prisma.Decimal; + createdAt: Date; + updatedAt: Date; +}; + +type BackfillException = { + code: string; + message: string; + assignmentId?: string; + billingAccountId?: number; + engagementId?: string; + externalId?: string | null; + paymentId?: string; + projectId?: string; + winningId?: string; + details?: Record; +}; + +type AbsoluteFeeAudit = { + assignmentId: string; + billingAccountId: number; + challengeFee: Prisma.Decimal; + consumedAmount: Prisma.Decimal; + paymentId: string; + totalAmount: Prisma.Decimal; + winningId: string; +}; + +type ExemptBillingAccountAudit = { + assignmentId: string; + billingAccountId: number; + engagementId: string; + paymentId: string; + projectId: string; + reason: 'topgear_billing_account_exempt'; + winningId: string; +}; + +type FallbackMarkupAudit = { + assignmentId: string; + billingAccountId: number; + consumedAmount: Prisma.Decimal; + markup: Prisma.Decimal; + paymentId: string; + totalAmount: Prisma.Decimal; + winningId: string; +}; + +type PlannedAction = + | { + action: 'already_correct'; + aggregate: AssignmentAggregate; + existingRows: ExistingConsumedRow[]; + } + | { + action: 'insert'; + aggregate: AssignmentAggregate; + createdId?: string; + existingRows: ExistingConsumedRow[]; + } + | { + action: 'update_single'; + aggregate: AssignmentAggregate; + existingRows: ExistingConsumedRow[]; + previous: ExistingConsumedRow; + updatedId?: string; + } + | { + action: 'move_existing_rows'; + aggregate: AssignmentAggregate; + existingRows: ExistingConsumedRow[]; + updatedIds?: string[]; + } + | { + action: 'manual_review'; + aggregate: AssignmentAggregate; + existingRows: ExistingConsumedRow[]; + reason: string; + }; + +type ApplyResult = { + actions: PlannedAction[]; + actualAfter: Prisma.Decimal; +}; + +type ReportInput = { + absoluteFee: AbsoluteFeeAudit[]; + actions: PlannedAction[]; + actualAfter?: Prisma.Decimal; + actualBefore: Prisma.Decimal; + aggregates: Map; + args: ParsedArgs; + contexts: PaymentConsumeContext[]; + exceptions: BackfillException[]; + exemptBillingAccounts: ExemptBillingAccountAudit[]; + exemptBillingAccountIds: Set; + expectedTotal: Prisma.Decimal; + fallbackMarkup: FallbackMarkupAudit[]; + payments: FinancePaymentRow[]; + projectedTotal: Prisma.Decimal; + resolvedAssignments: Map; +}; + +type Report = { + absoluteFee: AbsoluteFeeAudit[]; + actions: PlannedAction[]; + exceptions: BackfillException[]; + exemptBillingAccounts: ExemptBillingAccountAudit[]; + fallbackMarkup: FallbackMarkupAudit[]; + generatedAt: string; + mode: 'apply' | 'dry-run'; + summary: Record; + totals: { + actualAfterApplyForResolvedAssignments?: string; + actualBeforeForResolvedAssignments: string; + expectedResolvedAssignments: string; + projectedForResolvedAssignments: string; + }; +}; + +/** + * Prints CLI usage and terminates the script. + * + * @returns Never returns because the process exits with a non-zero status. + * @throws This function does not throw; it exits the Node process. + */ +function usage(): never { + console.error( + [ + 'Usage: pnpm run backfill:engagementPayments -- [--dry-run|--apply] [options]', + '', + 'Options:', + ' --assignmentId=[,...] Limit to one or more engagement assignments.', + ' --winningId=[,...] Limit to one or more finance winnings.', + ' --since= Include winnings created at or after this date.', + ' --until= Include winnings created before this date.', + ' --limit= Limit finance payment rows for rehearsal runs.', + ' --report= Write the JSON audit report to this path.', + '', + 'Environment:', + ' DATABASE_URL billing-accounts-api-v6 database.', + ' FINANCE_DB_URL tc-finance-api database.', + ' ENGAGEMENTS_DB_URL engagements-api-v6 database.', + ' PROJECTS_DB_URL projects-api-v6 database.', + ' TGBillingAccounts Optional comma/JSON list of TopGear-exempt billing account ids.', + ].join('\n'), + ); + process.exit(1); +} + +/** + * Parses command-line arguments for the backfill. + * + * @param argv Raw arguments after the script name. + * @returns Normalized flags, filters, and report destination. + * @throws Error when a date, limit, or unknown option is invalid. + */ +function parseArgs(argv: string[]): ParsedArgs { + let apply = false; + let reportPath: string | undefined; + let since: Date | undefined; + let until: Date | undefined; + let limit: number | undefined; + const assignmentIds = new Set(); + const winningIds = new Set(); + + for (const arg of argv) { + if (arg === '--help' || arg === '-h') usage(); + if (arg === '--apply') { + apply = true; + continue; + } + if (arg === '--dry-run') { + apply = false; + continue; + } + if (arg.startsWith('--report=')) { + reportPath = arg.slice('--report='.length).trim(); + continue; + } + if (arg.startsWith('--assignmentId=')) { + addCsvValues(assignmentIds, arg.slice('--assignmentId='.length)); + continue; + } + if (arg.startsWith('--winningId=')) { + addCsvValues(winningIds, arg.slice('--winningId='.length)); + continue; + } + if (arg.startsWith('--since=')) { + since = parseDateArg(arg.slice('--since='.length), '--since'); + continue; + } + if (arg.startsWith('--until=')) { + until = parseDateArg(arg.slice('--until='.length), '--until'); + continue; + } + if (arg.startsWith('--limit=')) { + limit = parseLimit(arg.slice('--limit='.length)); + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return { + apply, + assignmentIds, + limit, + reportPath: reportPath || defaultReportPath(apply), + since, + until, + winningIds, + }; +} + +/** + * Adds comma-separated CLI values into a set. + * + * @param target Set receiving normalized non-empty values. + * @param raw Comma-separated raw value. + * @returns Nothing. + * @throws This function does not throw. + */ +function addCsvValues(target: Set, raw: string): void { + raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .forEach((value) => target.add(value)); +} + +/** + * Parses a CLI date option. + * + * @param value Date string supplied by the caller. + * @param flag Flag name used in validation errors. + * @returns Parsed Date. + * @throws Error when the date is empty or invalid. + */ +function parseDateArg(value: string, flag: string): Date { + const parsed = new Date(value); + if (!value.trim() || Number.isNaN(parsed.getTime())) { + throw new Error(`${flag} must be a valid date string.`); + } + return parsed; +} + +/** + * Parses the optional row limit. + * + * @param value Numeric string supplied to --limit. + * @returns Positive integer limit. + * @throws Error when the limit is not a positive safe integer. + */ +function parseLimit(value: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('--limit must be a positive integer.'); + } + return parsed; +} + +/** + * Builds the default audit report path. + * + * @param apply Whether the run will mutate billing-account rows. + * @returns Absolute path for the report JSON file. + * @throws This function does not throw. + */ +function defaultReportPath(apply: boolean): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const mode = apply ? 'apply' : 'dry-run'; + return path.resolve( + process.cwd(), + 'scripts', + 'output', + `engagement-payment-backfill-${mode}-${timestamp}.json`, + ); +} + +/** + * Resolves one of several supported database URL environment variables. + * + * @param names Environment variable names in preference order. + * @returns The first configured URL. + * @throws Error when none of the names are configured. + */ +function requireDatabaseUrl(names: string[]): string { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + + throw new Error(`Missing database URL. Set one of: ${names.join(', ')}`); +} + +/** + * Creates a Prisma client pointed at an external database. + * + * @param databaseUrl Postgres connection string. + * @returns Prisma client that is only used for raw SQL reads. + * @throws This function does not throw directly, but Prisma can throw on use. + */ +function createExternalClient(databaseUrl: string): PrismaClient { + return new PrismaClient({ + datasources: { db: { url: databaseUrl } }, + }); +} + +/** + * Splits a list into fixed-size chunks for bounded database queries. + * + * @param items Items to split. + * @param size Maximum chunk size. + * @returns Array of item chunks. + * @throws This function does not throw. + */ +function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +} + +/** + * Normalizes an unknown value into a non-empty string. + * + * @param value Raw value. + * @returns Trimmed string or null. + * @throws This function does not throw. + */ +function normalizeString(value: unknown): string | null { + if (value === undefined || value === null) return null; + const normalized = String(value).trim(); + return normalized || null; +} + +/** + * Converts an unknown value into a Prisma Decimal. + * + * @param value Raw number, string, or Decimal value. + * @returns Decimal value, or null when the source is absent. + * @throws Error when a present value cannot be parsed as a decimal. + */ +function toDecimalOrNull(value: unknown): Prisma.Decimal | null { + if (value === undefined || value === null) return null; + return new Prisma.Decimal(String(value)); +} + +/** + * Compares two Decimal values at billing-ledger precision. + * + * @param left First decimal value. + * @param right Second decimal value. + * @returns True when both values match at the ledger scale. + * @throws This function does not throw. + */ +function decimalEquals(left: Prisma.Decimal, right: Prisma.Decimal): boolean { + return quantizeLedgerAmount(left).equals(quantizeLedgerAmount(right)); +} + +/** + * Rounds an amount to the billing ledger scale. + * + * @param amount Decimal amount to normalize. + * @returns Amount rounded to four decimal places with half-up rounding. + * @throws This function does not throw for valid Decimal inputs. + */ +function quantizeLedgerAmount(amount: Prisma.Decimal): Prisma.Decimal { + return amount.toDecimalPlaces( + LEDGER_DECIMAL_PLACES, + Prisma.Decimal.ROUND_HALF_UP, + ); +} + +/** + * Extracts a legacy assignment id from winnings attributes. + * + * @param attributes Finance winnings attributes JSON. + * @returns Normalized assignment id, or null when it is absent. + * @throws This function does not throw. + */ +function getAttributeAssignmentId(attributes: unknown): string | null { + if ( + !attributes || + typeof attributes !== 'object' || + Array.isArray(attributes) + ) { + return null; + } + + return normalizeString((attributes as Record).assignmentId); +} + +/** + * Normalizes a trusted project billing account id. + * + * @param value Raw billing account id from projects-api-v6. + * @returns Positive safe integer id, or null when the project has no billing account. + * @throws Error when a configured value is malformed. + */ +function normalizeBillingAccountId(value: unknown): number | null { + if (value === undefined || value === null) return null; + const normalized = String(value).trim(); + if (!normalized) return null; + if (!/^\d+$/.test(normalized)) { + throw new Error(`Invalid billing account id: ${normalized}`); + } + + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid billing account id: ${normalized}`); + } + + return parsed; +} + +/** + * Parses the finance-compatible TopGear billing-account exemption list. + * + * @returns Billing account ids that should not receive engagement consume rows. + * @throws Error when the configured list is not a JSON array or integer list. + */ +function loadExemptBillingAccountIds(): Set { + const raw = process.env.TGBillingAccounts?.trim(); + const values = raw + ? parseBillingAccountIdList(raw, 'TGBillingAccounts') + : [...DEFAULT_EXEMPT_BILLING_ACCOUNT_IDS]; + + return new Set( + values.map((value) => { + const parsed = normalizeBillingAccountId(value); + if (parsed === null) { + throw new Error('TGBillingAccounts contains an empty billing account id.'); + } + return parsed; + }), + ); +} + +/** + * Parses a billing-account id list from JSON array or comma/space-separated text. + * + * @param raw Raw environment variable value. + * @param label Environment variable name used in validation errors. + * @returns Raw id values ready for integer normalization. + * @throws Error when JSON syntax is invalid or the value is not a list. + */ +function parseBillingAccountIdList(raw: string, label: string): unknown[] { + if (raw.startsWith('[')) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `${label} must be a JSON array or comma-separated integer list: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + if (!Array.isArray(parsed)) { + throw new Error(`${label} must be a JSON array or integer list.`); + } + return parsed; + } + + return raw.split(/[,\s]+/).filter(Boolean); +} + +/** + * Normalizes a projects-api-v6 project id for BigInt lookups. + * + * @param value Engagement project id value. + * @returns BigInt-compatible project id string, or null when malformed. + * @throws This function does not throw. + */ +function normalizeProjectId(value: unknown): string | null { + const normalized = normalizeString(value); + if (!normalized || !/^\d+$/.test(normalized)) return null; + return normalized; +} + +/** + * Reads legacy engagement payment rows from tc-finance-api. + * + * @param financeClient Raw-query Prisma client for the finance database. + * @param args Parsed CLI filters. + * @returns Finance payment rows with category ENGAGEMENT_PAYMENT. + * @throws Error when the finance query fails. + */ +async function loadFinancePayments( + financeClient: PrismaClient, + args: ParsedArgs, +): Promise { + const whereParts: Prisma.Sql[] = [ + Prisma.sql`w.category::text = 'ENGAGEMENT_PAYMENT'`, + ]; + + if (args.since) { + whereParts.push(Prisma.sql`w.created_at >= ${args.since}`); + } + if (args.until) { + whereParts.push(Prisma.sql`w.created_at < ${args.until}`); + } + if (args.winningIds.size) { + whereParts.push( + Prisma.sql`w.winning_id::text IN (${Prisma.join([ + ...args.winningIds, + ])})`, + ); + } + if (args.assignmentIds.size) { + const assignmentIds = [...args.assignmentIds]; + whereParts.push( + Prisma.sql`( + w.external_id IN (${Prisma.join(assignmentIds)}) + OR w.attributes->>'assignmentId' IN (${Prisma.join(assignmentIds)}) + )`, + ); + } + + const limitClause = args.limit + ? Prisma.sql`LIMIT ${args.limit}` + : Prisma.empty; + + return financeClient.$queryRaw` + SELECT + w.winning_id::text AS "winningId", + w.winner_id AS "winnerId", + w.external_id AS "externalId", + w.attributes AS "attributes", + w.created_at AS "winningCreatedAt", + p.payment_id::text AS "paymentId", + p.total_amount AS "totalAmount", + p.billing_account AS "billingAccount", + p.challenge_markup AS "challengeMarkup", + p.challenge_fee AS "challengeFee", + p.payment_status::text AS "paymentStatus", + p.currency AS "currency", + p.installment_number AS "installmentNumber", + p.created_at AS "paymentCreatedAt" + FROM winnings w + INNER JOIN payment p ON p.winnings_id = w.winning_id + WHERE ${Prisma.join(whereParts, ' AND ')} + ORDER BY w.created_at ASC, p.installment_number ASC, p.created_at ASC + ${limitClause} + `; +} + +/** + * Loads engagement assignment rows by assignment id. + * + * @param engagementsClient Raw-query Prisma client for engagements-api-v6. + * @param assignmentIds Assignment ids to resolve. + * @returns Map keyed by assignment id. + * @throws Error when the engagements query fails. + */ +async function loadAssignmentsById( + engagementsClient: PrismaClient, + assignmentIds: string[], +): Promise> { + const rowsById = new Map(); + + for (const chunk of chunkArray([...new Set(assignmentIds)], CHUNK_SIZE)) { + if (!chunk.length) continue; + + const rows = await engagementsClient.$queryRaw` + SELECT + ea.id AS "assignmentId", + ea."engagementId" AS "engagementId", + ea."memberId" AS "memberId", + ea."memberHandle" AS "memberHandle", + e."projectId" AS "projectId", + e.title AS "engagementTitle" + FROM "EngagementAssignment" ea + INNER JOIN "Engagement" e ON e.id = ea."engagementId" + WHERE ea.id IN (${Prisma.join(chunk)}) + `; + + rows.forEach((row) => rowsById.set(row.assignmentId, row)); + } + + return rowsById; +} + +/** + * Loads assignments for finance rows whose external id stores an engagement id. + * + * @param engagementsClient Raw-query Prisma client for engagements-api-v6. + * @param engagementIds Engagement ids from finance external_id values. + * @returns Map from engagement id to its assignment rows. + * @throws Error when the engagements query fails. + */ +async function loadAssignmentsByEngagementId( + engagementsClient: PrismaClient, + engagementIds: string[], +): Promise> { + const rowsByEngagementId = new Map(); + + for (const chunk of chunkArray([...new Set(engagementIds)], CHUNK_SIZE)) { + if (!chunk.length) continue; + + const rows = await engagementsClient.$queryRaw` + SELECT + ea.id AS "assignmentId", + ea."engagementId" AS "engagementId", + ea."memberId" AS "memberId", + ea."memberHandle" AS "memberHandle", + e.id AS "externalEngagementId", + e."projectId" AS "projectId", + e.title AS "engagementTitle" + FROM "Engagement" e + INNER JOIN "EngagementAssignment" ea ON ea."engagementId" = e.id + WHERE e.id IN (${Prisma.join(chunk)}) + `; + + for (const row of rows) { + const externalEngagementId = row.externalEngagementId || row.engagementId; + const existing = rowsByEngagementId.get(externalEngagementId) || []; + existing.push(row); + rowsByEngagementId.set(externalEngagementId, existing); + } + } + + return rowsByEngagementId; +} + +/** + * Resolves one finance payment to an engagement assignment. + * + * @param payment Finance payment row. + * @param assignmentsById Assignment lookup keyed by assignment id. + * @param assignmentsByEngagementId Engagement fallback lookup. + * @param exceptions Mutable exception list receiving unresolved cases. + * @returns Resolved assignment context, or null when the row is ambiguous. + * @throws This function does not throw. + */ +function resolvePaymentAssignment( + payment: FinancePaymentRow, + assignmentsById: Map, + assignmentsByEngagementId: Map, + exceptions: BackfillException[], +): ResolvedAssignment | null { + const attributeAssignmentId = getAttributeAssignmentId(payment.attributes); + const externalId = normalizeString(payment.externalId); + + if (attributeAssignmentId) { + const assignment = assignmentsById.get(attributeAssignmentId); + if (assignment) { + return { ...assignment, resolutionSource: 'attributes.assignmentId' }; + } + + exceptions.push({ + code: 'missing_assignment', + message: + 'Finance attributes.assignmentId does not match an engagement assignment.', + assignmentId: attributeAssignmentId, + externalId, + paymentId: payment.paymentId, + winningId: payment.winningId, + }); + return null; + } + + if (!externalId) { + exceptions.push({ + code: 'missing_assignment_reference', + message: 'Finance winning has neither external_id nor assignmentId.', + paymentId: payment.paymentId, + winningId: payment.winningId, + }); + return null; + } + + const directAssignment = assignmentsById.get(externalId); + if (directAssignment) { + return { ...directAssignment, resolutionSource: 'external_id.assignment' }; + } + + const engagementAssignments = assignmentsByEngagementId.get(externalId) || []; + const memberMatches = engagementAssignments.filter( + (assignment) => String(assignment.memberId) === String(payment.winnerId), + ); + + if (memberMatches.length === 1) { + return { + ...memberMatches[0], + resolutionSource: 'external_id.engagement_member', + }; + } + + if (memberMatches.length > 1) { + exceptions.push({ + code: 'ambiguous_assignment', + message: + 'Finance external_id matches an engagement with multiple assignments for the winning member.', + externalId, + paymentId: payment.paymentId, + winningId: payment.winningId, + details: { + matchedAssignmentIds: memberMatches.map((row) => row.assignmentId), + }, + }); + return null; + } + + if (engagementAssignments.length === 1) { + return { + ...engagementAssignments[0], + resolutionSource: 'external_id.engagement_single', + }; + } + + exceptions.push({ + code: engagementAssignments.length + ? 'missing_assignment_for_winner' + : 'missing_assignment', + message: engagementAssignments.length + ? 'Finance external_id matches an engagement, but no assignment matches the winning member.' + : 'Finance external_id does not match an assignment or engagement.', + externalId, + paymentId: payment.paymentId, + winningId: payment.winningId, + details: engagementAssignments.length + ? { + engagementAssignmentIds: engagementAssignments.map( + (row) => row.assignmentId, + ), + } + : undefined, + }); + + return null; +} + +/** + * Loads trusted billing account ids from projects-api-v6. + * + * @param projectsClient Raw-query Prisma client for projects-api-v6. + * @param projectIds Project ids from engagements-api-v6. + * @returns Map from project id string to billing account id or null. + * @throws Error when the projects query fails. + */ +async function loadProjectBillingAccounts( + projectsClient: PrismaClient, + projectIds: string[], +): Promise> { + const billingAccountByProjectId = new Map(); + const normalizedProjectIds = [...new Set(projectIds)] + .map((projectId) => normalizeProjectId(projectId)) + .filter((projectId): projectId is string => Boolean(projectId)); + + for (const chunk of chunkArray(normalizedProjectIds, CHUNK_SIZE)) { + if (!chunk.length) continue; + + const rows = await projectsClient.$queryRaw< + { projectId: string; billingAccountId: string | null }[] + >` + SELECT + id::text AS "projectId", + "billingAccountId"::text AS "billingAccountId" + FROM projects + WHERE id IN (${Prisma.join(chunk.map((projectId) => BigInt(projectId)))}) + AND "deletedAt" IS NULL + `; + + for (const row of rows) { + try { + billingAccountByProjectId.set( + row.projectId, + normalizeBillingAccountId(row.billingAccountId), + ); + } catch { + billingAccountByProjectId.set(row.projectId, null); + } + } + } + + return billingAccountByProjectId; +} + +/** + * Loads billing-account metadata from billing-accounts-api-v6. + * + * @param billingClient Prisma client for the billing database. + * @param billingAccountIds Billing account ids to load. + * @returns Map keyed by billing account id. + * @throws Error when the billing query fails. + */ +async function loadBillingAccounts( + billingClient: PrismaClient, + billingAccountIds: number[], +): Promise> { + const metadata = new Map(); + + for (const chunk of chunkArray([...new Set(billingAccountIds)], CHUNK_SIZE)) { + if (!chunk.length) continue; + + const rows = await billingClient.billingAccount.findMany({ + where: { id: { in: chunk } }, + select: { id: true, markup: true }, + }); + + rows.forEach((row) => metadata.set(row.id, row)); + } + + return metadata; +} + +/** + * Resolves the persisted adjustment used to compute a consumed amount. + * + * @param payment Finance payment row. + * @param billingAccount Billing account metadata used as markup fallback. + * @returns Adjustment type, value, and source label. + * @throws Error when a persisted fee or markup is invalid. + */ +function resolveConsumeAmountAdjustment( + payment: FinancePaymentRow, + billingAccount: BillingAccountMetadata, +): ConsumeAmountResolution { + const challengeMarkup = toDecimalOrNull(payment.challengeMarkup); + if (challengeMarkup !== null) { + assertNonNegativeDecimal(challengeMarkup, 'finance challenge_markup'); + return { + adjustmentType: 'markup_rate', + challengeFee: null, + markupRate: challengeMarkup, + source: 'finance.challenge_markup_rate', + }; + } + + const challengeFee = toDecimalOrNull(payment.challengeFee); + if (challengeFee !== null) { + assertNonNegativeDecimal(challengeFee, 'finance challenge_fee'); + return { + adjustmentType: 'absolute_fee', + challengeFee, + markupRate: null, + source: 'finance.challenge_fee_absolute', + }; + } + + assertNonNegativeDecimal(billingAccount.markup, 'billing account markup'); + return { + adjustmentType: 'markup_rate', + challengeFee: null, + markupRate: billingAccount.markup, + source: 'billing_account_markup_fallback', + }; +} + +/** + * Validates that a Decimal is non-negative. + * + * @param value Decimal value being checked. + * @param label Human-readable field label for errors. + * @returns Nothing. + * @throws Error when the value is negative. + */ +function assertNonNegativeDecimal(value: Prisma.Decimal, label: string): void { + if (value.lessThan(0)) { + throw new Error(`${label} must be non-negative.`); + } +} + +/** + * Computes the consumed billing amount for one finance payment. + * + * @param totalAmount Legacy payment total amount. + * @param adjustment Fee or markup-rate adjustment resolved for the payment. + * @returns Ledger-scale consumed amount. + * @throws This function does not throw for valid Decimal inputs. + */ +function calculateConsumedAmount( + totalAmount: Prisma.Decimal, + adjustment: ConsumeAmountResolution, +): Prisma.Decimal { + if (adjustment.adjustmentType === 'absolute_fee') { + return quantizeLedgerAmount(totalAmount.plus(adjustment.challengeFee)); + } + + return quantizeLedgerAmount( + totalAmount.plus(totalAmount.mul(adjustment.markupRate)), + ); +} + +/** + * Builds payment consume contexts after assignment and billing account resolution. + * + * @param payments Finance payment rows. + * @param resolvedAssignments Resolved assignment context by payment id. + * @param projectBillingAccounts Trusted project billing account map. + * @param billingAccounts Billing-account metadata map. + * @param exemptBillingAccountIds Billing account ids skipped by finance. + * @param exceptions Mutable exception list receiving unreconstructable rows. + * @param fallbackMarkup Mutable audit list receiving fallback-markup usage. + * @param absoluteFee Mutable audit list receiving absolute-fee usage. + * @param exemptBillingAccounts Mutable audit list receiving skipped exemptions. + * @returns Payment consume contexts safe to aggregate. + * @throws This function does not throw; row-level issues are reported. + */ +function buildPaymentContexts( + payments: FinancePaymentRow[], + resolvedAssignments: Map, + projectBillingAccounts: Map, + billingAccounts: Map, + exemptBillingAccountIds: Set, + exceptions: BackfillException[], + fallbackMarkup: FallbackMarkupAudit[], + absoluteFee: AbsoluteFeeAudit[], + exemptBillingAccounts: ExemptBillingAccountAudit[], +): PaymentConsumeContext[] { + const contexts: PaymentConsumeContext[] = []; + + for (const payment of payments) { + const assignment = resolvedAssignments.get(payment.paymentId); + if (!assignment) continue; + + const normalizedProjectId = normalizeProjectId(assignment.projectId); + if (!normalizedProjectId) { + exceptions.push({ + code: 'invalid_project_id', + message: 'Engagement assignment projectId is not a projects-api-v6 id.', + assignmentId: assignment.assignmentId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: assignment.projectId, + winningId: payment.winningId, + }); + continue; + } + + if (!projectBillingAccounts.has(normalizedProjectId)) { + exceptions.push({ + code: 'missing_project', + message: 'Engagement project was not found in projects-api-v6.', + assignmentId: assignment.assignmentId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: normalizedProjectId, + winningId: payment.winningId, + }); + continue; + } + + const billingAccountId = projectBillingAccounts.get(normalizedProjectId); + if (billingAccountId === null || billingAccountId === undefined) { + exceptions.push({ + code: 'missing_project_billing_account', + message: 'Trusted project billingAccountId is not configured.', + assignmentId: assignment.assignmentId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: normalizedProjectId, + winningId: payment.winningId, + }); + continue; + } + + if (exemptBillingAccountIds.has(billingAccountId)) { + exemptBillingAccounts.push({ + assignmentId: assignment.assignmentId, + billingAccountId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: normalizedProjectId, + reason: 'topgear_billing_account_exempt', + winningId: payment.winningId, + }); + continue; + } + + const billingAccount = billingAccounts.get(billingAccountId); + if (!billingAccount) { + exceptions.push({ + code: 'missing_billing_account', + message: 'Trusted project billingAccountId does not exist in billing accounts.', + assignmentId: assignment.assignmentId, + billingAccountId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: normalizedProjectId, + winningId: payment.winningId, + }); + continue; + } + + let totalAmount: Prisma.Decimal; + let adjustment: ConsumeAmountResolution; + try { + const parsedTotal = toDecimalOrNull(payment.totalAmount); + if (parsedTotal === null) { + throw new Error('payment total_amount is missing.'); + } + assertNonNegativeDecimal(parsedTotal, 'payment total_amount'); + totalAmount = parsedTotal; + adjustment = resolveConsumeAmountAdjustment(payment, billingAccount); + } catch (error) { + exceptions.push({ + code: 'unreconstructable_markup_or_total', + message: error instanceof Error ? error.message : String(error), + assignmentId: assignment.assignmentId, + billingAccountId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: normalizedProjectId, + winningId: payment.winningId, + }); + continue; + } + + const consumedAmount = calculateConsumedAmount(totalAmount, adjustment); + if (!consumedAmount.greaterThan(0)) { + exceptions.push({ + code: 'non_positive_consumed_amount', + message: 'Computed consumed amount is not positive.', + assignmentId: assignment.assignmentId, + billingAccountId, + engagementId: assignment.engagementId, + paymentId: payment.paymentId, + projectId: normalizedProjectId, + winningId: payment.winningId, + details: { + adjustmentSource: adjustment.source, + adjustmentType: adjustment.adjustmentType, + challengeFee: adjustment.challengeFee?.toFixed(), + markupRate: adjustment.markupRate?.toFixed(), + totalAmount: totalAmount.toFixed(), + }, + }); + continue; + } + + if (adjustment.source === 'billing_account_markup_fallback') { + fallbackMarkup.push({ + assignmentId: assignment.assignmentId, + billingAccountId, + consumedAmount, + markup: adjustment.markupRate, + paymentId: payment.paymentId, + totalAmount, + winningId: payment.winningId, + }); + } + + if (adjustment.source === 'finance.challenge_fee_absolute') { + absoluteFee.push({ + assignmentId: assignment.assignmentId, + billingAccountId, + challengeFee: adjustment.challengeFee, + consumedAmount, + paymentId: payment.paymentId, + totalAmount, + winningId: payment.winningId, + }); + } + + contexts.push({ + assignment, + billingAccountId, + challengeFee: adjustment.challengeFee, + consumedAmount, + consumeAmountSource: adjustment.source, + consumeAmountType: adjustment.adjustmentType, + markupRate: adjustment.markupRate, + payment, + totalAmount, + }); + } + + return contexts; +} + +/** + * Aggregates payment contexts into one expected consumed value per assignment. + * + * @param contexts Per-payment consume contexts. + * @returns Assignment aggregate map. + * @throws This function does not throw. + */ +function aggregateByAssignment( + contexts: PaymentConsumeContext[], +): Map { + const aggregates = new Map(); + + for (const context of contexts) { + const existing = aggregates.get(context.assignment.assignmentId); + if (existing) { + existing.consumedAmount = existing.consumedAmount.plus( + context.consumedAmount, + ); + incrementConsumeAmountSourceCount( + existing.consumeAmountSourceCounts, + context.consumeAmountSource, + ); + existing.paymentTotal = existing.paymentTotal.plus(context.totalAmount); + existing.paymentCount++; + existing.paymentIds.push(context.payment.paymentId); + existing.winningIds.push(context.payment.winningId); + if (context.consumeAmountSource === 'billing_account_markup_fallback') { + existing.fallbackMarkupCount++; + } + if ( + !existing.resolutionSources.includes( + context.assignment.resolutionSource, + ) + ) { + existing.resolutionSources.push(context.assignment.resolutionSource); + } + continue; + } + + const consumeAmountSourceCounts: ConsumeAmountSourceCounts = {}; + incrementConsumeAmountSourceCount( + consumeAmountSourceCounts, + context.consumeAmountSource, + ); + + aggregates.set(context.assignment.assignmentId, { + assignmentId: context.assignment.assignmentId, + billingAccountId: context.billingAccountId, + consumedAmount: context.consumedAmount, + consumeAmountSourceCounts, + engagementId: context.assignment.engagementId, + fallbackMarkupCount: + context.consumeAmountSource === 'billing_account_markup_fallback' + ? 1 + : 0, + paymentCount: 1, + paymentIds: [context.payment.paymentId], + paymentTotal: context.totalAmount, + projectId: context.assignment.projectId, + resolutionSources: [context.assignment.resolutionSource], + winningIds: [context.payment.winningId], + }); + } + + for (const aggregate of aggregates.values()) { + aggregate.consumedAmount = quantizeLedgerAmount(aggregate.consumedAmount); + aggregate.paymentTotal = quantizeLedgerAmount(aggregate.paymentTotal); + aggregate.paymentIds = [...new Set(aggregate.paymentIds)]; + aggregate.winningIds = [...new Set(aggregate.winningIds)]; + } + + return aggregates; +} + +/** + * Increments an assignment-level count for one consume amount source. + * + * @param counts Mutable source-count object stored on an assignment aggregate. + * @param source Adjustment source to count. + * @returns Nothing. + * @throws This function does not throw. + */ +function incrementConsumeAmountSourceCount( + counts: ConsumeAmountSourceCounts, + source: ConsumeAmountSource, +): void { + counts[source] = (counts[source] || 0) + 1; +} + +/** + * Loads existing engagement consumed rows for assignment ids. + * + * @param billingClient Prisma client for the billing database. + * @param assignmentIds Assignment ids to inspect. + * @returns Map keyed by assignment id. + * @throws Error when the billing query fails. + */ +async function loadExistingConsumedRows( + billingClient: BillingLedgerClient, + assignmentIds: string[], +): Promise> { + const rowsByAssignmentId = new Map(); + + for (const chunk of chunkArray([...new Set(assignmentIds)], CHUNK_SIZE)) { + if (!chunk.length) continue; + + const rows = await billingClient.consumedAmount.findMany({ + where: { + externalType: ENGAGEMENT_EXTERNAL_TYPE, + externalId: { in: chunk }, + }, + select: { + id: true, + billingAccountId: true, + externalId: true, + amount: true, + createdAt: true, + updatedAt: true, + }, + orderBy: [{ externalId: 'asc' }, { createdAt: 'asc' }], + }); + + for (const row of rows) { + const existing = rowsByAssignmentId.get(row.externalId) || []; + existing.push(row); + rowsByAssignmentId.set(row.externalId, existing); + } + } + + return rowsByAssignmentId; +} + +/** + * Sums consumed row amounts. + * + * @param rows Existing consumed rows. + * @returns Decimal sum. + * @throws This function does not throw. + */ +function sumExistingRows(rows: ExistingConsumedRow[]): Prisma.Decimal { + return rows.reduce( + (sum, row) => sum.plus(row.amount), + new Prisma.Decimal(0), + ); +} + +/** + * Builds a reconciliation plan for each assignment aggregate. + * + * @param aggregates Expected assignment aggregates. + * @param existingRows Existing billing consumed rows by assignment id. + * @param exceptions Mutable exception list receiving ambiguous duplicate cases. + * @returns Planned actions to apply or report. + * @throws This function does not throw. + */ +function planActions( + aggregates: Map, + existingRows: Map, + exceptions: BackfillException[], +): PlannedAction[] { + const actions: PlannedAction[] = []; + + for (const aggregate of aggregates.values()) { + const rows = existingRows.get(aggregate.assignmentId) || []; + + if (!rows.length) { + actions.push({ action: 'insert', aggregate, existingRows: [] }); + continue; + } + + if (rows.length === 1) { + const row = rows[0]; + const isCorrect = + row.billingAccountId === aggregate.billingAccountId && + decimalEquals(row.amount, aggregate.consumedAmount); + + actions.push( + isCorrect + ? { action: 'already_correct', aggregate, existingRows: rows } + : { + action: 'update_single', + aggregate, + existingRows: rows, + previous: row, + }, + ); + continue; + } + + const total = sumExistingRows(rows); + const allRowsUseTrustedBillingAccount = rows.every( + (row) => row.billingAccountId === aggregate.billingAccountId, + ); + + if (decimalEquals(total, aggregate.consumedAmount)) { + actions.push( + allRowsUseTrustedBillingAccount + ? { action: 'already_correct', aggregate, existingRows: rows } + : { action: 'move_existing_rows', aggregate, existingRows: rows }, + ); + continue; + } + + exceptions.push({ + code: 'ambiguous_existing_consumed_rows', + message: + 'Multiple existing consumed rows have the assignment externalId, ' + + 'but their total does not match the expected legacy amount.', + assignmentId: aggregate.assignmentId, + billingAccountId: aggregate.billingAccountId, + engagementId: aggregate.engagementId, + projectId: aggregate.projectId, + details: { + expectedAmount: aggregate.consumedAmount.toFixed(), + existingAmount: total.toFixed(), + existingRows: rows.map((row) => ({ + amount: row.amount.toFixed(), + billingAccountId: row.billingAccountId, + id: row.id, + })), + }, + }); + actions.push({ + action: 'manual_review', + aggregate, + existingRows: rows, + reason: 'duplicate rows with mismatched total', + }); + } + + return actions; +} + +/** + * Applies planned insert and update actions to billing consumed rows. + * + * @param billingClient Prisma client for the billing database. + * @param actions Planned reconciliation actions. + * @returns The same action objects annotated with created or updated ids. + * @throws Error when a billing write fails. + */ +async function applyActions( + billingClient: BillingLedgerClient, + actions: PlannedAction[], +): Promise { + for (const action of actions) { + if (action.action === 'insert') { + const created = await billingClient.consumedAmount.create({ + data: { + amount: action.aggregate.consumedAmount, + billingAccountId: action.aggregate.billingAccountId, + externalId: action.aggregate.assignmentId, + externalType: ENGAGEMENT_EXTERNAL_TYPE, + }, + select: { id: true }, + }); + action.createdId = created.id; + continue; + } + + if (action.action === 'update_single') { + const updated = await billingClient.consumedAmount.update({ + where: { id: action.previous.id }, + data: { + amount: action.aggregate.consumedAmount, + billingAccountId: action.aggregate.billingAccountId, + }, + select: { id: true }, + }); + action.updatedId = updated.id; + continue; + } + + if (action.action === 'move_existing_rows') { + action.updatedIds = []; + for (const row of action.existingRows) { + const updated = await billingClient.consumedAmount.update({ + where: { id: row.id }, + data: { billingAccountId: action.aggregate.billingAccountId }, + select: { id: true }, + }); + action.updatedIds.push(updated.id); + } + } + } + + return actions; +} + +/** + * Applies planned actions, verifies post-apply totals, and writes the report in + * one billing database transaction. + * + * @param billingClient Prisma client for the billing database. + * @param actions Planned reconciliation actions. + * @param assignmentIds Resolved assignment ids represented by the plan. + * @param reportPath Destination path for the apply audit report. + * @param buildReport Builds the final report once action ids and totals exist. + * @returns Applied actions with their post-apply billing total. + * @throws Error when any billing write, post-apply read, or report write fails. + */ +async function applyActionsAtomically( + billingClient: PrismaClient, + actions: PlannedAction[], + assignmentIds: string[], + reportPath: string, + buildReport: (actions: PlannedAction[], actualAfter: Prisma.Decimal) => Report, +): Promise { + return billingClient.$transaction( + async (transactionClient) => { + const appliedActions = await applyActions(transactionClient, actions); + const actualAfter = await sumExistingConsumedAmount( + transactionClient, + assignmentIds, + ); + + writeReport(reportPath, buildReport(appliedActions, actualAfter)); + + return { actions: appliedActions, actualAfter }; + }, + { timeout: APPLY_TRANSACTION_TIMEOUT_MS }, + ); +} + +/** + * Calculates the total amount represented by assignment aggregates. + * + * @param aggregates Assignment aggregates. + * @returns Decimal sum of expected consumed amounts. + * @throws This function does not throw. + */ +function sumAggregates( + aggregates: Iterable, +): Prisma.Decimal { + return [...aggregates].reduce( + (sum, aggregate) => sum.plus(aggregate.consumedAmount), + new Prisma.Decimal(0), + ); +} + +/** + * Calculates the current billing total for resolved assignment ids. + * + * @param billingClient Prisma client for the billing database. + * @param assignmentIds Assignment ids to total. + * @returns Decimal sum of matching consumed rows. + * @throws Error when the billing query fails. + */ +async function sumExistingConsumedAmount( + billingClient: BillingLedgerClient, + assignmentIds: string[], +): Promise { + let total = new Prisma.Decimal(0); + + for (const chunk of chunkArray([...new Set(assignmentIds)], CHUNK_SIZE)) { + if (!chunk.length) continue; + + const aggregate = await billingClient.consumedAmount.aggregate({ + where: { + externalType: ENGAGEMENT_EXTERNAL_TYPE, + externalId: { in: chunk }, + }, + _sum: { amount: true }, + }); + + total = total.plus(aggregate._sum.amount || 0); + } + + return quantizeLedgerAmount(total); +} + +/** + * Calculates the projected post-run total for the resolved assignment set. + * + * @param actions Planned actions to evaluate. + * @returns Projected total after applying automated actions. Manual-review + * assignments keep their current existing total. + * @throws This function does not throw. + */ +function calculateProjectedTotal(actions: PlannedAction[]): Prisma.Decimal { + return quantizeLedgerAmount( + actions.reduce((sum, action) => { + if (action.action === 'manual_review') { + return sum.plus(sumExistingRows(action.existingRows)); + } + + return sum.plus(action.aggregate.consumedAmount); + }, new Prisma.Decimal(0)), + ); +} + +/** + * Builds the JSON audit report and summary counters for a dry-run or apply run. + * + * @param input Source rows, planned actions, totals, and audit lists. + * @returns Complete report payload ready to serialize. + * @throws This function does not throw. + */ +function buildReport(input: ReportInput): Report { + const summary = { + absoluteFeeRows: input.absoluteFee.length, + alreadyCorrect: input.actions.filter( + (action) => action.action === 'already_correct', + ).length, + assignmentAggregates: input.aggregates.size, + consumablePaymentRows: input.contexts.length, + exceptions: input.exceptions.length, + exemptBillingAccountRows: input.exemptBillingAccounts.length, + exemptBillingAccountsConfigured: [...input.exemptBillingAccountIds] + .sort((left, right) => left - right) + .join(','), + fallbackMarkupRows: input.fallbackMarkup.length, + financePaymentRows: input.payments.length, + manualReview: input.actions.filter( + (action) => action.action === 'manual_review', + ).length, + plannedInserts: input.actions.filter((action) => action.action === 'insert') + .length, + plannedRowMoves: input.actions.filter( + (action) => action.action === 'move_existing_rows', + ).length, + plannedSingleUpdates: input.actions.filter( + (action) => action.action === 'update_single', + ).length, + reportPath: path.resolve(input.args.reportPath), + resolvedPaymentRows: input.resolvedAssignments.size, + }; + + return { + absoluteFee: input.absoluteFee, + actions: input.actions, + exceptions: input.exceptions, + exemptBillingAccounts: input.exemptBillingAccounts, + fallbackMarkup: input.fallbackMarkup, + generatedAt: new Date().toISOString(), + mode: input.args.apply ? 'apply' : 'dry-run', + summary, + totals: { + actualAfterApplyForResolvedAssignments: input.actualAfter?.toFixed(), + actualBeforeForResolvedAssignments: input.actualBefore.toFixed(), + expectedResolvedAssignments: input.expectedTotal.toFixed(), + projectedForResolvedAssignments: input.projectedTotal.toFixed(), + }, + }; +} + +/** + * Writes the JSON audit report to disk. + * + * @param reportPath Destination path. + * @param report Report payload. + * @returns Nothing. + * @throws Error when the directory cannot be created or the file cannot be written. + */ +function writeReport(reportPath: string, report: Report): void { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync( + reportPath, + JSON.stringify(report, jsonReplacer, 2) + '\n', + 'utf8', + ); +} + +/** + * Converts Decimal and BigInt values into JSON-safe representations. + * + * @param _key JSON object key. + * @param value JSON value. + * @returns JSON-safe value. + * @throws This function does not throw. + */ +function jsonReplacer(_key: string, value: unknown): unknown { + if (value instanceof Prisma.Decimal) return value.toFixed(); + if (typeof value === 'bigint') return value.toString(); + return value; +} + +/** + * Resolves all finance payment rows to assignment contexts. + * + * @param payments Finance payment rows. + * @param engagementsClient Raw-query Prisma client for engagements-api-v6. + * @param exceptions Mutable exception list receiving unresolved rows. + * @returns Map from payment id to resolved assignment context. + * @throws Error when engagement lookup queries fail. + */ +async function resolveAssignments( + payments: FinancePaymentRow[], + engagementsClient: PrismaClient, + exceptions: BackfillException[], +): Promise> { + const candidateAssignmentIds = new Set(); + const candidateEngagementIds = new Set(); + + for (const payment of payments) { + const attributeAssignmentId = getAttributeAssignmentId(payment.attributes); + const externalId = normalizeString(payment.externalId); + if (attributeAssignmentId) candidateAssignmentIds.add(attributeAssignmentId); + if (externalId) { + candidateAssignmentIds.add(externalId); + candidateEngagementIds.add(externalId); + } + } + + const [assignmentsById, assignmentsByEngagementId] = await Promise.all([ + loadAssignmentsById(engagementsClient, [...candidateAssignmentIds]), + loadAssignmentsByEngagementId(engagementsClient, [ + ...candidateEngagementIds, + ]), + ]); + + const resolvedAssignments = new Map(); + for (const payment of payments) { + const resolved = resolvePaymentAssignment( + payment, + assignmentsById, + assignmentsByEngagementId, + exceptions, + ); + if (resolved) { + resolvedAssignments.set(payment.paymentId, resolved); + } + } + + return resolvedAssignments; +} + +/** + * Orchestrates the engagement payment historical backfill. + * + * @returns Promise resolved when the report is written and all requested writes complete. + * @throws Error when required configuration is missing or a database operation fails. + */ +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const financeDbUrl = requireDatabaseUrl([ + 'FINANCE_DB_URL', + 'TC_FINANCE_DB_URL', + ]); + const engagementsDbUrl = requireDatabaseUrl([ + 'ENGAGEMENTS_DB_URL', + 'ENGAGEMENT_DB_URL', + ]); + const projectsDbUrl = requireDatabaseUrl([ + 'PROJECTS_DB_URL', + 'PROJECT_DB_URL', + ]); + + const billingClient = new PrismaClient(); + const financeClient = createExternalClient(financeDbUrl); + const engagementsClient = createExternalClient(engagementsDbUrl); + const projectsClient = createExternalClient(projectsDbUrl); + const exceptions: BackfillException[] = []; + const fallbackMarkup: FallbackMarkupAudit[] = []; + const absoluteFee: AbsoluteFeeAudit[] = []; + const exemptBillingAccounts: ExemptBillingAccountAudit[] = []; + + try { + console.log( + `Running engagement payment backfill in ${args.apply ? 'apply' : 'dry-run'} mode.`, + ); + const exemptBillingAccountIds = loadExemptBillingAccountIds(); + console.log( + `Loaded ${exemptBillingAccountIds.size} TopGear-exempt billing account id(s).`, + ); + + const payments = await loadFinancePayments(financeClient, args); + console.log(`Loaded ${payments.length} finance engagement payment row(s).`); + + const resolvedAssignments = await resolveAssignments( + payments, + engagementsClient, + exceptions, + ); + console.log( + `Resolved ${resolvedAssignments.size} payment row(s) to engagement assignments.`, + ); + + const projectIds = [...resolvedAssignments.values()].map( + (assignment) => assignment.projectId, + ); + const projectBillingAccounts = await loadProjectBillingAccounts( + projectsClient, + projectIds, + ); + const billingAccountIds = [...new Set([...projectBillingAccounts.values()])] + .filter((value): value is number => typeof value === 'number') + .filter((value) => !exemptBillingAccountIds.has(value)) + .sort((left, right) => left - right); + const billingAccounts = await loadBillingAccounts( + billingClient, + billingAccountIds, + ); + + const contexts = buildPaymentContexts( + payments, + resolvedAssignments, + projectBillingAccounts, + billingAccounts, + exemptBillingAccountIds, + exceptions, + fallbackMarkup, + absoluteFee, + exemptBillingAccounts, + ); + const aggregates = aggregateByAssignment(contexts); + const assignmentIds = [...aggregates.keys()]; + const existingRows = await loadExistingConsumedRows( + billingClient, + assignmentIds, + ); + let actions = planActions(aggregates, existingRows, exceptions); + + const expectedTotal = sumAggregates(aggregates.values()); + const actualBefore = await sumExistingConsumedAmount( + billingClient, + assignmentIds, + ); + const projectedTotal = calculateProjectedTotal(actions); + + const createReport = ( + reportActions: PlannedAction[], + actualAfter?: Prisma.Decimal, + ): Report => + buildReport({ + absoluteFee, + actions: reportActions, + actualAfter, + actualBefore, + aggregates, + args, + contexts, + exceptions, + exemptBillingAccounts, + exemptBillingAccountIds, + expectedTotal, + fallbackMarkup, + payments, + projectedTotal, + resolvedAssignments, + }); + + let actualAfter: Prisma.Decimal | undefined; + if (args.apply) { + const applyResult = await applyActionsAtomically( + billingClient, + actions, + assignmentIds, + args.reportPath, + createReport, + ); + actions = applyResult.actions; + actualAfter = applyResult.actualAfter; + } else { + writeReport(args.reportPath, createReport(actions)); + } + + const report = createReport(actions, actualAfter); + + console.log('Backfill analysis complete.'); + console.log(JSON.stringify(report.summary, null, 2)); + const afterApplyMessage = actualAfter !== undefined + ? ` | after apply: ${actualAfter.toFixed()}` + : ''; + console.log( + `Expected resolved consumed total: ${expectedTotal.toFixed()} | ` + + `current billing total: ${actualBefore.toFixed()}${afterApplyMessage}`, + ); + console.log(`Audit report written to ${path.resolve(args.reportPath)}`); + + if (!args.apply) { + console.log('Dry run only. Re-run with --apply to write billing rows.'); + } + } finally { + await Promise.all([ + billingClient.$disconnect(), + financeClient.$disconnect(), + engagementsClient.$disconnect(), + projectsClient.$disconnect(), + ]); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/verify-engagement-payment-backfill.sql b/scripts/verify-engagement-payment-backfill.sql new file mode 100644 index 0000000..64b072c --- /dev/null +++ b/scripts/verify-engagement-payment-backfill.sql @@ -0,0 +1,323 @@ +-- Verifies billing-account consumed totals against legacy engagement payments. +-- +-- This query expects the finance, engagements, projects, and billing schemas to +-- be visible from the same Postgres session, for example in a restored staging +-- database or through foreign data wrappers. +-- +-- Usage: +-- psql "$DATABASE_URL" \ +-- -v finance_schema=finance \ +-- -v engagements_schema=public \ +-- -v projects_schema=public \ +-- -v billing_schema=billing-accounts \ +-- -v exempt_billing_account_ids=80000062,80002800 \ +-- -f scripts/verify-engagement-payment-backfill.sql + +\set ON_ERROR_STOP on + +\if :{?exempt_billing_account_ids} +\else +\set exempt_billing_account_ids '80000062,80002800' +\endif + +WITH finance_payments AS ( + SELECT + w.winning_id::text AS winning_id, + w.winner_id::text AS winner_id, + NULLIF(w.external_id, '') AS external_id, + NULLIF(w.attributes->>'assignmentId', '') AS attribute_assignment_id, + p.payment_id::text AS payment_id, + p.total_amount::numeric AS total_amount, + p.challenge_markup::numeric AS challenge_markup, + p.challenge_fee::numeric AS challenge_fee + FROM :"finance_schema".winnings w + INNER JOIN :"finance_schema".payment p + ON p.winnings_id = w.winning_id + WHERE w.category::text = 'ENGAGEMENT_PAYMENT' +), +assignment_candidates AS ( + SELECT + fp.*, + ea.id AS assignment_id, + ea."engagementId" AS engagement_id, + e."projectId" AS project_id, + 'direct_assignment' AS resolution_source + FROM finance_payments fp + INNER JOIN :"engagements_schema"."EngagementAssignment" ea + ON ea.id = COALESCE(fp.attribute_assignment_id, fp.external_id) + INNER JOIN :"engagements_schema"."Engagement" e + ON e.id = ea."engagementId" +), +engagement_member_candidates AS ( + SELECT + fp.*, + ea.id AS assignment_id, + ea."engagementId" AS engagement_id, + e."projectId" AS project_id, + COUNT(*) OVER (PARTITION BY fp.payment_id) AS candidate_count, + 'engagement_member' AS resolution_source + FROM finance_payments fp + INNER JOIN :"engagements_schema"."Engagement" e + ON e.id = fp.external_id + INNER JOIN :"engagements_schema"."EngagementAssignment" ea + ON ea."engagementId" = e.id + AND ea."memberId" = fp.winner_id + WHERE NOT EXISTS ( + SELECT 1 + FROM assignment_candidates ac + WHERE ac.payment_id = fp.payment_id + ) +), +engagement_single_candidates AS ( + SELECT + fp.*, + ea.id AS assignment_id, + ea."engagementId" AS engagement_id, + e."projectId" AS project_id, + COUNT(*) OVER (PARTITION BY fp.payment_id) AS candidate_count, + 'engagement_single_assignment' AS resolution_source + FROM finance_payments fp + INNER JOIN :"engagements_schema"."Engagement" e + ON e.id = fp.external_id + INNER JOIN :"engagements_schema"."EngagementAssignment" ea + ON ea."engagementId" = e.id + WHERE NOT EXISTS ( + SELECT 1 + FROM assignment_candidates ac + WHERE ac.payment_id = fp.payment_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM engagement_member_candidates emc + WHERE emc.payment_id = fp.payment_id + ) +), +resolved_payments AS ( + SELECT + winning_id, + winner_id, + external_id, + attribute_assignment_id, + payment_id, + total_amount, + challenge_markup, + challenge_fee, + assignment_id, + engagement_id, + project_id, + resolution_source + FROM assignment_candidates + + UNION ALL + + SELECT + winning_id, + winner_id, + external_id, + attribute_assignment_id, + payment_id, + total_amount, + challenge_markup, + challenge_fee, + assignment_id, + engagement_id, + project_id, + resolution_source + FROM engagement_member_candidates + WHERE candidate_count = 1 + + UNION ALL + + SELECT + winning_id, + winner_id, + external_id, + attribute_assignment_id, + payment_id, + total_amount, + challenge_markup, + challenge_fee, + assignment_id, + engagement_id, + project_id, + resolution_source + FROM engagement_single_candidates + WHERE candidate_count = 1 +), +exempt_billing_accounts AS ( + SELECT btrim(value)::int AS billing_account_id + FROM regexp_split_to_table(:'exempt_billing_account_ids', ',') AS value + WHERE btrim(value) <> '' +), +resolved_payment_project AS ( + SELECT + rp.*, + pr."billingAccountId"::int AS billing_account_id, + EXISTS ( + SELECT 1 + FROM exempt_billing_accounts eba + WHERE eba.billing_account_id = pr."billingAccountId"::int + ) AS is_exempt_billing_account + FROM resolved_payments rp + INNER JOIN :"projects_schema".projects pr + ON pr.id::text = rp.project_id + AND pr."deletedAt" IS NULL + WHERE pr."billingAccountId" IS NOT NULL +), +resolved_payment_billing AS ( + SELECT + rpp.*, + ba.markup AS billing_account_markup + FROM resolved_payment_project rpp + LEFT JOIN :"billing_schema"."BillingAccount" ba + ON ba.id = rpp.billing_account_id + WHERE rpp.is_exempt_billing_account + OR ( + rpp.total_amount IS NOT NULL + AND rpp.total_amount >= 0 + AND (rpp.challenge_markup IS NULL OR rpp.challenge_markup >= 0) + AND (rpp.challenge_fee IS NULL OR rpp.challenge_fee >= 0) + AND ba.id IS NOT NULL + AND ba.markup >= 0 + ) +), +expected_payment_consumes AS ( + SELECT + rpb.assignment_id, + rpb.billing_account_id, + rpb.is_exempt_billing_account, + ROUND( + ( + CASE + WHEN rpb.total_amount IS NULL OR rpb.total_amount < 0 THEN NULL + WHEN rpb.challenge_markup IS NOT NULL AND rpb.challenge_markup >= 0 + THEN rpb.total_amount + (rpb.total_amount * rpb.challenge_markup) + WHEN rpb.challenge_fee IS NOT NULL AND rpb.challenge_fee >= 0 + THEN rpb.total_amount + rpb.challenge_fee + WHEN rpb.billing_account_markup IS NOT NULL + THEN rpb.total_amount + (rpb.total_amount * rpb.billing_account_markup) + ELSE NULL + END + )::numeric, + 4 + ) AS consumed_amount + FROM resolved_payment_billing rpb +), +expected AS ( + SELECT + assignment_id, + billing_account_id, + COUNT(*) AS payment_count, + ROUND(SUM(consumed_amount)::numeric, 4) AS expected_consumed + FROM expected_payment_consumes + WHERE consumed_amount > 0 + AND NOT is_exempt_billing_account + GROUP BY assignment_id, billing_account_id +), +exempt_expected AS ( + SELECT + assignment_id, + billing_account_id, + COUNT(*) AS payment_count, + ROUND(SUM(consumed_amount)::numeric, 4) AS expected_consumed + FROM expected_payment_consumes + WHERE is_exempt_billing_account + GROUP BY assignment_id, billing_account_id +), +actual AS ( + SELECT + "externalId" AS assignment_id, + "billingAccountId" AS billing_account_id, + COUNT(*) AS consumed_row_count, + ROUND(SUM(amount)::numeric, 4) AS actual_consumed + FROM :"billing_schema"."ConsumedAmount" + WHERE "externalType"::text = 'ENGAGEMENT' + GROUP BY "externalId", "billingAccountId" +), +non_exempt_actual AS ( + SELECT a.* + FROM actual a + WHERE NOT EXISTS ( + SELECT 1 + FROM exempt_billing_accounts eba + WHERE eba.billing_account_id = a.billing_account_id + ) +), +comparison AS ( + SELECT + COALESCE(e.assignment_id, a.assignment_id) AS assignment_id, + COALESCE(e.billing_account_id, a.billing_account_id) AS billing_account_id, + e.payment_count, + a.consumed_row_count, + e.expected_consumed, + a.actual_consumed, + CASE + WHEN e.assignment_id IS NULL THEN 'unexpected_actual' + WHEN a.assignment_id IS NULL THEN 'missing_actual' + WHEN e.expected_consumed = a.actual_consumed THEN 'matches' + ELSE 'amount_mismatch' + END AS status + FROM expected e + FULL OUTER JOIN non_exempt_actual a + ON a.assignment_id = e.assignment_id + AND a.billing_account_id = e.billing_account_id +), +exempt_comparison AS ( + SELECT + e.assignment_id, + e.billing_account_id, + e.payment_count, + a.consumed_row_count, + e.expected_consumed, + a.actual_consumed, + CASE + WHEN a.assignment_id IS NULL THEN 'topgear_exempt_skipped' + ELSE 'topgear_exempt_has_actual' + END AS status + FROM exempt_expected e + LEFT JOIN actual a + ON a.assignment_id = e.assignment_id + AND a.billing_account_id = e.billing_account_id + + UNION ALL + + SELECT + a.assignment_id, + a.billing_account_id, + NULL::bigint AS payment_count, + a.consumed_row_count, + NULL::numeric AS expected_consumed, + a.actual_consumed, + 'topgear_exempt_unexpected_actual' AS status + FROM actual a + WHERE EXISTS ( + SELECT 1 + FROM exempt_billing_accounts eba + WHERE eba.billing_account_id = a.billing_account_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM exempt_expected e + WHERE e.assignment_id = a.assignment_id + AND e.billing_account_id = a.billing_account_id + ) +) +SELECT + status, + assignment_id, + billing_account_id, + payment_count, + consumed_row_count, + expected_consumed, + actual_consumed, + COALESCE(actual_consumed, 0) - COALESCE(expected_consumed, 0) AS delta +FROM ( + SELECT * FROM comparison + UNION ALL + SELECT * FROM exempt_comparison +) results +ORDER BY + CASE WHEN status = 'matches' THEN 1 ELSE 0 END, + status, + assignment_id, + billing_account_id; diff --git a/src/auth/guards/scopes.guard.ts b/src/auth/guards/scopes.guard.ts index 7793f00..1247537 100644 --- a/src/auth/guards/scopes.guard.ts +++ b/src/auth/guards/scopes.guard.ts @@ -32,7 +32,14 @@ export class ScopesGuard implements CanActivate { .map((s: string) => s.trim()) .filter(Boolean); - const ok = required.some((s) => scopes.includes(s)); + const normalizedRequiredScopes = required.map((scope) => + scope.trim().toLowerCase(), + ); + const normalizedScopes = scopes.map((scope) => scope.trim().toLowerCase()); + + const ok = normalizedScopes.some((scope) => + normalizedRequiredScopes.includes(scope), + ); if (ok) return true; const fallbackRoles = this.reflector.getAllAndOverride( @@ -48,7 +55,16 @@ export class ScopesGuard implements CanActivate { .map((r: string) => r.trim()) .filter(Boolean); - const roleOk = roles.some((r: string) => fallbackRoles.includes(r)); + const normalizedFallbackRoles = fallbackRoles.map((role) => + role.trim().toLowerCase(), + ); + const normalizedRoles = roles.map((role: string) => + role.trim().toLowerCase(), + ); + + const roleOk = normalizedRoles.some((role: string) => + normalizedFallbackRoles.includes(role), + ); if (roleOk) return true; } diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index abb4e89..2f99809 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -18,6 +18,7 @@ import { CreateBillingAccountDto } from "./dto/create-billing-account.dto"; import { UpdateBillingAccountDto } from "./dto/update-billing-account.dto"; import { LockAmountDto } from "./dto/lock-amount.dto"; import { ConsumeAmountDto } from "./dto/consume-amount.dto"; +import { ConsumeAmountsDto } from "./dto/consume-amounts.dto"; import { Roles } from "../auth/decorators/roles.decorator"; import { Scopes } from "../auth/decorators/scopes.decorator"; import { RolesGuard } from "../auth/guards/roles.guard"; @@ -37,6 +38,7 @@ import { ApiBearerAuth, ApiOperation, ApiOkResponse, + ApiBadRequestResponse, ApiParam, ApiQuery, ApiTags, @@ -171,7 +173,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget and client data. Project Managers can read only billing accounts granted to them.", + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them.", jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), @@ -221,14 +223,18 @@ export class BillingAccountsController { @Scopes(SCOPES.UPDATE_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ - summary: "Lock funds for a challenge", + summary: "Lock funds for a typed external budget entry", description: - "Reserve an amount on a billing account for a specific challenge.", + "Reserve a non-negative amount on a billing account for a challenge reference. Locking supports CHALLENGE externalType only, accepts externalId/externalType as the canonical reference fields, treats amount 0 as unlock, and fails when the post-lock reserved total would exceed the account budget.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Lock created/updated or unlocked" }) + @ApiBadRequestResponse({ + description: + "Invalid typed external reference, negative amount, challenge already consumed, or insufficient remaining funds", + }) @ApiParam({ name: "billingAccountId", description: "Billing Account ID", @@ -242,20 +248,47 @@ export class BillingAccountsController { return this.service.lockAmount(id, dto); } + @Post("consume-amounts") + @UseGuards(RolesGuard, ScopesGuard) + @Roles(ADMIN_ROLE) + @Scopes(SCOPES.UPDATE_BA, SCOPES.ALL_BA) + @ApiOperation( + buildOperationDoc({ + summary: "Atomically consume engagement budget rows", + description: + "Validate and create one or more ENGAGEMENT consumed rows in a single transaction. The request fails without writing partial rows when any item is invalid or would exceed remaining budget.", + jwtRoles: [ADMIN_ROLE], + m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], + }), + ) + @ApiOkResponse({ description: "Engagement consumed rows recorded" }) + @ApiBadRequestResponse({ + description: + "Invalid engagement reference, non-positive amount, or insufficient remaining funds", + }) + @ApiBody({ type: ConsumeAmountsDto }) + async consumeBatch(@Body() dto: ConsumeAmountsDto) { + return this.service.consumeAmounts(dto); + } + @Patch(":billingAccountId/consume-amount") @UseGuards(RolesGuard, ScopesGuard) @Roles(ADMIN_ROLE) @Scopes(SCOPES.UPDATE_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ - summary: "Consume reserved funds", + summary: "Consume funds for a typed external budget entry", description: - "Consume a previously locked amount for a challenge and record the transaction.", + "Consume a positive amount for a typed external reference using externalId/externalType. Challenge entries remove the matching lock and overwrite the existing consumed row; engagement entries are append-only. The request fails when the post-consume reserved total would exceed the account budget.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Consumed amount recorded" }) + @ApiBadRequestResponse({ + description: + "Invalid typed external reference, non-positive amount, or insufficient remaining funds", + }) @ApiParam({ name: "billingAccountId", description: "Billing Account ID", diff --git a/src/billing-accounts/billing-accounts.module.ts b/src/billing-accounts/billing-accounts.module.ts index 7398afd..8913164 100644 --- a/src/billing-accounts/billing-accounts.module.ts +++ b/src/billing-accounts/billing-accounts.module.ts @@ -1,12 +1,23 @@ import { Module } from "@nestjs/common"; import { BillingAccountsController } from "./billing-accounts.controller"; import { BillingAccountsService } from "./billing-accounts.service"; +import { ExternalBudgetEntryLookupService } from "./external-budget-entry-lookup.service"; import { MembersLookupService } from "../common/members-lookup.service"; import SalesforceService from "../common/salesforce.service"; @Module({ controllers: [BillingAccountsController], - providers: [BillingAccountsService, MembersLookupService, SalesforceService], - exports: [BillingAccountsService, MembersLookupService, SalesforceService], + providers: [ + BillingAccountsService, + ExternalBudgetEntryLookupService, + MembersLookupService, + SalesforceService, + ], + exports: [ + BillingAccountsService, + ExternalBudgetEntryLookupService, + MembersLookupService, + SalesforceService, + ], }) export class BillingAccountsModule {} diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 4145be6..8254997 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { PrismaService } from "../common/prisma.service"; import { Prisma } from "@prisma/client"; import { QueryBillingAccountsDto } from "./dto/query-billing-accounts.dto"; @@ -6,6 +10,17 @@ import { CreateBillingAccountDto } from "./dto/create-billing-account.dto"; import { UpdateBillingAccountDto } from "./dto/update-billing-account.dto"; import { LockAmountDto } from "./dto/lock-amount.dto"; import { ConsumeAmountDto } from "./dto/consume-amount.dto"; +import { + ConsumeAmountsDto, + ConsumeAmountsItemDto, +} from "./dto/consume-amounts.dto"; +import { ExternalBudgetEntryLookupService } from "./external-budget-entry-lookup.service"; +import { + getBudgetEntryReferenceKey, + resolveBudgetEntryReference, + type BudgetEntryReference, + type BudgetEntryExternalTypeValue, +} from "./budget-entry.util"; import { MembersLookupService } from "../common/members-lookup.service"; import SalesforceService from "../common/salesforce.service"; import { @@ -18,8 +33,13 @@ import { } from "../auth/constants"; export interface BillingAccountsAuthUser { + id?: number | string; role?: string; roles?: string[] | string; + sub?: number | string; + tcUserId?: number | string; + user_id?: number | string; + userID?: number | string; userId?: number | string; } @@ -35,6 +55,43 @@ const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [ TOPCODER_PROJECT_MANAGER_ROLE, ]; +const BUDGET_AMOUNT_DECIMAL_PLACES = 4; + +interface BudgetAmountLineItem { + id: string; + billingAccountId: number; + externalId: string; + externalType: BudgetEntryExternalTypeValue; + amount: Prisma.Decimal; + createdAt: Date; + updatedAt: Date; +} + +interface BillingAccountBudgetLockRow { + budget: Prisma.Decimal; +} + +interface BudgetMutationContext { + budget: Prisma.Decimal; + lockedTotal: Prisma.Decimal; + consumedTotal: Prisma.Decimal; + matchingLock: BudgetAmountLineItem | null; + matchingConsumed: BudgetAmountLineItem | null; +} + +interface BudgetAccountTotals { + budget: Prisma.Decimal; + lockedTotal: Prisma.Decimal; + consumedTotal: Prisma.Decimal; +} + +interface NormalizedEngagementConsume { + amount: Prisma.Decimal; + billingAccountId: number; + externalId: string; + externalType: "ENGAGEMENT"; +} + /** * Normalizes authenticated caller roles for case-insensitive comparisons. * @@ -60,24 +117,33 @@ function getNormalizedAuthUserRoles( /** * Resolves the caller user id as a trimmed string when present. * + * Topcoder JWT middleware has used a few decoded claim names across services, + * so this accepts the canonical `userId` first and then falls back to the other + * common user-id claim spellings. + * * @param authUser Authenticated caller context from `req.authUser`. * @returns Normalized user id or `undefined` when missing. */ function getNormalizedAuthUserId( authUser?: BillingAccountsAuthUser, ): string | undefined { - if ( - typeof authUser?.userId === "number" && - Number.isFinite(authUser.userId) - ) { - return String(authUser.userId); + const candidateUserId = + authUser?.userId ?? + authUser?.user_id ?? + authUser?.userID ?? + authUser?.tcUserId ?? + authUser?.id ?? + authUser?.sub; + + if (typeof candidateUserId === "number" && Number.isFinite(candidateUserId)) { + return String(candidateUserId); } - if (typeof authUser?.userId !== "string") { + if (typeof candidateUserId !== "string") { return undefined; } - const normalizedUserId = authUser.userId.trim(); + const normalizedUserId = candidateUserId.trim(); return normalizedUserId || undefined; } @@ -121,6 +187,7 @@ function resolveRestrictedProjectManagerUserId( export class BillingAccountsService { constructor( private readonly prisma: PrismaService, + private readonly externalBudgetEntryLookup: ExternalBudgetEntryLookupService, private readonly membersLookup: MembersLookupService, private readonly salesforce: SalesforceService, ) {} @@ -308,11 +375,14 @@ export class BillingAccountsService { } /** - * Fetches a single billing account and its budget aggregates. + * Fetches a single billing account, its normalized budget line items, and + * budget aggregates. * * Project Manager callers can read only billing accounts granted to their own * `userId`. Missing access is surfaced as not found to avoid leaking account - * existence. + * existence. Locked and consumed line items expose `amount`, `date`, + * `externalId`, `externalType`, and `externalName`; challenge rows also expose + * the deprecated `challengeId` compatibility alias. * * @param billingAccountId Billing-account identifier. * @param authUser Authenticated caller context from `req.authUser`. @@ -361,9 +431,25 @@ export class BillingAccountsService { 0, ); const remaining = Number(ba.budget) - consumed - locked; + const externalNames = await this.externalBudgetEntryLookup.getExternalNames( + [ + ...ba.lockedAmounts.map((lineItem) => + this.toBudgetEntryReference(lineItem), + ), + ...ba.consumedAmounts.map((lineItem) => + this.toBudgetEntryReference(lineItem), + ), + ], + ); return { ...ba, + lockedAmounts: ba.lockedAmounts.map((lineItem) => + this.serializeBudgetLineItem(lineItem, externalNames), + ), + consumedAmounts: ba.consumedAmounts.map((lineItem) => + this.serializeBudgetLineItem(lineItem, externalNames), + ), lockedBudget: locked, consumedBudget: consumed, totalBudgetRemaining: remaining, @@ -417,27 +503,59 @@ export class BillingAccountsService { }); } + /** + * Locks or unlocks budget for a challenge external reference. + * + * `externalId`/`externalType` are the canonical request fields. Legacy + * `challengeId` remains accepted as an alias for challenge callers. Locking + * is challenge-only, overwrites the matching challenge lock row, and rejects + * requests when the post-operation locked plus consumed total would exceed + * the billing-account budget. + * + * @param billingAccountId Billing account identifier. + * @param dto Lock request containing amount and external reference. + * @returns Updated lock row, or `{ unlocked: true }` when amount is zero. + * @throws NotFoundException When the billing account does not exist. + * @throws BadRequestException When the reference is invalid, already consumed, + * amount is negative, or has insufficient remaining funds. + */ async lockAmount(billingAccountId: number, dto: LockAmountDto) { + const requestedLock = this.toLedgerBudgetAmount(dto.amount, "lock"); + this.assertBudgetAmountIsNonNegative(requestedLock, "lock"); + + const reference = resolveBudgetEntryReference(dto); + this.assertChallengeAliasMatchesType(dto, reference); + + if (reference.externalType !== "CHALLENGE") { + throw new BadRequestException( + "Only CHALLENGE externalType can be locked", + ); + } + // If amount is 0, unlock (delete any lock) return this.prisma.$transaction(async (tx) => { - // ensure no consumed record exists - const consumed = await tx.consumedAmount.findUnique({ - where: { - consumed_unique_challenge: { - billingAccountId, - challengeId: dto.challengeId, - }, - }, - }); - if (consumed) { - throw new Error( + const context = await this.loadBudgetMutationContext( + tx, + billingAccountId, + reference, + ); + if (context.matchingConsumed) { + throw new BadRequestException( "Challenge already consumed against this billing account", ); } - if (dto.amount === 0) { + this.assertBudgetCanReserve( + context.budget, + context.lockedTotal + .minus(context.matchingLock?.amount ?? 0) + .plus(context.consumedTotal) + .plus(requestedLock), + ); + + if (requestedLock.isZero()) { await tx.lockedAmount.deleteMany({ - where: { billingAccountId, challengeId: dto.challengeId }, + where: { billingAccountId, ...reference }, }); return { unlocked: true }; } @@ -445,48 +563,456 @@ export class BillingAccountsService { // upsert lock const rec = await tx.lockedAmount.upsert({ where: { - locked_unique_challenge: { + locked_unique_external: { billingAccountId, - challengeId: dto.challengeId, + externalId: reference.externalId, + externalType: reference.externalType, }, }, create: { billingAccountId, - challengeId: dto.challengeId, - amount: new Prisma.Decimal(dto.amount), + externalId: reference.externalId, + externalType: reference.externalType, + amount: requestedLock, }, - update: { amount: new Prisma.Decimal(dto.amount) }, + update: { amount: requestedLock }, }); return rec; }); } + /** + * Consumes budget for a typed external reference. + * + * Challenge entries preserve existing overwrite semantics and clear any + * matching lock. Engagement entries are append-only, one row per payment. + * Requests are rejected when the post-operation locked plus consumed total + * would exceed the billing-account budget. Zero consumes are rejected so + * `lockAmount` remains the only zero-value unlock path. + * + * @param billingAccountId Billing account identifier. + * @param dto Consume request containing amount and external reference. + * @returns Consumed amount row. + * @throws NotFoundException When the billing account does not exist. + * @throws BadRequestException When the external reference is missing or has + * a non-positive amount or insufficient remaining funds. + */ async consumeAmount(billingAccountId: number, dto: ConsumeAmountDto) { + const requestedConsume = this.toLedgerBudgetAmount(dto.amount, "consume"); + this.assertBudgetAmountIsPositive(requestedConsume, "consume"); + + const reference = resolveBudgetEntryReference(dto); + this.assertChallengeAliasMatchesType(dto, reference); + return this.prisma.$transaction(async (tx) => { + const context = await this.loadBudgetMutationContext( + tx, + billingAccountId, + reference, + ); + + if (reference.externalType === "ENGAGEMENT") { + this.assertBudgetCanReserve( + context.budget, + context.lockedTotal + .plus(context.consumedTotal) + .plus(requestedConsume), + ); + + return tx.consumedAmount.create({ + data: { + billingAccountId, + externalId: reference.externalId, + externalType: reference.externalType, + amount: requestedConsume, + }, + }); + } + + this.assertBudgetCanReserve( + context.budget, + context.lockedTotal + .minus(context.matchingLock?.amount ?? 0) + .plus( + context.consumedTotal.minus(context.matchingConsumed?.amount ?? 0), + ) + .plus(requestedConsume), + ); + // delete any lock first for this challenge await tx.lockedAmount.deleteMany({ - where: { billingAccountId, challengeId: dto.challengeId }, + where: { billingAccountId, ...reference }, }); - // upsert consumed amount - const rec = await tx.consumedAmount.upsert({ - where: { - consumed_unique_challenge: { - billingAccountId, - challengeId: dto.challengeId, - }, - }, - create: { + if (context.matchingConsumed) { + return tx.consumedAmount.update({ + where: { id: context.matchingConsumed.id }, + data: { amount: requestedConsume }, + }); + } + + return tx.consumedAmount.create({ + data: { billingAccountId, - challengeId: dto.challengeId, - amount: new Prisma.Decimal(dto.amount), + externalId: reference.externalId, + externalType: reference.externalType, + amount: requestedConsume, }, - update: { amount: new Prisma.Decimal(dto.amount) }, }); - return rec; }); } + /** + * Atomically consumes multiple engagement budget rows. + * + * All amounts are normalized to the `Decimal(20,4)` ledger scale before any + * remaining-budget comparison. The method validates every requested consume + * against locked billing-account rows first, then creates all consumed rows in + * one database transaction so partial engagement-payment requests roll back + * together. + * + * @param dto Batch consume request containing engagement consume items. + * @returns Count of consumed rows created. + * @throws NotFoundException When any billing account does not exist. + * @throws BadRequestException When an item is not an engagement consume, has + * invalid amount data, or would exceed remaining budget. + */ + async consumeAmounts(dto: ConsumeAmountsDto) { + if (!Array.isArray(dto.consumes) || dto.consumes.length === 0) { + throw new BadRequestException("At least one consume is required"); + } + + const consumes = dto.consumes.map((consume, index) => + this.normalizeEngagementConsume(consume, index), + ); + const consumesByBillingAccountId = + this.groupConsumesByBillingAccountId(consumes); + + return this.prisma.$transaction(async (tx) => { + for (const [billingAccountId, billingAccountConsumes] of Array.from( + consumesByBillingAccountId.entries(), + ).sort( + ([leftBillingAccountId], [rightBillingAccountId]) => + leftBillingAccountId - rightBillingAccountId, + )) { + const totals = await this.loadBudgetAccountTotals(tx, billingAccountId); + const requestedTotal = billingAccountConsumes.reduce( + (sum, consume) => sum.plus(consume.amount), + new Prisma.Decimal(0), + ); + + this.assertBudgetCanReserve( + totals.budget, + totals.lockedTotal.plus(totals.consumedTotal).plus(requestedTotal), + ); + } + + return tx.consumedAmount.createMany({ + data: consumes.map((consume) => ({ + amount: consume.amount, + billingAccountId: consume.billingAccountId, + externalId: consume.externalId, + externalType: consume.externalType, + })), + }); + }); + } + + /** + * Normalizes one batch item into an engagement consume ready for validation. + * + * @param consume Incoming batch item. + * @param index Zero-based item index used in validation messages. + * @returns Canonical engagement consume with a Decimal(20,4)-scaled amount. + * @throws BadRequestException When the billing account, reference, type, or + * amount is invalid. + */ + private normalizeEngagementConsume( + consume: ConsumeAmountsItemDto, + index: number, + ): NormalizedEngagementConsume { + if ( + !Number.isSafeInteger(consume.billingAccountId) || + consume.billingAccountId <= 0 + ) { + throw new BadRequestException( + `consumes[${index}].billingAccountId must be a positive integer`, + ); + } + + const reference = resolveBudgetEntryReference({ + challengeId: consume.challengeId, + externalId: consume.externalId, + externalType: consume.externalType ?? "ENGAGEMENT", + }); + this.assertChallengeAliasMatchesType(consume, reference); + + if (reference.externalType !== "ENGAGEMENT") { + throw new BadRequestException( + `consumes[${index}].externalType must be ENGAGEMENT`, + ); + } + + const amount = this.toLedgerBudgetAmount(consume.amount, "consume"); + this.assertBudgetAmountIsPositive(amount, "consume"); + + return { + amount, + billingAccountId: consume.billingAccountId, + externalId: reference.externalId, + externalType: "ENGAGEMENT", + }; + } + + /** + * Groups normalized engagement consumes by billing account id. + * + * @param consumes Normalized engagement consumes. + * @returns Map from billing account id to consumes targeting that account. + */ + private groupConsumesByBillingAccountId( + consumes: NormalizedEngagementConsume[], + ): Map { + const grouped = new Map(); + + for (const consume of consumes) { + const billingAccountConsumes = + grouped.get(consume.billingAccountId) ?? []; + billingAccountConsumes.push(consume); + grouped.set(consume.billingAccountId, billingAccountConsumes); + } + + return grouped; + } + + /** + * Ensures the deprecated `challengeId` alias is only used for challenge + * references and never as an engagement identifier. + * + * @param input Incoming lock or consume DTO. + * @param reference Resolved typed external reference. + * @throws BadRequestException When `challengeId` is paired with a + * non-challenge external type. + */ + private assertChallengeAliasMatchesType( + input: { challengeId?: string }, + reference: BudgetEntryReference, + ): void { + if (input.challengeId?.trim() && reference.externalType !== "CHALLENGE") { + throw new BadRequestException( + "challengeId can only be used with CHALLENGE externalType", + ); + } + } + + /** + * Quantizes a request amount to the billing ledger scale. + * + * The database stores budget ledger amounts as `Decimal(20,4)`, so budget + * comparisons and persisted consume/lock rows must use the same four-decimal + * representation. + * + * @param amount Request amount supplied by the caller. + * @param operation Name of the budget mutation operation for error messages. + * @returns Decimal value rounded to four fractional digits. + * @throws BadRequestException When the amount is missing or non-finite. + */ + private toLedgerBudgetAmount( + amount: number, + operation: "lock" | "consume", + ): Prisma.Decimal { + if (typeof amount !== "number" || !Number.isFinite(amount)) { + throw new BadRequestException( + `${operation} amount must be a finite number`, + ); + } + + return new Prisma.Decimal(amount).toDecimalPlaces( + BUDGET_AMOUNT_DECIMAL_PLACES, + Prisma.Decimal.ROUND_HALF_UP, + ); + } + + /** + * Rejects negative budget mutation amounts after ledger-scale quantization. + * + * @param amount Requested lock or consume amount. + * @param operation Name of the budget mutation operation for error messages. + * @throws BadRequestException When the amount is negative. + */ + private assertBudgetAmountIsNonNegative( + amount: Prisma.Decimal, + operation: "lock" | "consume", + ): void { + if (amount.lessThan(0)) { + throw new BadRequestException( + `${operation} amount must be a non-negative number`, + ); + } + } + + /** + * Rejects non-positive consume amounts. + * + * Consume requests do not treat zero as an unlock or no-op; zero-value + * unlocks are handled only by `lockAmount`. + * + * @param amount Requested consume amount after ledger-scale quantization. + * @param operation Name of the budget mutation operation for error messages. + * @throws BadRequestException When the amount is negative or zero. + */ + private assertBudgetAmountIsPositive( + amount: Prisma.Decimal, + operation: "consume", + ): void { + this.assertBudgetAmountIsNonNegative(amount, operation); + + if (amount.isZero()) { + throw new BadRequestException("consume amount must be greater than 0"); + } + } + + /** + * Locks the billing-account row and loads current budget totals. + * + * The row lock serializes budget writes for the same billing account inside + * the caller's transaction. + * + * @param tx Active Prisma transaction client. + * @param billingAccountId Billing account identifier. + * @returns Budget and aggregate locked/consumed totals. + * @throws NotFoundException When the billing account does not exist. + */ + private async loadBudgetAccountTotals( + tx: Prisma.TransactionClient, + billingAccountId: number, + ): Promise { + const [billingAccount] = await tx.$queryRaw( + Prisma.sql` + SELECT "budget" + FROM "BillingAccount" + WHERE "id" = ${billingAccountId} + FOR UPDATE + `, + ); + + if (!billingAccount) { + throw new NotFoundException( + `Billing account with ID ${billingAccountId} not found`, + ); + } + + const [lockedAggregate, consumedAggregate] = await Promise.all([ + tx.lockedAmount.aggregate({ + where: { billingAccountId }, + _sum: { amount: true }, + }), + tx.consumedAmount.aggregate({ + where: { billingAccountId }, + _sum: { amount: true }, + }), + ]); + + return { + budget: billingAccount.budget, + lockedTotal: new Prisma.Decimal(lockedAggregate._sum.amount ?? 0), + consumedTotal: new Prisma.Decimal(consumedAggregate._sum.amount ?? 0), + }; + } + + /** + * Loads current budget totals plus matching lock/consume rows for a reference. + * + * @param tx Active Prisma transaction client. + * @param billingAccountId Billing account identifier. + * @param reference Canonical typed external reference being mutated. + * @returns Budget totals and matching line items. + * @throws NotFoundException When the billing account does not exist. + */ + private async loadBudgetMutationContext( + tx: Prisma.TransactionClient, + billingAccountId: number, + reference: BudgetEntryReference, + ): Promise { + const totals = await this.loadBudgetAccountTotals(tx, billingAccountId); + const [matchingLock, matchingConsumed] = await Promise.all([ + tx.lockedAmount.findFirst({ + where: { billingAccountId, ...reference }, + }), + tx.consumedAmount.findFirst({ + where: { billingAccountId, ...reference }, + }), + ]); + + return { + ...totals, + matchingLock, + matchingConsumed, + }; + } + + /** + * Rejects a budget mutation when its post-operation reserved total exceeds + * the billing-account budget. + * + * @param budget Billing-account budget. + * @param reservedTotal Locked plus consumed total after the pending mutation. + * @throws BadRequestException When there are insufficient remaining funds. + */ + private assertBudgetCanReserve( + budget: Prisma.Decimal, + reservedTotal: Prisma.Decimal, + ): void { + if (reservedTotal.greaterThan(budget)) { + throw new BadRequestException( + "Insufficient remaining funds on billing account", + ); + } + } + + /** + * Converts a persisted budget row into an external reference lookup input. + * + * @param lineItem Locked or consumed budget row. + * @returns Typed external reference for name resolution. + */ + private toBudgetEntryReference(lineItem: BudgetAmountLineItem) { + return { + externalId: lineItem.externalId, + externalType: lineItem.externalType, + }; + } + + /** + * Shapes a budget row for API details responses. + * + * The legacy `challengeId` alias is retained only for challenge rows while + * `amount`, `date`, `externalId`, `externalType`, and `externalName` are the + * canonical line-item response fields. + * + * @param lineItem Locked or consumed budget row. + * @param externalNames Resolved names keyed by typed external reference. + * @returns API-ready line item with normalized date and resolved name. + */ + private serializeBudgetLineItem( + lineItem: BudgetAmountLineItem, + externalNames: Map, + ) { + const reference = this.toBudgetEntryReference(lineItem); + const serializedLineItem = { + amount: lineItem.amount, + date: lineItem.updatedAt, + externalId: lineItem.externalId, + externalType: lineItem.externalType, + externalName: + externalNames.get(getBudgetEntryReferenceKey(reference)) ?? null, + }; + + return lineItem.externalType === "CHALLENGE" + ? { ...serializedLineItem, challengeId: lineItem.externalId } + : serializedLineItem; + } + /** * List users (resources) assigned to a billing account. * Returns minimal objects including handle (as name) for UI consumption. diff --git a/src/billing-accounts/budget-entry.util.ts b/src/billing-accounts/budget-entry.util.ts new file mode 100644 index 0000000..4d841e1 --- /dev/null +++ b/src/billing-accounts/budget-entry.util.ts @@ -0,0 +1,67 @@ +import { BadRequestException } from "@nestjs/common"; + +export const BUDGET_ENTRY_EXTERNAL_TYPES = ["CHALLENGE", "ENGAGEMENT"] as const; + +export type BudgetEntryExternalTypeValue = + (typeof BUDGET_ENTRY_EXTERNAL_TYPES)[number]; + +export const DEFAULT_BUDGET_ENTRY_EXTERNAL_TYPE: BudgetEntryExternalTypeValue = + "CHALLENGE"; + +export interface BudgetEntryReferenceInput { + externalId?: string; + externalType?: BudgetEntryExternalTypeValue; + challengeId?: string; +} + +export interface BudgetEntryReference { + externalId: string; + externalType: BudgetEntryExternalTypeValue; +} + +/** + * Builds the stable map key used when joining external budget entries to + * resolved display names. + * + * @param reference Typed external budget-entry reference. + * @returns Composite string key for map lookups. + */ +export function getBudgetEntryReferenceKey( + reference: BudgetEntryReference, +): string { + return `${reference.externalType}:${reference.externalId}`; +} + +/** + * Resolves a budget-entry request into the canonical typed external reference. + * + * `challengeId` is accepted only as a compatibility alias for legacy challenge + * callers; new callers should send `externalId` and optionally `externalType`. + * + * @param input Incoming lock/consume request reference fields. + * @returns Canonical external id and external type. + * @throws BadRequestException When the reference is missing or ambiguous. + */ +export function resolveBudgetEntryReference( + input: BudgetEntryReferenceInput, +): BudgetEntryReference { + const externalId = input.externalId?.trim(); + const challengeId = input.challengeId?.trim(); + + if (externalId && challengeId && externalId !== challengeId) { + throw new BadRequestException( + "externalId and challengeId must match when both are provided", + ); + } + + const resolvedExternalId = externalId || challengeId; + + if (!resolvedExternalId) { + throw new BadRequestException("externalId is required"); + } + + return { + externalId: resolvedExternalId, + externalType: input.externalType ?? DEFAULT_BUDGET_ENTRY_EXTERNAL_TYPE, + }; +} diff --git a/src/billing-accounts/dto/consume-amount.dto.ts b/src/billing-accounts/dto/consume-amount.dto.ts index 16ca70d..d1f1ab9 100644 --- a/src/billing-accounts/dto/consume-amount.dto.ts +++ b/src/billing-accounts/dto/consume-amount.dto.ts @@ -1,12 +1,53 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNumber, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsIn, + IsNumber, + IsOptional, + IsString, + Min, + NotEquals, +} from "class-validator"; +import { BUDGET_ENTRY_EXTERNAL_TYPES } from "../budget-entry.util"; +import type { BudgetEntryExternalTypeValue } from "../budget-entry.util"; export class ConsumeAmountDto { - @ApiProperty({ example: "12345abcde" }) + @ApiProperty({ + example: "12345abcde", + description: + "External reference id. For challenges this is the challenge id; for engagements this is the assignment id.", + }) + @IsOptional() @IsString() - challengeId!: string; + externalId?: string; - @ApiProperty({ example: 1500 }) + @ApiPropertyOptional({ + example: "CHALLENGE", + enum: BUDGET_ENTRY_EXTERNAL_TYPES, + default: "CHALLENGE", + description: + "Typed external reference. ENGAGEMENT consumed entries are append-only.", + }) + @IsOptional() + @IsIn(BUDGET_ENTRY_EXTERNAL_TYPES) + externalType?: BudgetEntryExternalTypeValue; + + @ApiPropertyOptional({ + example: "12345abcde", + deprecated: true, + description: "Deprecated alias for externalId kept for challenge callers.", + }) + @IsOptional() + @IsString() + challengeId?: string; + + @ApiProperty({ + example: 1500, + minimum: 0, + exclusiveMinimum: true, + description: "Positive amount to consume. Zero consumes are rejected.", + }) @IsNumber() + @Min(0) + @NotEquals(0) amount!: number; } diff --git a/src/billing-accounts/dto/consume-amounts.dto.ts b/src/billing-accounts/dto/consume-amounts.dto.ts new file mode 100644 index 0000000..63b56c9 --- /dev/null +++ b/src/billing-accounts/dto/consume-amounts.dto.ts @@ -0,0 +1,93 @@ +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Min, + NotEquals, + ValidateNested, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { BUDGET_ENTRY_EXTERNAL_TYPES } from "../budget-entry.util"; +import type { BudgetEntryExternalTypeValue } from "../budget-entry.util"; + +/** + * Describes one engagement budget consume inside an atomic batch request. + * + * Each item carries the target billing account, the engagement assignment id as + * the external reference, and the positive amount to reserve from the + * billing-account ledger. + */ +export class ConsumeAmountsItemDto { + @ApiProperty({ + example: 80001063, + minimum: 1, + description: "Billing account id to consume from.", + }) + @IsInt() + @Min(1) + billingAccountId!: number; + + @ApiProperty({ + example: "assignment-123", + description: "Engagement assignment id to record on the consumed row.", + }) + @IsString() + externalId!: string; + + @ApiPropertyOptional({ + example: "ENGAGEMENT", + enum: BUDGET_ENTRY_EXTERNAL_TYPES, + default: "ENGAGEMENT", + description: + "Typed external reference. Batch consumes only accept ENGAGEMENT entries.", + }) + @IsOptional() + @IsIn(BUDGET_ENTRY_EXTERNAL_TYPES) + externalType?: BudgetEntryExternalTypeValue; + + @ApiPropertyOptional({ + example: "legacy-challenge-id", + deprecated: true, + description: + "Deprecated challenge alias. It is rejected for engagement batch consumes.", + }) + @IsOptional() + @IsString() + challengeId?: string; + + @ApiProperty({ + example: 1500, + minimum: 0, + exclusiveMinimum: true, + description: + "Positive amount to consume. The service quantizes this to Decimal(20,4).", + }) + @IsNumber() + @Min(0) + @NotEquals(0) + amount!: number; +} + +/** + * Request body for atomically consuming one or more engagement budget rows. + * + * The billing-account service validates all items and writes the consumed rows + * inside one database transaction so a later item cannot leave earlier remote + * side effects behind. + */ +export class ConsumeAmountsDto { + @ApiProperty({ + type: [ConsumeAmountsItemDto], + description: "Engagement budget consumes to validate and persist together.", + }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => ConsumeAmountsItemDto) + consumes!: ConsumeAmountsItemDto[]; +} diff --git a/src/billing-accounts/dto/lock-amount.dto.ts b/src/billing-accounts/dto/lock-amount.dto.ts index fc87fec..94f0139 100644 --- a/src/billing-accounts/dto/lock-amount.dto.ts +++ b/src/billing-accounts/dto/lock-amount.dto.ts @@ -1,12 +1,45 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNumber, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNumber, IsOptional, IsString, Min } from "class-validator"; + +const LOCK_AMOUNT_EXTERNAL_TYPES = ["CHALLENGE"] as const; +type LockAmountExternalType = (typeof LOCK_AMOUNT_EXTERNAL_TYPES)[number]; export class LockAmountDto { - @ApiProperty({ example: "12345abcde" }) + @ApiProperty({ + example: "12345abcde", + description: + "External reference id. For challenge locks this is the challenge id.", + }) + @IsOptional() + @IsString() + externalId?: string; + + @ApiPropertyOptional({ + example: "CHALLENGE", + enum: LOCK_AMOUNT_EXTERNAL_TYPES, + default: "CHALLENGE", + description: + "Typed external reference. Locking currently supports CHALLENGE only.", + }) + @IsOptional() + @IsIn(LOCK_AMOUNT_EXTERNAL_TYPES) + externalType?: LockAmountExternalType; + + @ApiPropertyOptional({ + example: "12345abcde", + deprecated: true, + description: "Deprecated alias for externalId kept for challenge callers.", + }) + @IsOptional() @IsString() - challengeId!: string; + challengeId?: string; - @ApiProperty({ example: 1500 }) + @ApiProperty({ + example: 1500, + minimum: 0, + description: "Non-negative amount to lock. Use 0 to unlock.", + }) @IsNumber() + @Min(0) amount!: number; // if 0, unlock } diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts new file mode 100644 index 0000000..2e2213b --- /dev/null +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -0,0 +1,292 @@ +import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import type { BudgetEntryReference } from "./budget-entry.util"; +import { + getBudgetEntryReferenceKey, + type BudgetEntryExternalTypeValue, +} from "./budget-entry.util"; + +interface ChallengeNameRow { + id: string; + legacyId: number | null; + name: string | null; +} + +interface EngagementNameRow { + id: string; + title: string | null; +} + +/** + * Resolves budget-entry external names from service-owned persistence stores. + * + * The service uses raw batched lookups so billing-account detail responses do + * not make one network/database call per line item. Missing DB URLs or missing + * referenced rows produce empty name mappings rather than failing the billing + * account response. + */ +@Injectable() +export class ExternalBudgetEntryLookupService implements OnModuleDestroy { + private readonly logger = new Logger(ExternalBudgetEntryLookupService.name); + private challengeClient?: PrismaClient; + private engagementsClient?: PrismaClient; + private challengeClientInitialized = false; + private engagementsClientInitialized = false; + + /** + * Resolve external display names for mixed budget-entry references. + * + * @param references Typed budget-entry references from locked/consumed rows. + * @returns Map keyed by `externalType:externalId` with resolved display names. + */ + async getExternalNames( + references: BudgetEntryReference[], + ): Promise> { + const result = new Map(); + const referencesByType = new Map< + BudgetEntryExternalTypeValue, + Set + >(); + + for (const reference of references) { + const externalIds = + referencesByType.get(reference.externalType) ?? new Set(); + externalIds.add(reference.externalId); + referencesByType.set(reference.externalType, externalIds); + } + + const [challengeNames, engagementNames] = await Promise.all([ + this.getChallengeNamesByIds([ + ...(referencesByType.get("CHALLENGE") ?? []), + ]), + this.getEngagementNamesByAssignmentIds([ + ...(referencesByType.get("ENGAGEMENT") ?? []), + ]), + ]); + + for (const [externalId, name] of challengeNames.entries()) { + result.set( + getBudgetEntryReferenceKey({ + externalType: "CHALLENGE", + externalId, + }), + name, + ); + } + + for (const [externalId, name] of engagementNames.entries()) { + result.set( + getBudgetEntryReferenceKey({ + externalType: "ENGAGEMENT", + externalId, + }), + name, + ); + } + + return result; + } + + /** + * Disconnects optional lookup clients created for external stores. + * + * @returns Promise that resolves after all initialized clients disconnect. + */ + async onModuleDestroy(): Promise { + await Promise.all([ + this.challengeClient?.$disconnect(), + this.engagementsClient?.$disconnect(), + ]); + } + + /** + * Resolve challenge names by current challenge ids and numeric legacy ids. + * + * @param externalIds Challenge ids stored on budget entries. + * @returns Map of each matched challenge id or legacy id to challenge name. + */ + private async getChallengeNamesByIds( + externalIds: string[], + ): Promise> { + const result = new Map(); + const uniqueExternalIds = [...new Set(externalIds.filter(Boolean))]; + + if (uniqueExternalIds.length === 0) { + return result; + } + + const client = this.getChallengeClient(); + + if (!client) { + return result; + } + + try { + const idRows = await client.$queryRaw( + Prisma.sql`SELECT "id", "legacyId", "name" FROM "Challenge" WHERE "id" IN (${Prisma.join(uniqueExternalIds)})`, + ); + + for (const row of idRows) { + if (row.name) { + result.set(row.id, row.name); + } + } + + const legacyIds = this.getNumericLegacyIds(uniqueExternalIds); + if (legacyIds.length > 0) { + const legacyRows = await client.$queryRaw( + Prisma.sql`SELECT "id", "legacyId", "name" FROM "Challenge" WHERE "legacyId" IN (${Prisma.join(legacyIds)})`, + ); + + for (const row of legacyRows) { + if (row.name && row.legacyId !== null) { + result.set(String(row.legacyId), row.name); + } + } + } + } catch (error) { + this.logger.warn( + `Failed to resolve challenge names for billing-account entries: ${this.getErrorMessage(error)}`, + ); + } + + return result; + } + + /** + * Resolve engagement titles by assignment ids. + * + * @param assignmentIds Engagement assignment ids stored on budget entries. + * @returns Map of assignment id to engagement title. + */ + private async getEngagementNamesByAssignmentIds( + assignmentIds: string[], + ): Promise> { + const result = new Map(); + const uniqueAssignmentIds = [...new Set(assignmentIds.filter(Boolean))]; + + if (uniqueAssignmentIds.length === 0) { + return result; + } + + const client = this.getEngagementsClient(); + + if (!client) { + return result; + } + + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT assignment."id", engagement."title" + FROM "EngagementAssignment" assignment + INNER JOIN "Engagement" engagement + ON engagement."id" = assignment."engagementId" + WHERE assignment."id" IN (${Prisma.join(uniqueAssignmentIds)}) + `, + ); + + for (const row of rows) { + if (row.title) { + result.set(row.id, row.title); + } + } + } catch (error) { + this.logger.warn( + `Failed to resolve engagement names for billing-account entries: ${this.getErrorMessage(error)}`, + ); + } + + return result; + } + + /** + * Converts string ids that can represent challenge legacy ids into numbers. + * + * @param externalIds Budget-entry external ids. + * @returns Safe integer legacy ids. + */ + private getNumericLegacyIds(externalIds: string[]): number[] { + return externalIds + .map((externalId) => Number(externalId)) + .filter( + (externalId) => Number.isSafeInteger(externalId) && externalId >= 0, + ); + } + + /** + * Lazily initializes the challenge lookup client. + * + * @returns Prisma client for the challenge DB, or undefined when not configured. + */ + private getChallengeClient(): PrismaClient | undefined { + if (this.challengeClientInitialized) { + return this.challengeClient; + } + + this.challengeClientInitialized = true; + this.challengeClient = this.createOptionalClient( + process.env.CHALLENGE_DB_URL || process.env.CHALLENGES_DB_URL, + "CHALLENGE_DB_URL or CHALLENGES_DB_URL", + ); + + return this.challengeClient; + } + + /** + * Lazily initializes the engagements lookup client. + * + * @returns Prisma client for the engagements DB, or undefined when not configured. + */ + private getEngagementsClient(): PrismaClient | undefined { + if (this.engagementsClientInitialized) { + return this.engagementsClient; + } + + this.engagementsClientInitialized = true; + this.engagementsClient = this.createOptionalClient( + process.env.ENGAGEMENTS_DB_URL || process.env.ENGAGEMENT_DB_URL, + "ENGAGEMENTS_DB_URL or ENGAGEMENT_DB_URL", + ); + + return this.engagementsClient; + } + + /** + * Creates a Prisma client for an optional external lookup database. + * + * @param databaseUrl External database connection string. + * @param envDescription Human-readable environment variable description. + * @returns Prisma client when configured, otherwise undefined. + */ + private createOptionalClient( + databaseUrl: string | undefined, + envDescription: string, + ): PrismaClient | undefined { + if (!databaseUrl) { + this.logger.warn( + `${envDescription} not set; billing-account external names will be omitted for that type.`, + ); + return undefined; + } + + return new PrismaClient({ + transactionOptions: { + timeout: process.env.BA_SERVICE_PRISMA_TIMEOUT + ? parseInt(process.env.BA_SERVICE_PRISMA_TIMEOUT, 10) + : 10000, + }, + datasources: { db: { url: databaseUrl } }, + }); + } + + /** + * Normalizes unknown errors for structured log messages. + * + * @param error Unknown caught error. + * @returns Error message string. + */ + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +}