diff --git a/API/openapi.yaml b/API/openapi.yaml index f6b066c67..1121c3cb5 100644 --- a/API/openapi.yaml +++ b/API/openapi.yaml @@ -2002,6 +2002,68 @@ components: type: string paths: + /org/organisations/{orgId}/teams/{teamId}/archive: + parameters: + - $ref: '#/components/parameters/orgIdPath' + - $ref: '#/components/parameters/teamIdPath' + - $ref: '#/components/parameters/domainQuery' + - $ref: '#/components/parameters/configUrl' + post: + operationId: archiveSharedTeam + tags: [Organizations] + summary: Archive a shared team with permanent deletion held + description: | + Requires a fresh exact-team human credential and live organisation teams.manage capability. + Backend-only callers cannot archive. Stops team access across products, including the + default or last team, without erasing its organisation, memberships, people or data. + Future team billing is reconciled independently; 202 never certifies that billing stopped. + security: + - domainHashBearer: [] + accessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [request_id, deletion_held] + properties: + request_id: { type: string, format: uuid } + deletion_held: { type: boolean, enum: [true] } + responses: + '202': + description: Durable archive receipt; status is STOPPING_BILLING until provider reconciliation succeeds. + '401': + description: Missing, expired or revoked human or product authority. + '403': + description: Exact selected team or organisation management capability refused. + '409': + description: Idempotency key belongs to a different request or the team is already terminal. + /domain/team-archives/{id}: + parameters: + - $ref: '#/components/parameters/domainQuery' + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + operationId: readTeamArchiveReceipt + tags: [Organizations] + summary: Read an archive receipt after the human session ends + description: | + Bound to the initiating registered product. Returns only operation and UOA scope references, + ARCHIVED or STOPPING_BILLING, access_stopped, billing_stopped, deletion_held=true, + permanently_deleted=false and timestamps. It grants no membership, content or mutation access. + security: + - domainHashBearer: [] + responses: + '200': + description: Minimal no-store receipt. Only ARCHIVED certifies that future team billing stopped. + '401': + description: Missing or revoked registered product credential. + '404': + description: Operation does not exist or belongs to another product. /auth/action-verification/start: post: operationId: startActionVerification diff --git a/API/prisma/migrations/20261107123000_hold_team_archives/migration.sql b/API/prisma/migrations/20261107123000_hold_team_archives/migration.sql new file mode 100644 index 000000000..88f2de9bd --- /dev/null +++ b/API/prisma/migrations/20261107123000_hold_team_archives/migration.sql @@ -0,0 +1,130 @@ +SET lock_timeout = '5s'; +SET statement_timeout = '120s'; + +ALTER TYPE "EntityLifecycleStatus" ADD VALUE IF NOT EXISTS 'ARCHIVED'; + +CREATE TABLE team_archive_operations ( + id UUID PRIMARY KEY, + team_id TEXT NOT NULL UNIQUE, + org_id TEXT NOT NULL, + source_client_domain_id TEXT NOT NULL, + actor_user_id TEXT NOT NULL, + deletion_held BOOLEAN NOT NULL DEFAULT true CHECK (deletion_held), + billing_stopped_at TIMESTAMP(3), + settle_after TIMESTAMP(3) NOT NULL, + lease_owner UUID, + lease_expires_at TIMESTAMP(3), + next_attempt_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id, org_id) REFERENCES teams(id, org_id) ON DELETE RESTRICT +); +CREATE INDEX team_archive_operations_billing_stopped_at_next_attempt_at_idx + ON team_archive_operations(billing_stopped_at, next_attempt_at); +CREATE UNIQUE INDEX team_archive_operations_team_id_org_id_key ON team_archive_operations(team_id, org_id); +ALTER TABLE team_archive_operations ENABLE ROW LEVEL SECURITY; +ALTER TABLE team_archive_operations FORCE ROW LEVEL SECURITY; +REVOKE ALL ON team_archive_operations FROM PUBLIC, uoa_app; +GRANT SELECT, INSERT, UPDATE ON team_archive_operations TO uoa_admin; + +CREATE FUNCTION hold_archived_team() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ +DECLARE held BOOLEAN; +BEGIN + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.team_archive_operations WHERE team_id=$1)', TG_TABLE_SCHEMA) + INTO held USING OLD.id; + IF held AND (TG_OP = 'DELETE' OR NEW.lifecycle_status::text <> 'ARCHIVED') THEN + RAISE EXCEPTION 'team archive deletion is held' USING ERRCODE = '23514'; + END IF; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER hold_archived_team BEFORE DELETE OR UPDATE OF lifecycle_status ON teams + FOR EACH ROW EXECUTE FUNCTION hold_archived_team(); + +CREATE FUNCTION preserve_team_archive_hold() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' OR ROW(NEW.id, NEW.team_id, NEW.org_id, + NEW.source_client_domain_id, NEW.actor_user_id, NEW.deletion_held, NEW.created_at) + IS DISTINCT FROM ROW(OLD.id, OLD.team_id, OLD.org_id, + OLD.source_client_domain_id, OLD.actor_user_id, OLD.deletion_held, OLD.created_at) + OR (OLD.billing_stopped_at IS NOT NULL AND NEW.billing_stopped_at IS DISTINCT FROM OLD.billing_stopped_at) + THEN RAISE EXCEPTION 'team archive evidence is immutable' USING ERRCODE = '23514'; END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER preserve_team_archive_hold BEFORE UPDATE OR DELETE ON team_archive_operations + FOR EACH ROW EXECUTE FUNCTION preserve_team_archive_hold(); + +-- Lifecycle cancellation has its own exact-team evidence. Retain the original +-- immutable consent revisions and monetary history; clear current consent pointers +-- without forging a customer billing action. +CREATE FUNCTION hold_archived_team_reload() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ +DECLARE held BOOLEAN; +BEGIN + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.team_archive_operations WHERE team_id=$1 AND org_id=$2)', TG_TABLE_SCHEMA) + INTO held USING NEW.team_id, NEW.org_id; + IF NEW.auto_top_up_state::text <> 'DISABLED' AND held THEN + RAISE EXCEPTION 'archived team cannot enable reload' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER hold_archived_team_reload BEFORE INSERT OR UPDATE ON billing_credit_accounts + FOR EACH ROW EXECUTE FUNCTION hold_archived_team_reload(); +CREATE OR REPLACE FUNCTION "billing_credit_auto_top_up_generation_guard"() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + snapshot_changed BOOLEAN; +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW."auto_top_up_generation" <> 0 THEN + RAISE EXCEPTION 'new credit account generation must begin at zero' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + snapshot_changed := ROW( + NEW."auto_top_up_policy_id", NEW."auto_top_up_service_id", + NEW."auto_top_up_app_key_id", NEW."auto_top_up_consent_revision_id", + NEW."auto_top_up_option_id", NEW."auto_top_up_threshold_microcredits", + NEW."auto_top_up_refill_offer_id", NEW."auto_top_up_monthly_charge_cap_minor", + NEW."auto_top_up_consent_version", NEW."auto_top_up_consented_at", + NEW."auto_top_up_consented_by_user_id", NEW."stripe_payment_method_id", + NEW."payment_method_summary" + ) IS DISTINCT FROM ROW( + OLD."auto_top_up_policy_id", OLD."auto_top_up_service_id", + OLD."auto_top_up_app_key_id", OLD."auto_top_up_consent_revision_id", + OLD."auto_top_up_option_id", OLD."auto_top_up_threshold_microcredits", + OLD."auto_top_up_refill_offer_id", OLD."auto_top_up_monthly_charge_cap_minor", + OLD."auto_top_up_consent_version", OLD."auto_top_up_consented_at", + OLD."auto_top_up_consented_by_user_id", OLD."stripe_payment_method_id", + OLD."payment_method_summary" + ); + IF snapshot_changed AND NEW."auto_top_up_generation" <> OLD."auto_top_up_generation" + 1 THEN + RAISE EXCEPTION 'automatic top-up consent change must advance generation once' + USING ERRCODE = '23514'; + ELSIF NOT snapshot_changed + AND NEW."auto_top_up_generation" <> OLD."auto_top_up_generation" THEN + RAISE EXCEPTION 'automatic top-up generation changed without a consent change' + USING ERRCODE = '23514'; + END IF; + IF OLD."auto_top_up_state" <> 'DISABLED' AND NEW."auto_top_up_state" = 'DISABLED' + AND NOT EXISTS (SELECT 1 FROM team_archive_operations a JOIN teams t ON t.id=a.team_id WHERE a.team_id=OLD.team_id AND a.org_id=OLD.org_id AND t.lifecycle_status::text='ARCHIVED') AND NOT EXISTS ( + SELECT 1 + FROM "billing_credit_auto_top_up_disable_events" AS disable_event + WHERE disable_event."credit_account_id" = OLD."id" + AND disable_event."account_id" = OLD."account_id" + AND disable_event."org_id" = OLD."org_id" + AND disable_event."team_id" IS NOT DISTINCT FROM OLD."team_id" + AND disable_event."previous_generation" = OLD."auto_top_up_generation" + AND disable_event."previous_consent_revision_id" + IS NOT DISTINCT FROM OLD."auto_top_up_consent_revision_id" + ) THEN + RAISE EXCEPTION 'automatic top-up disable requires manager-audited evidence' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; diff --git a/API/prisma/schema.prisma b/API/prisma/schema.prisma index bd55a2e40..a5bd398df 100644 --- a/API/prisma/schema.prisma +++ b/API/prisma/schema.prisma @@ -15,10 +15,33 @@ enum UserRole { enum EntityLifecycleStatus { ACTIVE DISABLED + ARCHIVED DELETING DELETED } +// An archive is a durable hold, never an executable erasure job. The authority +// and commercial evidence remain in UOA after every product loses access. +model TeamArchiveOperation { + id String @id @db.Uuid + teamId String @unique @map("team_id") + orgId String @map("org_id") + sourceClientDomainId String @map("source_client_domain_id") + actorUserId String @map("actor_user_id") + deletionHeld Boolean @default(true) @map("deletion_held") + billingStoppedAt DateTime? @map("billing_stopped_at") + settleAfter DateTime @map("settle_after") + leaseOwner String? @map("lease_owner") @db.Uuid + leaseExpiresAt DateTime? @map("lease_expires_at") + nextAttemptAt DateTime @default(now()) @map("next_attempt_at") + createdAt DateTime @default(now()) @map("created_at") + team Team @relation(fields: [teamId, orgId], references: [id, orgId], onDelete: Restrict) + + @@unique([teamId, orgId]) + @@index([billingStoppedAt, nextAttemptAt]) + @@map("team_archive_operations") +} + model LifecycleReasonTemplate { id String @id @default(cuid()) scope LifecycleScope @@ -1568,6 +1591,7 @@ model FeatureFlagUserOverride { } model Team { + archiveOperation TeamArchiveOperation? id String @id @default(cuid()) lifecycleStatus EntityLifecycleStatus @default(ACTIVE) @map("lifecycle_status") lifecycleChangedAt DateTime? @map("lifecycle_changed_at") diff --git a/API/src/app.ts b/API/src/app.ts index 91cc7eb7d..4c0493473 100644 --- a/API/src/app.ts +++ b/API/src/app.ts @@ -22,6 +22,7 @@ import tenantContextPlugin from './plugins/tenant-context.plugin.js'; import { registerRoutes } from './routes/index.js'; import { preloadTariffSnapshotSigningKey } from './services/billing-snapshot.service.js'; import { startStripeBillingScheduler } from './services/billing-stripe-scheduler.service.js'; +import { startTeamArchiveReconciliation } from './services/team-archive-reconciliation.service.js'; import { startCreditSettlementScheduler } from './services/billing-credit-settlement-scheduler.service.js'; import { startSeatTransitionScheduler } from './services/billing-seat-transition-scheduler.service.js'; import { startManualCycleReconciliationScheduler } from './services/billing-cycle-manual-reconciliation-scheduler.service.js'; @@ -302,6 +303,8 @@ export async function createApp(): Promise { stripeBillingScheduler.stop(); }); } + const teamArchives = startTeamArchiveReconciliation({ log: app.log }); + app.addHook('onClose', async () => { teamArchives.stop(); }); const creditSettlementScheduler = startCreditSettlementScheduler({ log: app.log }); app.addHook('onClose', async () => { creditSettlementScheduler.stop(); diff --git a/API/src/routes/domain/index.ts b/API/src/routes/domain/index.ts index c5398da44..e5fc712e8 100644 --- a/API/src/routes/domain/index.ts +++ b/API/src/routes/domain/index.ts @@ -1,6 +1,7 @@ import { registerHistoricalUserRoutes } from './historical-users.js'; import type { FastifyInstance } from 'fastify'; import { registerProductDeletionJobs } from './deletion-jobs.js'; +import { registerTeamArchiveReceipts } from './team-archives.js'; import { registerDomainDebugRoute } from './debug.js'; import { registerDomainLogsRoute } from './logs.js'; @@ -13,6 +14,7 @@ import { registerDomainSignatureRoutes } from './signatures.js'; export function registerDomainRoutes(app: FastifyInstance): void { registerHistoricalUserRoutes(app); registerProductDeletionJobs(app); + registerTeamArchiveReceipts(app); registerDomainDebugRoute(app); registerDomainLogsRoute(app); registerDomainUsersRoute(app); diff --git a/API/src/routes/domain/team-archives.ts b/API/src/routes/domain/team-archives.ts new file mode 100644 index 000000000..b007edfa3 --- /dev/null +++ b/API/src/routes/domain/team-archives.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { requireDomainHashAuthForDomainQuery } from '../../middleware/domain-hash-auth.js'; +import { readTeamArchive } from '../../services/team-archive.service.js'; +import { reconcileTeamArchives } from '../../services/team-archive-reconciliation.service.js'; +import { AppError } from '../../utils/errors.js'; + +export function registerTeamArchiveReceipts(app: FastifyInstance) { + app.get('/domain/team-archives/:id', { preHandler: [requireDomainHashAuthForDomainQuery] }, async (request, reply) => { + const { id } = z.object({ id: z.uuid() }).parse(request.params); + if (!request.domainAuthClientDomainId) throw new AppError('UNAUTHORIZED', 401); + const receipt = await readTeamArchive(id, request.domainAuthClientDomainId); + if (!receipt.billing_stopped) await reconcileTeamArchives({ operationId: id }); + return reply.header('Cache-Control', 'no-store').send(await readTeamArchive(id, request.domainAuthClientDomainId)); + }); +} diff --git a/API/src/routes/org/index.ts b/API/src/routes/org/index.ts index f33671dd3..ba7762bc6 100644 --- a/API/src/routes/org/index.ts +++ b/API/src/routes/org/index.ts @@ -6,6 +6,7 @@ import { registerOrganisationMemberRoutes } from './organisation-members.js'; import { registerGroupRoutes } from './groups.js'; import { registerTeamAvatarRoutes } from './team-avatar.js'; import { registerTeamRoutes } from './teams.js'; +import { registerTeamArchiveRoutes } from './team-archives.js'; import { registerTeamSelfJoinRoute } from './team-self-join.js'; import { registerAccessRequestRoutes } from './access-requests.js'; import { registerTeamInvitationRoutes } from './team-invitations.js'; @@ -19,6 +20,7 @@ export function registerOrgRoutes(app: FastifyInstance): void { registerOrganisationMemberRoutes(app); registerGroupRoutes(app); registerTeamRoutes(app); + registerTeamArchiveRoutes(app); registerTeamAvatarRoutes(app); registerTeamSelfJoinRoute(app); registerTeamInvitationRoutes(app); diff --git a/API/src/routes/org/team-archives.ts b/API/src/routes/org/team-archives.ts new file mode 100644 index 000000000..aa80adf83 --- /dev/null +++ b/API/src/routes/org/team-archives.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { configVerifier } from '../../middleware/config-verifier.js'; +import { requireDomainHashAuthForDomainQuery } from '../../middleware/domain-hash-auth.js'; +import { requireOrgRole } from '../../middleware/org-role-guard.js'; +import { requireOrgFeatures } from '../../middleware/org-features.js'; +import { beginTeamArchive } from '../../services/team-archive.service.js'; +import { AppError } from '../../utils/errors.js'; +import { parseDomainContext, parseDomainContextHook, requireVerifiedConfig } from './team-route.shared.js'; + +export function registerTeamArchiveRoutes(app: FastifyInstance) { + app.post('/org/organisations/:orgId/teams/:teamId/archive', { + preValidation: [requireDomainHashAuthForDomainQuery(), configVerifier, + parseDomainContextHook, requireOrgFeatures, requireOrgRole()], + }, async (request, reply) => { + const { orgId, teamId } = z.object({ orgId: z.string().min(1), teamId: z.string().min(1) }).parse(request.params); + const { request_id } = z.object({ request_id: z.uuid(), deletion_held: z.literal(true) }).strict().parse(request.body); + const { domain } = parseDomainContext(request); + if (!request.accessTokenClaims || !request.domainAuthClientId) throw new AppError('UNAUTHORIZED', 401); + const receipt = await beginTeamArchive({ id: request_id, orgId, teamId, domain, + domainToken: request.domainAuthClientId, claims: request.accessTokenClaims, config: requireVerifiedConfig(request) }); + return reply.header('Cache-Control', 'no-store').code(202).send(receipt); + }); +} diff --git a/API/src/routes/root/llm.ts b/API/src/routes/root/llm.ts index 097d33324..559ee6803 100644 --- a/API/src/routes/root/llm.ts +++ b/API/src/routes/root/llm.ts @@ -49,8 +49,8 @@ Proof-bearing team-switch grants validate the original source and current target ## DeepTest Nessie executor inference permission Platform superusers set the default-deny User.deeptestNessieInferenceAllowed property in User detail > Security or PATCH /internal/admin/users/:userId/deeptest-nessie-inference with {allowed:boolean}. DeepTest calls POST /apps/v1/deeptest/nessie-inference-access with {product:"deeptest",organisation_id,team_id,user_id}, its own customer_lifecycle X-UOA-App-Key and fresh RS256 X-UOA-Actor. The actor requires exact endpoint audience (no legacy fallback), exact subject/org/team and current nonnegative safe-integer tv. UOA rechecks active identity, memberships and key/service, returning only {allowed:boolean}, private/no-store. Check before every dispatch; errors deny and positive caching is forbidden. This grant does not authorize source access, external material transfer or live testing. ${llmJobGrantsMarkdown}\n## Entity access and deletion (2026-10-03) -Lifecycle ACTIVE/DISABLED/DELETING/DELETED is distinct from membership. Parent disable denies descendant access without changing child state; no superuser bypass. Offline products must introspect or bound JWT expiry to honor revocation. See /api lifecycle and endpoint schemas. Admin templates have scoped plain customer text and revision snapshots; internal notes never reach account-status proof. Access-status diagnostics are disabled by default: the auth-server environment must explicitly set AUTH_ACCESS_STATUS_ENABLED=true to show Check access status and enable all four website/native start/verify endpoints. False or unset returns 404 before configuration/client lookup, mail, challenge or proof work, including previously issued proof redemption. DEBUG_ENABLED, signed product config, URL parameters and browser storage cannot enable it. When enabled, POST /auth/lifecycle-status/start and verify use signed config_url, mailbox code and enrolled TOTP solely to read authorized reasons, never to log in. Native clients use /oauth/lifecycle-status/start and verify with registered client_id and exact redirect_uri; current app revision binds the proof. -Platform-admin lifecycle endpoints provide preview, typed confirmation, durable jobs and progress. RETAIN_REFERENCE removes PII/credentials and exposes history as {id,deleted:true,name:"Deleted user"} via authorized /domain/historical-users/:id. ERASE_REFERENCE erases mutable identity references; immutable financial/signing evidence may retain restricted IDs/stubs, explicitly inventoried. Ownership must transfer first for retained organisations. Last-team deletion includes the now-empty organisation; shared/dependent accounts survive. Active collection/capabilities must close or transfer before confirmation. Old direct organisation/team DELETE routes now return 409 ENTITY_DELETION_WORKFLOW_REQUIRED. +Lifecycle ACTIVE/DISABLED/ARCHIVED/DELETING/DELETED is distinct from membership. Parent disable denies descendant access without changing child state; no superuser bypass. Offline products must introspect or bound JWT expiry to honor revocation. See /api lifecycle and endpoint schemas. Admin templates have scoped plain customer text and revision snapshots; internal notes never reach account-status proof. Access-status diagnostics are disabled by default: the auth-server environment must explicitly set AUTH_ACCESS_STATUS_ENABLED=true to show Check access status and enable all four website/native start/verify endpoints. False or unset returns 404 before configuration/client lookup, mail, challenge or proof work, including previously issued proof redemption. DEBUG_ENABLED, signed product config, URL parameters and browser storage cannot enable it. When enabled, POST /auth/lifecycle-status/start and verify use signed config_url, mailbox code and enrolled TOTP solely to read authorized reasons, never to log in. Native clients use /oauth/lifecycle-status/start and verify with registered client_id and exact redirect_uri; current app revision binds the proof. +Shared team archive: POST /org/organisations/:orgId/teams/:teamId/archive with {request_id:UUID,deletion_held:true} requires a fresh exact-team person and live organisation teams.manage capability. It archives across products without erasure or an organisation cascade. GET /domain/team-archives/:id is an initiating-product-only receipt after session revocation. STOPPING_BILLING is pending; ARCHIVED confirms future team billing stopped. Permanent deletion remains held and cannot run. Shared organisation billing and sibling teams remain independent. Platform-admin lifecycle endpoints provide preview, typed confirmation, durable jobs and progress. RETAIN_REFERENCE removes PII/credentials and exposes history as {id,deleted:true,name:"Deleted user"} via authorized /domain/historical-users/:id. ERASE_REFERENCE erases mutable identity references; immutable financial/signing evidence may retain restricted IDs/stubs, explicitly inventoried. Ownership must transfer first for retained organisations. Last-team deletion includes the now-empty organisation; shared/dependent accounts survive. Active collection/capabilities must close or transfer before confirmation. Old direct organisation/team DELETE routes now return 409 ENTITY_DELETION_WORKFLOW_REQUIRED. Products authenticate their exact registered domain bearer to GET /domain/deletion-jobs?domain=... and POST /domain/deletion-jobs/:id/acknowledge?domain=... with {revision,outcome:"PURGED"|"RETAINED_EVIDENCE"}. Honor effectiveScope/effectiveTargetId, teamIds and committed accountsToDelete; replies acknowledge committed cleanup, never merely receipt. Keep only UOA references and product-owned data. Pending or unknown product inventory blocks completion; this repository does not deploy other product adapters. The pull response is {data:[jobs]}. Retained-evidence acknowledgements may include up to30 {label,count,reason} summaries, without unrelated PII; missing summaries are shown as details unavailable. The credential is rechecked under the product policy lock at acknowledgement. Admin retries finish bounded leased UOA cleanup stages only after all acknowledgements; READY after a retry means more stages remain. ${llmIntroMarkdown}\n## Admin activity and directory reads\nGET /internal/admin/logs accepts limit (default 100, maximum 500) and optional exact userId under the admin-superuser bearer boundary. It returns latest successful login events with userId and ISO UTC occurredAt, a bounded window rather than all history. Organisation preapprovedMembers is the legacy field for invitation history: status pending, accepted, declined, replaced, revoked or expired, separate approvalStatus, and targetTeamId.\n\n## Admin team membership\nPOST /internal/admin/users/:userId/teams accepts {orgId, teamId, teamRole: "member" | "admin"} with an admin-domain superuser bearer. It atomically adds missing organisation/default-team membership as member and the requested team role, using standard UOA membership limits. Exact retries are idempotent; active roles are never changed by Add. Removed memberships can be re-added with the requested role, without reviving revoked sessions. Suspended memberships and ownership changes are refused. All writes and audit records commit together.\n\n## Product tenant addresses\nThe /domain/organisations/resolve, /domain/teams/resolve and id-to-address reads use UOA's server-owned product team policy. Ordinary client domains remain isolated. An active recognized product may resolve active organisations and teams founded under another domain, as its sign-in chooser already permits. Organisation labels must identify exactly one active organisation in that namespace; ambiguous labels return 404 in both directions, including when building a link from an id. Never select the first match, invent a product-local hierarchy or infer authorization from a hostname. Organisation slug availability checks the same namespace.\n## One-use debug login\nPOST /auth/debug-login/issue?config_url=... with the domain bearer and {refresh_token, previous_token?} returns {token, expires_in}, capped at 1800 seconds and by the source family and original proof deadline. Renew invalidates the previous code atomically. POST /auth/debug-login/redeem with the same verified config_url and bearer plus {token} consumes the code and returns an independent ordinary token pair. Like /auth/token, a proof-bearing pair includes confidential assurance_ref and absolute assurance_expires_at for the product backend. Keep both out of browser responses and storage; preserve the original proof and deadline through later refresh. A proof-free legacy source never acquires invented provenance. Codes bind the exact product/environment, source family, credential epoch and selected team. Supported Nessie theme variants in a signed config URL are presentation-only; other query parameters remain part of the environment binding. Expiry, logout, revocation or loss of membership refuse redemption. Normal session logout calls POST /auth/revoke with {refresh_token,scope:"family"}; it revokes only that family and its unused debug grants. Omitting scope retains global credential epoch revocation. Tokens must stay in POST bodies; the product owns the bottom-right issue/redeem UI and exports exactly {url,token}.\n## First-party UOA Admin debug login\nThe production bottom-right Debug login button creates a one-use admin code through POST /internal/admin/debug-login/issue?config_url=, using the current admin bearer and its exact bearer-bound HttpOnly source-session cookie. Body {previous_token?}; response {url,token,expires_in}. Clipboard exports exactly {url,token}. /admin/login keeps a deliberate Google button and an independent debug importer accepting that JSON or a bare code. POST /internal/admin/debug-login/redeem with {token} returns only a new short-lived admin access bearer and sets a separate private family cookie. All three admin debug/logout mutations require the canonical service Origin and JSON content type before fetching config. Current ACTIVE user, credential epoch and admin-domain SUPERUSER are checked under issuance locks; ordinary product credentials cannot use these routes. Codes last at most1800seconds, capped by the source session lifetime. Renew, expiry, source logout or revocation invalidates outstanding codes. POST /internal/admin/logout with {} revokes only the exact bearer-associated family. Cookies are HttpOnly, SameSite=Strict, HTTPS Secure, scoped to /internal/admin and expire with the access session; they are ownership handles and cannot refresh the admin browser. Existing sessions without a handle must sign in again.\n## Selkie broker capability\nThe existing confidential exchange accepts session:broker only for product coder, source coder.unlikeotherai.com and exact resource https://api.selkie.live, with an explicit enabled operator mapping. Active org/team and credential epoch are required. Chained exchange is refused. POST /auth/session-broker/validate?config_url= with Selkie domain bearer and {token} rechecks signature, exact audience/scope/source, current mapping, credential epoch, source domain role and active team. Target domain role and canonical target ban policy remain required. The current signed target config must explicitly select personal subject-only devices (org_features.enabled=false) or team admission (enabled=true); only the latter requires registered target team policy. Missing target scope fails closed and caller input cannot change it. No MFA completion is asserted. Response {sub,expires_at,active:{orgId,teamId}} contains no profiles. Selkie validates each broker request and caps its opaque local handle at the capability expiry; rebroker after expiry. Broker capabilities cannot issue debug login grants or independent refresh families.\n${llmWebsiteServicesMarkdown}\n${llmIntegrationMarkdown}\n${llmIntegrationMarkdown2}\n${llmRostersMarkdown}\n## Organisation address admission Organisation labels reserve one global namespace across founding domains. Existing ambiguous labels remain blocked without renames. Availability is advisory; database admission also covers concurrent creation and explicit address changes. Team labels remain scoped to their organisation. diff --git a/API/src/routes/root/schema.lifecycle.ts b/API/src/routes/root/schema.lifecycle.ts index f73561eca..4fa9a4ba1 100644 --- a/API/src/routes/root/schema.lifecycle.ts +++ b/API/src/routes/root/schema.lifecycle.ts @@ -3,6 +3,15 @@ import type { EndpointSchema } from './schema.js'; const accessStatusNotes = 'Optional diagnostics: AUTH_ACCESS_STATUS_ENABLED=true on the auth server is required. False/unset returns404 before config/client/mail/proof work, including outstanding proof redemption.'; export const lifecycleEndpoints: EndpointSchema[] = [ + { method: 'POST', path: '/org/organisations/:orgId/teams/:teamId/archive', + auth: 'registered domain bearer, verified config, fresh exact-team human assertion and live org teams.manage capability', + query: { domain: 'registered product domain', config_url: 'signed product config URL' }, + body: { request_id: 'UUID reused for retries', deletion_held: 'true; required' }, + description: 'Archive this shared UOA team across products, revoke sessions and stop future team billing; never erase data or cascade to the organisation', + response: { id: 'request UUID', status: 'STOPPING_BILLING | ARCHIVED', access_stopped: 'true', billing_stopped: 'boolean', deletion_held: 'true', permanently_deleted: 'false' } }, + { method: 'GET', path: '/domain/team-archives/:id', auth: 'exact initiating registered domain bearer', + query: { domain: 'registered product domain' }, description: 'Read only the archive receipt after the initiating human session is revoked; no team data or mutation authority', + response: { id: 'request UUID', organisation_id: 'UOA organisation reference', team_id: 'UOA team reference', status: 'STOPPING_BILLING | ARCHIVED', billing_stopped: 'boolean', deletion_held: 'true', permanently_deleted: 'false' } }, { method: 'POST', path: '/oauth/account-renewal/start', auth: 'registered public client_id and exact redirect_uri; IP rate limited', query: { client_id: 'registered client', redirect_uri: 'exact registered callback' }, body: { email: 'valid email' }, description: 'Send a neutral mailbox code to start an expired-account renewal request' }, { method: 'POST', path: '/oauth/account-renewal/request', auth: 'one-use mailbox code and enrolled TOTP; current registered app revision', query: { client_id: 'same registered client', redirect_uri: 'same exact callback' }, body: { challengeId: 'UUID', code: '6 digits', twoFactorCode: '6 digits if enrolled' }, description: 'Notify platform administrators about the verified expired account; does not grant a session' }, { method: 'POST', path: '/oauth/lifecycle-status/start', notes: accessStatusNotes, auth: 'registered public client_id and exact redirect_uri; rate limited', query: { client_id: 'registered client', redirect_uri: 'exact registered callback' }, body: { email: 'valid email' }, description: 'Begin read-only mailbox proof for native/public OAuth account reasons; same neutral delivery behavior as website proof' }, @@ -24,7 +33,8 @@ export const lifecycleEndpoints: EndpointSchema[] = [ export const lifecycleDocumentation = { access_status_debug: 'Disabled by default. AUTH_ACCESS_STATUS_ENABLED=true on the auth server enables the login diagnostic and all four website/native proof endpoints. When false or unset, every start/verify returns 404 before config/client lookup, mail or proof work, including outstanding proof redemption. Product config, DEBUG_ENABLED, URL parameters and browser storage cannot enable it.', - states: ['ACTIVE', 'DISABLED', 'DELETING', 'DELETED'], + states: ['ACTIVE', 'DISABLED', 'ARCHIVED', 'DELETING', 'DELETED'], + team_archive: 'ARCHIVED is an all-products access denial with an immutable permanent-deletion hold. It is separate from executable deletion jobs. Completion waits for exact-team future billing to stop; organisation subscriptions and sibling teams remain independent.', deleted_identity: '{id,deleted:true,name:"Deleted user"}; PII null/omitted. The boolean derives from lifecycle state.', deletion_modes: 'RETAIN_REFERENCE retains a non-authenticating subject; ERASE_REFERENCE erases mutable references. Immutable financial/signing evidence can require a restricted identity stub and is explicitly listed.', product_delivery: 'Durable pull/ack, exact ClientDomain identity, exact revision, no arbitrary hooks. Missing registered participant authority or pending product acknowledgement blocks completion.', diff --git a/API/src/services/billing-credit-auto-top-up-consent.service.ts b/API/src/services/billing-credit-auto-top-up-consent.service.ts index e5b9309ee..0229effaf 100644 --- a/API/src/services/billing-credit-auto-top-up-consent.service.ts +++ b/API/src/services/billing-credit-auto-top-up-consent.service.ts @@ -7,6 +7,7 @@ import { } from '@prisma/client'; import { getAdminPrisma } from '../db/prisma.js'; +import { disabledAutoTopUpState } from './billing-credit-auto-top-up-disabled-state.js'; import { AppError } from '../utils/errors.js'; import type { BillingActorEndpoint } from './billing-actor-audience.service.js'; import type { VerifiedBillingAppKey } from './billing-app-key.service.js'; @@ -374,23 +375,7 @@ export async function disableBillingCreditAutoTopUp( }); await tx.billingCreditAccount.update({ where: { id: account.id }, - data: { - autoTopUpGeneration: { increment: 1 }, - autoTopUpState: BillingCreditAutoTopUpState.DISABLED, - autoTopUpPolicyId: null, - autoTopUpServiceId: null, - autoTopUpAppKeyId: null, - autoTopUpConsentRevisionId: null, - autoTopUpOptionId: null, - autoTopUpThresholdMicrocredits: null, - autoTopUpRefillOfferId: null, - autoTopUpMonthlyChargeCapMinor: null, - autoTopUpConsentVersion: null, - autoTopUpConsentedAt: null, - autoTopUpConsentedByUserId: null, - stripePaymentMethodId: null, - paymentMethodSummary: Prisma.DbNull, - }, + data: disabledAutoTopUpState(), }); await tx.orgAuditLog.create({ data: { diff --git a/API/src/services/billing-credit-auto-top-up-disabled-state.ts b/API/src/services/billing-credit-auto-top-up-disabled-state.ts new file mode 100644 index 000000000..dcff93f7d --- /dev/null +++ b/API/src/services/billing-credit-auto-top-up-disabled-state.ts @@ -0,0 +1,22 @@ +import { BillingCreditAutoTopUpState, Prisma } from '@prisma/client'; + +/** Clear only the current consent pointers. Immutable revisions and financial + * history remain; the caller must first persist its exact disable authority. + */ +export const disabledAutoTopUpState = () => ({ + autoTopUpGeneration: { increment: 1 }, + autoTopUpState: BillingCreditAutoTopUpState.DISABLED, + autoTopUpPolicyId: null, + autoTopUpServiceId: null, + autoTopUpAppKeyId: null, + autoTopUpConsentRevisionId: null, + autoTopUpOptionId: null, + autoTopUpThresholdMicrocredits: null, + autoTopUpRefillOfferId: null, + autoTopUpMonthlyChargeCapMinor: null, + autoTopUpConsentVersion: null, + autoTopUpConsentedAt: null, + autoTopUpConsentedByUserId: null, + stripePaymentMethodId: null, + paymentMethodSummary: Prisma.DbNull, +}) satisfies Prisma.BillingCreditAccountUncheckedUpdateManyInput; diff --git a/API/src/services/billing-credit-auto-top-up-setup.service.ts b/API/src/services/billing-credit-auto-top-up-setup.service.ts index 662dd5265..a495ecac5 100644 --- a/API/src/services/billing-credit-auto-top-up-setup.service.ts +++ b/API/src/services/billing-credit-auto-top-up-setup.service.ts @@ -1,3 +1,4 @@ +import { withLiveTeamBillingEffect } from './team-billing-effect.service.js'; import { BillingCreditAutoTopUpState, BillingCreditCheckoutStatus, @@ -352,7 +353,7 @@ export async function createBillingCreditAutoTopUpSetup( appKeyId: checkout.appKeyId, creditAccountId: checkout.creditAccountId, }); - const session = await context.stripe.checkout.sessions.create( + const session = await withLiveTeamBillingEffect(prisma, params.request, () => context.stripe.checkout.sessions.create( { mode: 'setup', ...(params.locale ? { locale: stripeBillingLocale(params.locale) } : {}), @@ -367,7 +368,9 @@ export async function createBillingCreditAutoTopUpSetup( { idempotencyKey: creditCheckoutIdempotencyKey(context.account, 'setup', checkout.id), }, - ); + ), () => prisma.billingCreditSetupCheckout.updateMany({ where: { + id: checkout.id, stripeCheckoutSessionId: null, status: 'CREATING', + }, data: { status: 'ABANDONED' } })); assertStripeObjectLivemode(session, context.account.livemode); assertCreditFundingMetadata(session.metadata, { localType: 'setup', diff --git a/API/src/services/billing-credit-top-up.service.ts b/API/src/services/billing-credit-top-up.service.ts index 3f4dd4c1d..e45dcfa87 100644 --- a/API/src/services/billing-credit-top-up.service.ts +++ b/API/src/services/billing-credit-top-up.service.ts @@ -1,3 +1,4 @@ +import { withLiveTeamBillingEffect } from './team-billing-effect.service.js'; import { BillingCreditCheckoutStatus, type PrismaClient } from '@prisma/client'; import { createHash } from 'node:crypto'; import type Stripe from 'stripe'; @@ -323,7 +324,7 @@ export async function createBillingCreditTopUpCheckout( appKeyId: checkout.appKeyId, creditAccountId: checkout.creditAccountId, }); - const session = await context.stripe.checkout.sessions.create( + const session = await withLiveTeamBillingEffect(prisma, params.request, () => context.stripe.checkout.sessions.create( { mode: 'payment', ...(params.locale ? { locale: stripeBillingLocale(params.locale) } : {}), @@ -340,7 +341,9 @@ export async function createBillingCreditTopUpCheckout( { idempotencyKey: creditCheckoutIdempotencyKey(context.account, 'top_up', checkout.id), }, - ); + ), () => prisma.billingCreditTopUpCheckout.updateMany({ where: { + id: checkout.id, stripeCheckoutSessionId: null, status: 'CREATING', + }, data: { status: 'ABANDONED' } })); assertStripeObjectLivemode(session, context.account.livemode); assertCreditFundingMetadata(session.metadata, { localType: 'top_up', diff --git a/API/src/services/billing-recurring-addon-checkout.service.ts b/API/src/services/billing-recurring-addon-checkout.service.ts index 7bcb9b0ff..5676679af 100644 --- a/API/src/services/billing-recurring-addon-checkout.service.ts +++ b/API/src/services/billing-recurring-addon-checkout.service.ts @@ -1,3 +1,4 @@ +import { withLiveTeamBillingEffect } from './team-billing-effect.service.js'; import type { BillingCustomerLocale } from '../contracts/billing-statement-v1.js'; import { stripeCheckoutLocale } from './billing-stripe-locale.js'; import { requireIdentityEmail } from './entity-lifecycle.service.js'; @@ -431,11 +432,12 @@ export async function createRecurringAddonCheckout( } } const metadata = recurringAddonMetadata(checkout, account); - const session = await stripe.checkout.sessions.create( + const prepared = { customerId: customer.stripeCustomerId, priceId: catalog.stripePriceId }; + const session = await withLiveTeamBillingEffect(prisma, params.request, () => stripe.checkout.sessions.create( { mode: 'subscription', ...stripeCheckoutLocale(checkout.checkoutLocale), - customer: customer.stripeCustomerId, + customer: prepared.customerId, client_reference_id: checkout.id, success_url: urls.success, cancel_url: urls.cancel, @@ -443,12 +445,14 @@ export async function createRecurringAddonCheckout( automatic_tax: { enabled: false }, tax_id_collection: { enabled: false }, payment_method_collection: 'always', - line_items: [{ price: catalog.stripePriceId, quantity: 1 }], + line_items: [{ price: prepared.priceId, quantity: 1 }], metadata, subscription_data: { metadata }, }, { idempotencyKey: checkoutIdempotencyKey(checkout) }, - ); + ), () => prisma.billingRecurringAddonCheckout.updateMany({ where: { + id: checkout.id, stripeCheckoutSessionId: null, status: 'CREATING', + }, data: { status: 'ABANDONED' } })); assertStripeObjectLivemode(session, account.livemode); assertRecurringAddonMetadata(session.metadata, checkout, account); if ( diff --git a/API/src/services/billing-stripe-canceled-renewal.service.ts b/API/src/services/billing-stripe-canceled-renewal.service.ts index 870e37fa6..0b71988cb 100644 --- a/API/src/services/billing-stripe-canceled-renewal.service.ts +++ b/API/src/services/billing-stripe-canceled-renewal.service.ts @@ -7,6 +7,7 @@ import { verifyStripePaymentInvoiceLines } from './billing-stripe-payment-lines. import { quoteUnexportedClosedPeriodLiability } from './billing-stripe-invoice-close-quote.service.js'; import { stripeExternalId } from './billing-stripe-webhook-utils.service.js'; +import { assertSubscriptionNotArchived, withUnarchivedSubscriptionEffect } from './team-billing-effect.service.js'; /** Cancellation disables automatic collection on existing invoices too. * Resume the same verified earned obligation; never create another seat fee. */ @@ -14,6 +15,7 @@ export async function resumeCanceledStripeRenewal(params: { sourceId: string; subscriptionId: string; }, deps: { prisma: PrismaClient; stripe: Pick; quoteUsage?: typeof quoteUnexportedClosedPeriodLiability }) { + await assertSubscriptionNotArchived(deps.prisma, params.subscriptionId); const source = await deps.prisma.billingStripeMonthlyCharge.findUniqueOrThrow({ where: { id: params.sourceId } }); const subscription = await deps.prisma.billingStripeSubscription.findUniqueOrThrow({ @@ -52,9 +54,9 @@ export async function resumeCanceledStripeRenewal(params: { if (proof.currency !== source.currency || proof.amountMicroMinor !== 0n) { throw new AppError('INTERNAL', 409, 'STRIPE_CANCELED_RENEWAL_USAGE_UNRESOLVED'); } - const resumed = invoice.status === 'draft' ? - await deps.stripe.invoices.finalizeInvoice(invoice.id, { auto_advance: true }) : - await deps.stripe.invoices.update(invoice.id, { auto_advance: true }); + const resumed = await withUnarchivedSubscriptionEffect(deps.prisma, subscription.id, () => + invoice.status === 'draft' ? deps.stripe.invoices.finalizeInvoice(invoice.id, { auto_advance: true }) : + deps.stripe.invoices.update(invoice.id, { auto_advance: true })); assertStripeObjectLivemode(resumed, subscription.livemode); if (resumed.id !== invoice.id || resumed.auto_advance !== true || stripeExternalId(resumed.customer) !== subscription.customer.stripeCustomerId || diff --git a/API/src/services/billing-stripe-checkout.service.ts b/API/src/services/billing-stripe-checkout.service.ts index 2d2be5667..4260fced0 100644 --- a/API/src/services/billing-stripe-checkout.service.ts +++ b/API/src/services/billing-stripe-checkout.service.ts @@ -1,3 +1,4 @@ +import { withLiveTeamBillingEffect } from './team-billing-effect.service.js'; import type { BillingCustomerLocale } from '../contracts/billing-statement-v1.js'; import { stripeCheckoutLocale } from './billing-stripe-locale.js'; import { requireIdentityEmail } from './entity-lifecycle.service.js'; @@ -398,11 +399,13 @@ export async function createStripeCheckoutSession( throw new AppError('INTERNAL', 500, 'STRIPE_CATALOG_INCOMPLETE'); } - const session = await stripe.checkout.sessions.create( + const prepared = { customerId: customer.stripeCustomerId, + usagePriceId: catalog.stripeUsagePriceId, tariff: details.tariff }; + const session = await withLiveTeamBillingEffect(prisma, params.request, () => stripe.checkout.sessions.create( { mode: 'subscription', ...stripeCheckoutLocale(checkout.checkoutLocale), - customer: customer.stripeCustomerId, + customer: prepared.customerId, client_reference_id: checkout.id, success_url: successUrl, cancel_url: cancelUrl, @@ -410,11 +413,11 @@ export async function createStripeCheckoutSession( billing_address_collection: 'required', payment_method_collection: 'always', line_items: [ - ...(details.tariff.monthlyChargeBasis !== BillingMonthlyChargeBasis.PER_SEAT && + ...(prepared.tariff.monthlyChargeBasis !== BillingMonthlyChargeBasis.PER_SEAT && tariffPrice.stripeMonthlyPriceId ? [{ price: tariffPrice.stripeMonthlyPriceId, quantity: 1 }] : []), - { price: catalog.stripeUsagePriceId }, + { price: prepared.usagePriceId }, ], metadata: { uoa_checkout_id: checkout.id }, subscription_data: { @@ -423,7 +426,7 @@ export async function createStripeCheckoutSession( metadata: { uoa_checkout_id: checkout.id, uoa_service_id: params.credential.service.id, - uoa_tariff_id: details.tariff.id, + uoa_tariff_id: prepared.tariff.id, uoa_scope_key: selectedScope.scopeKey, uoa_stripe_account_id: account.stripeAccountId, uoa_stripe_mode: account.livemode ? 'live' : 'test', @@ -432,7 +435,9 @@ export async function createStripeCheckoutSession( }, }, { idempotencyKey: checkoutIdempotencyKey(account, checkout.id) }, - ); + ), () => prisma.billingStripeCheckoutSession.updateMany({ where: { + id: checkout.id, stripeCheckoutSessionId: null, status: 'creating', + }, data: { status: 'expired' } })); assertStripeObjectLivemode(session, account.livemode); if ( session.client_reference_id !== checkout.id || diff --git a/API/src/services/billing-stripe-closing-seat-invoice.service.ts b/API/src/services/billing-stripe-closing-seat-invoice.service.ts index 4ec75c66a..e1832aa5a 100644 --- a/API/src/services/billing-stripe-closing-seat-invoice.service.ts +++ b/API/src/services/billing-stripe-closing-seat-invoice.service.ts @@ -12,6 +12,7 @@ import { collectStripeMonthlyCharge } from './billing-stripe-monthly-charge.serv import { freezeStripeMonthlyChargeSource } from './billing-stripe-monthly-charge-source.service.js'; import { stripeExternalId } from './billing-stripe-webhook-utils.service.js'; import { resumeCanceledStripeRenewal } from './billing-stripe-canceled-renewal.service.js'; +import { assertSubscriptionNotArchived, withUnarchivedSubscriptionEffect } from './team-billing-effect.service.js'; import type { quoteUnexportedClosedPeriodLiability } from './billing-stripe-invoice-close-quote.service.js'; @@ -80,6 +81,7 @@ export async function collectStripeClosingSeatInvoice(params: { }); if (!row || row.status !== 'canceled' || row.tariff.monthlyChargeBasis !== 'PER_SEAT' || !row.billableUntil || !row.billableFrom) return null; + await assertSubscriptionNotArchived(deps.prisma, row.id); const lastMonth = new Date(row.billableUntil.getTime() - 1).toISOString().slice(0, 7); if (lastMonth !== params.billingMonth) return null; const start = new Date(`${lastMonth}-01T00:00:00.000Z`); @@ -170,7 +172,9 @@ export async function collectStripeClosingSeatInvoice(params: { const accepted = await deps.prisma.billingStripeMonthlyCharge.findUniqueOrThrow({ where: { id: source.id } }); await verifyClosingDraftLines(stripe, invoice, source, accepted.stripeInvoiceItemId); await assertLease(); - const finalized = await stripe.invoices.finalizeInvoice(invoice.id, { auto_advance: true }); + const invoiceId = invoice.id; + const finalized = await withUnarchivedSubscriptionEffect(deps.prisma, row.id, + () => stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: true })); verifyClosingInvoice(finalized, source, row.customer.stripeCustomerId, row.livemode); if (finalized.id !== invoice.id || finalized.status === 'draft') hold('STRIPE_CLOSING_INVOICE_FINALIZATION_PENDING'); } else if (source.state !== 'ACCEPTED') hold('STRIPE_CLOSING_INVOICE_ITEM_UNPROVEN'); diff --git a/API/src/services/billing-stripe-correction-invoice.service.ts b/API/src/services/billing-stripe-correction-invoice.service.ts index ed4e5a97a..3c0e0faf3 100644 --- a/API/src/services/billing-stripe-correction-invoice.service.ts +++ b/API/src/services/billing-stripe-correction-invoice.service.ts @@ -12,6 +12,7 @@ import { compensateFinalizedStripeInvoice } from './billing-stripe-invoice-close-resolution.service.js'; import type { StripeInvoiceCashClient } from './billing-stripe-payment-evidence.service.js'; import { stripeExternalId } from './billing-stripe-webhook-utils.service.js'; +import { assertSubscriptionNotArchived, withUnarchivedSubscriptionEffect } from './team-billing-effect.service.js'; type Client = StripeInvoiceCashClient & Pick; @@ -37,9 +38,10 @@ export async function collectStripeCorrectionInvoice(params: { closeId: string; amountMicroMinor: bigint; cursor: string; }, deps: { prisma: PrismaClient; stripe: Client }) { const { prisma, stripe } = deps; - const source = await freezeStripeCorrectionSource(params, prisma, stripe); const close = await prisma.billingStripeInvoiceClose.findUniqueOrThrow({ - where: { id: source.closeId }, include: { subscription: true } }); + where: { id: params.closeId }, include: { subscription: true } }); + await assertSubscriptionNotArchived(prisma, close.subscriptionId); + const source = await freezeStripeCorrectionSource(params, prisma, stripe); const account = await prisma.billingStripeAccount.findUniqueOrThrow({ where: { id: close.accountId } }); if ((await stripe.accounts.retrieveCurrent()).id !== account.stripeAccountId || @@ -149,7 +151,9 @@ export async function collectStripeCorrectionInvoice(params: { await lines(invoice, itemId); if (invoice.status === 'draft') { await assertLease(); - invoice = await stripe.invoices.finalizeInvoice(invoice.id, { auto_advance: false }); + const invoiceId = invoice.id; + invoice = await withUnarchivedSubscriptionEffect(prisma, close.subscriptionId, + () => stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })); verifyInvoice(invoice); if (invoice.status === 'draft') correctionHold('STRIPE_CORRECTION_FINALIZATION_PENDING'); } @@ -160,7 +164,9 @@ export async function collectStripeCorrectionInvoice(params: { observedAt: new Date(), correctionId: source.id }, { prisma, stripe }); } else if (!invoice.auto_advance) { await assertLease(); - const resumed = await stripe.invoices.update(invoice.id, { auto_advance: true }); + const invoiceId = invoice.id; + const resumed = await withUnarchivedSubscriptionEffect(prisma, close.subscriptionId, + () => stripe.invoices.update(invoiceId, { auto_advance: true })); verifyInvoice(resumed); if (!resumed.auto_advance) correctionHold('STRIPE_CORRECTION_COLLECTION_UNCONFIRMED'); } diff --git a/API/src/services/billing-stripe-invoice-close-scheduler.service.ts b/API/src/services/billing-stripe-invoice-close-scheduler.service.ts index 5e3c0edd8..7d8513400 100644 --- a/API/src/services/billing-stripe-invoice-close-scheduler.service.ts +++ b/API/src/services/billing-stripe-invoice-close-scheduler.service.ts @@ -9,6 +9,7 @@ import { quoteUnexportedClosedPeriodLiability } from './billing-stripe-invoice-c import { collectStripeCorrectionInvoice } from './billing-stripe-correction-invoice.service.js'; import type { StripeInvoiceCashClient } from './billing-stripe-payment-evidence.service.js'; import { stripeInvoiceMinor } from './billing-stripe-payment-evidence.service.js'; +import { withUnarchivedSubscriptionEffect } from './team-billing-effect.service.js'; const INTERVAL_MS = 5 * 60_000; const LEASE_MS = 10 * 60_000; @@ -68,6 +69,7 @@ export async function runStripeInvoiceCloseCycle(deps?: { where: { state: { in: ['HELD', 'READY', 'RELEASED', 'FINALIZED_HOLD', 'FINALIZED_CLEAR', 'COMPENSATED'] }, nextCheckAt: { lte: now }, + subscription: { OR: [{ teamId: null }, { team: { lifecycleStatus: { not: 'ARCHIVED' } } }] }, }, orderBy: [{ nextCheckAt: 'asc' }, { id: 'asc' }], take: 50, @@ -104,7 +106,8 @@ export async function runStripeInvoiceCloseCycle(deps?: { }); continue; } - const resumed = await stripe.invoices.update(invoice.id, { auto_advance: true }); + const resumed = await withUnarchivedSubscriptionEffect(prisma, row.subscriptionId, + () => stripe.invoices.update(invoice.id, { auto_advance: true })); assertStripeObjectLivemode(resumed, account.livemode); if (resumed.id !== invoice.id || resumed.auto_advance !== true) { throw new AppError('INTERNAL', 502, 'STRIPE_INVOICE_RESUME_NOT_CONFIRMED'); diff --git a/API/src/services/billing-stripe-scheduler.service.ts b/API/src/services/billing-stripe-scheduler.service.ts index b1df13113..6ad3a6f07 100644 --- a/API/src/services/billing-stripe-scheduler.service.ts +++ b/API/src/services/billing-stripe-scheduler.service.ts @@ -87,6 +87,7 @@ export async function runStripeUsageExportCycle(deps?: { currentPeriodStart: { not: null, lte: now }, currentPeriodEnd: { not: null, gt: now }, service: { active: true }, + OR: [{ teamId: null }, { team: { lifecycleStatus: { not: 'ARCHIVED' } } }], tariff: { collectionMode: BillingCollectionMode.STRIPE, mode: { not: BillingTariffMode.FREE }, diff --git a/API/src/services/billing-stripe-usage.service.ts b/API/src/services/billing-stripe-usage.service.ts index 5177ff4a3..7e1947efd 100644 --- a/API/src/services/billing-stripe-usage.service.ts +++ b/API/src/services/billing-stripe-usage.service.ts @@ -4,6 +4,7 @@ import type Stripe from 'stripe'; import { getAdminPrisma } from '../db/prisma.js'; import { AppError } from '../utils/errors.js'; +import { assertSubscriptionNotArchived, withUnarchivedSubscriptionEffect } from './team-billing-effect.service.js'; import { fetchLedgerMeteringUsage } from './billing-ledger-collector.service.js'; import { settleSubscriptionCreditLiability } from './billing-credit-liability.service.js'; import { assertPrepaidUsageCovered } from './billing-prepaid-coverage.service.js'; @@ -116,6 +117,7 @@ async function prepareExports( if (!subscription) { throw new AppError('NOT_FOUND', 404, 'STRIPE_SUBSCRIPTION_NOT_FOUND'); } + await assertSubscriptionNotArchived(tx, subscription.id); assertStripeUsageSubscription(subscription, { allowCanceledInvoicePeriod: Boolean(params.invoicePeriod), }); @@ -329,7 +331,7 @@ async function sendPendingExports( throw new AppError('INTERNAL', 409, 'STRIPE_METER_EVENT_DELIVERY_UNCERTAIN'); } const timestamp = stripeUsageMeterTimestamp(row.createdAt, row.billingMonth, params.now); - const event = await stripe.billing.meterEvents.create( + const event = await withUnarchivedSubscriptionEffect(prisma, row.subscriptionId, () => stripe.billing.meterEvents.create( { event_name: params.meterEventName, payload: { @@ -340,7 +342,7 @@ async function sendPendingExports( timestamp, }, { idempotencyKey: row.stripeMeterEventIdentifier }, - ); + )); assertStripeObjectLivemode(event, params.account.livemode); const accepted = await prisma.billingStripeUsageExport.updateMany({ where: { diff --git a/API/src/services/entity-deletion-preview.service.ts b/API/src/services/entity-deletion-preview.service.ts index 3f2f1ec6d..d230665ce 100644 --- a/API/src/services/entity-deletion-preview.service.ts +++ b/API/src/services/entity-deletion-preview.service.ts @@ -7,6 +7,10 @@ type Counter = { count(args: { where: Record }): Promise m.name.startsWith('Billing') && /Checkout|Subscription|AutoTopUpAttempt|CancellationIntent|OrgResponsibility/.test(m.name))) { const keys = model.fields.filter(f => f.kind === 'object' && f.type === modelName).flatMap(f => f.relationFromFields ?? []); const state = model.fields.find(f => ['status', 'state', 'active'].includes(f.name)); diff --git a/API/src/services/internal-admin-lifecycle.service.ts b/API/src/services/internal-admin-lifecycle.service.ts index c6f58a3ff..30e612cc5 100644 --- a/API/src/services/internal-admin-lifecycle.service.ts +++ b/API/src/services/internal-admin-lifecycle.service.ts @@ -76,6 +76,7 @@ export async function setEntityLifecycle(params: { await lockProductTeamPolicyExclusive(tx); const actorEmail = await requireLifecycleActor(tx, params.actor); const row = await lifecycleTarget(tx, params.scope, params.id); + if (params.scope === 'TEAM' && row.lifecycleStatus === 'ARCHIVED') throw new AppError('BAD_REQUEST', 409, 'TEAM_ARCHIVE_HELD'); if (row.lifecycleStatus === 'DELETING' || row.lifecycleStatus === 'DELETED') throw new AppError('BAD_REQUEST', 409, 'ENTITY_TERMINAL'); if (params.status === 'DISABLED' && params.scope === 'USER') await protectLastAdmin(tx, params.id); const template = params.templateId ? await tx.lifecycleReasonTemplate.findUnique({ where: { id: params.templateId } }) : null; diff --git a/API/src/services/team-archive-billing.service.ts b/API/src/services/team-archive-billing.service.ts new file mode 100644 index 000000000..041ce4790 --- /dev/null +++ b/API/src/services/team-archive-billing.service.ts @@ -0,0 +1,79 @@ +import type { PrismaClient, TeamArchiveOperation } from '@prisma/client'; +import type Stripe from 'stripe'; +import { AppError } from '../utils/errors.js'; +import { assertStripeObjectLivemode, requireStripeWebhookConfigured, + resolveStripeAccountContext } from './billing-stripe-client.service.js'; +import { observedBillingTime } from './billing-seat-observed-time.service.js'; + +export type ArchiveStripe = Pick; +const externalId = (value: string | { id: string } | null): string | null => + typeof value === 'string' ? value : value?.id ?? null; + +/** Re-read provider state on every retry. Customer ownership comes from UOA's + * exact TEAM scope, never a product-supplied Stripe identifier or metadata. + * Shared organisation customers and historical payments remain untouched. + */ +export async function stopArchivedTeamBilling(operation: TeamArchiveOperation, deps: { + prisma: PrismaClient; stripe?: ArchiveStripe; livemode?: boolean; now?: () => Date; +}): Promise { + const customers = await deps.prisma.billingStripeCustomer.findMany({ where: { + orgId: operation.orgId, teamId: operation.teamId, scope: 'TEAM', + }, include: { account: true } }); + const now = deps.now?.() ?? await observedBillingTime(deps.prisma); + const remoteCustomers = customers.filter(customer => customer.stripeCustomerId); + if (!remoteCustomers.length) return operation.settleAfter <= now; + const configured = deps.stripe ? { client: deps.stripe, livemode: deps.livemode ?? false } + : requireStripeWebhookConfigured(); + const stripe = configured.client; + const livemode = configured.livemode; + const account = await resolveStripeAccountContext(stripe, livemode, deps.prisma); + for (const customer of remoteCustomers) { + if (customer.accountId !== account.id || customer.account.livemode !== livemode || + customer.scopeKey !== `${operation.orgId}:${operation.teamId}`) { + throw new AppError('SERVICE_UNAVAILABLE', 503, 'ARCHIVE_BILLING_ACCOUNT_UNAVAILABLE'); + } + const id = customer.stripeCustomerId; + if (!id) continue; + for await (const session of stripe.checkout.sessions.list({ customer: id, status: 'open', limit: 100 })) { + assertStripeObjectLivemode(session, livemode); + if (externalId(session.customer) !== id) throw new Error('ARCHIVE_CHECKOUT_SCOPE_MISMATCH'); + try { await stripe.checkout.sessions.expire(session.id); } + catch (error) { + const current = await stripe.checkout.sessions.retrieve(session.id); + if (current.status === 'open') throw error; + } + } + for await (const subscription of stripe.subscriptions.list({ customer: id, status: 'all', limit: 100 })) { + assertStripeObjectLivemode(subscription, livemode); + if (externalId(subscription.customer) !== id) throw new Error('ARCHIVE_SUBSCRIPTION_SCOPE_MISMATCH'); + if (['canceled', 'incomplete_expired'].includes(subscription.status)) continue; + try { + const stopped = await stripe.subscriptions.cancel(subscription.id, { invoice_now: false, prorate: false }); + if (stopped.status !== 'canceled') return false; + } catch (error) { + const current = await stripe.subscriptions.retrieve(subscription.id); + if (!['canceled', 'incomplete_expired'].includes(current.status)) throw error; + } + } + for (const status of ['draft', 'open'] as const) { + for await (const invoice of stripe.invoices.list({ customer: id, status, limit: 100 })) { + assertStripeObjectLivemode(invoice, livemode); + if (externalId(invoice.customer) !== id) throw new Error('ARCHIVE_INVOICE_SCOPE_MISMATCH'); + if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false }); + } + } + for await (const payment of stripe.paymentIntents.list({ customer: id, limit: 100 })) { + assertStripeObjectLivemode(payment, livemode); + if (externalId(payment.customer) !== id) throw new Error('ARCHIVE_PAYMENT_SCOPE_MISMATCH'); + if (['succeeded', 'canceled'].includes(payment.status)) continue; + try { + const stopped = await stripe.paymentIntents.cancel(payment.id); + if (stopped.status !== 'canceled' && stopped.status !== 'succeeded') return false; + } catch (error) { + const current = await stripe.paymentIntents.retrieve(payment.id); + if (!['canceled', 'succeeded'].includes(current.status)) throw error; + } + } + } + return operation.settleAfter <= now; +} diff --git a/API/src/services/team-archive-reconciliation.service.ts b/API/src/services/team-archive-reconciliation.service.ts new file mode 100644 index 000000000..3499a1780 --- /dev/null +++ b/API/src/services/team-archive-reconciliation.service.ts @@ -0,0 +1,60 @@ +import { randomUUID } from 'node:crypto'; +import type { PrismaClient } from '@prisma/client'; +import { getAdminPrisma } from '../db/prisma.js'; +import { stopArchivedTeamBilling } from './team-archive-billing.service.js'; +import { observedBillingTime } from './billing-seat-observed-time.service.js'; + +/** A failed provider call leaves the original operation pending. Leases expire + * after process loss; retries inspect provider state rather than re-admit work. + */ +export async function reconcileTeamArchives(deps?: { + prisma?: PrismaClient; stopBilling?: typeof stopArchivedTeamBilling; now?: () => Date; operationId?: string; +}) { + const prisma = deps?.prisma ?? getAdminPrisma(); + const now = deps?.now?.() ?? await observedBillingTime(prisma); + const rows = await prisma.teamArchiveOperation.findMany({ where: { + billingStoppedAt: null, nextAttemptAt: { lte: now }, + ...(deps?.operationId ? { id: deps.operationId } : {}), + OR: [{ leaseExpiresAt: null }, { leaseExpiresAt: { lte: now } }], + }, orderBy: { nextAttemptAt: 'asc' }, take: 10 }); + let completed = 0, failed = 0; + for (const row of rows) { + const leaseOwner = randomUUID(); + const claimed = await prisma.teamArchiveOperation.updateMany({ where: { + id: row.id, billingStoppedAt: null, + OR: [{ leaseExpiresAt: null }, { leaseExpiresAt: { lte: now } }], + }, data: { leaseOwner, leaseExpiresAt: new Date(now.getTime() + 120_000) } }); + if (!claimed.count) continue; + let stopped = false; + try { stopped = await (deps?.stopBilling ?? stopArchivedTeamBilling)(row, { prisma }); } + catch { failed += 1; } + const observedAt = deps?.now?.() ?? await observedBillingTime(prisma); + const updated = await prisma.teamArchiveOperation.updateMany({ where: { id: row.id, leaseOwner }, data: { + ...(stopped ? { billingStoppedAt: observedAt } : {}), leaseOwner: null, leaseExpiresAt: null, + nextAttemptAt: new Date(Math.max(observedAt.getTime() + 30_000, row.settleAfter.getTime())), + } }); + if (stopped) completed += updated.count; + } + return { checked: rows.length, completed, failed }; +} + +export function startTeamArchiveReconciliation(params: { + log: { error(details: object, message: string): void }; + runCycle?: typeof reconcileTeamArchives; +}) { + let running = false; + const run = async () => { + if (running) return; + running = true; + try { + const result = await (params.runCycle ?? reconcileTeamArchives)(); + if (result.failed) params.log.error(result, 'Team billing stop remains pending; reconciliation will retry'); + } + catch { params.log.error({}, 'Team archive reconciliation failed; pending operations will retry'); } + finally { running = false; } + }; + void run(); + const timer = setInterval(() => { void run(); }, 30_000); + timer.unref(); + return { stop: () => clearInterval(timer) }; +} diff --git a/API/src/services/team-archive.service.ts b/API/src/services/team-archive.service.ts new file mode 100644 index 000000000..aba02f6d5 --- /dev/null +++ b/API/src/services/team-archive.service.ts @@ -0,0 +1,136 @@ +import { Prisma, type PrismaClient, type TeamArchiveOperation } from '@prisma/client'; +import { getAdminPrisma } from '../db/prisma.js'; +import { AppError } from '../utils/errors.js'; +import type { AccessTokenClaims } from './access-token.service.js'; +import type { ClientConfig } from './config.service.js'; +import { verifyDomainAuthToken } from './domain-secret.service.js'; +import { assertEntityAccess } from './entity-lifecycle.service.js'; +import { runEnterpriseAuthorityTransaction } from './enterprise-authority-transaction.service.js'; +import { revokeLifecycleSessions } from './internal-admin-lifecycle.service.js'; +import { lockProductTeamPolicyExclusive } from './product-team-policy-lock.service.js'; +import { lockRefreshSessionUser } from './refresh-session-lock.service.js'; +import { requireTeamCapability } from './team.service.base.js'; +import { disabledAutoTopUpState } from './billing-credit-auto-top-up-disabled-state.js'; +import { observedBillingTime } from './billing-seat-observed-time.service.js'; + +export function teamArchiveReceipt(row: TeamArchiveOperation) { + return { id: row.id, organisation_id: row.orgId, team_id: row.teamId, + status: row.billingStoppedAt ? 'ARCHIVED' as const : 'STOPPING_BILLING' as const, + access_stopped: true, billing_stopped: Boolean(row.billingStoppedAt), + deletion_held: true, permanently_deleted: false, + archived_at: row.createdAt.toISOString(), billing_stopped_at: row.billingStoppedAt?.toISOString() ?? null }; +} + +/** Wait for already admitted provider calls to settle before certifying a stop. + * The persisted claims cover response-loss recovery too; no new claim can pass + * the exclusive policy fence after the lifecycle transition commits. + */ +async function providerSettleAfter(tx: PrismaClient, teamId: string, observedAt: Date): Promise { + let deadline = observedAt.getTime(); + if (await tx.billingStripeCustomer.findFirst({ where: { teamId, scope: 'TEAM', stripeCustomerId: { not: null } }, + select: { id: true } })) deadline += 90_000; + for (const model of Prisma.dmmf.datamodel.models.filter(model => model.name.startsWith('Billing') && + model.fields.some(field => ['leaseExpiresAt', 'invoiceLeaseExpiresAt'].includes(field.name)) && + model.fields.some(field => ['teamId', 'customerId', 'subscriptionId', 'closeId'].includes(field.name)))) { + const column = ['teamId', 'customerId', 'subscriptionId', 'closeId'] + .map(name => model.fields.find(field => field.name === name)).find(Boolean); + if (!column) throw new Error('ARCHIVE_PROVIDER_SCOPE_UNAVAILABLE'); + const field = Prisma.raw(`"${column.dbName ?? column.name}"`); + const scope = column.name === 'teamId' ? Prisma.sql`${field} = ${teamId}` + : column.name === 'customerId' ? Prisma.sql`${field} IN + (SELECT id FROM billing_stripe_customers WHERE team_id=${teamId} AND scope::text='TEAM')` + : column.name === 'subscriptionId' ? Prisma.sql`${field} IN + (SELECT id FROM billing_stripe_subscriptions WHERE team_id=${teamId})` + : Prisma.sql`${field} IN (SELECT c.id FROM billing_stripe_invoice_closes c + JOIN billing_stripe_subscriptions s ON s.id=c.subscription_id WHERE s.team_id=${teamId})`; + for (const lease of model.fields.filter(field => ['leaseExpiresAt', 'invoiceLeaseExpiresAt'].includes(field.name))) { + const rows = await tx.$queryRaw>(Prisma.sql` + SELECT max(${Prisma.raw(`"${lease.dbName}"`)}) AS deadline + FROM ${Prisma.raw(`"${model.dbName}"`)} WHERE ${scope}`); + if (rows[0]?.deadline) deadline = Math.max(deadline, rows[0].deadline.getTime() + 90_000); + } + } + const attempt = await tx.billingCreditAutoTopUpAttempt.findFirst({ where: { + creditAccount: { teamId }, status: { in: ['PENDING', 'PROCESSING', 'REQUIRES_ACTION', 'NEEDS_REVIEW'] }, + }, orderBy: { createdAt: 'desc' }, select: { createdAt: true } }); + if (attempt) deadline = Math.max(deadline, attempt.createdAt.getTime() + 180_000); + return new Date(deadline); +} + +export async function beginTeamArchive(params: { + id: string; orgId: string; teamId: string; claims: AccessTokenClaims; + config: ClientConfig; domain: string; domainToken: string; +}, deps?: { prisma?: PrismaClient }) { + const prisma = deps?.prisma ?? getAdminPrisma(); + return runEnterpriseAuthorityTransaction(prisma, async tx => { + await lockProductTeamPolicyExclusive(tx); + const authority = await verifyDomainAuthToken({ domain: params.domain, token: params.domainToken }, { prisma: tx }); + const previous = await tx.teamArchiveOperation.findUnique({ where: { id: params.id } }); + if (previous) { + if (previous.teamId !== params.teamId || previous.orgId !== params.orgId || + previous.actorUserId !== params.claims.userId || previous.sourceClientDomainId !== authority.clientDomainId) { + throw new AppError('BAD_REQUEST', 409, 'ARCHIVE_RETRY_MISMATCH'); + } + return teamArchiveReceipt(previous); + } + if (params.claims.domain !== params.domain || params.claims.org?.org_id !== params.orgId || + params.claims.active?.teamId !== params.teamId || params.claims.active.orgId !== params.orgId) { + throw new AppError('FORBIDDEN', 403); + } + const members = await tx.teamMember.findMany({ where: { teamId: params.teamId }, select: { userId: true } }); + for (const userId of [...new Set([params.claims.userId, ...members.map(member => member.userId)])].sort()) { + await lockRefreshSessionUser(userId, { prisma: tx }); + } + await tx.$queryRaw`SELECT id FROM organisations WHERE id=${params.orgId} FOR UPDATE`; + await tx.$queryRaw`SELECT id FROM teams WHERE id=${params.teamId} AND org_id=${params.orgId} FOR UPDATE`; + const user = await tx.user.findUnique({ where: { id: params.claims.userId }, select: { tokenVersion: true } }); + if (!user || user.tokenVersion !== params.claims.tokenVersion) throw new AppError('UNAUTHORIZED', 401); + if (params.claims.assuranceId && !await tx.sessionAssurance.findFirst({ where: { + id: params.claims.assuranceId, userId: params.claims.userId, clientDomain: params.domain, + orgId: params.orgId, teamId: params.teamId, credentialEpoch: params.claims.tokenVersion, + revokedAt: null, expiresAt: { gt: new Date() }, + } })) throw new AppError('UNAUTHORIZED', 401); + await assertEntityAccess({ userId: params.claims.userId, orgId: params.orgId, teamId: params.teamId }, tx); + await requireTeamCapability(tx, 'teams.manage', { + orgId: params.orgId, actorUserId: params.claims.userId, config: params.config, + }); + if (!await tx.teamMember.findFirst({ where: { teamId: params.teamId, + userId: params.claims.userId, status: 'ACTIVE' } })) throw new AppError('FORBIDDEN', 403); + const now = await observedBillingTime(tx); + const operation = await tx.teamArchiveOperation.create({ data: { + id: params.id, orgId: params.orgId, teamId: params.teamId, + sourceClientDomainId: authority.clientDomainId, actorUserId: params.claims.userId, + createdAt: now, + settleAfter: await providerSettleAfter(tx, params.teamId, now), + } }); + await tx.team.update({ where: { id: params.teamId }, data: { + lifecycleStatus: 'ARCHIVED', lifecycleChangedAt: now, + lifecycleReason: 'Team archived by an organisation owner or admin.', + } }); + await revokeLifecycleSessions(tx, 'TEAM', params.teamId); + await tx.billingCreditSetupCheckout.updateMany({ where: { + creditAccount: { teamId: params.teamId, scope: 'TEAM' }, status: { in: ['CREATING', 'OPEN', 'NEEDS_REVIEW'] }, + }, data: { status: 'ABANDONED' } }); + await tx.billingCreditAccount.updateMany({ where: { + teamId: params.teamId, scope: 'TEAM', autoTopUpState: { not: 'DISABLED' }, + }, data: disabledAutoTopUpState() }); + const seats = await tx.billingSeatSubscription.findMany({ where: { teamId: params.teamId, endedAt: null }, + select: { id: true, commercialEndsAt: true, commercialEffectiveAt: true } }); + for (const seat of seats) await tx.billingSeatSubscription.update({ where: { id: seat.id }, data: { + endedAt: now, + ...(!seat.commercialEndsAt && now > seat.commercialEffectiveAt ? { commercialEndsAt: now } : {}), + } }); + // Canonical roster triggers close membership evidence at the observed end. + await tx.orgAuditLog.create({ data: { orgId: params.orgId, actorUserId: params.claims.userId, + action: 'team.archived_deletion_held', targetType: 'team', targetId: params.teamId, + metadata: { operationId: operation.id, scope: 'all_products', deletionHeld: true } } }); + return teamArchiveReceipt(operation); + }); +} + +/** Recovery grants no membership or content access and is bound to the initiating product. */ +export async function readTeamArchive(id: string, clientDomainId: string, prisma = getAdminPrisma()) { + const row = await prisma.teamArchiveOperation.findFirst({ where: { id, sourceClientDomainId: clientDomainId } }); + if (!row) throw new AppError('NOT_FOUND', 404); + return teamArchiveReceipt(row); +} diff --git a/API/src/services/team-billing-effect.service.ts b/API/src/services/team-billing-effect.service.ts new file mode 100644 index 000000000..1d87e2a7b --- /dev/null +++ b/API/src/services/team-billing-effect.service.ts @@ -0,0 +1,55 @@ +import type { PrismaClient } from '@prisma/client'; +import { assertEntityAccess } from './entity-lifecycle.service.js'; +import { lockProductTeamPolicyShared } from './product-team-policy-lock.service.js'; +import { AppError } from '../utils/errors.js'; + +/** Hold lifecycle admission through a provider create. The callback performs + * only the provider request, using already prepared immutable request data. + * This prevents a checkout admitted earlier from appearing after an archive's + * final provider scan. No database pool is checked out inside the callback. + */ +export async function withLiveTeamBillingEffect(prisma: PrismaClient, + scope: { organisationId: string; teamId: string }, effect: () => Promise, + onRefused?: () => Promise): Promise { + try { + return await prisma.$transaction(async tx => { + await lockProductTeamPolicyShared(tx); + try { await assertEntityAccess({ orgId: scope.organisationId, teamId: scope.teamId }, tx); } + catch (error) { + if (error instanceof AppError && error.statusCode === 403) throw new AppError('FORBIDDEN', 403, 'TEAM_BILLING_RETIRED'); + throw error; + } + return effect(); + }, { timeout: 90_000 }); + } catch (error) { + if (error instanceof AppError && error.message === 'TEAM_BILLING_RETIRED') await onRefused?.(); + throw error; + } +} + +/** Scheduled collection may settle a suspended team's earned liabilities. + * An archive explicitly stops billing, so it must also fence the jobs that + * normally resume collection after an ordinary subscription cancellation. + */ +export async function withUnarchivedSubscriptionEffect(prisma: PrismaClient, + subscriptionId: string, effect: () => Promise): Promise { + return prisma.$transaction(async tx => { + await lockProductTeamPolicyShared(tx); + await assertSubscriptionNotArchived(tx, subscriptionId); + return effect(); + }, { timeout: 90_000 }); +} + +export async function assertSubscriptionNotArchived( + prisma: Pick, subscriptionId: string, +): Promise { + const subscription = await prisma.billingStripeSubscription.findUniqueOrThrow({ + where: { id: subscriptionId }, select: { orgId: true, teamId: true }, + }); + if (!subscription.teamId) return; + const team = await prisma.team.findUniqueOrThrow({ where: { id: subscription.teamId }, + select: { orgId: true, lifecycleStatus: true } }); + if (team.orgId !== subscription.orgId || team.lifecycleStatus === 'ARCHIVED') { + throw new AppError('FORBIDDEN', 403, 'ARCHIVE_BILLING_COLLECTION_HELD'); + } +} diff --git a/API/tests/helpers/credit-auto-top-up-runtime-fixture.ts b/API/tests/helpers/credit-auto-top-up-runtime-fixture.ts new file mode 100644 index 000000000..50e8dd67f --- /dev/null +++ b/API/tests/helpers/credit-auto-top-up-runtime-fixture.ts @@ -0,0 +1,339 @@ +import { Prisma, type PrismaClient } from '@prisma/client'; +import type Stripe from 'stripe'; +import { vi } from 'vitest'; +import { disableBillingCreditAutoTopUp } from '../../src/services/billing-credit-auto-top-up-consent.service.js'; + +export const ids = { + user: 'usr_auto_top_up_runtime', + org: 'org_auto_top_up_runtime', + service: 'svc_auto_top_up_runtime', + appKey: 'bak_auto_top_up_runtime', + otherService: 'svc_auto_top_up_other', + otherAppKey: 'bak_auto_top_up_other', + rotatedAppKey: 'bak_auto_top_up_rotated', + nonManagerUser: 'usr_auto_top_up_non_manager', + account: 'bsa_auto_top_up_runtime', + policy: 'bcfp_auto_top_up_runtime', + offer: 'bcto_auto_top_up_runtime', + option: 'bcat_auto_top_up_runtime', + catalog: 'bctc_auto_top_up_runtime', +} as const; + +export const stripeAccount = { + id: ids.account, + stripeAccountId: 'acct_auto_top_up_runtime', + livemode: false, +}; + +export function scopedIds(suffix: string) { + return { + team: `team_auto_top_up_${suffix}`, + teamMember: `tm_auto_top_up_${suffix}`, + customer: `bsc_auto_top_up_${suffix}`, + creditAccount: `bca_auto_top_up_${suffix}`, + revision: `bcar_auto_top_up_${suffix}`, + }; +} + +export const concurrency = scopedIds('concurrency'); +export const recovery = scopedIds('recovery'); +export const embeddedError = scopedIds('embedded'); +export const aboveThreshold = scopedIds('above'); +export const disabled = scopedIds('disabled'); +export const pendingDisabled = scopedIds('pending-disabled'); + +export async function seedCreditAccount( + tx: Prisma.TransactionClient, + suffix: string, + balanceMicrocredits: bigint, + active: boolean, +): Promise { + const row = scopedIds(suffix); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "teams" ("id", "org_id", "name", "slug", "updated_at") + VALUES ( + ${row.team}, ${ids.org}, ${`Auto Top Up ${suffix}`}, ${`auto-top-up-${suffix}`}, + CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "team_members" ( + "id", "team_id", "user_id", "team_role", "status", "updated_at" + ) VALUES ( + ${row.teamMember}, ${row.team}, ${ids.user}, 'owner', 'ACTIVE', CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_stripe_customers" ( + "id", "account_id", "org_id", "team_id", "scope", "scope_key", + "stripe_customer_id", "updated_at" + ) VALUES ( + ${row.customer}, ${ids.account}, ${ids.org}, ${row.team}, 'TEAM', + ${`${ids.org}:${row.team}`}, ${`cus_auto_top_up_${suffix}`}, CURRENT_TIMESTAMP + ) + `); + if (!active) { + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_accounts" ( + "id", "account_id", "customer_id", "org_id", "team_id", "scope", "scope_key", "currency", + "balance_microcredits", "updated_at" + ) VALUES ( + ${row.creditAccount}, ${ids.account}, ${row.customer}, ${ids.org}, ${row.team}, + 'TEAM', ${`${ids.org}:${row.team}`}, 'USD', ${balanceMicrocredits}, CURRENT_TIMESTAMP + ) + `); + return; + } + const consentedAt = new Date('2026-07-21T12:00:00.000Z'); + const paymentMethodSummary = JSON.stringify({ type: 'card', brand: 'visa', last4: '4242' }); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_auto_top_up_consent_revisions" ( + "id", "account_id", "credit_account_id", "org_id", "team_id", "service_id", + "app_key_id", "policy_id", "option_id", "refill_offer_id", "source", "actor_jti", + "consented_by_user_id", "consent_version", "threshold_microcredits", + "refill_credits_microcredits", "refill_payment_amount_minor", + "monthly_charge_cap_minor", "stripe_payment_method_id", "payment_method_summary", + "consented_at" + ) VALUES ( + ${row.revision}, ${ids.account}, ${row.creditAccount}, ${ids.org}, ${row.team}, + ${ids.service}, ${ids.appKey}, ${ids.policy}, ${ids.option}, ${ids.offer}, + 'CUSTOMER_UPDATE', ${`actor-auto-top-up-${suffix}`}, ${ids.user}, 'auto-top-up-v1', + 200000000, 5000000000, 500, 1500, ${`pm_auto_top_up_${suffix}`}, + ${paymentMethodSummary}::jsonb, ${consentedAt} + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_accounts" ( + "id", "account_id", "customer_id", "org_id", "team_id", "scope", "scope_key", "currency", + "balance_microcredits", "auto_top_up_state", "auto_top_up_policy_id", + "auto_top_up_service_id", "auto_top_up_app_key_id", "auto_top_up_consent_revision_id", + "auto_top_up_option_id", "auto_top_up_threshold_microcredits", + "auto_top_up_refill_offer_id", "auto_top_up_monthly_charge_cap_minor", + "auto_top_up_consent_version", "auto_top_up_consented_at", + "auto_top_up_consented_by_user_id", "stripe_payment_method_id", + "payment_method_summary", "updated_at" + ) VALUES ( + ${row.creditAccount}, ${ids.account}, ${row.customer}, ${ids.org}, ${row.team}, + 'TEAM', ${`${ids.org}:${row.team}`}, 'USD', + ${balanceMicrocredits}, 'ACTIVE', ${ids.policy}, ${ids.service}, ${ids.appKey}, + ${row.revision}, ${ids.option}, 200000000, ${ids.offer}, 1500, 'auto-top-up-v1', + ${consentedAt}, ${ids.user}, ${`pm_auto_top_up_${suffix}`}, + ${paymentMethodSummary}::jsonb, CURRENT_TIMESTAMP + ) + `); +} + +export async function seed(prisma: PrismaClient): Promise { + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe('SET LOCAL session_replication_role = replica'); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "users" ("id", "email", "user_key", "name") + VALUES ( + ${ids.user}, 'auto-top-up@example.com', 'auto-top-up@example.com', 'Auto Top Up Owner' + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "organisations" ( + "id", "domain", "name", "slug", "owner_id", "updated_at" + ) VALUES ( + ${ids.org}, 'auto-top-up.example.com', 'Auto Top Up Org', 'auto-top-up-org', + ${ids.user}, CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "org_members" ( + "id", "org_id", "user_id", "role", "status", "updated_at" + ) VALUES ( + 'om_auto_top_up_runtime', ${ids.org}, ${ids.user}, 'owner', 'ACTIVE', CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_services" ("id", "identifier", "name", "updated_at") + VALUES (${ids.service}, 'auto-top-up-test', 'Auto Top Up Test', CURRENT_TIMESTAMP) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_services" ("id", "identifier", "name", "updated_at") + VALUES (${ids.otherService}, 'auto-top-up-other-test', 'Other Top Up Test', CURRENT_TIMESTAMP) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_app_keys" ( + "id", "service_id", "purpose", "name", "key_prefix", "secret_digest", + "actor_issuer", "actor_audience", "actor_key_id", "actor_public_jwk", + "checkout_return_origins", "updated_at" + ) VALUES ( + ${ids.appKey}, ${ids.service}, 'CUSTOMER_LIFECYCLE', 'Auto top-up runtime', + 'uoa_auto_test', ${'a'.repeat(64)}, 'https://auto-top-up.example.com', + 'https://uoa.example.com', 'auto-top-up-key', + ${JSON.stringify({ kty: 'RSA' })}::jsonb, + ARRAY['https://auto-top-up.example.com'], CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_app_keys" ( + "id", "service_id", "purpose", "name", "key_prefix", "secret_digest", + "actor_issuer", "actor_audience", "actor_key_id", "actor_public_jwk", + "checkout_return_origins", "updated_at" + ) VALUES + ( + ${ids.otherAppKey}, ${ids.otherService}, 'CUSTOMER_LIFECYCLE', 'Other top-up service', + 'uoa_auto_other', ${'b'.repeat(64)}, 'https://other-auto-top-up.example.com', + 'https://uoa.example.com', 'other-auto-top-up-key', + ${JSON.stringify({ kty: 'RSA' })}::jsonb, + ARRAY['https://other-auto-top-up.example.com'], CURRENT_TIMESTAMP + ), + ( + ${ids.rotatedAppKey}, ${ids.service}, 'CUSTOMER_LIFECYCLE', 'Rotated top-up key', + 'uoa_auto_rotated', ${'c'.repeat(64)}, 'https://auto-top-up.example.com', + 'https://uoa.example.com', 'rotated-auto-top-up-key', + ${JSON.stringify({ kty: 'RSA' })}::jsonb, + ARRAY['https://auto-top-up.example.com'], CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_stripe_accounts" ( + "id", "stripe_account_id", "livemode", "updated_at" + ) VALUES (${ids.account}, ${stripeAccount.stripeAccountId}, false, CURRENT_TIMESTAMP) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_funding_policies" ( + "id", "service_id", "currency", "version", "top_up_enabled", + "automatic_top_up_enabled", "automatic_consent_version", "active", "updated_at" + ) VALUES ( + ${ids.policy}, ${ids.service}, 'USD', 1, true, true, 'auto-top-up-v1', true, + CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_top_up_offers" ( + "id", "policy_id", "service_id", "key", "version", "catalog_key", + "catalog_version", "name", "description", "payment_amount_minor", + "credits_received_microcredits", "automatic_top_up_eligible", "active", "updated_at" + ) VALUES ( + ${ids.offer}, ${ids.policy}, ${ids.service}, 'five-dollar-refill', 1, + 'credits-five-dollar', 1, 'Five dollar refill', 'Five thousand credits', + 500, 5000000000, true, true, CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_auto_top_up_options" ( + "id", "policy_id", "service_id", "refill_offer_id", "key", "version", + "threshold_microcredits", "monthly_charge_cap_minor", "active", "updated_at" + ) VALUES ( + ${ids.option}, ${ids.policy}, ${ids.service}, ${ids.offer}, 'low-balance-refill', 1, + 200000000, 1500, true, CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "billing_credit_top_up_catalogs" ( + "id", "account_id", "key", "version", "currency", "payment_amount_minor", + "credits_received_microcredits", "stripe_lookup_key", "stripe_product_id", + "stripe_price_id", "updated_at" + ) VALUES ( + ${ids.catalog}, ${ids.account}, 'credits-five-dollar', 1, 'USD', 500, + 5000000000, 'uoa_credits_five_dollar_v1', 'prod_auto_top_up', + 'price_auto_top_up', CURRENT_TIMESTAMP + ) + `); + await seedCreditAccount(tx, 'concurrency', 100_000_000n, true); + await seedCreditAccount(tx, 'recovery', 100_000_000n, true); + await seedCreditAccount(tx, 'embedded', 100_000_000n, true); + await seedCreditAccount(tx, 'above', 300_000_000n, true); + await seedCreditAccount(tx, 'disabled', 100_000_000n, false); + await seedCreditAccount(tx, 'pending-disabled', 100_000_000n, true); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "users" ("id", "email", "user_key", "name") + VALUES ( + ${ids.nonManagerUser}, 'auto-top-up-member@example.com', + 'auto-top-up-member@example.com', 'Auto Top Up Member' + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "org_members" ( + "id", "org_id", "user_id", "role", "status", "updated_at" + ) VALUES ( + 'om_auto_top_up_non_manager', ${ids.org}, ${ids.nonManagerUser}, 'member', + 'ACTIVE', CURRENT_TIMESTAMP + ) + `); + await tx.$executeRaw(Prisma.sql` + INSERT INTO "team_members" ( + "id", "team_id", "user_id", "team_role", "status", "updated_at" + ) VALUES ( + 'tm_auto_top_up_non_manager', ${embeddedError.team}, ${ids.nonManagerUser}, + 'member', 'ACTIVE', CURRENT_TIMESTAMP + ) + `); + }); +} + +export function paymentIntent(attemptId: string, suffix: string): Stripe.PaymentIntent { + return { + id: `pi_auto_top_up_${suffix}`, + object: 'payment_intent', + amount: 500, + currency: 'usd', + customer: `cus_auto_top_up_${suffix}`, + payment_method: `pm_auto_top_up_${suffix}`, + metadata: { + uoa_credit_auto_top_up_attempt_id: attemptId, + uoa_service_id: ids.service, + uoa_app_key_id: ids.appKey, + uoa_credit_account_id: scopedIds(suffix).creditAccount, + }, + livemode: false, + status: 'processing', + } as Stripe.PaymentIntent; +} + +export async function disableCreditAccountWithAuditEvidence( + prisma: PrismaClient, + creditAccountId: string, + actorJti: string, + requestTeamId: string, + authorityScope: 'TEAM' | 'ORGANISATION' = 'TEAM', + actor = { serviceId: ids.service, appKeyId: ids.appKey, product: 'auto-top-up-test' }, + userId = ids.user, +): Promise { + const original = await prisma.billingCreditAccount.findUniqueOrThrow({ + where: { id: creditAccountId }, + }); + const request = { + product: actor.product, + organisationId: ids.org, + teamId: requestTeamId, + userId, + }; + await disableBillingCreditAutoTopUp( + { + request, + actorToken: 'synthetic-manager-token', + credential: { id: actor.appKeyId, service: { id: actor.serviceId } } as never, + endpoint: '/billing/v1/credits/auto-top-up/disable' as never, + }, + { + prisma, + resolveContext: vi.fn().mockResolvedValue({ + actor: { jti: actorJti, tv: 0, exp: 1_893_456_000 }, + account: stripeAccount, + creditAccount: original, + authorizeAction: async (tx: Prisma.TransactionClient) => + tx.billingCustomerActionIntent.create({ + data: { + appKeyId: actor.appKeyId, + serviceId: actor.serviceId, + orgId: ids.org, + teamId: requestTeamId, + requestedByUserId: userId, + authorityScope, + operation: 'credit_auto_top_up_disable', + actorJti, + actorTokenVersion: 0, + actorExpiresAt: new Date('2030-01-01T00:00:00.000Z'), + requestDigest: 'd'.repeat(64), + }, + }), + }) as never, + }, + ); +} + diff --git a/API/tests/integration/billing-credit-auto-top-up-disable.persistence.test.ts b/API/tests/integration/billing-credit-auto-top-up-disable.persistence.test.ts new file mode 100644 index 000000000..3ba95bc20 --- /dev/null +++ b/API/tests/integration/billing-credit-auto-top-up-disable.persistence.test.ts @@ -0,0 +1,227 @@ +import { BillingAssignmentScope, BillingCreditAutoTopUpConsentSource } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runCreditAutoTopUpAccount } from '../../src/services/billing-credit-auto-top-up-runtime.service.js'; +import { createTestDb } from '../helpers/test-db.js'; +import { ids, stripeAccount, concurrency, embeddedError, seed, paymentIntent, disableCreditAccountWithAuditEvidence } from '../helpers/credit-auto-top-up-runtime-fixture.js'; +const databaseTestsEnabled = process.env.BILLING_FUNDING_DATABASE_TESTS === 'true' && Boolean(process.env.DATABASE_URL); + +describe.skipIf(!databaseTestsEnabled)('credit automatic top-up PostgreSQL disable authority', () => { + let handle: Awaited>; + + beforeAll(async () => { + handle = await createTestDb(); + if (!handle) throw new Error('DATABASE_URL is required for DB-backed tests'); + await seed(handle.prisma); + await runCreditAutoTopUpAccount({ account: stripeAccount, creditAccountId: embeddedError.creditAccount }, { prisma: handle.prisma, stripe: { paymentIntents: { create: async () => { const attempt = await handle!.prisma.billingCreditAutoTopUpAttempt.findFirstOrThrow({ where: { creditAccountId: embeddedError.creditAccount } }); return paymentIntent(attempt.id, 'embedded'); } } } as never }); + }); + + afterAll(async () => { + if (handle) await handle.cleanup(); + }); + + it('requires current manager authority and allows an audited disable with a rotated key', async () => { + const attempt = await handle!.prisma.billingCreditAutoTopUpAttempt.findFirstOrThrow({ + where: { creditAccountId: embeddedError.creditAccount }, + }); + expect(attempt.stripePaymentIntentId).toBe('pi_auto_top_up_embedded'); + + await expect( + handle!.prisma.billingCreditAccount.update({ + where: { id: embeddedError.creditAccount }, + data: { autoTopUpThresholdMicrocredits: 150_000_000n }, + }), + ).rejects.toBeDefined(); + + await expect( + disableCreditAccountWithAuditEvidence( + handle!.prisma, + embeddedError.creditAccount, + 'actor_auto_top_up_non_manager_disable', + embeddedError.team, + 'TEAM', + undefined, + ids.nonManagerUser, + ), + ).rejects.toBeDefined(); + await expect( + handle!.prisma.billingCreditAccount.findUniqueOrThrow({ + where: { id: embeddedError.creditAccount }, + }), + ).resolves.toMatchObject({ autoTopUpState: 'ACTIVE' }); + + await disableCreditAccountWithAuditEvidence( + handle!.prisma, + embeddedError.creditAccount, + 'actor_auto_top_up_embedded_disable', + embeddedError.team, + 'TEAM', + { serviceId: ids.service, appKeyId: ids.rotatedAppKey, product: 'auto-top-up-test' }, + ); + + const [creditAccount, savedAttempt, disableEvent] = await Promise.all([ + handle!.prisma.billingCreditAccount.findUniqueOrThrow({ + where: { id: embeddedError.creditAccount }, + }), + handle!.prisma.billingCreditAutoTopUpAttempt.findUniqueOrThrow({ where: { id: attempt.id } }), + handle!.prisma.billingCreditAutoTopUpDisableEvent.findUniqueOrThrow({ + where: { + appKeyId_actorJti: { + appKeyId: ids.rotatedAppKey, + actorJti: 'actor_auto_top_up_embedded_disable', + }, + }, + }), + ]); + expect(creditAccount.autoTopUpState).toBe('DISABLED'); + expect(disableEvent).toMatchObject({ + serviceId: ids.service, + appKeyId: ids.rotatedAppKey, + teamId: embeddedError.team, + previousConsentRevisionId: attempt.consentRevisionId, + previousGeneration: 0, + }); + expect(savedAttempt).toMatchObject({ + consentRevisionId: attempt.consentRevisionId, + stripePaymentIntentId: 'pi_auto_top_up_embedded', + }); + }, 20_000); + + it('matches the audited disable to a NULL team for organization-scoped credits', async () => { + const customerId = 'bsc_auto_top_up_organization'; + const creditAccountId = 'bca_auto_top_up_organization'; + const revisionId = 'bcar_auto_top_up_organization'; + const attemptId = 'bcattempt_auto_top_up_organization'; + const paymentMethodSummary = { type: 'card', brand: 'visa', last4: '4242' }; + const consentedAt = new Date('2026-07-21T12:00:00.000Z'); + + await handle!.prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe('SET LOCAL session_replication_role = replica'); + await tx.billingStripeCustomer.create({ + data: { + id: customerId, + accountId: ids.account, + orgId: ids.org, + scope: BillingAssignmentScope.ORGANISATION, + scopeKey: ids.org, + stripeCustomerId: 'cus_auto_top_up_organization', + }, + }); + await tx.billingCreditAccount.create({ + data: { + id: creditAccountId, + accountId: ids.account, + customerId, + orgId: ids.org, + teamId: null, + scope: BillingAssignmentScope.ORGANISATION, + scopeKey: ids.org, + }, + }); + await tx.billingCreditAutoTopUpConsentRevision.create({ + data: { + id: revisionId, + accountId: ids.account, + creditAccountId, + orgId: ids.org, + teamId: null, + serviceId: ids.service, + appKeyId: ids.appKey, + policyId: ids.policy, + optionId: ids.option, + refillOfferId: ids.offer, + source: BillingCreditAutoTopUpConsentSource.CUSTOMER_UPDATE, + actorJti: 'actor_auto_top_up_organization_setup', + consentedByUserId: ids.user, + consentVersion: 'auto-top-up-v1', + thresholdMicrocredits: 200_000_000n, + refillCreditsMicrocredits: 5_000_000_000n, + refillPaymentAmountMinor: 500n, + monthlyChargeCapMinor: 1_500n, + stripePaymentMethodId: 'pm_auto_top_up_organization', + paymentMethodSummary, + consentedAt, + }, + }); + await tx.billingCreditAccount.update({ + where: { id: creditAccountId }, + data: { + autoTopUpGeneration: 1, + autoTopUpState: 'ACTIVE', + autoTopUpPolicyId: ids.policy, + autoTopUpServiceId: ids.service, + autoTopUpAppKeyId: ids.appKey, + autoTopUpConsentRevisionId: revisionId, + autoTopUpOptionId: ids.option, + autoTopUpThresholdMicrocredits: 200_000_000n, + autoTopUpRefillOfferId: ids.offer, + autoTopUpMonthlyChargeCapMinor: 1_500n, + autoTopUpConsentVersion: 'auto-top-up-v1', + autoTopUpConsentedAt: consentedAt, + autoTopUpConsentedByUserId: ids.user, + stripePaymentMethodId: 'pm_auto_top_up_organization', + paymentMethodSummary, + }, + }); + await tx.billingCreditAutoTopUpAttempt.create({ + data: { + id: attemptId, + accountId: ids.account, + creditAccountId, + catalogId: ids.catalog, + serviceId: ids.service, + appKeyId: ids.appKey, + attributedUserId: ids.user, + optionId: ids.option, + offerId: ids.offer, + consentRevisionId: revisionId, + consentVersion: 'auto-top-up-v1', + thresholdMicrocredits: 200_000_000n, + monthlyChargeCapMinor: 1_500n, + chargedThisMonthBeforeMinor: 0n, + observedBalanceMicrocredits: 0n, + paymentAmountMinor: 500n, + creditsReceivedMicrocredits: 5_000_000_000n, + billingMonth: '2026-10', + idempotencyKey: `uoa:auto-top-up:${attemptId}`, + }, + }); + }); + + await expect( + handle!.prisma.$transaction(async (tx) => { + await tx.billingCreditAutoTopUpDisableEvent.create({ + data: { + accountId: ids.account, + creditAccountId, + orgId: ids.org, + teamId: concurrency.team, + serviceId: ids.service, + appKeyId: ids.appKey, + previousConsentRevisionId: revisionId, + previousGeneration: 1, + actorJti: 'actor_auto_top_up_wrong_team', + requestedByUserId: ids.user, + }, + }); + }), + ).rejects.toBeDefined(); + + await disableCreditAccountWithAuditEvidence( + handle!.prisma, + creditAccountId, + 'actor_auto_top_up_organization_disable', + concurrency.team, + 'ORGANISATION', + ); + const [creditAccount, attempt] = await Promise.all([ + handle!.prisma.billingCreditAccount.findUniqueOrThrow({ where: { id: creditAccountId } }), + handle!.prisma.billingCreditAutoTopUpAttempt.findUniqueOrThrow({ where: { id: attemptId } }), + ]); + expect(creditAccount).toMatchObject({ + teamId: null, + autoTopUpGeneration: 2, + autoTopUpState: 'DISABLED', + }); + expect(attempt).toMatchObject({ status: 'PENDING', consentRevisionId: revisionId }); + }, 20_000); +}); diff --git a/API/tests/integration/billing-credit-auto-top-up-runtime.persistence.test.ts b/API/tests/integration/billing-credit-auto-top-up-runtime.persistence.test.ts index 5b99d1c61..c711cc595 100644 --- a/API/tests/integration/billing-credit-auto-top-up-runtime.persistence.test.ts +++ b/API/tests/integration/billing-credit-auto-top-up-runtime.persistence.test.ts @@ -1,361 +1,12 @@ -import { - BillingAssignmentScope, - BillingCreditAutoTopUpConsentSource, - Prisma, - type PrismaClient, -} from '@prisma/client'; +import { Prisma } from '@prisma/client'; import type Stripe from 'stripe'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; - -import { - claimCreditAutoTopUpAttempt, - listCreditAutoTopUpCandidateIds, - listCreditAutoTopUpWebhookCandidates, -} from '../../src/services/billing-credit-auto-top-up-attempt.service.js'; -import { - runCreditAutoTopUpCycle, - runCreditAutoTopUpAccount, -} from '../../src/services/billing-credit-auto-top-up-runtime.service.js'; -import { disableBillingCreditAutoTopUp } from '../../src/services/billing-credit-auto-top-up-consent.service.js'; +import { claimCreditAutoTopUpAttempt, listCreditAutoTopUpCandidateIds, listCreditAutoTopUpWebhookCandidates } from '../../src/services/billing-credit-auto-top-up-attempt.service.js'; +import { runCreditAutoTopUpCycle, runCreditAutoTopUpAccount } from '../../src/services/billing-credit-auto-top-up-runtime.service.js'; import { applyTrustedCreditFundingStripeEvent } from '../../src/services/billing-stripe-webhook-event.service.js'; import { createTestDb } from '../helpers/test-db.js'; - -const databaseTestsEnabled = - process.env.BILLING_FUNDING_DATABASE_TESTS === 'true' && Boolean(process.env.DATABASE_URL); - -const ids = { - user: 'usr_auto_top_up_runtime', - org: 'org_auto_top_up_runtime', - service: 'svc_auto_top_up_runtime', - appKey: 'bak_auto_top_up_runtime', - otherService: 'svc_auto_top_up_other', - otherAppKey: 'bak_auto_top_up_other', - rotatedAppKey: 'bak_auto_top_up_rotated', - nonManagerUser: 'usr_auto_top_up_non_manager', - account: 'bsa_auto_top_up_runtime', - policy: 'bcfp_auto_top_up_runtime', - offer: 'bcto_auto_top_up_runtime', - option: 'bcat_auto_top_up_runtime', - catalog: 'bctc_auto_top_up_runtime', -} as const; - -const stripeAccount = { - id: ids.account, - stripeAccountId: 'acct_auto_top_up_runtime', - livemode: false, -}; - -function scopedIds(suffix: string) { - return { - team: `team_auto_top_up_${suffix}`, - teamMember: `tm_auto_top_up_${suffix}`, - customer: `bsc_auto_top_up_${suffix}`, - creditAccount: `bca_auto_top_up_${suffix}`, - revision: `bcar_auto_top_up_${suffix}`, - }; -} - -const concurrency = scopedIds('concurrency'); -const recovery = scopedIds('recovery'); -const embeddedError = scopedIds('embedded'); -const aboveThreshold = scopedIds('above'); -const disabled = scopedIds('disabled'); -const pendingDisabled = scopedIds('pending-disabled'); - -async function seedCreditAccount( - tx: Prisma.TransactionClient, - suffix: string, - balanceMicrocredits: bigint, - active: boolean, -): Promise { - const row = scopedIds(suffix); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "teams" ("id", "org_id", "name", "slug", "updated_at") - VALUES ( - ${row.team}, ${ids.org}, ${`Auto Top Up ${suffix}`}, ${`auto-top-up-${suffix}`}, - CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "team_members" ( - "id", "team_id", "user_id", "team_role", "status", "updated_at" - ) VALUES ( - ${row.teamMember}, ${row.team}, ${ids.user}, 'owner', 'ACTIVE', CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_stripe_customers" ( - "id", "account_id", "org_id", "team_id", "scope", "scope_key", - "stripe_customer_id", "updated_at" - ) VALUES ( - ${row.customer}, ${ids.account}, ${ids.org}, ${row.team}, 'TEAM', - ${`${ids.org}:${row.team}`}, ${`cus_auto_top_up_${suffix}`}, CURRENT_TIMESTAMP - ) - `); - if (!active) { - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_accounts" ( - "id", "account_id", "customer_id", "org_id", "team_id", "scope", "scope_key", "currency", - "balance_microcredits", "updated_at" - ) VALUES ( - ${row.creditAccount}, ${ids.account}, ${row.customer}, ${ids.org}, ${row.team}, - 'TEAM', ${`${ids.org}:${row.team}`}, 'USD', ${balanceMicrocredits}, CURRENT_TIMESTAMP - ) - `); - return; - } - const consentedAt = new Date('2026-07-21T12:00:00.000Z'); - const paymentMethodSummary = JSON.stringify({ type: 'card', brand: 'visa', last4: '4242' }); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_auto_top_up_consent_revisions" ( - "id", "account_id", "credit_account_id", "org_id", "team_id", "service_id", - "app_key_id", "policy_id", "option_id", "refill_offer_id", "source", "actor_jti", - "consented_by_user_id", "consent_version", "threshold_microcredits", - "refill_credits_microcredits", "refill_payment_amount_minor", - "monthly_charge_cap_minor", "stripe_payment_method_id", "payment_method_summary", - "consented_at" - ) VALUES ( - ${row.revision}, ${ids.account}, ${row.creditAccount}, ${ids.org}, ${row.team}, - ${ids.service}, ${ids.appKey}, ${ids.policy}, ${ids.option}, ${ids.offer}, - 'CUSTOMER_UPDATE', ${`actor-auto-top-up-${suffix}`}, ${ids.user}, 'auto-top-up-v1', - 200000000, 5000000000, 500, 1500, ${`pm_auto_top_up_${suffix}`}, - ${paymentMethodSummary}::jsonb, ${consentedAt} - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_accounts" ( - "id", "account_id", "customer_id", "org_id", "team_id", "scope", "scope_key", "currency", - "balance_microcredits", "auto_top_up_state", "auto_top_up_policy_id", - "auto_top_up_service_id", "auto_top_up_app_key_id", "auto_top_up_consent_revision_id", - "auto_top_up_option_id", "auto_top_up_threshold_microcredits", - "auto_top_up_refill_offer_id", "auto_top_up_monthly_charge_cap_minor", - "auto_top_up_consent_version", "auto_top_up_consented_at", - "auto_top_up_consented_by_user_id", "stripe_payment_method_id", - "payment_method_summary", "updated_at" - ) VALUES ( - ${row.creditAccount}, ${ids.account}, ${row.customer}, ${ids.org}, ${row.team}, - 'TEAM', ${`${ids.org}:${row.team}`}, 'USD', - ${balanceMicrocredits}, 'ACTIVE', ${ids.policy}, ${ids.service}, ${ids.appKey}, - ${row.revision}, ${ids.option}, 200000000, ${ids.offer}, 1500, 'auto-top-up-v1', - ${consentedAt}, ${ids.user}, ${`pm_auto_top_up_${suffix}`}, - ${paymentMethodSummary}::jsonb, CURRENT_TIMESTAMP - ) - `); -} - -async function seed(prisma: PrismaClient): Promise { - await prisma.$transaction(async (tx) => { - await tx.$executeRawUnsafe('SET LOCAL session_replication_role = replica'); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "users" ("id", "email", "user_key", "name") - VALUES ( - ${ids.user}, 'auto-top-up@example.com', 'auto-top-up@example.com', 'Auto Top Up Owner' - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "organisations" ( - "id", "domain", "name", "slug", "owner_id", "updated_at" - ) VALUES ( - ${ids.org}, 'auto-top-up.example.com', 'Auto Top Up Org', 'auto-top-up-org', - ${ids.user}, CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "org_members" ( - "id", "org_id", "user_id", "role", "status", "updated_at" - ) VALUES ( - 'om_auto_top_up_runtime', ${ids.org}, ${ids.user}, 'owner', 'ACTIVE', CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_services" ("id", "identifier", "name", "updated_at") - VALUES (${ids.service}, 'auto-top-up-test', 'Auto Top Up Test', CURRENT_TIMESTAMP) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_services" ("id", "identifier", "name", "updated_at") - VALUES (${ids.otherService}, 'auto-top-up-other-test', 'Other Top Up Test', CURRENT_TIMESTAMP) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_app_keys" ( - "id", "service_id", "purpose", "name", "key_prefix", "secret_digest", - "actor_issuer", "actor_audience", "actor_key_id", "actor_public_jwk", - "checkout_return_origins", "updated_at" - ) VALUES ( - ${ids.appKey}, ${ids.service}, 'CUSTOMER_LIFECYCLE', 'Auto top-up runtime', - 'uoa_auto_test', ${'a'.repeat(64)}, 'https://auto-top-up.example.com', - 'https://uoa.example.com', 'auto-top-up-key', - ${JSON.stringify({ kty: 'RSA' })}::jsonb, - ARRAY['https://auto-top-up.example.com'], CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_app_keys" ( - "id", "service_id", "purpose", "name", "key_prefix", "secret_digest", - "actor_issuer", "actor_audience", "actor_key_id", "actor_public_jwk", - "checkout_return_origins", "updated_at" - ) VALUES - ( - ${ids.otherAppKey}, ${ids.otherService}, 'CUSTOMER_LIFECYCLE', 'Other top-up service', - 'uoa_auto_other', ${'b'.repeat(64)}, 'https://other-auto-top-up.example.com', - 'https://uoa.example.com', 'other-auto-top-up-key', - ${JSON.stringify({ kty: 'RSA' })}::jsonb, - ARRAY['https://other-auto-top-up.example.com'], CURRENT_TIMESTAMP - ), - ( - ${ids.rotatedAppKey}, ${ids.service}, 'CUSTOMER_LIFECYCLE', 'Rotated top-up key', - 'uoa_auto_rotated', ${'c'.repeat(64)}, 'https://auto-top-up.example.com', - 'https://uoa.example.com', 'rotated-auto-top-up-key', - ${JSON.stringify({ kty: 'RSA' })}::jsonb, - ARRAY['https://auto-top-up.example.com'], CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_stripe_accounts" ( - "id", "stripe_account_id", "livemode", "updated_at" - ) VALUES (${ids.account}, ${stripeAccount.stripeAccountId}, false, CURRENT_TIMESTAMP) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_funding_policies" ( - "id", "service_id", "currency", "version", "top_up_enabled", - "automatic_top_up_enabled", "automatic_consent_version", "active", "updated_at" - ) VALUES ( - ${ids.policy}, ${ids.service}, 'USD', 1, true, true, 'auto-top-up-v1', true, - CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_top_up_offers" ( - "id", "policy_id", "service_id", "key", "version", "catalog_key", - "catalog_version", "name", "description", "payment_amount_minor", - "credits_received_microcredits", "automatic_top_up_eligible", "active", "updated_at" - ) VALUES ( - ${ids.offer}, ${ids.policy}, ${ids.service}, 'five-dollar-refill', 1, - 'credits-five-dollar', 1, 'Five dollar refill', 'Five thousand credits', - 500, 5000000000, true, true, CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_auto_top_up_options" ( - "id", "policy_id", "service_id", "refill_offer_id", "key", "version", - "threshold_microcredits", "monthly_charge_cap_minor", "active", "updated_at" - ) VALUES ( - ${ids.option}, ${ids.policy}, ${ids.service}, ${ids.offer}, 'low-balance-refill', 1, - 200000000, 1500, true, CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "billing_credit_top_up_catalogs" ( - "id", "account_id", "key", "version", "currency", "payment_amount_minor", - "credits_received_microcredits", "stripe_lookup_key", "stripe_product_id", - "stripe_price_id", "updated_at" - ) VALUES ( - ${ids.catalog}, ${ids.account}, 'credits-five-dollar', 1, 'USD', 500, - 5000000000, 'uoa_credits_five_dollar_v1', 'prod_auto_top_up', - 'price_auto_top_up', CURRENT_TIMESTAMP - ) - `); - await seedCreditAccount(tx, 'concurrency', 100_000_000n, true); - await seedCreditAccount(tx, 'recovery', 100_000_000n, true); - await seedCreditAccount(tx, 'embedded', 100_000_000n, true); - await seedCreditAccount(tx, 'above', 300_000_000n, true); - await seedCreditAccount(tx, 'disabled', 100_000_000n, false); - await seedCreditAccount(tx, 'pending-disabled', 100_000_000n, true); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "users" ("id", "email", "user_key", "name") - VALUES ( - ${ids.nonManagerUser}, 'auto-top-up-member@example.com', - 'auto-top-up-member@example.com', 'Auto Top Up Member' - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "org_members" ( - "id", "org_id", "user_id", "role", "status", "updated_at" - ) VALUES ( - 'om_auto_top_up_non_manager', ${ids.org}, ${ids.nonManagerUser}, 'member', - 'ACTIVE', CURRENT_TIMESTAMP - ) - `); - await tx.$executeRaw(Prisma.sql` - INSERT INTO "team_members" ( - "id", "team_id", "user_id", "team_role", "status", "updated_at" - ) VALUES ( - 'tm_auto_top_up_non_manager', ${embeddedError.team}, ${ids.nonManagerUser}, - 'member', 'ACTIVE', CURRENT_TIMESTAMP - ) - `); - }); -} - -function paymentIntent(attemptId: string, suffix: string): Stripe.PaymentIntent { - return { - id: `pi_auto_top_up_${suffix}`, - object: 'payment_intent', - amount: 500, - currency: 'usd', - customer: `cus_auto_top_up_${suffix}`, - payment_method: `pm_auto_top_up_${suffix}`, - metadata: { - uoa_credit_auto_top_up_attempt_id: attemptId, - uoa_service_id: ids.service, - uoa_app_key_id: ids.appKey, - uoa_credit_account_id: scopedIds(suffix).creditAccount, - }, - livemode: false, - status: 'processing', - } as Stripe.PaymentIntent; -} - -async function disableCreditAccountWithAuditEvidence( - prisma: PrismaClient, - creditAccountId: string, - actorJti: string, - requestTeamId: string, - authorityScope: 'TEAM' | 'ORGANISATION' = 'TEAM', - actor = { serviceId: ids.service, appKeyId: ids.appKey, product: 'auto-top-up-test' }, - userId = ids.user, -): Promise { - const original = await prisma.billingCreditAccount.findUniqueOrThrow({ - where: { id: creditAccountId }, - }); - const request = { - product: actor.product, - organisationId: ids.org, - teamId: requestTeamId, - userId, - }; - await disableBillingCreditAutoTopUp( - { - request, - actorToken: 'synthetic-manager-token', - credential: { id: actor.appKeyId, service: { id: actor.serviceId } } as never, - endpoint: '/billing/v1/credits/auto-top-up/disable' as never, - }, - { - prisma, - resolveContext: vi.fn().mockResolvedValue({ - actor: { jti: actorJti, tv: 0, exp: 1_893_456_000 }, - account: stripeAccount, - creditAccount: original, - authorizeAction: async (tx: Prisma.TransactionClient) => - tx.billingCustomerActionIntent.create({ - data: { - appKeyId: actor.appKeyId, - serviceId: actor.serviceId, - orgId: ids.org, - teamId: requestTeamId, - requestedByUserId: userId, - authorityScope, - operation: 'credit_auto_top_up_disable', - actorJti, - actorTokenVersion: 0, - actorExpiresAt: new Date('2030-01-01T00:00:00.000Z'), - requestDigest: 'd'.repeat(64), - }, - }), - }) as never, - }, - ); -} +import { ids, stripeAccount, scopedIds, concurrency, recovery, embeddedError, aboveThreshold, disabled, pendingDisabled, seedCreditAccount, seed, paymentIntent, disableCreditAccountWithAuditEvidence } from '../helpers/credit-auto-top-up-runtime-fixture.js'; +const databaseTestsEnabled = process.env.BILLING_FUNDING_DATABASE_TESTS === 'true' && Boolean(process.env.DATABASE_URL); describe.skipIf(!databaseTestsEnabled)('credit automatic top-up PostgreSQL runtime', () => { let handle: Awaited>; @@ -768,209 +419,4 @@ describe.skipIf(!databaseTestsEnabled)('credit automatic top-up PostgreSQL runti ).resolves.toBe(1); }, 20_000); - it('requires current manager authority and allows an audited disable with a rotated key', async () => { - const attempt = await handle!.prisma.billingCreditAutoTopUpAttempt.findFirstOrThrow({ - where: { creditAccountId: embeddedError.creditAccount }, - }); - expect(attempt.stripePaymentIntentId).toBe('pi_auto_top_up_embedded'); - - await expect( - handle!.prisma.billingCreditAccount.update({ - where: { id: embeddedError.creditAccount }, - data: { autoTopUpThresholdMicrocredits: 150_000_000n }, - }), - ).rejects.toBeDefined(); - - await expect( - disableCreditAccountWithAuditEvidence( - handle!.prisma, - embeddedError.creditAccount, - 'actor_auto_top_up_non_manager_disable', - embeddedError.team, - 'TEAM', - undefined, - ids.nonManagerUser, - ), - ).rejects.toBeDefined(); - await expect( - handle!.prisma.billingCreditAccount.findUniqueOrThrow({ - where: { id: embeddedError.creditAccount }, - }), - ).resolves.toMatchObject({ autoTopUpState: 'ACTIVE' }); - - await disableCreditAccountWithAuditEvidence( - handle!.prisma, - embeddedError.creditAccount, - 'actor_auto_top_up_embedded_disable', - embeddedError.team, - 'TEAM', - { serviceId: ids.service, appKeyId: ids.rotatedAppKey, product: 'auto-top-up-test' }, - ); - - const [creditAccount, savedAttempt, disableEvent] = await Promise.all([ - handle!.prisma.billingCreditAccount.findUniqueOrThrow({ - where: { id: embeddedError.creditAccount }, - }), - handle!.prisma.billingCreditAutoTopUpAttempt.findUniqueOrThrow({ where: { id: attempt.id } }), - handle!.prisma.billingCreditAutoTopUpDisableEvent.findUniqueOrThrow({ - where: { - appKeyId_actorJti: { - appKeyId: ids.rotatedAppKey, - actorJti: 'actor_auto_top_up_embedded_disable', - }, - }, - }), - ]); - expect(creditAccount.autoTopUpState).toBe('DISABLED'); - expect(disableEvent).toMatchObject({ - serviceId: ids.service, - appKeyId: ids.rotatedAppKey, - teamId: embeddedError.team, - previousConsentRevisionId: attempt.consentRevisionId, - previousGeneration: 0, - }); - expect(savedAttempt).toMatchObject({ - consentRevisionId: attempt.consentRevisionId, - stripePaymentIntentId: 'pi_auto_top_up_embedded', - }); - }, 20_000); - - it('matches the audited disable to a NULL team for organization-scoped credits', async () => { - const customerId = 'bsc_auto_top_up_organization'; - const creditAccountId = 'bca_auto_top_up_organization'; - const revisionId = 'bcar_auto_top_up_organization'; - const attemptId = 'bcattempt_auto_top_up_organization'; - const paymentMethodSummary = { type: 'card', brand: 'visa', last4: '4242' }; - const consentedAt = new Date('2026-07-21T12:00:00.000Z'); - - await handle!.prisma.$transaction(async (tx) => { - await tx.$executeRawUnsafe('SET LOCAL session_replication_role = replica'); - await tx.billingStripeCustomer.create({ - data: { - id: customerId, - accountId: ids.account, - orgId: ids.org, - scope: BillingAssignmentScope.ORGANISATION, - scopeKey: ids.org, - stripeCustomerId: 'cus_auto_top_up_organization', - }, - }); - await tx.billingCreditAccount.create({ - data: { - id: creditAccountId, - accountId: ids.account, - customerId, - orgId: ids.org, - teamId: null, - scope: BillingAssignmentScope.ORGANISATION, - scopeKey: ids.org, - }, - }); - await tx.billingCreditAutoTopUpConsentRevision.create({ - data: { - id: revisionId, - accountId: ids.account, - creditAccountId, - orgId: ids.org, - teamId: null, - serviceId: ids.service, - appKeyId: ids.appKey, - policyId: ids.policy, - optionId: ids.option, - refillOfferId: ids.offer, - source: BillingCreditAutoTopUpConsentSource.CUSTOMER_UPDATE, - actorJti: 'actor_auto_top_up_organization_setup', - consentedByUserId: ids.user, - consentVersion: 'auto-top-up-v1', - thresholdMicrocredits: 200_000_000n, - refillCreditsMicrocredits: 5_000_000_000n, - refillPaymentAmountMinor: 500n, - monthlyChargeCapMinor: 1_500n, - stripePaymentMethodId: 'pm_auto_top_up_organization', - paymentMethodSummary, - consentedAt, - }, - }); - await tx.billingCreditAccount.update({ - where: { id: creditAccountId }, - data: { - autoTopUpGeneration: 1, - autoTopUpState: 'ACTIVE', - autoTopUpPolicyId: ids.policy, - autoTopUpServiceId: ids.service, - autoTopUpAppKeyId: ids.appKey, - autoTopUpConsentRevisionId: revisionId, - autoTopUpOptionId: ids.option, - autoTopUpThresholdMicrocredits: 200_000_000n, - autoTopUpRefillOfferId: ids.offer, - autoTopUpMonthlyChargeCapMinor: 1_500n, - autoTopUpConsentVersion: 'auto-top-up-v1', - autoTopUpConsentedAt: consentedAt, - autoTopUpConsentedByUserId: ids.user, - stripePaymentMethodId: 'pm_auto_top_up_organization', - paymentMethodSummary, - }, - }); - await tx.billingCreditAutoTopUpAttempt.create({ - data: { - id: attemptId, - accountId: ids.account, - creditAccountId, - catalogId: ids.catalog, - serviceId: ids.service, - appKeyId: ids.appKey, - attributedUserId: ids.user, - optionId: ids.option, - offerId: ids.offer, - consentRevisionId: revisionId, - consentVersion: 'auto-top-up-v1', - thresholdMicrocredits: 200_000_000n, - monthlyChargeCapMinor: 1_500n, - chargedThisMonthBeforeMinor: 0n, - observedBalanceMicrocredits: 0n, - paymentAmountMinor: 500n, - creditsReceivedMicrocredits: 5_000_000_000n, - billingMonth: '2026-10', - idempotencyKey: `uoa:auto-top-up:${attemptId}`, - }, - }); - }); - - await expect( - handle!.prisma.$transaction(async (tx) => { - await tx.billingCreditAutoTopUpDisableEvent.create({ - data: { - accountId: ids.account, - creditAccountId, - orgId: ids.org, - teamId: concurrency.team, - serviceId: ids.service, - appKeyId: ids.appKey, - previousConsentRevisionId: revisionId, - previousGeneration: 1, - actorJti: 'actor_auto_top_up_wrong_team', - requestedByUserId: ids.user, - }, - }); - }), - ).rejects.toBeDefined(); - - await disableCreditAccountWithAuditEvidence( - handle!.prisma, - creditAccountId, - 'actor_auto_top_up_organization_disable', - concurrency.team, - 'ORGANISATION', - ); - const [creditAccount, attempt] = await Promise.all([ - handle!.prisma.billingCreditAccount.findUniqueOrThrow({ where: { id: creditAccountId } }), - handle!.prisma.billingCreditAutoTopUpAttempt.findUniqueOrThrow({ where: { id: attemptId } }), - ]); - expect(creditAccount).toMatchObject({ - teamId: null, - autoTopUpGeneration: 2, - autoTopUpState: 'DISABLED', - }); - expect(attempt).toMatchObject({ status: 'PENDING', consentRevisionId: revisionId }); - }, 20_000); }); diff --git a/API/tests/integration/billing-team-archive.persistence.test.ts b/API/tests/integration/billing-team-archive.persistence.test.ts new file mode 100644 index 000000000..92a0baf12 --- /dev/null +++ b/API/tests/integration/billing-team-archive.persistence.test.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestDb } from '../helpers/test-db.js'; +import { ids, concurrency, aboveThreshold, seed } from '../helpers/credit-auto-top-up-runtime-fixture.js'; +import { baseClientConfigPayload } from '../helpers/test-config.js'; +import { validateConfigFields } from '../../src/services/config.service.js'; +import { digestDomainClientHash } from '../../src/utils/client-hash.js'; +import { beginTeamArchive } from '../../src/services/team-archive.service.js'; +import { claimCreditAutoTopUpAttempt } from '../../src/services/billing-credit-auto-top-up-attempt.service.js'; +import { observedBillingTime } from '../../src/services/billing-seat-observed-time.service.js'; + +describe.skipIf(process.env.BILLING_FUNDING_DATABASE_TESTS !== 'true' || !process.env.DATABASE_URL)( + 'team archive automatic reload evidence', () => { + let db: NonNullable>>; + beforeAll(async () => { db = (await createTestDb())!; await seed(db.prisma); }); + afterAll(async () => { await db?.cleanup(); }); + it('disables future reload with archive evidence and retains consent, money and sibling configuration', async () => { + const domain = 'auto-top-up.example.com', token = 'b'.repeat(64); + const client = await db.prisma.clientDomain.create({ data: { domain, label: 'Archive funding test' } }); + await db.prisma.clientDomainSecret.create({ data: { domainId: client.id, + secretDigest: digestDomainClientHash(token), hashPrefix: token.slice(0, 16) } }); + const before = await db.prisma.billingCreditAccount.findUniqueOrThrow({ where: { id: concurrency.creditAccount } }); + const sibling = await db.prisma.billingCreditAccount.findUniqueOrThrow({ where: { id: aboveThreshold.creditAccount } }); + const user = await db.prisma.user.findUniqueOrThrow({ where: { id: ids.user } }); + const service = await db.prisma.billingService.create({ data: { identifier: 'archive-seat-test', name: 'Seats' } }); + const tariff = await db.prisma.billingTariff.create({ data: { + serviceId: service.id, key: 'seats', version: 1, name: 'Seats', mode: 'CUSTOM', collectionMode: 'MANUAL', + markupBps: 0, currency: 'USD', monthlyChargeBasis: 'PER_SEAT', seatPolicy: 'AUTOMATIC', + seatChargeTiming: 'PRORATED', monthlyAmountMinor: 1000n, + } }); + const contract = await db.prisma.billingOrganisationContract.create({ data: { + orgId: ids.org, reference: 'archive-seats', name: 'Archive seats', + } }); + const future = new Date(Date.now() + 86400_000); + const seats = []; + for (const versionNumber of [1, 2]) { + const version = await db.prisma.billingOrganisationContractVersion.create({ data: { + contractId: contract.id, version: versionNumber, usageMarkupBps: 0, currency: 'USD', + paymentTermsDays: 30, effectiveFromMonth: versionNumber === 1 ? '2026-11' : '2026-12', + } }); + const term = await db.prisma.billingContractServiceTerm.create({ data: { + contractVersionId: version.id, serviceId: service.id, tariffId: tariff.id, monthlyAmountMinor: 1000n, + } }); + seats.push(await db.prisma.$transaction(async tx => { + const now = await observedBillingTime(tx); + return tx.billingSeatSubscription.create({ data: { + contractServiceTermId: term.id, serviceId: service.id, tariffId: tariff.id, orgId: ids.org, + teamId: concurrency.team, scope: 'TEAM', seatPolicy: 'AUTOMATIC', seatChargeTiming: 'PRORATED', + unitAmountMinor: 1000n, currency: 'USD', activatedAt: now, baselineCapturedAt: now, + commercialEffectiveAt: versionNumber === 1 ? now : future, + commercialEndsAt: versionNumber === 1 ? future : null, + } }); + })); + } + await beginTeamArchive({ id: randomUUID(), orgId: ids.org, teamId: concurrency.team, domain, domainToken: token, + config: validateConfigFields(baseClientConfigPayload({ domain })), claims: { + userId: ids.user, tokenVersion: user.tokenVersion, email: user.email!, domain, clientId: token, role: 'user', + org: { org_id: ids.org, org_role: 'owner', teams: [concurrency.team], team_roles: {} }, + active: { orgId: ids.org, teamId: concurrency.team }, + }, + }, { prisma: db.prisma }); + const after = await db.prisma.billingCreditAccount.findUniqueOrThrow({ where: { id: concurrency.creditAccount } }); + expect(after).toMatchObject({ autoTopUpState: 'DISABLED', autoTopUpGeneration: before.autoTopUpGeneration + 1, + balanceMicrocredits: before.balanceMicrocredits, autoTopUpConsentRevisionId: null, stripePaymentMethodId: null }); + expect(await db.prisma.billingCreditAutoTopUpDisableEvent.count({ where: { creditAccountId: after.id } })).toBe(0); + expect(await db.prisma.billingCreditAutoTopUpConsentRevision.findUnique({ where: { id: before.autoTopUpConsentRevisionId! } })).not.toBeNull(); + expect(await db.prisma.billingCreditAccount.findUniqueOrThrow({ where: { id: sibling.id } })).toEqual(sibling); + await expect(db.prisma.billingCreditAccount.update({ where: { id: after.id }, data: { autoTopUpState: 'ACTIVE' } })) + .rejects.toThrow('archived team cannot enable reload'); + expect(await claimCreditAutoTopUpAttempt({ accountId: ids.account, creditAccountId: after.id }, { prisma: db.prisma })) + .toMatchObject({ kind: 'skipped', reason: 'configuration_inactive' }); + for (const seat of seats) { + const ended = await db.prisma.billingSeatSubscription.findUniqueOrThrow({ where: { id: seat.id } }); + expect(ended.endedAt).toBeInstanceOf(Date); + expect(ended.commercialEndsAt).toEqual(seat.commercialEndsAt); + expect(await db.prisma.billingSeatMembershipInterval.count({ where: { seatSubscriptionId: seat.id, endsAt: null } })).toBe(0); + } + }); + }, +); diff --git a/API/tests/integration/team-archive-collection.test.ts b/API/tests/integration/team-archive-collection.test.ts new file mode 100644 index 000000000..978654eaf --- /dev/null +++ b/API/tests/integration/team-archive-collection.test.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createTestDb } from '../helpers/test-db.js'; +import { correctionFixture } from '../helpers/stripe-correction-fixture.js'; +import { ids } from './billing-org-responsibility.persistence.fixture.js'; +import { baseClientConfigPayload } from '../helpers/test-config.js'; +import { validateConfigFields } from '../../src/services/config.service.js'; +import { digestDomainClientHash } from '../../src/utils/client-hash.js'; +import { beginTeamArchive } from '../../src/services/team-archive.service.js'; +import { withUnarchivedSubscriptionEffect } from '../../src/services/team-billing-effect.service.js'; +import { collectStripeCorrectionInvoice } from '../../src/services/billing-stripe-correction-invoice.service.js'; + +describe.skipIf(!process.env.DATABASE_URL)('archive collection fencing', () => { + let db: NonNullable>>; + let fixture: Awaited>; + beforeAll(async () => { db = (await createTestDb())!; fixture = await correctionFixture(db.prisma); }); + afterAll(async () => { await db?.cleanup(); }); + it('drains an admitted collection and prevents cancellation recovery from starting collection again', async () => { + const domain = 'org-billing.example.com', token = 'c'.repeat(64); + const client = await db.prisma.clientDomain.create({ data: { domain, label: 'Collection archive test' } }); + await db.prisma.clientDomainSecret.create({ data: { domainId: client.id, + secretDigest: digestDomainClientHash(token), hashPrefix: token.slice(0, 16) } }); + const owner = await db.prisma.user.findUniqueOrThrow({ where: { id: ids.owner } }); + await db.prisma.team.update({ where: { id: ids.teamA }, data: { lifecycleStatus: 'DISABLED' } }); + await expect(withUnarchivedSubscriptionEffect(db.prisma, fixture.sub.id, async () => 'earned liability')) + .resolves.toBe('earned liability'); + await db.prisma.team.update({ where: { id: ids.teamA }, data: { lifecycleStatus: 'ACTIVE' } }); + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const hold = new Promise(resolve => { release = resolve; }); + const inFlight = withUnarchivedSubscriptionEffect(db.prisma, fixture.sub.id, async () => { entered(); await hold; }); + await started; + let archived = false; + const archive = beginTeamArchive({ id: randomUUID(), orgId: ids.org, teamId: ids.teamA, domain, domainToken: token, + config: validateConfigFields(baseClientConfigPayload({ domain })), claims: { + userId: owner.id, tokenVersion: owner.tokenVersion, email: owner.email!, domain, clientId: token, role: 'user', + org: { org_id: ids.org, org_role: 'owner', teams: [ids.teamA], team_roles: {} }, active: { orgId: ids.org, teamId: ids.teamA }, + }, + }, { prisma: db.prisma }).then(value => { archived = true; return value; }); + try { await new Promise(resolve => setTimeout(resolve, 50)); expect(archived).toBe(false); } + finally { release(); } + await inFlight; + await archive; + const collection = vi.fn(); + await expect(withUnarchivedSubscriptionEffect(db.prisma, fixture.sub.id, collection)) + .rejects.toThrow('ARCHIVE_BILLING_COLLECTION_HELD'); + expect(collection).not.toHaveBeenCalled(); + await expect(collectStripeCorrectionInvoice({ closeId: fixture.close.id, + amountMicroMinor: 130_000_000n, cursor: 'bus_correction_1' }, { prisma: db.prisma, stripe: fixture.stripe })) + .rejects.toThrow('ARCHIVE_BILLING_COLLECTION_HELD'); + expect(fixture.client.invoices.finalizeInvoice).not.toHaveBeenCalled(); + expect(fixture.client.invoices.update).not.toHaveBeenCalled(); + expect(fixture.client.invoices.create).not.toHaveBeenCalled(); + expect((await db.prisma.team.findUniqueOrThrow({ where: { id: ids.teamB } })).lifecycleStatus).toBe('ACTIVE'); + }); +}); diff --git a/API/tests/integration/team-archive.test.ts b/API/tests/integration/team-archive.test.ts new file mode 100644 index 000000000..a91b7df56 --- /dev/null +++ b/API/tests/integration/team-archive.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createRlsTestDb } from '../helpers/test-db.js'; +import { runWithTenantContext } from '../../src/db/tenant-context.js'; +import { baseClientConfigPayload } from '../helpers/test-config.js'; +import { validateConfigFields } from '../../src/services/config.service.js'; +import { digestDomainClientHash } from '../../src/utils/client-hash.js'; +import { beginTeamArchive, readTeamArchive } from '../../src/services/team-archive.service.js'; +import { assertEntityAccess } from '../../src/services/entity-lifecycle.service.js'; +import { reconcileTeamArchives } from '../../src/services/team-archive-reconciliation.service.js'; +import { buildSidebarTeams } from '../../src/services/team-directory.service.js'; +import { withLiveTeamBillingEffect } from '../../src/services/team-billing-effect.service.js'; +import { previewEntityDeletion } from '../../src/services/entity-deletion-preview.service.js'; + +const domain = 'archive.example.com'; +const store = vi.hoisted(() => ({ prisma: null as PrismaClient | null })); +vi.mock('../../src/db/prisma.js', () => ({ getAdminPrisma: () => store.prisma!, getPrisma: () => store.prisma! })); + +describe.skipIf(!process.env.DATABASE_URL)('held team archive on PostgreSQL', () => { + let db: NonNullable>>; + const original = process.env.DATABASE_URL; + beforeAll(async () => { db = (await createRlsTestDb())!; store.prisma = db.prisma; process.env.DATABASE_URL = db.databaseUrl; }); + afterAll(async () => { process.env.DATABASE_URL = original; await db?.cleanup(); }); + + async function seed(options?: { role?: string; sibling?: boolean }) { + const owner = await db.prisma.user.create({ data: { email: `${randomUUID()}@example.com`, userKey: randomUUID() } }); + const org = await db.prisma.organisation.create({ data: { domain, name: 'Studio', slug: randomUUID(), ownerId: owner.id } }); + const team = await db.prisma.team.create({ data: { orgId: org.id, name: 'Design', slug: randomUUID(), isDefault: true } }); + const sibling = options?.sibling ? await db.prisma.team.create({ data: { orgId: org.id, name: 'Other', slug: randomUUID() } }) : null; + await db.prisma.orgMember.create({ data: { orgId: org.id, userId: owner.id, role: options?.role ?? 'admin' } }); + await db.prisma.teamMember.create({ data: { teamId: team.id, userId: owner.id } }); + if (sibling) await db.prisma.teamMember.create({ data: { teamId: sibling.id, userId: owner.id } }); + const token = 'a'.repeat(64); + const client = await db.prisma.clientDomain.upsert({ where: { domain }, create: { domain, label: 'Archive test' }, update: {} }); + if (!await db.prisma.clientDomainSecret.findFirst({ where: { domainId: client.id } })) { + await db.prisma.clientDomainSecret.create({ data: { domainId: client.id, secretDigest: digestDomainClientHash(token), hashPrefix: token.slice(0, 16) } }); + } + const claims = { userId: owner.id, tokenVersion: owner.tokenVersion, email: owner.email!, clientId: token, + role: 'user' as const, domain, org: { org_id: org.id, org_role: options?.role ?? 'admin', teams: [team.id], team_roles: {} }, + active: { orgId: org.id, teamId: team.id } }; + const params = { id: randomUUID(), orgId: org.id, teamId: team.id, claims, domain, + domainToken: token, config: validateConfigFields(baseClientConfigPayload({ domain })) }; + return { owner, org, team, sibling, params, client }; + } + + it('archives the default and last team without erasing the organisation or people', async () => { + const { owner, org, team, params, client } = await seed(); + const receipt = await beginTeamArchive(params, { prisma: db.prisma }); + expect(receipt).toMatchObject({ status: 'STOPPING_BILLING', deletion_held: true, permanently_deleted: false }); + expect((await db.prisma.team.findUniqueOrThrow({ where: { id: team.id } })).lifecycleStatus).toBe('ARCHIVED'); + expect((await db.prisma.organisation.findUniqueOrThrow({ where: { id: org.id } })).lifecycleStatus).toBe('ACTIVE'); + expect((await db.prisma.user.findUniqueOrThrow({ where: { id: owner.id } })).tokenVersion).toBe(owner.tokenVersion); + await expect(assertEntityAccess({ userId: owner.id, teamId: team.id }, db.prisma)).rejects.toThrow('ACCESS_DENIED'); + expect(await buildSidebarTeams({ userId: owner.id, domain }, { prisma: db.prisma })).toEqual([]); + expect(await db.prisma.entityDeletionJob.count()).toBe(0); + await expect(db.prisma.team.update({ where: { id: team.id }, data: { lifecycleStatus: 'ACTIVE' } })).rejects.toThrow('deletion is held'); + await expect(db.prisma.team.delete({ where: { id: team.id } })).rejects.toThrow(); + await expect(db.prisma.teamArchiveOperation.delete({ where: { id: params.id } })).rejects.toThrow('immutable'); + expect((await previewEntityDeletion(db.prisma, 'ORGANISATION', org.id, 'RETAIN_REFERENCE')).blockers) + .toContain('Permanent deletion of an archived team is on hold.'); + await expect(beginTeamArchive(params, { prisma: db.prisma })).resolves.toEqual(receipt); + await expect(readTeamArchive(params.id, 'another-product', db.prisma)).rejects.toThrow(); + await reconcileTeamArchives({ prisma: db.prisma }); + expect(await readTeamArchive(params.id, client.id, db.prisma)).toMatchObject({ status: 'ARCHIVED', billing_stopped: true }); + }); + + it('revokes every consuming product while leaving the sibling team usable', async () => { + const { owner, org, team, sibling, params } = await seed({ sibling: true }); + for (const [product, target] of [[domain, team.id], ['another.example.com', team.id], [domain, sibling!.id]]) { + await db.prisma.refreshToken.create({ data: { tokenHash: randomUUID(), familyId: randomUUID(), userId: owner.id, + domain: product, clientId: randomUUID(), configUrl: `https://${product}/config`, orgId: org.id, + teamId: target, expiresAt: new Date(Date.now() + 3600_000) } }); + } + await beginTeamArchive(params, { prisma: db.prisma }); + const target = await db.prisma.refreshToken.findMany({ where: { teamId: team.id } }); + expect(target).toHaveLength(2); expect(target.every(token => token.revokedAt)).toBe(true); + expect((await db.prisma.orgRevocationEvent.findMany({ where: { teamId: team.id } })).map(event => event.clientDomain).sort()) + .toEqual(['another.example.com', domain].sort()); + expect((await db.prisma.refreshToken.findFirstOrThrow({ where: { teamId: sibling!.id } })).revokedAt).toBeNull(); + await expect(assertEntityAccess({ userId: owner.id, teamId: sibling!.id }, db.prisma)).resolves.toBeUndefined(); + }); + + it('refuses a member and a stale credential before changing lifecycle', async () => { + const member = await seed({ role: 'member' }); + await expect(beginTeamArchive(member.params, { prisma: db.prisma })).rejects.toThrow(); + const stale = await seed(); stale.params.claims.tokenVersion += 1; + await expect(beginTeamArchive(stale.params, { prisma: db.prisma })).rejects.toThrow(); + for (const id of [member.team.id, stale.team.id]) { + expect((await db.prisma.team.findUniqueOrThrow({ where: { id } })).lifecycleStatus).toBe('ACTIVE'); + expect(await db.prisma.teamArchiveOperation.count({ where: { teamId: id } })).toBe(0); + } + }); + + it('keeps ordinary tenant writes working without exposing archive evidence through RLS', async () => { + const { owner, org, team, params } = await seed(); + const app = new PrismaClient({ datasources: { db: { url: db.appDatabaseUrl } } }); + try { + await runWithTenantContext({ prisma: app, context: { domain, orgId: org.id, userId: owner.id, domainBackend: true } }, async tx => { + await expect(tx.team.update({ where: { id: team.id }, data: { lifecycleStatus: 'ACTIVE' } })).resolves.toMatchObject({ id: team.id }); + expect(await tx.teamArchiveOperation.findMany()).toEqual([]); + }); + await beginTeamArchive(params, { prisma: db.prisma }); + expect(await app.teamArchiveOperation.findMany()).toEqual([]); + await expect(app.teamArchiveOperation.create({ data: { id: randomUUID(), orgId: org.id, teamId: team.id, + actorUserId: owner.id, sourceClientDomainId: 'foreign', settleAfter: new Date() } })).rejects.toThrow(); + } finally { await app.$disconnect(); } + }); + + it('denies a provider create after archiving and retries failed billing without certifying success', async () => { + const { params, client } = await seed(); + await beginTeamArchive(params, { prisma: db.prisma }); + const provider = vi.fn(); + await expect(withLiveTeamBillingEffect(db.prisma, { organisationId: params.orgId, teamId: params.teamId }, provider)).rejects.toThrow(); + expect(provider).not.toHaveBeenCalled(); + await reconcileTeamArchives({ prisma: db.prisma, stopBilling: vi.fn().mockRejectedValue(new Error('provider offline')) }); + expect(await readTeamArchive(params.id, client.id, db.prisma)).toMatchObject({ billing_stopped: false }); + await reconcileTeamArchives({ prisma: db.prisma, now: () => new Date(Date.now() + 60_000), stopBilling: vi.fn().mockResolvedValue(true) }); + expect(await readTeamArchive(params.id, client.id, db.prisma)).toMatchObject({ billing_stopped: true }); + }); +}); diff --git a/API/tests/unit/billing-credit-auto-top-up-setup-race.service.test.ts b/API/tests/unit/billing-credit-auto-top-up-setup-race.service.test.ts index ddf477b5b..0a825d937 100644 --- a/API/tests/unit/billing-credit-auto-top-up-setup-race.service.test.ts +++ b/API/tests/unit/billing-credit-auto-top-up-setup-race.service.test.ts @@ -99,3 +99,8 @@ describe('automatic top-up Setup Checkout finalization', () => { }); }); }); + +// Lifecycle/provider fencing is exercised against PostgreSQL in team-archive.test.ts. +vi.mock('../../src/services/team-billing-effect.service.js', () => ({ + withLiveTeamBillingEffect: async (_db: unknown, _scope: unknown, effect: () => Promise) => effect(), +})); diff --git a/API/tests/unit/billing-credit-funding-actions.service.test.ts b/API/tests/unit/billing-credit-funding-actions.service.test.ts index 6a6c45879..7fe59ccea 100644 --- a/API/tests/unit/billing-credit-funding-actions.service.test.ts +++ b/API/tests/unit/billing-credit-funding-actions.service.test.ts @@ -1,188 +1,15 @@ -import { - BillingAppKeyPurpose, - BillingCreditAutoTopUpConsentSource, - BillingCreditAutoTopUpState, - BillingCreditCheckoutStatus, - type PrismaClient, -} from '@prisma/client'; -import { createHash } from 'node:crypto'; +import { BillingCreditAutoTopUpState, BillingCreditCheckoutStatus, type PrismaClient } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; - -import { - disableBillingCreditAutoTopUp, - updateBillingCreditAutoTopUp, -} from '../../src/services/billing-credit-auto-top-up-consent.service.js'; import { createBillingCreditAutoTopUpSetup } from '../../src/services/billing-credit-auto-top-up-setup.service.js'; -import type { CreditFundingActionContext } from '../../src/services/billing-credit-funding-context.service.js'; import { createBillingCreditTopUpCheckout } from '../../src/services/billing-credit-top-up.service.js'; +import { now, credential, request, offer, catalog, option, fundingMetadata, baseContext, selections, checkoutRow, openTopUpSession } from './billing-credit-funding.fixture.js'; -const now = new Date('2026-07-21T12:00:00.000Z'); -const credential = { - id: 'app_key_deepwater', - purpose: BillingAppKeyPurpose.CUSTOMER_LIFECYCLE, - actorIssuer: 'https://api.deepwater.example', - actorAudience: 'https://authentication.unlikeotherai.com/billing/v1/effective-tariff', - actorKeyId: 'actor_key_1', - actorPublicJwk: {}, - checkoutReturnOrigins: ['https://app.deepwater.example'], - service: { id: 'service_deepwater', identifier: 'deepwater', name: 'DeepWater' }, -}; -const request = { - product: 'deepwater', - organisationId: 'org_1', - teamId: 'team_1', - userId: 'user_1', -}; -const account = { id: 'account_1', stripeAccountId: 'acct_uoa', livemode: false }; -const policy = { - id: 'policy_1', - currency: 'USD', - automaticConsentVersion: 'credits-auto-2026-07', -}; -const offer = { - id: 'offer_20k', - paymentAmountMinor: 2_000n, - creditsReceivedMicrocredits: 20_000_000_000n, -}; -const catalog = { - id: 'catalog_20k', - stripePriceId: 'price_20k', - stripeProductId: 'prod_20k', - stripeLookupKey: 'credits-20k-v1', - paymentAmountMinor: offer.paymentAmountMinor, -}; -const option = { - id: 'option_safe', - thresholdMicrocredits: 5_000_000_000n, - monthlyChargeCapMinor: 10_000n, -}; - -function fundingMetadata(local: Record) { - return { - uoa_service_id: credential.service.id, - uoa_app_key_id: credential.id, - uoa_credit_account_id: 'credit_account_1', - ...local, - }; -} - -function baseContext(overrides?: Record) { - const sessionsCreate = vi.fn(); - const stripe = { - checkout: { sessions: { create: sessionsCreate, retrieve: vi.fn(), list: vi.fn() } }, - paymentMethods: { retrieve: vi.fn() }, - paymentIntents: { retrieve: vi.fn(), cancel: vi.fn() }, - prices: { retrieve: vi.fn() }, - accounts: { retrieveCurrent: vi.fn() }, - customers: { create: vi.fn(), retrieve: vi.fn() }, - }; - const creditAccount = { - id: 'credit_account_1', - accountId: account.id, - customerId: 'customer_1', - orgId: request.organisationId, - teamId: request.teamId, - autoTopUpState: BillingCreditAutoTopUpState.DISABLED, - autoTopUpGeneration: 0, - autoTopUpOptionId: null, - autoTopUpConsentRevisionId: null, - autoTopUpThresholdMicrocredits: null, - autoTopUpRefillOfferId: null, - autoTopUpMonthlyChargeCapMinor: null, - autoTopUpConsentVersion: null, - autoTopUpConsentedAt: null, - autoTopUpConsentedByUserId: null, - stripePaymentMethodId: null, - paymentMethodSummary: null, - ...overrides, - }; - const customer = { id: 'customer_1', stripeCustomerId: 'cus_team_1' }; - const context = { - actor: { jti: 'actor_jti_1', tv: 0, exp: 2_000_000_000 }, - viewer: { billingManager: true }, - account, - creditAccount, - customer, - stripe, - authorizeAction: vi.fn(), - ensureCustomer: vi.fn().mockResolvedValue(customer), - } as unknown as CreditFundingActionContext; - return { context, stripe, sessionsCreate }; -} - -function selections() { - return { - offer: { policy, offer, catalog }, - option: { policy, option, offer, catalog }, - }; -} - -function checkoutRow(kind: 'setup' | 'top_up', actorJti = 'actor_jti_1') { - const returnDigest = (value: string) => createHash('sha256').update(value).digest('hex'); - const common = { - id: kind === 'top_up' ? 'checkout_topup_1' : 'checkout_setup_1', - accountId: account.id, - creditAccountId: 'credit_account_1', - customerId: 'customer_1', - serviceId: credential.service.id, - appKeyId: credential.id, - actorJti, - requestedByUserId: request.userId, - successUrlDigest: returnDigest('https://app.deepwater.example/?uoa_billing=checkout_complete'), - cancelUrlDigest: returnDigest('https://app.deepwater.example/?uoa_billing=checkout_cancelled'), - status: BillingCreditCheckoutStatus.CREATING, - stripeCheckoutSessionId: null, - leaseExpiresAt: new Date(now.getTime() + 60_000), - createdAt: now, - }; - return kind === 'top_up' - ? { - ...common, - catalogId: catalog.id, - offerId: offer.id, - paymentAmountMinor: offer.paymentAmountMinor, - creditsReceivedMicrocredits: offer.creditsReceivedMicrocredits, - currency: 'USD', - } - : { - ...common, - policyId: policy.id, - optionId: option.id, - expectedGeneration: 0, - expectedConsentRevisionId: null, - consentVersion: policy.automaticConsentVersion, - thresholdMicrocredits: option.thresholdMicrocredits, - refillOfferId: offer.id, - refillCreditsMicrocredits: offer.creditsReceivedMicrocredits, - refillPaymentAmountMinor: offer.paymentAmountMinor, - monthlyChargeCapMinor: option.monthlyChargeCapMinor, - }; -} - -function openTopUpSession( - checkout: ReturnType & Record, - overrides: Record = {}, -) { - return { - id: 'cs_existing', - livemode: false, - mode: 'payment', - status: 'open', - url: 'https://checkout.stripe.com/c/pay/cs_existing', - customer: 'cus_team_1', - client_reference_id: checkout.id, - metadata: { - uoa_credit_top_up_checkout_id: checkout.id, - uoa_service_id: checkout.serviceId, - uoa_app_key_id: checkout.appKeyId, - uoa_credit_account_id: checkout.creditAccountId, - }, - expires_at: 2_000_000_000, - ...overrides, - }; -} +// Admission and provider fencing are covered by the PostgreSQL team-archive suite. +vi.mock('../../src/services/team-billing-effect.service.js', () => ({ + withLiveTeamBillingEffect: async (_db: unknown, _scope: unknown, effect: () => Promise) => effect(), +})); -describe('UOA credit funding mutation services', () => { +describe('UOA credit Checkout bindings and recovery', () => { it('persists fixed top-up intent before creating exact-price Stripe Checkout', async () => { const state = baseContext(); const order: string[] = []; @@ -511,190 +338,5 @@ describe('UOA credit funding mutation services', () => { expect(prisma.billingCreditTopUpCheckout.create).not.toHaveBeenCalled(); }); - it('does not append action evidence when automatic top-up is already disabled', async () => { - const state = baseContext(); - - await disableBillingCreditAutoTopUp( - { request, actorToken: 'actor', credential }, - { - prisma: {} as PrismaClient, - resolveContext: vi.fn().mockResolvedValue(state.context), - }, - ); - - expect(state.context.authorizeAction).not.toHaveBeenCalled(); - }); - it('updates immutable consent from the verified customer card and exact option', async () => { - const state = baseContext({ - autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, - autoTopUpOptionId: option.id, - autoTopUpConsentRevisionId: 'consent_old', - stripePaymentMethodId: 'pm_1', - }); - state.stripe.paymentMethods.retrieve.mockResolvedValue({ - id: 'pm_1', - livemode: false, - type: 'card', - customer: 'cus_team_1', - card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2030 }, - }); - const revisionCreate = vi.fn().mockResolvedValue({ id: 'consent_new' }); - const accountUpdate = vi.fn().mockResolvedValue({ count: 1 }); - const tx = { - $queryRaw: vi.fn().mockResolvedValue([ - { - id: 'credit_account_1', - autoTopUpGeneration: 0, - autoTopUpConsentRevisionId: 'consent_old', - autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, - stripePaymentMethodId: 'pm_1', - }, - ]), - billingCreditAutoTopUpConsentRevision: { - findUnique: vi.fn().mockResolvedValue(null), - create: revisionCreate, - }, - billingCreditAccount: { updateMany: accountUpdate }, - billingCreditSetupCheckout: { updateMany: vi.fn() }, - orgAuditLog: { create: vi.fn() }, - }; - const prisma = { - $transaction: vi.fn((callback) => callback(tx)), - billingCreditAutoTopUpConsentRevision: { findUnique: vi.fn() }, - } as unknown as PrismaClient; - - await updateBillingCreditAutoTopUp( - { request: { ...request, optionId: option.id }, actorToken: 'actor', credential }, - { - prisma, - now: () => now, - resolveContext: vi.fn().mockResolvedValue(state.context), - resolveOption: vi.fn().mockResolvedValue(selections().option), - readRegion: vi.fn().mockResolvedValue(null), - validateCatalog: vi.fn(), - }, - ); - - expect(revisionCreate).toHaveBeenCalledWith({ - data: expect.objectContaining({ - source: BillingCreditAutoTopUpConsentSource.CUSTOMER_UPDATE, - optionId: option.id, - refillPaymentAmountMinor: offer.paymentAmountMinor, - stripePaymentMethodId: 'pm_1', - paymentMethodSummary: expect.objectContaining({ last4: '4242' }), - }), - }); - expect(accountUpdate).toHaveBeenCalledWith({ - where: expect.objectContaining({ id: 'credit_account_1', autoTopUpGeneration: 0 }), - data: expect.objectContaining({ - autoTopUpGeneration: { increment: 1 }, - autoTopUpConsentRevisionId: 'consent_new', - autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, - }), - }); - }); - - it('disables future consent while leaving an older unresolved attempt independent', async () => { - const state = baseContext({ - autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, - autoTopUpConsentRevisionId: 'consent_1', - autoTopUpOptionId: option.id, - stripePaymentMethodId: 'pm_1', - }); - const accountUpdate = vi.fn(); - const tx = { - $queryRaw: vi.fn().mockResolvedValue([ - { - id: 'credit_account_1', - autoTopUpGeneration: 0, - autoTopUpConsentRevisionId: 'consent_1', - autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, - stripePaymentMethodId: 'pm_1', - }, - ]), - billingCreditAccount: { - update: accountUpdate, - }, - billingCreditAutoTopUpDisableEvent: { create: vi.fn() }, - billingCreditSetupCheckout: { updateMany: vi.fn() }, - orgAuditLog: { create: vi.fn() }, - }; - const prisma = { - $transaction: vi.fn((callback) => callback(tx)), - } as unknown as PrismaClient; - - await disableBillingCreditAutoTopUp( - { request, actorToken: 'actor', credential }, - { prisma, resolveContext: vi.fn().mockResolvedValue(state.context) }, - ); - - expect(accountUpdate).toHaveBeenCalledWith({ - where: { id: 'credit_account_1' }, - data: expect.objectContaining({ - autoTopUpState: BillingCreditAutoTopUpState.DISABLED, - autoTopUpConsentRevisionId: null, - stripePaymentMethodId: null, - }), - }); - expect(tx.billingCreditAutoTopUpDisableEvent.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - previousConsentRevisionId: 'consent_1', - previousGeneration: 0, - }), - }); - }); - - it('keeps disable safe when an unknown pending attempt has no persisted Stripe id', async () => { - const state = baseContext({ - autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, - autoTopUpConsentRevisionId: 'consent_1', - autoTopUpOptionId: option.id, - stripePaymentMethodId: 'pm_1', - }); - const disableCreate = vi.fn(); - const accountUpdate = vi.fn(); - const attemptRead = vi.fn().mockResolvedValue({ - id: 'attempt_pending', - status: 'PENDING', - stripePaymentIntentId: null, - }); - const tx = { - $queryRaw: vi.fn().mockResolvedValue([ - { - id: 'credit_account_1', - autoTopUpGeneration: 0, - autoTopUpConsentRevisionId: 'consent_1', - autoTopUpState: BillingCreditAutoTopUpState.NEEDS_REVIEW, - stripePaymentMethodId: 'pm_1', - }, - ]), - billingCreditAutoTopUpDisableEvent: { create: disableCreate }, - billingCreditAutoTopUpAttempt: { findFirst: attemptRead }, - billingCreditSetupCheckout: { updateMany: vi.fn() }, - billingCreditAccount: { update: accountUpdate }, - orgAuditLog: { create: vi.fn() }, - }; - const prisma = { - billingCreditAutoTopUpAttempt: { findFirst: attemptRead }, - $transaction: vi.fn((callback) => callback(tx)), - } as unknown as PrismaClient; - - await disableBillingCreditAutoTopUp( - { request, actorToken: 'actor', credential }, - { prisma, resolveContext: vi.fn().mockResolvedValue(state.context) }, - ); - - expect(disableCreate).toHaveBeenCalledWith({ - data: expect.objectContaining({ previousConsentRevisionId: 'consent_1' }), - }); - expect(accountUpdate).toHaveBeenCalledWith({ - where: { id: 'credit_account_1' }, - data: expect.objectContaining({ - autoTopUpState: BillingCreditAutoTopUpState.DISABLED, - autoTopUpConsentRevisionId: null, - }), - }); - expect(attemptRead).not.toHaveBeenCalled(); - }); }); diff --git a/API/tests/unit/billing-credit-funding-consent.service.test.ts b/API/tests/unit/billing-credit-funding-consent.service.test.ts new file mode 100644 index 000000000..f4567612b --- /dev/null +++ b/API/tests/unit/billing-credit-funding-consent.service.test.ts @@ -0,0 +1,193 @@ +import { BillingCreditAutoTopUpConsentSource, BillingCreditAutoTopUpState, type PrismaClient } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; +import { disableBillingCreditAutoTopUp, updateBillingCreditAutoTopUp } from '../../src/services/billing-credit-auto-top-up-consent.service.js'; +import { now, credential, request, offer, option, baseContext, selections } from './billing-credit-funding.fixture.js'; + +describe('UOA automatic credit consent changes', () => { + it('does not append action evidence when automatic top-up is already disabled', async () => { + const state = baseContext(); + + await disableBillingCreditAutoTopUp( + { request, actorToken: 'actor', credential }, + { + prisma: {} as PrismaClient, + resolveContext: vi.fn().mockResolvedValue(state.context), + }, + ); + + expect(state.context.authorizeAction).not.toHaveBeenCalled(); + }); + + it('updates immutable consent from the verified customer card and exact option', async () => { + const state = baseContext({ + autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, + autoTopUpOptionId: option.id, + autoTopUpConsentRevisionId: 'consent_old', + stripePaymentMethodId: 'pm_1', + }); + state.stripe.paymentMethods.retrieve.mockResolvedValue({ + id: 'pm_1', + livemode: false, + type: 'card', + customer: 'cus_team_1', + card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2030 }, + }); + const revisionCreate = vi.fn().mockResolvedValue({ id: 'consent_new' }); + const accountUpdate = vi.fn().mockResolvedValue({ count: 1 }); + const tx = { + $queryRaw: vi.fn().mockResolvedValue([ + { + id: 'credit_account_1', + autoTopUpGeneration: 0, + autoTopUpConsentRevisionId: 'consent_old', + autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, + stripePaymentMethodId: 'pm_1', + }, + ]), + billingCreditAutoTopUpConsentRevision: { + findUnique: vi.fn().mockResolvedValue(null), + create: revisionCreate, + }, + billingCreditAccount: { updateMany: accountUpdate }, + billingCreditSetupCheckout: { updateMany: vi.fn() }, + orgAuditLog: { create: vi.fn() }, + }; + const prisma = { + $transaction: vi.fn((callback) => callback(tx)), + billingCreditAutoTopUpConsentRevision: { findUnique: vi.fn() }, + } as unknown as PrismaClient; + + await updateBillingCreditAutoTopUp( + { request: { ...request, optionId: option.id }, actorToken: 'actor', credential }, + { + prisma, + now: () => now, + resolveContext: vi.fn().mockResolvedValue(state.context), + resolveOption: vi.fn().mockResolvedValue(selections().option), + readRegion: vi.fn().mockResolvedValue(null), + validateCatalog: vi.fn(), + }, + ); + + expect(revisionCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + source: BillingCreditAutoTopUpConsentSource.CUSTOMER_UPDATE, + optionId: option.id, + refillPaymentAmountMinor: offer.paymentAmountMinor, + stripePaymentMethodId: 'pm_1', + paymentMethodSummary: expect.objectContaining({ last4: '4242' }), + }), + }); + expect(accountUpdate).toHaveBeenCalledWith({ + where: expect.objectContaining({ id: 'credit_account_1', autoTopUpGeneration: 0 }), + data: expect.objectContaining({ + autoTopUpGeneration: { increment: 1 }, + autoTopUpConsentRevisionId: 'consent_new', + autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, + }), + }); + }); + + it('disables future consent while leaving an older unresolved attempt independent', async () => { + const state = baseContext({ + autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, + autoTopUpConsentRevisionId: 'consent_1', + autoTopUpOptionId: option.id, + stripePaymentMethodId: 'pm_1', + }); + const accountUpdate = vi.fn(); + const tx = { + $queryRaw: vi.fn().mockResolvedValue([ + { + id: 'credit_account_1', + autoTopUpGeneration: 0, + autoTopUpConsentRevisionId: 'consent_1', + autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, + stripePaymentMethodId: 'pm_1', + }, + ]), + billingCreditAccount: { + update: accountUpdate, + }, + billingCreditAutoTopUpDisableEvent: { create: vi.fn() }, + billingCreditSetupCheckout: { updateMany: vi.fn() }, + orgAuditLog: { create: vi.fn() }, + }; + const prisma = { + $transaction: vi.fn((callback) => callback(tx)), + } as unknown as PrismaClient; + + await disableBillingCreditAutoTopUp( + { request, actorToken: 'actor', credential }, + { prisma, resolveContext: vi.fn().mockResolvedValue(state.context) }, + ); + + expect(accountUpdate).toHaveBeenCalledWith({ + where: { id: 'credit_account_1' }, + data: expect.objectContaining({ + autoTopUpState: BillingCreditAutoTopUpState.DISABLED, + autoTopUpConsentRevisionId: null, + stripePaymentMethodId: null, + }), + }); + expect(tx.billingCreditAutoTopUpDisableEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + previousConsentRevisionId: 'consent_1', + previousGeneration: 0, + }), + }); + }); + + it('keeps disable safe when an unknown pending attempt has no persisted Stripe id', async () => { + const state = baseContext({ + autoTopUpState: BillingCreditAutoTopUpState.ACTIVE, + autoTopUpConsentRevisionId: 'consent_1', + autoTopUpOptionId: option.id, + stripePaymentMethodId: 'pm_1', + }); + const disableCreate = vi.fn(); + const accountUpdate = vi.fn(); + const attemptRead = vi.fn().mockResolvedValue({ + id: 'attempt_pending', + status: 'PENDING', + stripePaymentIntentId: null, + }); + const tx = { + $queryRaw: vi.fn().mockResolvedValue([ + { + id: 'credit_account_1', + autoTopUpGeneration: 0, + autoTopUpConsentRevisionId: 'consent_1', + autoTopUpState: BillingCreditAutoTopUpState.NEEDS_REVIEW, + stripePaymentMethodId: 'pm_1', + }, + ]), + billingCreditAutoTopUpDisableEvent: { create: disableCreate }, + billingCreditAutoTopUpAttempt: { findFirst: attemptRead }, + billingCreditSetupCheckout: { updateMany: vi.fn() }, + billingCreditAccount: { update: accountUpdate }, + orgAuditLog: { create: vi.fn() }, + }; + const prisma = { + billingCreditAutoTopUpAttempt: { findFirst: attemptRead }, + $transaction: vi.fn((callback) => callback(tx)), + } as unknown as PrismaClient; + + await disableBillingCreditAutoTopUp( + { request, actorToken: 'actor', credential }, + { prisma, resolveContext: vi.fn().mockResolvedValue(state.context) }, + ); + + expect(disableCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ previousConsentRevisionId: 'consent_1' }), + }); + expect(accountUpdate).toHaveBeenCalledWith({ + where: { id: 'credit_account_1' }, + data: expect.objectContaining({ + autoTopUpState: BillingCreditAutoTopUpState.DISABLED, + autoTopUpConsentRevisionId: null, + }), + }); + expect(attemptRead).not.toHaveBeenCalled(); + }); +}); diff --git a/API/tests/unit/billing-credit-funding.fixture.ts b/API/tests/unit/billing-credit-funding.fixture.ts new file mode 100644 index 000000000..fe7cb469f --- /dev/null +++ b/API/tests/unit/billing-credit-funding.fixture.ts @@ -0,0 +1,171 @@ +import { BillingAppKeyPurpose, BillingCreditAutoTopUpState, BillingCreditCheckoutStatus } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { vi } from 'vitest'; +import type { CreditFundingActionContext } from '../../src/services/billing-credit-funding-context.service.js'; + +export const now = new Date('2026-07-21T12:00:00.000Z'); +export const credential = { + id: 'app_key_deepwater', + purpose: BillingAppKeyPurpose.CUSTOMER_LIFECYCLE, + actorIssuer: 'https://api.deepwater.example', + actorAudience: 'https://authentication.unlikeotherai.com/billing/v1/effective-tariff', + actorKeyId: 'actor_key_1', + actorPublicJwk: {}, + checkoutReturnOrigins: ['https://app.deepwater.example'], + service: { id: 'service_deepwater', identifier: 'deepwater', name: 'DeepWater' }, +}; +export const request = { + product: 'deepwater', + organisationId: 'org_1', + teamId: 'team_1', + userId: 'user_1', +}; +export const account = { id: 'account_1', stripeAccountId: 'acct_uoa', livemode: false }; +export const policy = { + id: 'policy_1', + currency: 'USD', + automaticConsentVersion: 'credits-auto-2026-07', +}; +export const offer = { + id: 'offer_20k', + paymentAmountMinor: 2_000n, + creditsReceivedMicrocredits: 20_000_000_000n, +}; +export const catalog = { + id: 'catalog_20k', + stripePriceId: 'price_20k', + stripeProductId: 'prod_20k', + stripeLookupKey: 'credits-20k-v1', + paymentAmountMinor: offer.paymentAmountMinor, +}; +export const option = { + id: 'option_safe', + thresholdMicrocredits: 5_000_000_000n, + monthlyChargeCapMinor: 10_000n, +}; + +export function fundingMetadata(local: Record) { + return { + uoa_service_id: credential.service.id, + uoa_app_key_id: credential.id, + uoa_credit_account_id: 'credit_account_1', + ...local, + }; +} + +export function baseContext(overrides?: Record) { + const sessionsCreate = vi.fn(); + const stripe = { + checkout: { sessions: { create: sessionsCreate, retrieve: vi.fn(), list: vi.fn() } }, + paymentMethods: { retrieve: vi.fn() }, + paymentIntents: { retrieve: vi.fn(), cancel: vi.fn() }, + prices: { retrieve: vi.fn() }, + accounts: { retrieveCurrent: vi.fn() }, + customers: { create: vi.fn(), retrieve: vi.fn() }, + }; + const creditAccount = { + id: 'credit_account_1', + accountId: account.id, + customerId: 'customer_1', + orgId: request.organisationId, + teamId: request.teamId, + autoTopUpState: BillingCreditAutoTopUpState.DISABLED, + autoTopUpGeneration: 0, + autoTopUpOptionId: null, + autoTopUpConsentRevisionId: null, + autoTopUpThresholdMicrocredits: null, + autoTopUpRefillOfferId: null, + autoTopUpMonthlyChargeCapMinor: null, + autoTopUpConsentVersion: null, + autoTopUpConsentedAt: null, + autoTopUpConsentedByUserId: null, + stripePaymentMethodId: null, + paymentMethodSummary: null, + ...overrides, + }; + const customer = { id: 'customer_1', stripeCustomerId: 'cus_team_1' }; + const context = { + actor: { jti: 'actor_jti_1', tv: 0, exp: 2_000_000_000 }, + viewer: { billingManager: true }, + account, + creditAccount, + customer, + stripe, + authorizeAction: vi.fn(), + ensureCustomer: vi.fn().mockResolvedValue(customer), + } as unknown as CreditFundingActionContext; + return { context, stripe, sessionsCreate }; +} + +export function selections() { + return { + offer: { policy, offer, catalog }, + option: { policy, option, offer, catalog }, + }; +} + +export function checkoutRow(kind: 'setup' | 'top_up', actorJti = 'actor_jti_1') { + const returnDigest = (value: string) => createHash('sha256').update(value).digest('hex'); + const common = { + id: kind === 'top_up' ? 'checkout_topup_1' : 'checkout_setup_1', + accountId: account.id, + creditAccountId: 'credit_account_1', + customerId: 'customer_1', + serviceId: credential.service.id, + appKeyId: credential.id, + actorJti, + requestedByUserId: request.userId, + successUrlDigest: returnDigest('https://app.deepwater.example/?uoa_billing=checkout_complete'), + cancelUrlDigest: returnDigest('https://app.deepwater.example/?uoa_billing=checkout_cancelled'), + status: BillingCreditCheckoutStatus.CREATING, + stripeCheckoutSessionId: null, + leaseExpiresAt: new Date(now.getTime() + 60_000), + createdAt: now, + }; + return kind === 'top_up' + ? { + ...common, + catalogId: catalog.id, + offerId: offer.id, + paymentAmountMinor: offer.paymentAmountMinor, + creditsReceivedMicrocredits: offer.creditsReceivedMicrocredits, + currency: 'USD', + } + : { + ...common, + policyId: policy.id, + optionId: option.id, + expectedGeneration: 0, + expectedConsentRevisionId: null, + consentVersion: policy.automaticConsentVersion, + thresholdMicrocredits: option.thresholdMicrocredits, + refillOfferId: offer.id, + refillCreditsMicrocredits: offer.creditsReceivedMicrocredits, + refillPaymentAmountMinor: offer.paymentAmountMinor, + monthlyChargeCapMinor: option.monthlyChargeCapMinor, + }; +} + +export function openTopUpSession( + checkout: ReturnType & Record, + overrides: Record = {}, +) { + return { + id: 'cs_existing', + livemode: false, + mode: 'payment', + status: 'open', + url: 'https://checkout.stripe.com/c/pay/cs_existing', + customer: 'cus_team_1', + client_reference_id: checkout.id, + metadata: { + uoa_credit_top_up_checkout_id: checkout.id, + uoa_service_id: checkout.serviceId, + uoa_app_key_id: checkout.appKeyId, + uoa_credit_account_id: checkout.creditAccountId, + }, + expires_at: 2_000_000_000, + ...overrides, + }; +} + diff --git a/API/tests/unit/billing-credit-region-top-up.service.test.ts b/API/tests/unit/billing-credit-region-top-up.service.test.ts index 9f51d6c7c..03622f92a 100644 --- a/API/tests/unit/billing-credit-region-top-up.service.test.ts +++ b/API/tests/unit/billing-credit-region-top-up.service.test.ts @@ -127,3 +127,8 @@ describe('credit top-ups in the billing region currency', () => { expect(state.sessionsCreate).not.toHaveBeenCalled(); }); }); + +// Pricing is isolated here; the PostgreSQL team-archive suite covers lifecycle fencing. +vi.mock('../../src/services/team-billing-effect.service.js', () => ({ + withLiveTeamBillingEffect: async (_db: unknown, _scope: unknown, effect: () => Promise) => effect(), +})); diff --git a/API/tests/unit/billing-recurring-addon-checkout-locale.test.ts b/API/tests/unit/billing-recurring-addon-checkout-locale.test.ts index 2ce97f110..397b66eda 100644 --- a/API/tests/unit/billing-recurring-addon-checkout-locale.test.ts +++ b/API/tests/unit/billing-recurring-addon-checkout-locale.test.ts @@ -171,3 +171,8 @@ describe('recurring add-on Checkout language retry', () => { ); }); }); + +// Lifecycle/provider fencing is exercised against PostgreSQL in team-archive.test.ts. +vi.mock('../../src/services/team-billing-effect.service.js', () => ({ + withLiveTeamBillingEffect: async (_db: unknown, _scope: unknown, effect: () => Promise) => effect(), +})); diff --git a/API/tests/unit/billing-stripe-checkout.service.test.ts b/API/tests/unit/billing-stripe-checkout.service.test.ts index a1d2748dd..17981f861 100644 --- a/API/tests/unit/billing-stripe-checkout.service.test.ts +++ b/API/tests/unit/billing-stripe-checkout.service.test.ts @@ -483,3 +483,8 @@ describe('Stripe Checkout authorization, recovery, and account binding', () => { expect(state.checkouts).toHaveLength(2); }); }); + +// Lifecycle/provider fencing is exercised against PostgreSQL in team-archive.test.ts. +vi.mock('../../src/services/team-billing-effect.service.js', () => ({ + withLiveTeamBillingEffect: async (_db: unknown, _scope: unknown, effect: () => Promise) => effect(), +})); diff --git a/API/tests/unit/billing-stripe-scheduler.service.test.ts b/API/tests/unit/billing-stripe-scheduler.service.test.ts index 250802949..917d53acb 100644 --- a/API/tests/unit/billing-stripe-scheduler.service.test.ts +++ b/API/tests/unit/billing-stripe-scheduler.service.test.ts @@ -74,6 +74,7 @@ describe('recurring Stripe usage export scheduler', () => { currentPeriodStart: { not: null, lte: now }, currentPeriodEnd: { not: null, gt: now }, service: { active: true }, + OR: [{ teamId: null }, { team: { lifecycleStatus: { not: 'ARCHIVED' } } }], tariff: { collectionMode: BillingCollectionMode.STRIPE, mode: { not: BillingTariffMode.FREE }, diff --git a/API/tests/unit/billing-stripe-usage-delivery.service.test.ts b/API/tests/unit/billing-stripe-usage-delivery.service.test.ts new file mode 100644 index 000000000..c30fd8744 --- /dev/null +++ b/API/tests/unit/billing-stripe-usage-delivery.service.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BillingStripeMeterEventState } from '@prisma/client'; +import { exportStripeUsage } from '../../src/services/billing-stripe-usage.service.js'; +import { capturedAt, stripeAccount, usageFixture as usage } from './billing-stripe-usage.test-fixtures.js'; +import { setup } from './billing-stripe-usage-export.fixture.js'; +import type { NormalizedMeteringUsage } from '../../src/services/billing-metering.types.js'; + +describe('Stripe usage delivery and recovery', () => { + it('holds an uncertain accepted event after the identifier safety window', async () => { + const pending = { + id: 'export_uncertain', + accountId: stripeAccount.id, + subscriptionId: 'subscription_1', + ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', + billingMonth: '2026-07', + billingProduct: 'deepwater', + callerProduct: 'deepsignal', + currency: 'USD', + cumulativeCustomerCharge: '2.5', + cumulativeMeterQuantity: 250_000_000n, + cumulativeGrossMeterQuantity: 250_000_000n, + deltaMeterQuantity: 250_000_000n, + stripeMeterEventIdentifier: 'uoa_me_uncertain', + stripeMeterEventCreatedAt: null, + stripeMeterEventFirstAttemptedAt: capturedAt, + stripeMeterEventAttemptedAt: capturedAt, + stripeMeterEventState: BillingStripeMeterEventState.UNCERTAIN, + createdAt: capturedAt, + }; + const { prisma, stripe, meterCreate } = setup([pending]); + await expect(exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(usage()), + now: () => new Date(capturedAt.getTime() + 24 * 60 * 60_000), + }, + )).rejects.toThrow('STRIPE_METER_EVENT_RECONCILIATION_REQUIRED'); + expect(meterCreate).not.toHaveBeenCalled(); + }); + + it('anchors retry expiry to the first possible acceptance across repeated crashes', async () => { + const { prisma, stripe, meterCreate, rows } = setup(); + meterCreate.mockRejectedValue(new Error('lost Stripe acknowledgement')); + let clock = new Date(capturedAt); + const run = () => exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(usage()), + now: () => clock, + }, + ); + await expect(run()).rejects.toThrow('lost Stripe acknowledgement'); + expect(rows[0]?.stripeMeterEventFirstAttemptedAt).toEqual(capturedAt); + clock = new Date(capturedAt.getTime() + 3 * 60_000); + await expect(run()).rejects.toThrow('lost Stripe acknowledgement'); + expect(rows[0]?.stripeMeterEventAttemptedAt).toEqual(clock); + expect(rows[0]?.stripeMeterEventFirstAttemptedAt).toEqual(capturedAt); + clock = new Date(capturedAt.getTime() + 24 * 60 * 60_000); + await expect(run()).rejects.toThrow('STRIPE_METER_EVENT_RECONCILIATION_REQUIRED'); + expect(meterCreate).toHaveBeenCalledTimes(2); + expect(rows[0]?.stripeMeterEventState).toBe(BillingStripeMeterEventState.RECONCILIATION_REQUIRED); + }); + + it('cannot settle a new identifier generation with an old in-flight response', async () => { + const { prisma, stripe, meterCreate, rows } = setup(); + let release!: (value: { created: number; livemode: boolean }) => void; + const pendingResponse = new Promise<{ created: number; livemode: boolean }>((resolve) => { + release = resolve; + }); + meterCreate.mockImplementation(() => pendingResponse); + const inFlight = exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(usage()), + now: () => capturedAt, + }, + ); + await vi.waitFor(() => expect(meterCreate).toHaveBeenCalledTimes(1)); + const row = rows[0]; + if (!row) throw new Error('export fixture missing'); + const oldIdentifier = row.stripeMeterEventIdentifier; + row.stripeMeterEventIdentifier = `${oldIdentifier}_r1`; + row.stripeMeterEventAttemptGeneration = 1; + row.stripeMeterEventState = BillingStripeMeterEventState.PENDING; + row.stripeMeterEventAttemptedAt = null; + row.stripeMeterEventFirstAttemptedAt = null; + release({ created: Math.floor(capturedAt.getTime() / 1000), livemode: false }); + await expect(inFlight).rejects.toThrow('STRIPE_METER_EVENT_ACCEPTANCE_UNCERTAIN'); + expect(row.stripeMeterEventIdentifier).toBe(`${oldIdentifier}_r1`); + expect(row.stripeMeterEventState).toBe(BillingStripeMeterEventState.PENDING); + expect(row.stripeMeterEventCreatedAt).toBeNull(); + }); + + it('holds a lower corrected snapshot for an auditable Stripe correction', async () => { + const prior = { + id: 'export_1', + accountId: stripeAccount.id, + subscriptionId: 'subscription_1', + ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', + billingMonth: '2026-07', + billingProduct: 'deepwater', + callerProduct: 'deepsignal', + currency: 'USD', + cumulativeCustomerCharge: '2.5', + cumulativeMeterQuantity: 250_000_000n, + cumulativeGrossMeterQuantity: 250_000_000n, + deltaMeterQuantity: 250_000_000n, + stripeMeterEventIdentifier: 'uoa_me_prior', + stripeMeterEventCreatedAt: capturedAt, + createdAt: capturedAt, + }; + const later = new Date('2026-07-20T12:00:00.000Z'); + const nextUsage = usage('1', 'mus_1123456789ABCDEFGHIJKLMNOPQRSTUV'); + nextUsage.snapshot.capturedAt = later.toISOString(); + const { prisma, stripe, meterCreate } = setup([prior]); + + await expect(exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(nextUsage), + now: () => later, + }, + )).rejects.toThrow('STRIPE_BUCKET_USAGE_RECONCILIATION_REQUIRED'); + expect(meterCreate).not.toHaveBeenCalled(); + }); + + it('retries a durable pending export without creating a second delta row', async () => { + const pending = { + id: 'export_1', + accountId: stripeAccount.id, + subscriptionId: 'subscription_1', + ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', + billingMonth: '2026-07', + billingProduct: 'deepwater', + callerProduct: 'deepsignal', + currency: 'USD', + cumulativeCustomerCharge: '2.5', + cumulativeMeterQuantity: 250_000_000n, + cumulativeGrossMeterQuantity: 250_000_000n, + deltaMeterQuantity: 250_000_000n, + stripeMeterEventIdentifier: 'uoa_me_pending', + stripeMeterEventCreatedAt: null, + createdAt: capturedAt, + }; + const { prisma, stripe, meterCreate, payerVersionBump, createExport } = setup([pending]); + + await exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(usage()), + now: () => capturedAt, + }, + ); + + expect(createExport).not.toHaveBeenCalled(); + expect(payerVersionBump).not.toHaveBeenCalled(); + expect(meterCreate).toHaveBeenCalledWith( + expect.objectContaining({ identifier: 'uoa_me_pending' }), + { idempotencyKey: 'uoa_me_pending' }, + ); + }); + + it('reuses each pending row snapshot time when a later snapshot triggers delivery', async () => { + const pending = { + id: 'export_1', + accountId: stripeAccount.id, + subscriptionId: 'subscription_1', + ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', + billingMonth: '2026-07', + billingProduct: 'deepwater', + callerProduct: 'deepsignal', + currency: 'USD', + cumulativeCustomerCharge: '2.5', + cumulativeMeterQuantity: 250_000_000n, + cumulativeGrossMeterQuantity: 250_000_000n, + deltaMeterQuantity: 250_000_000n, + stripeMeterEventIdentifier: 'uoa_me_pending', + stripeMeterEventCreatedAt: null, + createdAt: capturedAt, + }; + const later = new Date('2026-07-20T12:00:00.000Z'); + const nextUsage = usage('2.6', 'mus_1123456789ABCDEFGHIJKLMNOPQRSTUV'); + nextUsage.snapshot.capturedAt = later.toISOString(); + const { prisma, stripe, meterCreate } = setup([pending]); + + await exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(nextUsage), + now: () => later, + }, + ); + + expect(meterCreate).toHaveBeenCalledTimes(2); + expect(meterCreate.mock.calls[0]?.[0]).toMatchObject({ + identifier: 'uoa_me_pending', + timestamp: Math.floor(capturedAt.getTime() / 1000), + }); + expect(meterCreate.mock.calls[1]?.[0]).toMatchObject({ + timestamp: Math.floor(later.getTime() / 1000), + }); + }); + + it('exports usage captured after the pre-boundary safety pass into the draft renewal invoice', async () => { + const preBoundaryCapturedAt = new Date('2026-07-31T23:59:00.000Z'); + const preBoundary = { + id: 'export_pre_boundary', + accountId: stripeAccount.id, + subscriptionId: 'subscription_1', + ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', + billingMonth: '2026-07', + billingProduct: 'deepwater', + callerProduct: 'deepsignal', + currency: 'USD', + cumulativeCustomerCharge: '2.5', + cumulativeMeterQuantity: 250_000_000n, + cumulativeGrossMeterQuantity: 250_000_000n, + deltaMeterQuantity: 250_000_000n, + stripeMeterEventIdentifier: 'uoa_me_pre_boundary', + stripeMeterEventCreatedAt: preBoundaryCapturedAt, + createdAt: preBoundaryCapturedAt, + }; + const afterBoundary = new Date('2026-08-01T00:00:10.000Z'); + const finalUsage = usage('2.2', 'mus_2123456789ABCDEFGHIJKLMNOPQRSTUV'); + finalUsage.snapshot.capturedAt = afterBoundary.toISOString(); + const { prisma, stripe, fullSubscription, meterCreate, createExport } = setup([preBoundary]); + fullSubscription.currentPeriodStart = new Date('2026-08-01T00:00:00.000Z'); + fullSubscription.currentPeriodEnd = new Date('2026-09-01T00:00:00.000Z'); + + await exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(finalUsage), + invoicePeriod: { + startsAt: new Date('2026-07-01T00:00:00.000Z'), + endsAt: new Date('2026-08-01T00:00:00.000Z'), + }, + now: () => afterBoundary, + }, + ); + + expect(createExport).toHaveBeenCalledWith({ + data: expect.objectContaining({ + cumulativeMeterQuantity: 275_000_000n, + deltaMeterQuantity: 25_000_000n, + createdAt: afterBoundary, + }), + }); + expect(meterCreate).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ value: '25000000' }), + timestamp: 1_785_542_399, + }), + expect.any(Object), + ); + }); + + it('rejects snapshots beyond Stripe clock tolerance before persisting', async () => { + const futureUsage = usage(); + futureUsage.snapshot.capturedAt = new Date(capturedAt.getTime() + 6 * 60 * 1000).toISOString(); + const { prisma, stripe, createExport } = setup(); + + await expect( + exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(futureUsage), + now: () => capturedAt, + }, + ), + ).rejects.toThrow('STRIPE_USAGE_MONTH_OUT_OF_RANGE'); + expect(createExport).not.toHaveBeenCalled(); + }); + + it('rejects usage outside the subscription exact UTC billing period', async () => { + const { prisma, stripe, fullSubscription, createExport } = setup(); + fullSubscription.currentPeriodStart = new Date('2026-07-02T00:00:00.000Z'); + + await expect( + exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(usage()), + now: () => capturedAt, + }, + ), + ).rejects.toThrow('LEDGER_METERING_SCOPE_MISMATCH'); + expect(createExport).not.toHaveBeenCalled(); + }); + + it('fails closed on cross-tenant, tariff, or collection mismatches', async () => { + const scenarios = [ + (value: NormalizedMeteringUsage) => { + value.scope.organizationId = 'org_other'; + }, + (value: NormalizedMeteringUsage) => { + value.product = 'deeptest'; + }, + (value: NormalizedMeteringUsage) => { + value.lines[0]!.billingProduct = 'deeptest'; + }, + (value: NormalizedMeteringUsage) => { + value.lines[0]!.currency = 'EUR'; + }, + (value: NormalizedMeteringUsage) => { + value.lines[0]!.selectedProviderCost = null; + }, + ]; + + for (const mutate of scenarios) { + const value = usage(); + mutate(value); + const { prisma, stripe, meterCreate } = setup(); + await expect( + exportStripeUsage( + { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, + { + prisma: prisma as never, + stripe: stripe as never, + settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(value), + now: () => capturedAt, + }, + ), + ).rejects.toBeInstanceOf(Error); + expect(meterCreate).not.toHaveBeenCalled(); + } + }); +}); diff --git a/API/tests/unit/billing-stripe-usage-export.fixture.ts b/API/tests/unit/billing-stripe-usage-export.fixture.ts new file mode 100644 index 000000000..3bf7b3cce --- /dev/null +++ b/API/tests/unit/billing-stripe-usage-export.fixture.ts @@ -0,0 +1,162 @@ +import { vi } from 'vitest'; +import { BillingStripeMeterEventState } from '@prisma/client'; +import { capturedAt, stripeAccount, subscriptionFixture } from './billing-stripe-usage.test-fixtures.js'; + +type ExportRow = { + id: string; + accountId: string; + subscriptionId: string; + ledgerSnapshotCursor: string; + billingMonth: string; + billingProduct: string; + callerProduct: string; + currency: string; + cumulativeCustomerCharge: string; + cumulativeMeterQuantity: bigint; + cumulativeGrossMeterQuantity?: bigint | null; + deltaMeterQuantity: bigint; + stripeMeterEventIdentifier: string; + stripeMeterEventCreatedAt: Date | null; + stripeMeterEventFirstAttemptedAt?: Date | null; + stripeMeterEventAttemptedAt?: Date | null; + stripeMeterEventAttemptGeneration?: number; + stripeMeterEventState?: BillingStripeMeterEventState; + createdAt: Date; +}; + +export function setup(existing: ExportRow[] = [], confirmedOffset = 0n, + teamStatus: 'ACTIVE' | 'DISABLED' | 'ARCHIVED' = 'ACTIVE') { + const rows = [...existing]; + const fullSubscription = subscriptionFixture(); + const findSubscription = vi.fn(async (args: { include?: unknown }) => + args.include + ? fullSubscription + : { + id: fullSubscription.id, + accountId: fullSubscription.accountId, + livemode: fullSubscription.livemode, + account: { + stripeAccountId: fullSubscription.account.stripeAccountId, + livemode: fullSubscription.account.livemode, + }, + orgId: fullSubscription.orgId, + teamId: fullSubscription.teamId, + service: { identifier: fullSubscription.service.identifier }, + tariff: { usagePaymentMode: fullSubscription.tariff.usagePaymentMode }, + }, + ); + const findExports = vi.fn(async ({ where }: { where: Record }) => + rows + .filter( + (row) => + row.subscriptionId === where.subscriptionId && + (!where.billingMonth || row.billingMonth === where.billingMonth) && + (!where.ledgerSnapshotCursor || + row.ledgerSnapshotCursor === where.ledgerSnapshotCursor) && + (!('stripeMeterEventCreatedAt' in where) || + row.stripeMeterEventCreatedAt === where.stripeMeterEventCreatedAt) && + (!('stripeMeterEventState' in where) || + typeof where.stripeMeterEventState !== 'object' || + row.stripeMeterEventState !== (where.stripeMeterEventState as { not: string }).not), + ) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), + ); + const createExport = vi.fn(async ({ data }: { data: Omit }) => { + const row = { + id: `export_${rows.length + 1}`, + stripeMeterEventCreatedAt: null, + stripeMeterEventAttemptedAt: null, + stripeMeterEventFirstAttemptedAt: null, + stripeMeterEventAttemptGeneration: 0, + stripeMeterEventState: BillingStripeMeterEventState.PENDING, + ...data, + }; + rows.push(row); + return row; + }); + const updateExports = vi.fn( + async ({ + where, + data, + }: { + where: { id: string; stripeMeterEventCreatedAt: null; stripeMeterEventState?: BillingStripeMeterEventState; + stripeMeterEventAttemptedAt?: Date | null; stripeMeterEventFirstAttemptedAt?: Date | null; + stripeMeterEventIdentifier?: string; stripeMeterEventAttemptGeneration?: number }; + data: { + stripeMeterEventCreatedAt?: Date; + stripeMeterEventAttemptedAt?: Date; + stripeMeterEventFirstAttemptedAt?: Date; + stripeMeterEventState?: BillingStripeMeterEventState; + }; + }) => { + const row = rows.find( + (candidate) => candidate.id === where.id && candidate.stripeMeterEventCreatedAt === null && + (!('stripeMeterEventState' in where) || candidate.stripeMeterEventState === where.stripeMeterEventState) && + (!('stripeMeterEventAttemptedAt' in where) || candidate.stripeMeterEventAttemptedAt === where.stripeMeterEventAttemptedAt) && + (!('stripeMeterEventFirstAttemptedAt' in where) || + candidate.stripeMeterEventFirstAttemptedAt === where.stripeMeterEventFirstAttemptedAt) && + (!('stripeMeterEventIdentifier' in where) || + candidate.stripeMeterEventIdentifier === where.stripeMeterEventIdentifier) && + (!('stripeMeterEventAttemptGeneration' in where) || + candidate.stripeMeterEventAttemptGeneration === where.stripeMeterEventAttemptGeneration), + ); + if (!row) return { count: 0 }; + if (data.stripeMeterEventCreatedAt) row.stripeMeterEventCreatedAt = data.stripeMeterEventCreatedAt; + if (data.stripeMeterEventAttemptedAt) row.stripeMeterEventAttemptedAt = data.stripeMeterEventAttemptedAt; + if (data.stripeMeterEventFirstAttemptedAt) { + row.stripeMeterEventFirstAttemptedAt = data.stripeMeterEventFirstAttemptedAt; + } + if (data.stripeMeterEventState) row.stripeMeterEventState = data.stripeMeterEventState; + return { count: 1 }; + }, + ); + const payerVersionBump = vi.fn().mockResolvedValue({ count: 1 }); + const tx = { + team: { findUniqueOrThrow: vi.fn().mockResolvedValue({ orgId: 'org_1', lifecycleStatus: teamStatus }) }, + $queryRaw: vi.fn().mockImplementation(async (query: { strings?: string[] }) => + query.strings?.join('').includes('billing_credit_accounts') + ? [{ id: 'credit_account_1' }] + : [{ id: fullSubscription.id }]), + billingStripeSubscription: { findUnique: findSubscription, findUniqueOrThrow: findSubscription }, + billingCreditAccount: { updateMany: payerVersionBump }, + billingCreditUsageSettlement: { findMany: vi.fn().mockResolvedValue( + confirmedOffset ? [{ cumulativeCreditsConsumedMicrocredits: confirmedOffset * 10n }] : [], + ) }, + billingStripeUsageExport: { + findMany: findExports, + create: createExport, + }, + }; + const prisma = { + billingStripeAccount: { upsert: vi.fn().mockResolvedValue(stripeAccount) }, + billingStripeSubscription: { + findUnique: findSubscription, + }, + billingStripeUsageExport: { + findMany: findExports, + updateMany: updateExports, + }, + $transaction: vi.fn(async (run: (client: typeof tx) => unknown) => run(tx)), + }; + const meterCreate = vi.fn().mockResolvedValue({ + created: Math.floor(capturedAt.getTime() / 1000), + livemode: false, + }); + const stripe = { + accounts: { + retrieveCurrent: vi.fn().mockResolvedValue({ id: stripeAccount.stripeAccountId }), + }, + billing: { meterEvents: { create: meterCreate } }, + }; + return { + rows, + fullSubscription, + prisma, + stripe, + meterCreate, + payerVersionBump, + createExport, + updateExports, + }; +} + diff --git a/API/tests/unit/billing-stripe-usage.service.test.ts b/API/tests/unit/billing-stripe-usage.service.test.ts index 1985fa2d0..081aeaafa 100644 --- a/API/tests/unit/billing-stripe-usage.service.test.ts +++ b/API/tests/unit/billing-stripe-usage.service.test.ts @@ -1,175 +1,24 @@ import { describe, expect, it, vi } from 'vitest'; import { BillingStripeMeterEventState } from '@prisma/client'; - -import type { NormalizedMeteringUsage } from '../../src/services/billing-metering.types.js'; -import { - exportStripeUsage, - stripeMeterQuantityFromMajorAmount, -} from '../../src/services/billing-stripe-usage.service.js'; -import { - capturedAt, - stripeAccount, - subscriptionFixture, - usageFixture as usage, -} from './billing-stripe-usage.test-fixtures.js'; - -type ExportRow = { - id: string; - accountId: string; - subscriptionId: string; - ledgerSnapshotCursor: string; - billingMonth: string; - billingProduct: string; - callerProduct: string; - currency: string; - cumulativeCustomerCharge: string; - cumulativeMeterQuantity: bigint; - cumulativeGrossMeterQuantity?: bigint | null; - deltaMeterQuantity: bigint; - stripeMeterEventIdentifier: string; - stripeMeterEventCreatedAt: Date | null; - stripeMeterEventFirstAttemptedAt?: Date | null; - stripeMeterEventAttemptedAt?: Date | null; - stripeMeterEventAttemptGeneration?: number; - stripeMeterEventState?: BillingStripeMeterEventState; - createdAt: Date; -}; - -function setup(existing: ExportRow[] = [], confirmedOffset = 0n) { - const rows = [...existing]; - const fullSubscription = subscriptionFixture(); - const findSubscription = vi.fn(async (args: { include?: unknown }) => - args.include - ? fullSubscription - : { - id: fullSubscription.id, - accountId: fullSubscription.accountId, - livemode: fullSubscription.livemode, - account: { - stripeAccountId: fullSubscription.account.stripeAccountId, - livemode: fullSubscription.account.livemode, - }, - orgId: fullSubscription.orgId, - teamId: fullSubscription.teamId, - service: { identifier: fullSubscription.service.identifier }, - tariff: { usagePaymentMode: fullSubscription.tariff.usagePaymentMode }, - }, - ); - const findExports = vi.fn(async ({ where }: { where: Record }) => - rows - .filter( - (row) => - row.subscriptionId === where.subscriptionId && - (!where.billingMonth || row.billingMonth === where.billingMonth) && - (!where.ledgerSnapshotCursor || - row.ledgerSnapshotCursor === where.ledgerSnapshotCursor) && - (!('stripeMeterEventCreatedAt' in where) || - row.stripeMeterEventCreatedAt === where.stripeMeterEventCreatedAt) && - (!('stripeMeterEventState' in where) || - typeof where.stripeMeterEventState !== 'object' || - row.stripeMeterEventState !== (where.stripeMeterEventState as { not: string }).not), - ) - .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), - ); - const createExport = vi.fn(async ({ data }: { data: Omit }) => { - const row = { - id: `export_${rows.length + 1}`, - stripeMeterEventCreatedAt: null, - stripeMeterEventAttemptedAt: null, - stripeMeterEventFirstAttemptedAt: null, - stripeMeterEventAttemptGeneration: 0, - stripeMeterEventState: BillingStripeMeterEventState.PENDING, - ...data, - }; - rows.push(row); - return row; - }); - const updateExports = vi.fn( - async ({ - where, - data, - }: { - where: { id: string; stripeMeterEventCreatedAt: null; stripeMeterEventState?: BillingStripeMeterEventState; - stripeMeterEventAttemptedAt?: Date | null; stripeMeterEventFirstAttemptedAt?: Date | null; - stripeMeterEventIdentifier?: string; stripeMeterEventAttemptGeneration?: number }; - data: { - stripeMeterEventCreatedAt?: Date; - stripeMeterEventAttemptedAt?: Date; - stripeMeterEventFirstAttemptedAt?: Date; - stripeMeterEventState?: BillingStripeMeterEventState; - }; - }) => { - const row = rows.find( - (candidate) => candidate.id === where.id && candidate.stripeMeterEventCreatedAt === null && - (!('stripeMeterEventState' in where) || candidate.stripeMeterEventState === where.stripeMeterEventState) && - (!('stripeMeterEventAttemptedAt' in where) || candidate.stripeMeterEventAttemptedAt === where.stripeMeterEventAttemptedAt) && - (!('stripeMeterEventFirstAttemptedAt' in where) || - candidate.stripeMeterEventFirstAttemptedAt === where.stripeMeterEventFirstAttemptedAt) && - (!('stripeMeterEventIdentifier' in where) || - candidate.stripeMeterEventIdentifier === where.stripeMeterEventIdentifier) && - (!('stripeMeterEventAttemptGeneration' in where) || - candidate.stripeMeterEventAttemptGeneration === where.stripeMeterEventAttemptGeneration), - ); - if (!row) return { count: 0 }; - if (data.stripeMeterEventCreatedAt) row.stripeMeterEventCreatedAt = data.stripeMeterEventCreatedAt; - if (data.stripeMeterEventAttemptedAt) row.stripeMeterEventAttemptedAt = data.stripeMeterEventAttemptedAt; - if (data.stripeMeterEventFirstAttemptedAt) { - row.stripeMeterEventFirstAttemptedAt = data.stripeMeterEventFirstAttemptedAt; - } - if (data.stripeMeterEventState) row.stripeMeterEventState = data.stripeMeterEventState; - return { count: 1 }; - }, - ); - const payerVersionBump = vi.fn().mockResolvedValue({ count: 1 }); - const tx = { - $queryRaw: vi.fn().mockImplementation(async (query: { strings?: string[] }) => - query.strings?.join('').includes('billing_credit_accounts') - ? [{ id: 'credit_account_1' }] - : [{ id: fullSubscription.id }]), - billingStripeSubscription: { findUnique: findSubscription }, - billingCreditAccount: { updateMany: payerVersionBump }, - billingCreditUsageSettlement: { findMany: vi.fn().mockResolvedValue( - confirmedOffset ? [{ cumulativeCreditsConsumedMicrocredits: confirmedOffset * 10n }] : [], - ) }, - billingStripeUsageExport: { - findMany: findExports, - create: createExport, - }, - }; - const prisma = { - billingStripeAccount: { upsert: vi.fn().mockResolvedValue(stripeAccount) }, - billingStripeSubscription: { - findUnique: findSubscription, - }, - billingStripeUsageExport: { - findMany: findExports, - updateMany: updateExports, - }, - $transaction: vi.fn(async (run: (client: typeof tx) => unknown) => run(tx)), - }; - const meterCreate = vi.fn().mockResolvedValue({ - created: Math.floor(capturedAt.getTime() / 1000), - livemode: false, +import { exportStripeUsage, stripeMeterQuantityFromMajorAmount } from '../../src/services/billing-stripe-usage.service.js'; +import { capturedAt, stripeAccount, usageFixture as usage } from './billing-stripe-usage.test-fixtures.js'; +import { setup } from './billing-stripe-usage-export.fixture.js'; + +describe('Stripe usage rating', () => { + it.each(['ACTIVE', 'DISABLED', 'ARCHIVED'] as const)('settles earned usage unless the team is archived (%s)', async status => { + const { prisma, stripe, meterCreate } = setup([], 0n, status); + const result = exportStripeUsage({ subscriptionId: 'subscription_1', billingMonth: '2026-07' }, { + prisma: prisma as never, stripe: stripe as never, settleCredits: vi.fn().mockResolvedValue(0n), + fetchUsage: vi.fn().mockResolvedValue(usage()), now: () => capturedAt, + }); + if (status === 'ARCHIVED') { + await expect(result).rejects.toThrow('ARCHIVE_BILLING_COLLECTION_HELD'); + expect(meterCreate).not.toHaveBeenCalled(); + } else { + await expect(result).resolves.toMatchObject({ billingMonth: '2026-07' }); + expect(meterCreate).toHaveBeenCalledOnce(); + } }); - const stripe = { - accounts: { - retrieveCurrent: vi.fn().mockResolvedValue({ id: stripeAccount.stripeAccountId }), - }, - billing: { meterEvents: { create: meterCreate } }, - }; - return { - rows, - fullSubscription, - prisma, - stripe, - meterCreate, - payerVersionBump, - createExport, - updateExports, - }; -} - -describe('Stripe usage export', () => { it('converts major currency exactly to integer micro-minor units', () => { expect(stripeMeterQuantityFromMajorAmount('2.5', 'USD')).toBe(250_000_000n); expect(stripeMeterQuantityFromMajorAmount('2.5', 'JPY')).toBe(2_500_000n); @@ -348,349 +197,4 @@ describe('Stripe usage export', () => { expect(meterCreate).not.toHaveBeenCalled(); }); - it('holds an uncertain accepted event after the identifier safety window', async () => { - const pending = { - id: 'export_uncertain', - accountId: stripeAccount.id, - subscriptionId: 'subscription_1', - ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', - billingMonth: '2026-07', - billingProduct: 'deepwater', - callerProduct: 'deepsignal', - currency: 'USD', - cumulativeCustomerCharge: '2.5', - cumulativeMeterQuantity: 250_000_000n, - cumulativeGrossMeterQuantity: 250_000_000n, - deltaMeterQuantity: 250_000_000n, - stripeMeterEventIdentifier: 'uoa_me_uncertain', - stripeMeterEventCreatedAt: null, - stripeMeterEventFirstAttemptedAt: capturedAt, - stripeMeterEventAttemptedAt: capturedAt, - stripeMeterEventState: BillingStripeMeterEventState.UNCERTAIN, - createdAt: capturedAt, - }; - const { prisma, stripe, meterCreate } = setup([pending]); - await expect(exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(usage()), - now: () => new Date(capturedAt.getTime() + 24 * 60 * 60_000), - }, - )).rejects.toThrow('STRIPE_METER_EVENT_RECONCILIATION_REQUIRED'); - expect(meterCreate).not.toHaveBeenCalled(); - }); - - it('anchors retry expiry to the first possible acceptance across repeated crashes', async () => { - const { prisma, stripe, meterCreate, rows } = setup(); - meterCreate.mockRejectedValue(new Error('lost Stripe acknowledgement')); - let clock = new Date(capturedAt); - const run = () => exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(usage()), - now: () => clock, - }, - ); - await expect(run()).rejects.toThrow('lost Stripe acknowledgement'); - expect(rows[0]?.stripeMeterEventFirstAttemptedAt).toEqual(capturedAt); - clock = new Date(capturedAt.getTime() + 3 * 60_000); - await expect(run()).rejects.toThrow('lost Stripe acknowledgement'); - expect(rows[0]?.stripeMeterEventAttemptedAt).toEqual(clock); - expect(rows[0]?.stripeMeterEventFirstAttemptedAt).toEqual(capturedAt); - clock = new Date(capturedAt.getTime() + 24 * 60 * 60_000); - await expect(run()).rejects.toThrow('STRIPE_METER_EVENT_RECONCILIATION_REQUIRED'); - expect(meterCreate).toHaveBeenCalledTimes(2); - expect(rows[0]?.stripeMeterEventState).toBe(BillingStripeMeterEventState.RECONCILIATION_REQUIRED); - }); - - it('cannot settle a new identifier generation with an old in-flight response', async () => { - const { prisma, stripe, meterCreate, rows } = setup(); - let release!: (value: { created: number; livemode: boolean }) => void; - const pendingResponse = new Promise<{ created: number; livemode: boolean }>((resolve) => { - release = resolve; - }); - meterCreate.mockImplementation(() => pendingResponse); - const inFlight = exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(usage()), - now: () => capturedAt, - }, - ); - await vi.waitFor(() => expect(meterCreate).toHaveBeenCalledTimes(1)); - const row = rows[0]; - if (!row) throw new Error('export fixture missing'); - const oldIdentifier = row.stripeMeterEventIdentifier; - row.stripeMeterEventIdentifier = `${oldIdentifier}_r1`; - row.stripeMeterEventAttemptGeneration = 1; - row.stripeMeterEventState = BillingStripeMeterEventState.PENDING; - row.stripeMeterEventAttemptedAt = null; - row.stripeMeterEventFirstAttemptedAt = null; - release({ created: Math.floor(capturedAt.getTime() / 1000), livemode: false }); - await expect(inFlight).rejects.toThrow('STRIPE_METER_EVENT_ACCEPTANCE_UNCERTAIN'); - expect(row.stripeMeterEventIdentifier).toBe(`${oldIdentifier}_r1`); - expect(row.stripeMeterEventState).toBe(BillingStripeMeterEventState.PENDING); - expect(row.stripeMeterEventCreatedAt).toBeNull(); - }); - - it('holds a lower corrected snapshot for an auditable Stripe correction', async () => { - const prior = { - id: 'export_1', - accountId: stripeAccount.id, - subscriptionId: 'subscription_1', - ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', - billingMonth: '2026-07', - billingProduct: 'deepwater', - callerProduct: 'deepsignal', - currency: 'USD', - cumulativeCustomerCharge: '2.5', - cumulativeMeterQuantity: 250_000_000n, - cumulativeGrossMeterQuantity: 250_000_000n, - deltaMeterQuantity: 250_000_000n, - stripeMeterEventIdentifier: 'uoa_me_prior', - stripeMeterEventCreatedAt: capturedAt, - createdAt: capturedAt, - }; - const later = new Date('2026-07-20T12:00:00.000Z'); - const nextUsage = usage('1', 'mus_1123456789ABCDEFGHIJKLMNOPQRSTUV'); - nextUsage.snapshot.capturedAt = later.toISOString(); - const { prisma, stripe, meterCreate } = setup([prior]); - - await expect(exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(nextUsage), - now: () => later, - }, - )).rejects.toThrow('STRIPE_BUCKET_USAGE_RECONCILIATION_REQUIRED'); - expect(meterCreate).not.toHaveBeenCalled(); - }); - - it('retries a durable pending export without creating a second delta row', async () => { - const pending = { - id: 'export_1', - accountId: stripeAccount.id, - subscriptionId: 'subscription_1', - ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', - billingMonth: '2026-07', - billingProduct: 'deepwater', - callerProduct: 'deepsignal', - currency: 'USD', - cumulativeCustomerCharge: '2.5', - cumulativeMeterQuantity: 250_000_000n, - cumulativeGrossMeterQuantity: 250_000_000n, - deltaMeterQuantity: 250_000_000n, - stripeMeterEventIdentifier: 'uoa_me_pending', - stripeMeterEventCreatedAt: null, - createdAt: capturedAt, - }; - const { prisma, stripe, meterCreate, payerVersionBump, createExport } = setup([pending]); - - await exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(usage()), - now: () => capturedAt, - }, - ); - - expect(createExport).not.toHaveBeenCalled(); - expect(payerVersionBump).not.toHaveBeenCalled(); - expect(meterCreate).toHaveBeenCalledWith( - expect.objectContaining({ identifier: 'uoa_me_pending' }), - { idempotencyKey: 'uoa_me_pending' }, - ); - }); - - it('reuses each pending row snapshot time when a later snapshot triggers delivery', async () => { - const pending = { - id: 'export_1', - accountId: stripeAccount.id, - subscriptionId: 'subscription_1', - ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', - billingMonth: '2026-07', - billingProduct: 'deepwater', - callerProduct: 'deepsignal', - currency: 'USD', - cumulativeCustomerCharge: '2.5', - cumulativeMeterQuantity: 250_000_000n, - cumulativeGrossMeterQuantity: 250_000_000n, - deltaMeterQuantity: 250_000_000n, - stripeMeterEventIdentifier: 'uoa_me_pending', - stripeMeterEventCreatedAt: null, - createdAt: capturedAt, - }; - const later = new Date('2026-07-20T12:00:00.000Z'); - const nextUsage = usage('2.6', 'mus_1123456789ABCDEFGHIJKLMNOPQRSTUV'); - nextUsage.snapshot.capturedAt = later.toISOString(); - const { prisma, stripe, meterCreate } = setup([pending]); - - await exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(nextUsage), - now: () => later, - }, - ); - - expect(meterCreate).toHaveBeenCalledTimes(2); - expect(meterCreate.mock.calls[0]?.[0]).toMatchObject({ - identifier: 'uoa_me_pending', - timestamp: Math.floor(capturedAt.getTime() / 1000), - }); - expect(meterCreate.mock.calls[1]?.[0]).toMatchObject({ - timestamp: Math.floor(later.getTime() / 1000), - }); - }); - - it('exports usage captured after the pre-boundary safety pass into the draft renewal invoice', async () => { - const preBoundaryCapturedAt = new Date('2026-07-31T23:59:00.000Z'); - const preBoundary = { - id: 'export_pre_boundary', - accountId: stripeAccount.id, - subscriptionId: 'subscription_1', - ledgerSnapshotCursor: 'mus_0123456789ABCDEFGHIJKLMNOPQRSTUV', - billingMonth: '2026-07', - billingProduct: 'deepwater', - callerProduct: 'deepsignal', - currency: 'USD', - cumulativeCustomerCharge: '2.5', - cumulativeMeterQuantity: 250_000_000n, - cumulativeGrossMeterQuantity: 250_000_000n, - deltaMeterQuantity: 250_000_000n, - stripeMeterEventIdentifier: 'uoa_me_pre_boundary', - stripeMeterEventCreatedAt: preBoundaryCapturedAt, - createdAt: preBoundaryCapturedAt, - }; - const afterBoundary = new Date('2026-08-01T00:00:10.000Z'); - const finalUsage = usage('2.2', 'mus_2123456789ABCDEFGHIJKLMNOPQRSTUV'); - finalUsage.snapshot.capturedAt = afterBoundary.toISOString(); - const { prisma, stripe, fullSubscription, meterCreate, createExport } = setup([preBoundary]); - fullSubscription.currentPeriodStart = new Date('2026-08-01T00:00:00.000Z'); - fullSubscription.currentPeriodEnd = new Date('2026-09-01T00:00:00.000Z'); - - await exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(finalUsage), - invoicePeriod: { - startsAt: new Date('2026-07-01T00:00:00.000Z'), - endsAt: new Date('2026-08-01T00:00:00.000Z'), - }, - now: () => afterBoundary, - }, - ); - - expect(createExport).toHaveBeenCalledWith({ - data: expect.objectContaining({ - cumulativeMeterQuantity: 275_000_000n, - deltaMeterQuantity: 25_000_000n, - createdAt: afterBoundary, - }), - }); - expect(meterCreate).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ value: '25000000' }), - timestamp: 1_785_542_399, - }), - expect.any(Object), - ); - }); - - it('rejects snapshots beyond Stripe clock tolerance before persisting', async () => { - const futureUsage = usage(); - futureUsage.snapshot.capturedAt = new Date(capturedAt.getTime() + 6 * 60 * 1000).toISOString(); - const { prisma, stripe, createExport } = setup(); - - await expect( - exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(futureUsage), - now: () => capturedAt, - }, - ), - ).rejects.toThrow('STRIPE_USAGE_MONTH_OUT_OF_RANGE'); - expect(createExport).not.toHaveBeenCalled(); - }); - - it('rejects usage outside the subscription exact UTC billing period', async () => { - const { prisma, stripe, fullSubscription, createExport } = setup(); - fullSubscription.currentPeriodStart = new Date('2026-07-02T00:00:00.000Z'); - - await expect( - exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(usage()), - now: () => capturedAt, - }, - ), - ).rejects.toThrow('LEDGER_METERING_SCOPE_MISMATCH'); - expect(createExport).not.toHaveBeenCalled(); - }); - - it('fails closed on cross-tenant, tariff, or collection mismatches', async () => { - const scenarios = [ - (value: NormalizedMeteringUsage) => { - value.scope.organizationId = 'org_other'; - }, - (value: NormalizedMeteringUsage) => { - value.product = 'deeptest'; - }, - (value: NormalizedMeteringUsage) => { - value.lines[0]!.billingProduct = 'deeptest'; - }, - (value: NormalizedMeteringUsage) => { - value.lines[0]!.currency = 'EUR'; - }, - (value: NormalizedMeteringUsage) => { - value.lines[0]!.selectedProviderCost = null; - }, - ]; - - for (const mutate of scenarios) { - const value = usage(); - mutate(value); - const { prisma, stripe, meterCreate } = setup(); - await expect( - exportStripeUsage( - { subscriptionId: 'subscription_1', billingMonth: '2026-07' }, - { - prisma: prisma as never, - stripe: stripe as never, - settleCredits: vi.fn().mockResolvedValue(0n), - fetchUsage: vi.fn().mockResolvedValue(value), - now: () => capturedAt, - }, - ), - ).rejects.toBeInstanceOf(Error); - expect(meterCreate).not.toHaveBeenCalled(); - } - }); }); diff --git a/API/tests/unit/team-archive-billing.service.test.ts b/API/tests/unit/team-archive-billing.service.test.ts new file mode 100644 index 000000000..846624d86 --- /dev/null +++ b/API/tests/unit/team-archive-billing.service.test.ts @@ -0,0 +1,62 @@ +import type { PrismaClient, TeamArchiveOperation } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; +import { stopArchivedTeamBilling, type ArchiveStripe } from '../../src/services/team-archive-billing.service.js'; + +const now = new Date('2026-11-07T12:00:00Z'); +const operation: TeamArchiveOperation = { id: '7ed35e23-a6a0-46fa-a32f-ee6447a3b173', orgId: 'org', teamId: 'team', + sourceClientDomainId: 'product', actorUserId: 'actor', deletionHeld: true, billingStoppedAt: null, + settleAfter: now, leaseOwner: null, leaseExpiresAt: null, nextAttemptAt: now, createdAt: now }; +function pages(values: T[]) { return { async *[Symbol.asyncIterator]() { yield* values; } }; } +function fixture() { + const customer = { accountId: 'account', account: { livemode: false }, scopeKey: 'org:team', stripeCustomerId: 'cus_team' }; + const prisma = { billingStripeCustomer: { findMany: vi.fn().mockResolvedValue([customer]) }, + billingStripeAccount: { upsert: vi.fn().mockResolvedValue({ id: 'account', stripeAccountId: 'acct_test', livemode: false }) } }; + const stripe = { + accounts: { retrieveCurrent: vi.fn().mockResolvedValue({ id: 'acct_test' }) }, + checkout: { sessions: { list: vi.fn().mockReturnValue(pages([{ id: 'cs_open', customer: 'cus_team', livemode: false }])), + expire: vi.fn().mockResolvedValue({ status: 'expired' }), retrieve: vi.fn() } }, + subscriptions: { list: vi.fn().mockReturnValue(pages([{ id: 'sub_active', customer: 'cus_team', livemode: false, status: 'active' }])), + cancel: vi.fn().mockResolvedValue({ status: 'canceled' }), retrieve: vi.fn() }, + invoices: { list: vi.fn().mockReturnValue(pages([{ id: 'in_open', customer: 'cus_team', livemode: false, auto_advance: true }])), + update: vi.fn().mockResolvedValue({ auto_advance: false }) }, + paymentIntents: { list: vi.fn().mockReturnValue(pages([{ id: 'pi_pending', customer: 'cus_team', livemode: false, status: 'requires_payment_method' }])), + cancel: vi.fn().mockResolvedValue({ status: 'canceled' }), retrieve: vi.fn() }, + }; + const run = (row = operation) => stopArchivedTeamBilling(row, { prisma: prisma as unknown as PrismaClient, + stripe: stripe as unknown as ArchiveStripe, livemode: false, now: () => now }); + return { prisma, stripe, customer, run }; +} + +describe('archive billing stop evidence', () => { + it('cancels immediately without a final invoice or proration and scopes every call to the team customer', async () => { + const { run, prisma, stripe } = fixture(); + expect(await run()).toBe(true); + expect(prisma.billingStripeCustomer.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { orgId: 'org', teamId: 'team', scope: 'TEAM' } })); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith('sub_active', { invoice_now: false, prorate: false }); + expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith('cs_open'); + expect(stripe.invoices.update).toHaveBeenCalledWith('in_open', { auto_advance: false }); + expect(stripe.paymentIntents.cancel).toHaveBeenCalledWith('pi_pending'); + }); + it('waits for previously admitted provider work before certifying a stop', async () => { + const { run, stripe } = fixture(); + expect(await run({ ...operation, settleAfter: new Date(now.getTime() + 60_000) })).toBe(false); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith('sub_active', { invoice_now: false, prorate: false }); + }); + it('refuses a foreign account or customer binding before provider mutation', async () => { + const { run, customer, stripe } = fixture(); customer.scopeKey = 'org:sibling'; + await expect(run()).rejects.toThrow('ARCHIVE_BILLING_ACCOUNT_UNAVAILABLE'); + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + }); + it('does not certify billing when a payment remains processing', async () => { + const { run, stripe } = fixture(); + stripe.paymentIntents.cancel.mockRejectedValue(new Error('processing')); + stripe.paymentIntents.retrieve.mockResolvedValue({ status: 'processing' }); + await expect(run()).rejects.toThrow('processing'); + }); + it('accepts a terminal state after a lost cancel response and safely repeats the scan', async () => { + const { run, stripe } = fixture(); + stripe.subscriptions.cancel.mockRejectedValue(new Error('lost response')); + stripe.subscriptions.retrieve.mockResolvedValue({ status: 'canceled' }); + expect(await run()).toBe(true); expect(await run()).toBe(true); + }); +}); diff --git a/Admin/src/features/admin/EntityLifecyclePanel.tsx b/Admin/src/features/admin/EntityLifecyclePanel.tsx index bf83e7050..522f14e4e 100644 --- a/Admin/src/features/admin/EntityLifecyclePanel.tsx +++ b/Admin/src/features/admin/EntityLifecyclePanel.tsx @@ -8,7 +8,7 @@ import { createApiClient } from '../../services/api-client'; export type LifecycleScope = 'USER' | 'ORGANISATION' | 'TEAM'; export type LifecycleTemplate = { id: string; scope: LifecycleScope; title: string; message: string; revision: number; enabled: boolean }; -type Lifecycle = { status: 'ACTIVE' | 'DISABLED' | 'DELETING' | 'DELETED'; reason: string | null; internalNote: string | null; deletionJobId: string | null }; +type Lifecycle = { status: 'ACTIVE' | 'DISABLED' | 'ARCHIVED' | 'DELETING' | 'DELETED'; reason: string | null; internalNote: string | null; deletionJobId: string | null }; type Preview = { digest: string; confirmation: string; name: string; mode: string; deletesEmptyOrganisation: boolean; teamIds: string[]; candidates: { id: string; eligible: boolean; reasons: string[] }[]; participants: { domain: string }[]; retainedEvidence: { model: string; count: number; reason: string }[]; blockers: string[] }; @@ -38,11 +38,12 @@ function EntityLifecyclePanelBody({ scope, id }: { scope: LifecycleScope; id: st finally { setPending(false); } } if (!lifecycle.data) return

