Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions docs/observability/subscription-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<OTX>_<previousHolderAccountId>_<timestampMs>`. 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.
141 changes: 127 additions & 14 deletions src/api/v2/accounts/handlers/subscription-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -197,7 +201,10 @@ const handleAppleBranch = async (
res: Response,
accountId: string,
body: z.infer<typeof appleBodySchema>,
): Promise<VerifyInput | null> => {
): Promise<{
input: AppleVerifyInput;
ownership: AppleOwnershipProof;
} | null> => {
let decoded: JWSTransactionDecodedPayload;
try {
decoded = await verifyAndDecodeTransaction(body.jwsRepresentation);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof attemptAutoReclaim>> | 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",
Expand Down
Loading
Loading