diff --git a/.env.example b/.env.example index 26adf28a..02a3d50d 100644 --- a/.env.example +++ b/.env.example @@ -245,6 +245,15 @@ APPLE_API_ISSUER_ID= # parsing; wrap in single quotes or use a real multi-line .env loader. APPLE_API_SIGNING_KEY= +# Guarded subscription ownership auto-reclaim (default: false). +SUBSCRIPTION_AUTO_RECLAIM_ENABLED=false +# Holder inactivity window required before auto-reclaim (default: 7 days). +SUBSCRIPTION_AUTO_RECLAIM_DORMANCY_DAYS=7 +# Minimum interval between transfers of the same Apple OTX (default: 7 days). +SUBSCRIPTION_AUTO_RECLAIM_COOLDOWN_DAYS=7 +# Maximum accepted age of Apple's verified transaction JWS (default: 24 hours). +SUBSCRIPTION_AUTO_RECLAIM_MAX_JWS_AGE_HOURS=24 + # --- Google Play Billing --- # Android package name from Play Console. Must EXACTLY match the package the # Android app builds with. Used by the Play Developer API client to scope diff --git a/docs/observability/subscription-notifications.md b/docs/observability/subscription-notifications.md index 8534da42..913bf581 100644 --- a/docs/observability/subscription-notifications.md +++ b/docs/observability/subscription-notifications.md @@ -192,3 +192,41 @@ log, and it is the primary detection signal for orphaned subscriptions (it names `existingAccountId`). Same shape as monitor 2: `env:convos-otr-prod @msg:"subscription.verify.account_mismatch"`, count `> 0` over `1h` ⇒ warn. + +### 4. Subscription auto-reclaim transfers + +The verify handler emits three stable events for the guarded Apple ownership +transfer path: + +| Event | Level | Meaning | +| --------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `subscription.transfer.auto` | info | The dormant holder passed every guard, the existing Subscription row moved to the claimant, and the handler is retrying the normal verify upsert. Carries account/subscription/provider/product/tier/period/status/environment and Apple ownership fields. | +| `subscription.transfer.auto_ineligible` | warn | The mismatch stayed on the legacy 409 path. Carries claimant and holder account ids, OTX, ownership type, resolvable subscription id, and the guard `reason`. | +| `subscription.transfer.auto_error` | error | The eligibility/transfer attempt threw unexpectedly. The request still fails closed to the byte-identical legacy 409. | + +`subscription.transfer.grant_skipped` is an internal money-safety event emitted +when the transferred Apple period already has its canonical `sub_grant` on the +previous holder. The retry skips that one period instead of funding it twice; +later periods use different keys and grant normally. + +Successful transfers persist an `AdminAudit.idempotencyKey` in the format +`auto_reclaim_apple___`. The grant +choke point uses the previous-holder segment to suppress re-materialization of +an Apple period that was already funded before the transfer. + +Ineligibility reasons: + +| `reason` | Meaning | +| ------------------------- | ------------------------------------------------------------------------------- | +| `disabled` | `SUBSCRIPTION_AUTO_RECLAIM_ENABLED` is not exactly `"true"`. | +| `provider_not_supported` | The mismatch is not the supported Apple OTX flavor. | +| `not_purchased_ownership` | The verified JWS is missing `PURCHASED` ownership (including Family Sharing). | +| `stale_jws` | The verified JWS has no signed date or exceeds the configured maximum age. | +| `not_entitled` | Provider status is not entitled or the verified period has ended. | +| `holder_active` | A recent holder VERIFY receipt, consume ledger row, or device update was found. | +| `cooldown` | The same OTX was already auto-transferred inside the cooldown window. | +| `holder_changed` | The OTX row was missing or its owner changed before/under the row lock. | + +Auto-transfers should be rare. Suggested log monitor: +`env:convos-otr-prod @msg:"subscription.transfer.auto"`, count `> 3` over `1h` +⇒ alert, warn at `> 1`. A spike means someone may be farming the reclaim path. diff --git a/src/api/v2/accounts/handlers/subscription-verify.ts b/src/api/v2/accounts/handlers/subscription-verify.ts index 7e487bde..79472878 100644 --- a/src/api/v2/accounts/handlers/subscription-verify.ts +++ b/src/api/v2/accounts/handlers/subscription-verify.ts @@ -4,6 +4,10 @@ import { } from "@apple/app-store-server-library"; import type { Request, Response } from "express"; import { z } from "zod"; +import { + attemptAutoReclaim, + type AppleOwnershipProof, +} from "@/subscriptions/auto-reclaim"; import { acknowledgePurchase, fetchSubscriptionPurchaseV2, @@ -197,7 +201,10 @@ const handleAppleBranch = async ( res: Response, accountId: string, body: z.infer, -): Promise => { +): Promise<{ + input: AppleVerifyInput; + ownership: AppleOwnershipProof; +} | null> => { let decoded: JWSTransactionDecodedPayload; try { decoded = await verifyAndDecodeTransaction(body.jwsRepresentation); @@ -254,12 +261,18 @@ const handleAppleBranch = async ( } try { - return buildAppleInput( - accountId, - appAccountToken, - decoded, - body.jwsRepresentation, - ); + return { + input: buildAppleInput( + accountId, + appAccountToken, + decoded, + body.jwsRepresentation, + ), + ownership: { + inAppOwnershipType: decoded.inAppOwnershipType, + signedDate: decoded.signedDate, + }, + }; } catch (err) { if (err instanceof AppError) { res.status(err.statusCode).json({ error: err.message }); @@ -378,11 +391,13 @@ export async function subscriptionVerifyHandler(req: Request, res: Response) { let input: VerifyInput; let playPurchase: SubscriptionPurchaseV2 | null = null; + let appleOwnership: AppleOwnershipProof | null = null; if (parsed.data.platform === "apple") { const built = await handleAppleBranch(req, res, accountId, parsed.data); if (!built) return; - input = built; + input = built.input; + appleOwnership = built.ownership; } else { const built = await handlePlayBranch(req, res, accountId, parsed.data); if (!built) return; @@ -393,15 +408,13 @@ export async function subscriptionVerifyHandler(req: Request, res: Response) { // Strict ownership is enforced inside upsertFromVerify's transaction // (atomic with the upsert). A re-verify from a different signed-in account // is rejected to block session-stealing where a leaked receipt/token could - // be replayed under a different caller's account. Cross-account transfer - // is a support operation, not a code path. + // be replayed under a different caller's account. The Apple mismatch catch + // below permits only the separately guarded dormant-holder reclaim path. try { const { subscription } = await upsertFromVerify(input); - // Subscription credit allotments are derived from the Subscription row + - // per-tier config at read time (see GET /v2/accounts/me/credits). We do - // NOT write a grant() ledger row on verify. grant() is reserved for - // additive credits — top-ups, NUX trial, manual ops, promo. + // The repository has already materialized any eligible period grant in + // the ledger transactionally with the subscription/receipt update. req.log.info( { accountId, @@ -443,6 +456,106 @@ export async function subscriptionVerifyHandler(req: Request, res: Response) { }, "subscription.verify.account_mismatch", ); + + if (input.provider === BillingProvider.apple && appleOwnership !== null) { + let reclaim: Awaited> | null = + null; + try { + reclaim = await attemptAutoReclaim({ + input, + decoded: appleOwnership, + expectedHolderAccountId: error.existingAccountId, + }); + } catch (reclaimError) { + req.log.error( + { + error: reclaimError, + stack: + reclaimError instanceof Error ? reclaimError.stack : undefined, + accountId, + existingAccountId: error.existingAccountId, + originalTransactionId: input.originalTransactionId, + provider: input.provider, + }, + "subscription.transfer.auto_error", + ); + } + + if (reclaim !== null) { + if (!reclaim.eligible) { + req.log.warn( + { + accountId, + existingAccountId: error.existingAccountId, + subscriptionId: error.subscriptionId, + originalTransactionId: input.originalTransactionId, + ownershipType: appleOwnership.inAppOwnershipType, + reason: reclaim.reason, + }, + "subscription.transfer.auto_ineligible", + ); + } else { + req.log.info( + { + accountId, + previousAccountId: reclaim.previousAccountId, + subscriptionId: reclaim.subscriptionId, + originalTransactionId: input.originalTransactionId, + transactionId: input.transactionId, + provider: input.provider, + productId: input.productId, + tier: input.tier, + period: input.period, + status: input.status, + environment: input.environment, + ownershipType: appleOwnership.inAppOwnershipType, + }, + "subscription.transfer.auto", + ); + + try { + const retry = await upsertFromVerify(input); + const subscription = retry.subscription; + req.log.info( + { + accountId, + subscriptionId: subscription.id, + provider: subscription.provider, + productId: subscription.productId, + tier: subscription.tier, + period: subscription.period, + status: subscription.status, + }, + "subscription.verify.applied", + ); + res.status(200).json({ + subscription: serializeUserSubscription(subscription), + }); + return; + } catch (retryError) { + if (!(retryError instanceof SubscriptionAccountMismatchError)) { + req.log.error( + { + error: retryError, + stack: + retryError instanceof Error + ? retryError.stack + : undefined, + accountId, + provider: input.provider, + }, + "Failed to persist verified subscription", + ); + res + .status(500) + .json({ error: "Failed to verify subscription" }); + return; + } + } + } + } + } + res.status(409).json({ error: "Subscription belongs to a different account. Contact support.", code: "subscription_account_mismatch", diff --git a/src/subscriptions/auto-reclaim.ts b/src/subscriptions/auto-reclaim.ts new file mode 100644 index 00000000..efd28994 --- /dev/null +++ b/src/subscriptions/auto-reclaim.ts @@ -0,0 +1,291 @@ +import { BillingProvider, LedgerReason, type Prisma } from "@prisma/client"; +import { + autoReclaimAuditKey, + autoReclaimAuditKeyPrefix, +} from "@/subscriptions/grants"; +import type { VerifyInput } from "@/subscriptions/repository"; +import { isEntitledSubscriptionStatus } from "@/subscriptions/status"; +import { prisma } from "@/utils/prisma"; + +export type IneligibleReason = + | "disabled" + | "provider_not_supported" + | "not_purchased_ownership" + | "stale_jws" + | "not_entitled" + | "holder_active" + | "cooldown" + | "holder_changed"; + +export type AutoReclaimResult = + | { eligible: false; reason: IneligibleReason } + | { + eligible: true; + previousAccountId: string; + subscriptionId: string; + }; + +type LockedSubscriptionOwner = { + id: string; + accountId: string; +}; + +// Cap on any numeric knob (units are hours or days). Prevents an absurd or +// fat-fingered env value from overflowing downstream ms arithmetic past +// Number.MAX_SAFE_INTEGER — which would make the freshness comparison always +// false and silently DISABLE the stale-JWS guard. Out-of-range falls back to +// the safe default instead. +const NUMERIC_ENV_MAX = 1_000_000; + +const numericEnv = (name: string, fallback: number): number => { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 && parsed <= NUMERIC_ENV_MAX + ? parsed + : fallback; +}; + +const lockSubscriptionOwner = async ( + tx: Prisma.TransactionClient, + subscriptionId: string, +): Promise => { + const rows = await tx.$queryRaw` + SELECT "id", "accountId" + FROM "Subscription" + WHERE "id" = ${subscriptionId}::uuid + FOR UPDATE + `; + return rows[0] ?? null; +}; + +/** + * The Apple ownership evidence extracted from the VERIFIED JWS (never from the + * request body). Shared with the verify handler so the shape is declared once. + */ +export type AppleOwnershipProof = { + inAppOwnershipType?: string; + signedDate?: number; +}; + +/** + * True when the holder shows ANY of the three dormancy signals inside the + * window. Runs twice per eligible attempt: once pre-transaction as a cheap + * early exit (avoids taking the row lock for plainly active holders), and once + * more INSIDE the transfer transaction after the subscription row lock, so a + * holder write committing between the first sampling and the row lock is seen + * and aborts the transfer (TOCTOU hardening; a holder re-verify serializes on + * the same subscription row lock, so that signal is fully race-free). + */ +export const holderShowsActivity = async ( + client: Prisma.TransactionClient, + holderAccountId: string, + dormancyCutoff: Date, +): Promise => { + const [recentVerify, recentConsume, recentDevice] = await Promise.all([ + client.billingReceipt.findFirst({ + where: { + notificationType: "VERIFY", + receivedAt: { gt: dormancyCutoff }, + subscription: { accountId: holderAccountId }, + }, + select: { id: true }, + }), + client.creditLedger.findFirst({ + where: { + accountId: holderAccountId, + reason: LedgerReason.consume, + createdAt: { gt: dormancyCutoff }, + }, + select: { id: true }, + }), + client.deviceRegistration.findFirst({ + where: { + accountId: holderAccountId, + updatedAt: { gt: dormancyCutoff }, + }, + select: { deviceId: true }, + }), + ]); + return ( + recentVerify !== null || recentConsume !== null || recentDevice !== null + ); +}; + +/** + * Guarded, audited, single-transaction transfer of an Apple Subscription row + * from a DORMANT holder to the verifying claimant, evaluated only at the + * verify account-mismatch 409. Every guard fails CLOSED to the existing 409. + * + * KNOWN RESIDUAL RISKS (why this is gated off by default via + * SUBSCRIPTION_AUTO_RECLAIM_ENABLED, and must stay off until the deeper fixes + * land): + * + * - BEARER-JWS SCOPE (tracked to #377): possession of a fresh (<24h), + * PURCHASED, entitled JWS is treated as sufficient proof to move the row off + * a dormant holder. The claimant is NOT cryptographically bound to the + * signed appAccountToken, so a leaked/stolen fresh JWS replayed by another + * authenticated account CAN move the subscription — this is the same + * session-stealing surface the plain 409 was designed to block, deliberately + * pierced for the dormant-reinstall case. The dormancy signals here (VERIFY + * receipts, consume ledger rows, device updates) do NOT include ordinary + * authenticated reads, so an active-reader / non-writer holder can look + * dormant. The real hardening is #377's per-request activity stamp + * (Account.lastAuthAt) + a possession/contest step; do not enable this flag + * in prod until that exists. + * - STRANDED PERIOD CREDITS (tracked to #374 lineage custody): the transfer + * moves ONLY Subscription.accountId. A period already granted to the old + * holder stays in the old wallet, and a later forfeit (refund/expiry) runs + * against the NEW owner and cannot claw the old one back + * (skipped_nothing_to_forfeit). Bounded to at most one period's grant per + * transfer; this mirrors the drift the interim/manual re-home path already + * accepts. #374's LineagePeriodCustody is the correct escrow fix. + * + * The SEQUENTIAL client flow (the real ~15s iOS re-verify loop) is fully + * protected against double-minting a transferred period by the durable + * previous-holder guard in grantSubscriptionPeriod. + */ +export const attemptAutoReclaim = async (args: { + input: VerifyInput; + decoded: AppleOwnershipProof; + expectedHolderAccountId: string; +}): Promise => { + const { input, decoded, expectedHolderAccountId } = args; + + if (process.env.SUBSCRIPTION_AUTO_RECLAIM_ENABLED !== "true") { + return { eligible: false, reason: "disabled" }; + } + if (input.provider !== BillingProvider.apple) { + return { eligible: false, reason: "provider_not_supported" }; + } + if (decoded.inAppOwnershipType !== "PURCHASED") { + return { eligible: false, reason: "not_purchased_ownership" }; + } + + const now = new Date(); + const signedDate = decoded.signedDate; + const maxJwsAgeMs = + numericEnv("SUBSCRIPTION_AUTO_RECLAIM_MAX_JWS_AGE_HOURS", 24) * + 60 * + 60 * + 1000; + if ( + signedDate === undefined || + !Number.isFinite(signedDate) || + now.getTime() - signedDate > maxJwsAgeMs + ) { + return { eligible: false, reason: "stale_jws" }; + } + if ( + !isEntitledSubscriptionStatus(input.status) || + input.currentPeriodEnd <= now + ) { + return { eligible: false, reason: "not_entitled" }; + } + + const dormancyCutoff = new Date( + now.getTime() - + numericEnv("SUBSCRIPTION_AUTO_RECLAIM_DORMANCY_DAYS", 7) * + 24 * + 60 * + 60 * + 1000, + ); + // Pre-transaction sampling: cheap early exit before taking any lock. The + // authoritative re-check runs again inside the transaction below. + if ( + await holderShowsActivity(prisma, expectedHolderAccountId, dormancyCutoff) + ) { + return { eligible: false, reason: "holder_active" }; + } + + return prisma.$transaction(async (tx): Promise => { + const subscription = await tx.subscription.findUnique({ + where: { + subscription_apple_otx_unique: { + provider: BillingProvider.apple, + originalTransactionId: input.originalTransactionId, + }, + }, + select: { id: true, accountId: true }, + }); + if (!subscription || subscription.accountId !== expectedHolderAccountId) { + return { eligible: false, reason: "holder_changed" }; + } + + const locked = await lockSubscriptionOwner(tx, subscription.id); + if (!locked || locked.accountId !== expectedHolderAccountId) { + return { eligible: false, reason: "holder_changed" }; + } + + // Authoritative dormancy re-check UNDER the row lock: a holder VERIFY, + // consume, or device write committing between the pre-transaction sampling + // and this point is now visible and aborts the transfer. Closes the TOCTOU + // window on the active-holder guard (review finding on PR #399). + if ( + await holderShowsActivity(tx, expectedHolderAccountId, dormancyCutoff) + ) { + return { eligible: false, reason: "holder_active" }; + } + + const cooldownCutoff = new Date( + now.getTime() - + numericEnv("SUBSCRIPTION_AUTO_RECLAIM_COOLDOWN_DAYS", 7) * + 24 * + 60 * + 60 * + 1000, + ); + const recentTransfer = await tx.adminAudit.findFirst({ + where: { + action: "auto_reclaim_transfer", + idempotencyKey: { + startsWith: autoReclaimAuditKeyPrefix(input.originalTransactionId), + }, + createdAt: { gte: cooldownCutoff }, + }, + select: { id: true }, + }); + if (recentTransfer) { + return { eligible: false, reason: "cooldown" }; + } + + const transferred = await tx.subscription.updateMany({ + where: { + id: locked.id, + provider: BillingProvider.apple, + originalTransactionId: input.originalTransactionId, + accountId: expectedHolderAccountId, + }, + data: { accountId: input.accountId }, + }); + if (transferred.count !== 1) { + return { eligible: false, reason: "holder_changed" }; + } + + const signedAt = new Date(signedDate); + await tx.adminAudit.create({ + data: { + accountId: input.accountId, + actorEmail: "system:auto-reclaim", + action: "auto_reclaim_transfer", + deltaCredits: 0n, + reason: + `Auto-reclaimed Apple subscription otx=${input.originalTransactionId} ` + + `subscriptionId=${locked.id} from=${expectedHolderAccountId} ` + + `to=${input.accountId} jwsSignedAt=${signedAt.toISOString()}`, + idempotencyKey: autoReclaimAuditKey( + input.originalTransactionId, + expectedHolderAccountId, + now.getTime(), + ), + }, + }); + + return { + eligible: true, + previousAccountId: expectedHolderAccountId, + subscriptionId: locked.id, + }; + }); +}; diff --git a/src/subscriptions/grants.ts b/src/subscriptions/grants.ts index 147d1b53..cdf7e34f 100644 --- a/src/subscriptions/grants.ts +++ b/src/subscriptions/grants.ts @@ -1,10 +1,19 @@ -import { LedgerReason, type Prisma, type Subscription } from "@prisma/client"; +import { + BillingProvider, + LedgerReason, + type Prisma, + type Subscription, +} from "@prisma/client"; import { applyDeltaWithTx, lockUserCreditsBalance } from "@/payments/ledger"; import { tierGrant } from "@/subscriptions/tier-config"; import { requireSubscriptionTier } from "@/subscriptions/tiers"; +import logger from "@/utils/logger"; type TxClient = Prisma.TransactionClient; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** * Single-ledger subscription money-in / money-out. * @@ -39,6 +48,40 @@ export const subForfeitKey = ( periodStart: Date, ): string => `sub_forfeit_${subscriptionId}_${periodEpoch(periodStart)}`; +// --- Auto-reclaim AdminAudit key schema ------------------------------------- +// ONE schema, defined here, used by BOTH sides: the writer (auto-reclaim's +// transfer transaction) builds keys with `autoReclaimAuditKey`, and the grant +// choke point below parses them with `previousHolderFromAuditKey`. Keeping +// build + parse adjacent prevents silent format drift from bypassing the +// previous-holder double-mint guard. Segments (underscore-separated; Apple OTX +// is numeric so the delimiter is unambiguous): +// auto_reclaim_apple___ + +/** `startsWith` prefix matching every auto-reclaim audit key for one OTX. */ +export const autoReclaimAuditKeyPrefix = ( + originalTransactionId: string, +): string => `auto_reclaim_apple_${originalTransactionId}_`; + +/** Audit idempotencyKey for one executed transfer. */ +export const autoReclaimAuditKey = ( + originalTransactionId: string, + previousHolderAccountId: string, + atMs: number, +): string => + `${autoReclaimAuditKeyPrefix(originalTransactionId)}${previousHolderAccountId}_${atMs}`; + +/** + * Extract the previous holder account id from an auto-reclaim audit key, or + * null when the key does not match the schema above. + */ +export const previousHolderFromAuditKey = ( + idempotencyKey: string, +): string | null => { + const previousAccountId = idempotencyKey.split("_")[4]; + if (!previousAccountId || !UUID_RE.test(previousAccountId)) return null; + return previousAccountId; +}; + const findLedgerRow = ( tx: TxClient, accountId: string, @@ -103,6 +146,7 @@ const sumPeriodGrants = async ( export type GrantSubscriptionPeriodResult = | { kind: "granted"; credits: number; subscription: Subscription } | { kind: "replayed" } + | { kind: "skipped_already_funded_to_previous_holder" } | { kind: "skipped_nonpositive" }; /** @@ -146,6 +190,65 @@ export const grantSubscriptionPeriod = async ( return { kind: "replayed" }; } + if ( + subscription.provider === BillingProvider.apple && + subscription.originalTransactionId !== null + ) { + // Sequencing safety of this check-then-grant: for the CURRENT holder to be + // granted at all, the transfer that made them the holder must already have + // COMMITTED (the caller read subscription.accountId from committed state), + // and that same committed transaction wrote the audit row — so this read + // always sees every transfer that produced the current owner. The only + // residual is an in-flight grant on the PREVIOUS holder's wallet racing + // this one (different UserCredits locks) — adjudicated on PR #399 as equal + // to the accepted one-period drift of the manual re-home path. + const transferAudits = await tx.adminAudit.findMany({ + where: { + action: "auto_reclaim_transfer", + idempotencyKey: { + startsWith: autoReclaimAuditKeyPrefix( + subscription.originalTransactionId, + ), + }, + }, + select: { idempotencyKey: true }, + }); + for (const audit of transferAudits) { + const previousAccountId = previousHolderFromAuditKey( + audit.idempotencyKey, + ); + if (previousAccountId === null) { + logger.warn( + { + subscriptionId: subscription.id, + idempotencyKey: audit.idempotencyKey, + }, + "subscription.transfer.invalid_audit_key", + ); + continue; + } + if (previousAccountId === subscription.accountId) continue; + const previousHolderGrant = await findLedgerRow( + tx, + previousAccountId, + idempotencyKey, + ); + if (previousHolderGrant) { + // This event is emitted inside the caller's transaction and may remain + // in logs even if a later operation causes that transaction to roll back. + logger.info( + { + subscriptionId: subscription.id, + periodStart, + previousAccountId, + }, + "subscription.transfer.grant_skipped", + ); + return { kind: "skipped_already_funded_to_previous_holder" }; + } + } + } + await applyDeltaWithTx(tx, { accountId: subscription.accountId, delta: BigInt(credits), diff --git a/src/subscriptions/repository.ts b/src/subscriptions/repository.ts index 3906218d..f945da46 100644 --- a/src/subscriptions/repository.ts +++ b/src/subscriptions/repository.ts @@ -163,6 +163,7 @@ export class SubscriptionAccountMismatchError extends Error { public readonly existingAccountId: string, public readonly attemptedAccountId: string, public readonly providerSubscriptionId: string, + public readonly subscriptionId?: string, ) { super("Subscription belongs to a different account"); this.name = "SubscriptionAccountMismatchError"; @@ -398,6 +399,7 @@ export const upsertFromVerify = async ( existing.accountId, input.accountId, externalId, + existing.id, ); } @@ -439,6 +441,24 @@ export const upsertFromVerify = async ( // below would P2002 and resolve via the outer conflict handler. const replayed = existingReceipt.subscription; if (replayed) { + // Defense-in-depth against a READ COMMITTED cross-statement window: + // the ownership check above reads the row via findExistingForVerify, + // but `replayed` is a SEPARATE (later) read through the receipt + // include — and, in the account-recreation shape, can even resolve a + // row the OTX lookup never saw. If the snapshot's owner is not the + // caller, surface the SAME mismatch the pre-check throws (409): never + // materialize a grant onto another account's row, and never hand the + // old holder a 200 for a row that just moved (auto-reclaim race). + // Under the normal no-transfer flow replayed.accountId always equals + // input.accountId here, so this never fires. + if (replayed.accountId !== input.accountId) { + throw new SubscriptionAccountMismatchError( + replayed.accountId, + input.accountId, + externalId, + replayed.id, + ); + } const isStaleReplay = input.currentPeriodEnd < replayed.currentPeriodEnd; if (!isStaleReplay && isEntitledSubscription(replayed)) { @@ -598,6 +618,7 @@ export const upsertFromVerify = async ( current.accountId, input.accountId, externalId, + current.id, ); } return { subscription: current, receiptCreated: false }; @@ -634,6 +655,7 @@ export const upsertFromVerify = async ( holder.accountId, input.accountId, externalId, + holder.id, ); } return { subscription: holder, receiptCreated: false }; diff --git a/tests/subscriptions/auto-reclaim.test.ts b/tests/subscriptions/auto-reclaim.test.ts new file mode 100644 index 00000000..2904f1c5 --- /dev/null +++ b/tests/subscriptions/auto-reclaim.test.ts @@ -0,0 +1,687 @@ +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import { + Environment, + SignedDataVerifier, +} from "@apple/app-store-server-library"; +import { + AppleEnv, + BillingProvider, + SubscriptionPeriod, + SubscriptionStatus, +} from "@prisma/client"; +import express, { json } from "express"; +import { importPKCS8, SignJWT } from "jose"; +import request from "supertest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { accountsMeRouter } from "@/api/v2/accounts/accountsMeRouter"; +import { authMiddleware } from "@/middleware/auth"; +import { pinoMiddleware } from "@/middleware/pino"; +import { consume, getBalance } from "@/payments"; +import { + attemptAutoReclaim, + holderShowsActivity, +} from "@/subscriptions/auto-reclaim"; +import { grantSubscriptionPeriod, subGrantKey } from "@/subscriptions/grants"; +import { + resetVerifierForTests, + setVerifierForTests, +} from "@/subscriptions/jws-verifier"; +import { + SUBSCRIPTION_TIER_PLUS, + SubscriptionAccountMismatchError, + upsertFromVerify, + type AppleVerifyInput, +} from "@/subscriptions/repository"; +import { tierGrant } from "@/subscriptions/tier-config"; +import { createJwtToken, validateJWTKeys } from "@/utils/jwt"; +import { prisma } from "@/utils/prisma"; + +vi.mock("firebase-admin/app"); +vi.mock("firebase-admin/app-check"); +vi.mock("firebase-admin/messaging"); + +const TEST_BUNDLE_ID = "app.convos.test"; +const DAY_MS = 24 * 60 * 60 * 1000; + +const CLAIMANT_ID = "d8975fce-c00b-4dd4-9a44-f2a273813543"; +const HOLDER_ID = "5886ede2-6925-4f42-8d35-9301b13d2a85"; +const SUBSCRIPTION_ID = "07e60917-f5dc-45b5-8e9c-4ec444b493bc"; +const OTX = "560002661368306"; +const PERIOD_START = new Date("2026-07-16T00:00:00.000Z"); +const PERIOD_END = new Date("2026-08-16T00:00:00.000Z"); +const PRODUCT_ID = "app.convos.subs.monthly"; +const HOLDER_AAT = "5886ede2-6925-4f42-8d35-9301b13d2a85"; +const CLAIMANT_AAT = "d8975fce-c00b-4dd4-9a44-f2a273813543"; + +const legacyMismatchBody = { + error: "Subscription belongs to a different account. Contact support.", + code: "subscription_account_mismatch", +}; + +const makeApp = () => { + const app = express(); + app.use(pinoMiddleware); + app.use(json()); + app.use("/v2/accounts/me", authMiddleware, accountsMeRouter); + return app; +}; + +const installLocalTestingVerifier = () => { + const verifier = new SignedDataVerifier( + [], + false, + Environment.LOCAL_TESTING, + TEST_BUNDLE_ID, + 1234, + ); + setVerifierForTests(verifier); +}; + +let signingPrivateKey: string; + +const signTransaction = async (overrides: Record = {}) => { + const payload = { + transactionId: `tx-${randomUUID()}`, + originalTransactionId: OTX, + bundleId: TEST_BUNDLE_ID, + productId: PRODUCT_ID, + purchaseDate: PERIOD_START.getTime(), + originalPurchaseDate: PERIOD_START.getTime(), + expiresDate: PERIOD_END.getTime(), + type: "Auto-Renewable Subscription", + appAccountToken: CLAIMANT_AAT, + inAppOwnershipType: "PURCHASED", + signedDate: Date.now(), + environment: "LocalTesting", + ...overrides, + }; + const privateKey = await importPKCS8(signingPrivateKey, "ES256"); + return new SignJWT(payload) + .setProtectedHeader({ alg: "ES256" }) + .sign(privateKey); +}; + +const tokenFor = (accountId: string) => + createJwtToken({ deviceId: `dev-${accountId.slice(0, 8)}`, accountId }); + +const verifyJwsAs = async (accountId: string, jwsRepresentation: string) => + request(makeApp()) + .post("/v2/accounts/me/subscription/verify") + .set("X-Convos-AuthToken", await tokenFor(accountId)) + .send({ + platform: "apple", + jwsRepresentation, + }); + +const verifyAs = async ( + accountId: string, + overrides: Record = {}, +) => verifyJwsAs(accountId, await signTransaction(overrides)); + +const seedAccounts = async () => { + await prisma.account.createMany({ + data: [{ id: HOLDER_ID }, { id: CLAIMANT_ID }], + }); +}; + +const seedSubscription = async () => + prisma.subscription.create({ + data: { + id: SUBSCRIPTION_ID, + accountId: HOLDER_ID, + provider: BillingProvider.apple, + productId: PRODUCT_ID, + tier: SUBSCRIPTION_TIER_PLUS, + period: SubscriptionPeriod.monthly, + status: SubscriptionStatus.active, + originalTransactionId: OTX, + appAccountToken: HOLDER_AAT, + startedAt: PERIOD_START, + currentPeriodStart: PERIOD_START, + currentPeriodEnd: PERIOD_END, + willRenew: true, + isInTrial: false, + environment: AppleEnv.production, + }, + }); + +const seedCurrentPeriodGrant = async () => { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }); + await prisma.$transaction((tx) => + grantSubscriptionPeriod(tx, { + subscription, + periodStart: PERIOD_START, + }), + ); +}; + +const seedVerifyReceipt = async ( + transactionId: string, + receivedAt = new Date(Date.now() - 8 * DAY_MS), +) => + prisma.billingReceipt.create({ + data: { + subscriptionId: SUBSCRIPTION_ID, + provider: BillingProvider.apple, + idempotencyKey: `apple-verify:${transactionId}`, + transactionId, + notificationType: "VERIFY", + signedPayload: "seeded.jws", + receivedAt, + }, + }); + +const countTransferAudits = () => + prisma.adminAudit.count({ + where: { + action: "auto_reclaim_transfer", + idempotencyKey: { startsWith: `auto_reclaim_apple_${OTX}_` }, + }, + }); + +const countPeriodGrants = (periodStart: Date) => + prisma.creditLedger.count({ + where: { + accountId: { in: [HOLDER_ID, CLAIMANT_ID] }, + idempotencyKey: subGrantKey(SUBSCRIPTION_ID, periodStart), + grantKindId: "sub_grant", + }, + }); + +const directInput = (): AppleVerifyInput => ({ + provider: BillingProvider.apple, + accountId: CLAIMANT_ID, + appAccountToken: CLAIMANT_AAT, + productId: PRODUCT_ID, + tier: SUBSCRIPTION_TIER_PLUS, + period: SubscriptionPeriod.monthly, + status: SubscriptionStatus.active, + originalTransactionId: OTX, + transactionId: `tx-${randomUUID()}`, + startedAt: PERIOD_START, + currentPeriodStart: PERIOD_START, + currentPeriodEnd: PERIOD_END, + willRenew: true, + isInTrial: false, + environment: AppleEnv.production, + signedPayload: "signed.jws", +}); + +const wipe = async () => { + await prisma.adminAudit.deleteMany({ + where: { + OR: [ + { accountId: { in: [HOLDER_ID, CLAIMANT_ID] } }, + { idempotencyKey: { startsWith: `auto_reclaim_apple_${OTX}_` } }, + ], + }, + }); + await prisma.billingReceipt.deleteMany({ + where: { + OR: [ + { subscriptionId: SUBSCRIPTION_ID }, + { transactionId: { startsWith: "auto-reclaim-test-" } }, + ], + }, + }); + await prisma.subscription.deleteMany({ + where: { + OR: [ + { id: SUBSCRIPTION_ID }, + { accountId: { in: [HOLDER_ID, CLAIMANT_ID] } }, + ], + }, + }); + await prisma.deviceRegistration.deleteMany({ + where: { accountId: { in: [HOLDER_ID, CLAIMANT_ID] } }, + }); + await prisma.creditLedger.deleteMany({ + where: { accountId: { in: [HOLDER_ID, CLAIMANT_ID] } }, + }); + await prisma.userCredits.deleteMany({ + where: { accountId: { in: [HOLDER_ID, CLAIMANT_ID] } }, + }); + await prisma.account.deleteMany({ + where: { id: { in: [HOLDER_ID, CLAIMANT_ID] } }, + }); +}; + +beforeAll(async () => { + await validateJWTKeys(); + const { privateKey } = generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + signingPrivateKey = privateKey; +}); + +beforeEach(async () => { + await wipe(); + installLocalTestingVerifier(); + process.env.SUBSCRIPTION_AUTO_RECLAIM_ENABLED = "true"; + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_DORMANCY_DAYS; + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_COOLDOWN_DAYS; + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_MAX_JWS_AGE_HOURS; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await wipe(); + resetVerifierForTests(); + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_ENABLED; + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_DORMANCY_DAYS; + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_COOLDOWN_DAYS; + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_MAX_JWS_AGE_HOURS; +}); + +describe("subscription verify auto-reclaim", () => { + test("MONEY-PRINTER GUARD: ping-pong verifies produce exactly one transfer and zero double-minted period grants", async () => { + await seedAccounts(); + await seedSubscription(); + await seedCurrentPeriodGrant(); + const holderBalanceBefore = await getBalance(HOLDER_ID); + + const claimantJws = await signTransaction({ + appAccountToken: CLAIMANT_AAT, + transactionId: "auto-reclaim-test-ping-b", + }); + const first = await verifyJwsAs(CLAIMANT_ID, claimantJws); + expect(first.status).toBe(200); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(CLAIMANT_ID); + expect(await countPeriodGrants(PERIOD_START)).toBe(1); + expect(await getBalance(CLAIMANT_ID)).toBe(0n); + + const pingPong = await verifyAs(HOLDER_ID, { + appAccountToken: HOLDER_AAT, + transactionId: "auto-reclaim-test-ping-a", + signedDate: Date.now(), + }); + expect(pingPong.status).toBe(409); + expect(pingPong.body).toEqual(legacyMismatchBody); + + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(CLAIMANT_ID); + expect(await countTransferAudits()).toBe(1); + expect(await countPeriodGrants(PERIOD_START)).toBe(1); + expect(await getBalance(CLAIMANT_ID)).toBe(0n); + expect(await getBalance(HOLDER_ID)).toBe(holderBalanceBefore); + const transferAudit = await prisma.adminAudit.findFirstOrThrow({ + where: { action: "auto_reclaim_transfer" }, + }); + expect(transferAudit.idempotencyKey).toMatch( + new RegExp(`^auto_reclaim_apple_${OTX}_${HOLDER_ID}_\\d+$`), + ); + + const identicalReplay = await verifyJwsAs(CLAIMANT_ID, claimantJws); + expect(identicalReplay.status).toBe(200); + expect(await countPeriodGrants(PERIOD_START)).toBe(1); + expect(await getBalance(CLAIMANT_ID)).toBe(0n); + + const freshSamePeriodReplay = await verifyAs(CLAIMANT_ID, { + appAccountToken: CLAIMANT_AAT, + transactionId: "auto-reclaim-test-ping-b-fresh", + }); + expect(freshSamePeriodReplay.status).toBe(200); + expect(await countPeriodGrants(PERIOD_START)).toBe(1); + expect(await getBalance(CLAIMANT_ID)).toBe(0n); + }); + + test.each([ + { + signal: "consume ledger activity", + seed: async () => { + await seedCurrentPeriodGrant(); + await consume({ + accountId: HOLDER_ID, + usdCostMicros: 1_000n, + idempotencyKey: "consume_auto_reclaim_holder_recent", + requestId: "auto-reclaim-dormancy", + }); + }, + }, + { + signal: "device activity", + seed: async () => { + await prisma.deviceRegistration.create({ + data: { + deviceId: "auto-reclaim-holder-device", + accountId: HOLDER_ID, + }, + }); + }, + }, + { + signal: "VERIFY receipt activity", + seed: async () => { + await seedVerifyReceipt("auto-reclaim-test-holder-recent", new Date()); + }, + }, + ])("dormancy gate rejects recent $signal", async ({ seed }) => { + await seedAccounts(); + await seedSubscription(); + await seed(); + + const response = await verifyAs(CLAIMANT_ID, { + transactionId: `auto-reclaim-test-dormancy-${randomUUID()}`, + }); + + expect(response.status).toBe(409); + expect(response.body).toEqual(legacyMismatchBody); + expect(await countTransferAudits()).toBe(0); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); + + test("Family-Shared ownership never transfers", async () => { + await seedAccounts(); + await seedSubscription(); + + const response = await verifyAs(CLAIMANT_ID, { + transactionId: "auto-reclaim-test-family", + inAppOwnershipType: "FAMILY_SHARED", + }); + + expect(response.status).toBe(409); + expect(response.body).toEqual(legacyMismatchBody); + expect(await countTransferAudits()).toBe(0); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); + + test("cooldown rejects a second transfer for the OTX", async () => { + await seedAccounts(); + await seedSubscription(); + await prisma.adminAudit.create({ + data: { + accountId: HOLDER_ID, + actorEmail: "system:auto-reclaim", + action: "auto_reclaim_transfer", + deltaCredits: 0n, + reason: "seeded recent transfer", + idempotencyKey: `auto_reclaim_apple_${OTX}_seeded`, + createdAt: new Date(Date.now() - DAY_MS), + }, + }); + + const response = await verifyAs(CLAIMANT_ID, { + transactionId: "auto-reclaim-test-cooldown", + }); + + expect(response.status).toBe(409); + expect(response.body).toEqual(legacyMismatchBody); + expect(await countTransferAudits()).toBe(1); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); + + test("skip-already-granted canonical period, then grant exactly once next period", async () => { + await seedAccounts(); + await seedSubscription(); + await seedCurrentPeriodGrant(); + const replayTransactionId = "auto-reclaim-test-canonical-replay"; + await seedVerifyReceipt(replayTransactionId); + + const current = await verifyAs(CLAIMANT_ID, { + transactionId: replayTransactionId, + }); + expect(current.status).toBe(200); + expect(await countPeriodGrants(PERIOD_START)).toBe(1); + expect(await getBalance(CLAIMANT_ID)).toBe(0n); + + const nextStart = PERIOD_END; + const nextEnd = new Date("2026-09-16T00:00:00.000Z"); + const renewed = await verifyAs(CLAIMANT_ID, { + transactionId: "auto-reclaim-test-next-period", + purchaseDate: nextStart.getTime(), + expiresDate: nextEnd.getTime(), + signedDate: Date.now(), + }); + + expect(renewed.status).toBe(200); + expect(await countPeriodGrants(nextStart)).toBe(1); + expect(await getBalance(CLAIMANT_ID)).toBe( + BigInt( + tierGrant(SUBSCRIPTION_TIER_PLUS, SubscriptionPeriod.monthly).perPeriod, + ), + ); + }); + + test("pinned-update race returns holder_changed without update or audit", async () => { + await seedAccounts(); + await seedSubscription(); + + const result = await attemptAutoReclaim({ + input: directInput(), + decoded: { + inAppOwnershipType: "PURCHASED", + signedDate: Date.now(), + }, + expectedHolderAccountId: CLAIMANT_ID, + }); + + expect(result).toEqual({ eligible: false, reason: "holder_changed" }); + expect(await countTransferAudits()).toBe(0); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); + + test("happy path heals orphan, preserves receipt FK, audits, and grants claimant", async () => { + await seedAccounts(); + await seedSubscription(); + const originalReceipt = await seedVerifyReceipt( + "auto-reclaim-test-original-receipt", + ); + + const response = await verifyAs(CLAIMANT_ID, { + transactionId: "auto-reclaim-test-happy", + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + subscription: { + provider: "apple", + tier: "plus", + period: "monthly", + status: "active", + productId: PRODUCT_ID, + currentPeriodEnd: PERIOD_END.toISOString(), + willRenew: true, + isInTrial: false, + }, + }); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(CLAIMANT_ID); + expect( + ( + await prisma.billingReceipt.findUniqueOrThrow({ + where: { id: originalReceipt.id }, + }) + ).subscriptionId, + ).toBe(SUBSCRIPTION_ID); + expect(await countPeriodGrants(PERIOD_START)).toBe(1); + const claimantGrant = await prisma.creditLedger.findUnique({ + where: { + accountId_idempotencyKey: { + accountId: CLAIMANT_ID, + idempotencyKey: subGrantKey(SUBSCRIPTION_ID, PERIOD_START), + }, + }, + }); + expect(claimantGrant).not.toBeNull(); + const audit = await prisma.adminAudit.findFirst({ + where: { action: "auto_reclaim_transfer" }, + }); + expect(audit?.actorEmail).toBe("system:auto-reclaim"); + expect(audit?.accountId).toBe(CLAIMANT_ID); + }); + + test("flag off preserves the byte-identical 409 and writes nothing", async () => { + delete process.env.SUBSCRIPTION_AUTO_RECLAIM_ENABLED; + await seedAccounts(); + await seedSubscription(); + + const response = await verifyAs(CLAIMANT_ID, { + transactionId: "auto-reclaim-test-disabled", + }); + + expect(response.status).toBe(409); + expect(response.body).toEqual(legacyMismatchBody); + expect(await countTransferAudits()).toBe(0); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); + + test("unexpected auto-reclaim errors fail closed to the legacy 409", async () => { + await seedAccounts(); + await seedSubscription(); + const activityRead = vi + .spyOn(prisma.billingReceipt, "findFirst") + .mockRejectedValueOnce(new Error("forced eligibility read failure")); + + const response = await verifyAs(CLAIMANT_ID, { + transactionId: "auto-reclaim-test-error", + }); + activityRead.mockRestore(); + + expect(response.status).toBe(409); + expect(response.body).toEqual(legacyMismatchBody); + expect(await countTransferAudits()).toBe(0); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); + + // The TOCTOU fix re-runs the dormancy check with the transaction client AFTER + // the row lock. This exercises exactly that in-transaction code path (the + // same `holderShowsActivity` the transfer body calls post-lock) against a + // real `tx` client, proving each signal is seen under the transaction. The + // pre-lock active-holder rejection is covered by the dormancy-gate test + // above; together they pin both samplings. (Prisma's client is a Proxy that + // vitest cannot spy without corrupting sibling delegates, so a mock-injected + // mid-flight write is not viable here — this white-box check is the robust + // equivalent.) + test("in-transaction dormancy re-check sees each holder-activity signal via the tx client", async () => { + await seedAccounts(); + await seedSubscription(); + const cutoff = new Date(Date.now() - 7 * DAY_MS); + + const dormant = await prisma.$transaction((tx) => + holderShowsActivity(tx, HOLDER_ID, cutoff), + ); + expect(dormant).toBe(false); + + await prisma.deviceRegistration.create({ + data: { deviceId: "auto-reclaim-holder-toctou", accountId: HOLDER_ID }, + }); + const afterDevice = await prisma.$transaction((tx) => + holderShowsActivity(tx, HOLDER_ID, cutoff), + ); + expect(afterDevice).toBe(true); + + await prisma.deviceRegistration.deleteMany({ + where: { accountId: HOLDER_ID }, + }); + await seedVerifyReceipt("auto-reclaim-test-toctou-verify", new Date()); + const afterVerify = await prisma.$transaction((tx) => + holderShowsActivity(tx, HOLDER_ID, cutoff), + ); + expect(afterVerify).toBe(true); + }); + + test("replay materializer surfaces the mismatch 409 (never a 200) when the snapshot belongs to another account", async () => { + await seedAccounts(); + await seedSubscription(); + await seedCurrentPeriodGrant(); + // A VERIFY receipt whose subscription is the HOLDER's row. + const sharedTransactionId = "auto-reclaim-test-replay-snapshot"; + await seedVerifyReceipt(sharedTransactionId); + + // Reach the replay branch WITHOUT tripping the up-front ownership check: + // a novel OTX makes findExistingForVerify (Apple resolves by OTX only) + // return null, while the matching transactionId resolves the receipt whose + // subscription is owned by the holder — the cross-statement race shape. + const input: AppleVerifyInput = { + ...directInput(), + accountId: CLAIMANT_ID, + originalTransactionId: `${OTX}999`, + transactionId: sharedTransactionId, + }; + + await expect(upsertFromVerify(input)).rejects.toBeInstanceOf( + SubscriptionAccountMismatchError, + ); + // No grant materialized onto the claimant for that period. + const claimantGrant = await prisma.creditLedger.findUnique({ + where: { + accountId_idempotencyKey: { + accountId: CLAIMANT_ID, + idempotencyKey: subGrantKey(SUBSCRIPTION_ID, PERIOD_START), + }, + }, + }); + expect(claimantGrant).toBeNull(); + expect( + ( + await prisma.subscription.findUniqueOrThrow({ + where: { id: SUBSCRIPTION_ID }, + }) + ).accountId, + ).toBe(HOLDER_ID); + }); +});