{lifecycle.isError ? 'Could not load lifecycle state.' : 'Loading lifecycle…'}

; - const terminal = ['DELETING', 'DELETED'].includes(lifecycle.data.status); + const terminal = ['ARCHIVED', 'DELETING', 'DELETED'].includes(lifecycle.data.status); + const archived = lifecycle.data.status === 'ARCHIVED'; return -

Access and deletion

{`Status: ${lifecycle.data.status.toLowerCase()}`}

+

Access and deletion

{`Status: ${archived ? 'Archived' : lifecycle.data.status.toLowerCase()}`}

- {lifecycle.data.reason ?

Customer reason: {lifecycle.data.reason}

: null} + {lifecycle.data.reason ?

{archived ? 'Reason' : 'Customer reason'}: {lifecycle.data.reason}

: null} {lifecycle.data.internalNote ?

Internal note: {lifecycle.data.internalNote}

: null} {error ?

{error}

: null} {!terminal ? <> @@ -59,7 +60,9 @@ function EntityLifecyclePanelBody({ scope, id }: { scope: LifecycleScope; id: st

Deletion removes operational data and credentials. Retained history uses “Deleted user”. Protected financial or signing evidence may require restricted identity references.

- :

This entity cannot sign in or regain access. Deletion is terminal.

} + :

{lifecycle.data.status === 'ARCHIVED' + ? 'Permanent deletion is on hold. No data has been erased.' + : 'This entity cannot sign in or regain access. Deletion is terminal.'}

} {preview && !terminal ?

Delete {preview.name} ({scope.toLowerCase()}) — {id}

{preview.teamIds.length} teams in scope.{preview.deletesEmptyOrganisation ? ' The last team and its now-empty organisation will both be deleted.' : ''}

diff --git a/Docs/Auth/architecture-api.md b/Docs/Auth/architecture-api.md index e2664e2fd..92385153a 100644 --- a/Docs/Auth/architecture-api.md +++ b/Docs/Auth/architecture-api.md @@ -1024,3 +1024,7 @@ the existing selected-subject members API and original-assurance proof. See Hosted organisation selection is owned by `login-organization.service.ts` and `session-choices.service.ts`; the signed `login_flow.organization_id` narrows only interactive entry. See the organisation-scoped sign-in addendum in `Docs/brief.md`. + +### Held shared-team archive + +The archive authority, receipt recovery, provider reconciliation and database holds are defined in [Shared team archive](team-archive.md). These endpoints use UOA as the sole cross-product lifecycle authority and never invoke erasure jobs. diff --git a/Docs/Auth/team-archive.md b/Docs/Auth/team-archive.md new file mode 100644 index 000000000..95f4a36a0 --- /dev/null +++ b/Docs/Auth/team-archive.md @@ -0,0 +1,71 @@ +# Shared team archive + +`POST /org/organisations/:orgId/teams/:teamId/archive` archives the shared UOA +team across connected products. It does not execute the platform erasure flow. +The default and last team can be archived without cascading to an organisation +or deleting a person. Membership, product data references and financial history +remain. `Team.lifecycleStatus=ARCHIVED` denies authentication and scoped +credential issuance and removes the team from active directories and choosers. + +The caller supplies `{request_id: UUID, deletion_held: true}`. The normal domain +credential, signed config and exact-team human assertion are required. The +service checks the current credential epoch, live organisation `teams.manage` +capability and active team membership under the enterprise outbox and exclusive +product policy fences. An organisation owner and administrator have the same +authority. Backend-only and team-only administrators cannot initiate this global +commercial operation. + +The transaction writes one immutable `TeamArchiveOperation`, transitions the +team, revokes its refresh families, authorization codes and assurances across +every observed consumer domain, and emits the existing cross-product revocation +events. It disables exact-team automatic reload and closes team seat evidence. +Disabling reload advances the consent generation and clears current card and +consent pointers; immutable consent revisions and monetary balances remain. +It does not revoke the person's other teams or global identity. Products must +continue their existing live admission and revocation-consumer contracts; an +offline JWT alone cannot provide immediate revocation. + +Billing reconciliation runs at startup and every 30 seconds, independently of +the new-collection enable flag. It uses the configured Stripe account and mode, +and only UOA-owned TEAM customers matching the exact organisation and team. +Subscriptions cancel immediately with `invoice_now=false, prorate=false`; open +checkout sessions expire, draft/open invoices stop automatic advancement, and +unsettled payment intents are cancelled or re-read to confirm a terminal state. +Shared organisation customers, subscriptions and credits remain independent. +Historical accepted payments, liabilities and legal evidence are not refunded +or erased. A provider create holds the shared policy fence through its request, +so the archive drains admitted creates before its provider scan. Existing +persisted provider leases receive a settlement window before certification. +Cancellation starts immediately; certification waits for those leases and a +90-second provider settlement window, measured with the database clock. +Cancellation-recovery jobs cannot create or resume archived-team collection. +An ordinary disabled team can still settle its earned liabilities. +Usage export keeps that same distinction: archived teams cannot reserve or +dispatch meter events, while disabled teams remain eligible for earned usage. +Provider dispatch holds the archive fence through the meter request. + +The initiating product reads the no-store receipt through +`GET /domain/team-archives/:id?domain=...` using its registered domain bearer +after the human session is revoked. This route cannot mutate the team or return +its content. An authorized receipt read also advances due reconciliation for +that exact operation, including on a request-driven host. `STOPPING_BILLING` is +not a success notice. `ARCHIVED` means that +reconciliation confirmed future team billing stopped. Provider failures and +processing payments leave the original operation pending, with expiring leases +and automatic retries; a lost cancellation response is recovered by inspecting +provider state. Browser retries must reuse the request UUID. + +Permanent deletion is held by database constraints and triggers. The operation +cannot be deleted or rebound, the team cannot be reactivated or removed, and +platform organisation/team erasure previews include a hold blocker. No erasure +job or timer is created. Releasing the hold needs a separately designed and +authorized change; this API has no release action. + +Focused checks: API typecheck; `team-archive` PostgreSQL integration suite; +`entity-lifecycle` regression suite; `team-archive-billing` provider tests; and +the checkout, recurring add-on, reload setup, usage export and scheduler unit +suites. PostgreSQL financial suites also cover consent disable, correction +collection fencing and organisation responsibility; set +`BILLING_FUNDING_DATABASE_TESTS=true` with `DATABASE_URL`. Use an isolated local +database and run browser journeys in the calling +product. Never archive a real team as a test. diff --git a/Docs/brief.md b/Docs/brief.md index 1744b8b86..33374db7b 100644 --- a/Docs/brief.md +++ b/Docs/brief.md @@ -3465,3 +3465,7 @@ continuation recheck current authority and every ancestor's expiry/revocation. The contract is limited to DeepWater-origin research, never a new logical run, another product or a scope turn. A job token cannot mint an unrelated fresh grant or lose its resource binding through generic token exchange. + +## Shared team archive (7 November 2026) + +A product may archive a shared UOA team across all connected products while permanent deletion stays held. This is separate from executable erasure, including for the default or last team. UOA owns the lifecycle, billing stop proof and revocation; products store references only. The contract and focused checks are in [Shared team archive](Auth/team-archive.md).