diff --git a/.env.example b/.env.example index 88dbb59..a81de9a 100644 --- a/.env.example +++ b/.env.example @@ -188,6 +188,10 @@ DEVICE_PURGE_CRON=0 6 * * * SUBSCRIPTION_RECONCILE_CRON=0 3 * * * SUBSCRIPTION_RECONCILE_BATCH_SIZE=500 +# Shared secret Pub/Sub appends to the Play notification push URL. Empty keeps +# that endpoint closed - it writes billing state and carries no session. +PLAY_NOTIFICATIONS_TOKEN= + # --- Mobile clients --- # How long after a rotation a retired refresh token is still accepted as a # retry rather than treated as a stolen one. Mobile clients lose the *response* diff --git a/CLAUDE.md b/CLAUDE.md index aa37c5d..f6031e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,6 +210,10 @@ A monthly paid subscription, and the only way to get a tick — no official badg `NoopBillingService` stands in until there is a store, and deliberately never reports a subscription as active — a stub that granted entitlements would be free badges on any misconfigured environment. +**Google Play** enters through two endpoints. `POST /billing/play/purchases` is authenticated and is the *only* place the link between a purchase and an account is learned — Google's notifications name a token and a product and nothing else. It trusts the client for nothing: the token is verified with Google and what comes back is stored, so a purchase that cannot be confirmed links as `PENDING` with no badge. `POST /billing/play/notifications` takes the Pub/Sub push, guarded by a shared secret on the URL (`PLAY_NOTIFICATIONS_TOKEN`, empty = closed). A notification is a **nudge, never state**: it says a purchase changed, and the state is then read from Google, because deliveries are unordered and redelivered. It answers 204 for anything it understood — a redelivery, or a purchase no account claims — since Pub/Sub retries every non-2xx and retrying those achieves nothing. `mapPlayState` is the single place Google's vocabulary becomes ours, and an unrecognised state reads as *not* entitling. + +`GooglePlayBillingService` does not exist yet: `NoopBillingService` stands in until the Play Console side is configured. + `docs/verified-badge.md` is the contract and the operator's SQL. ### Realtime and background jobs diff --git a/docs/verified-badge.md b/docs/verified-badge.md index 4f0e919..47d4bec 100644 --- a/docs/verified-badge.md +++ b/docs/verified-badge.md @@ -116,12 +116,70 @@ A provider that answers "I do not know this subscription" is left alone. That is not the same as "it ended", and guessing between them is how a paying user loses a badge; the expiry already on the row retires it if it really is over. +## Google Play + +Two endpoints, and a deliberate split between what the client is trusted for +and what it is not. + +**`POST /api/v1/billing/play/purchases`** — authenticated. The app calls it +right after Play reports a successful purchase, with the `purchaseToken` the +billing library produced and the product id. + +```json +{ "purchaseToken": "…", "productId": "verified_monthly" } +``` + +This call is the **only** place the link between a purchase and an account is +ever learned: Google's notifications name a token and a product and nothing +else. It grants nothing on the client's word — the token goes straight to +Google for verification and what comes back is what gets stored, so a +fabricated token buys a row that says `PENDING` and no badge. A purchase Google +cannot confirm right now is still linked, as `PENDING`, because without the row +nothing would ever connect that token to that account again; the nightly +reconcile finishes it. + +**`POST /api/v1/billing/play/notifications`** — where Pub/Sub pushes Google's +notifications. No session, guarded by a shared secret on the URL +(`?token=…`, `PLAY_NOTIFICATIONS_TOKEN`). **Empty means the endpoint is +closed**, which is the right default for an unauthenticated route that writes +billing state. + +The notification is treated as a **nudge, never as state**. It says a purchase +changed; what it changed to is then read from the Play Developer API, and that +answer is what gets stored. Notifications arrive out of order and are +redelivered, so believing their contents would mean reinstating subscriptions +that have ended. + +It answers `204` for everything it understood — including a redelivery and a +purchase no account claims yet, since Pub/Sub retries anything that is not a +2xx and retrying either of those achieves nothing. A genuine failure escapes as +a 5xx, which is exactly the answer that makes Google try again. + +Google's state maps onto ours in one place, `mapPlayState`. `ACTIVE` and +`IN_GRACE_PERIOD` entitle; `PAUSED`, `ON_HOLD`, `CANCELED` and `EXPIRED` do +not — none of them are being paid for — and a state we do not recognise reads +as *not* entitling, because Google adds values over time and the safe reading +of "I do not know this" is that the badge is off. + +### Not done yet + +`GooglePlayBillingService` — the `BillingPort` implementation that actually +calls the Play Developer API to verify a purchase and to cancel one. Until it +exists `NoopBillingService` stands in, so purchases link as `PENDING` and no +badge is granted. It needs the Play Console work: a subscription product, a +service account with "View financial data" and "Manage orders and +subscriptions", and the Pub/Sub topic. + +Verifying the OIDC token Google can attach to a push is the stronger +alternative to the shared secret, and belongs with that same work. + ## Settings | Variable | Default | What it does | | --- | --- | --- | | `SUBSCRIPTION_RECONCILE_CRON` | `0 3 * * *` | When the repair pass runs. | | `SUBSCRIPTION_RECONCILE_BATCH_SIZE` | `500` | Rows examined per pass. | +| `PLAY_NOTIFICATIONS_TOKEN` | _(empty)_ | Shared secret on the push URL. Empty closes the endpoint. | There is no provider yet. `NoopBillingService` stands in, and it deliberately never reports a subscription as active — a stub that granted entitlements would diff --git a/render.yaml b/render.yaml index 073dc2e..34f13b5 100644 --- a/render.yaml +++ b/render.yaml @@ -189,6 +189,11 @@ projects: sync: false - key: DEVICE_PURGE_CRON sync: false + # Shared secret on the Play notification push URL. Empty keeps that + # endpoint closed, which is the right default for an unauthenticated + # route that writes billing state. + - key: PLAY_NOTIFICATIONS_TOKEN + sync: false # The paid verification badge. Both have defaults; they are declared # because the reconcile pass is what notices a ban, and turning it off or # slowing it down is a decision somebody should have to make on purpose. diff --git a/src/core/ports/repositories/billing-event.repository.ts b/src/core/ports/repositories/billing-event.repository.ts new file mode 100644 index 0000000..e97820b --- /dev/null +++ b/src/core/ports/repositories/billing-event.repository.ts @@ -0,0 +1,39 @@ +import type { BillingProvider } from "@core/domain/enums"; + +/** + * One provider notification, as recorded. + */ +export interface BillingEventRecord { + /** The provider's own identifier for the delivery. */ + id: string; + + provider: BillingProvider; + + /** The provider's event type, as sent. */ + type: string; + + /** Which subscription it concerned, when it named one. */ + providerSubscriptionId?: string | null; +} + +/** + * Persistence contract for the record of provider notifications. + */ +export interface IBillingEventRepository { + /** + * Records a notification, unless it has already been recorded. + * + * The insert is the check: providers redeliver, and reading first would + * leave a window two deliveries of the same event both pass through. + * + * Not what makes replays safe - every sync writes the provider's absolute + * state, so applying one twice lands in the same place. This spares the + * duplicate work and, more usefully, leaves a trail of what arrived and + * when, which is the first thing anybody wants when a subscription is in + * the wrong state. + * + * @param event - The notification to record. + * @returns True when this caller recorded it, false when it was a repeat. + */ + recordIfNew(event: BillingEventRecord): Promise; +} diff --git a/src/core/use-cases/billing/register-play-purchase/index.ts b/src/core/use-cases/billing/register-play-purchase/index.ts new file mode 100644 index 0000000..25af64f --- /dev/null +++ b/src/core/use-cases/billing/register-play-purchase/index.ts @@ -0,0 +1,6 @@ +/** + * This module exports the RegisterPlayPurchaseUseCase, which attaches a Play + * purchase to the account that made it. + */ +export { RegisterPlayPurchaseUseCase } from "./register-play-purchase.usecase"; +export type { RegisterPlayPurchaseInput } from "./register-play-purchase.usecase"; diff --git a/src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts b/src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts new file mode 100644 index 0000000..89f070e --- /dev/null +++ b/src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts @@ -0,0 +1,88 @@ +import { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { SyncSubscriptionUseCase } from "../sync-subscription"; + +/** + * Input DTO for the RegisterPlayPurchaseUseCase. + */ +export interface RegisterPlayPurchaseInput { + /** The account that made the purchase, from its own session. */ + currentUserId: string; + + /** Google's identifier for the purchase, from the billing library. */ + purchaseToken: string; + + /** The subscription product that was bought. */ + productId: string; +} + +/** + * Use case for attaching a Play purchase to the account that made it. + * + * This call is the only place the link between a purchase and an account is + * ever learned. Google's notifications name a purchase token and a product and + * nothing else; the account behind it is known here, and only here, because + * this request carries a session. + * + * It grants nothing on the client's word. The token is handed straight to the + * provider for verification, and what comes back is what gets stored - so a + * fabricated token buys a row that says `PENDING` and no badge. + */ +export class RegisterPlayPurchaseUseCase { + /** + * Creates a new instance of RegisterPlayPurchaseUseCase. + * + * @param billingService - Asks Google what the purchase actually is + * @param syncSubscriptionUseCase - The one door billing state enters by + * @param logger - Records a purchase the provider could not confirm + */ + constructor( + private readonly billingService: BillingPort, + private readonly syncSubscriptionUseCase: SyncSubscriptionUseCase, + private readonly logger: LoggerPort, + ) {} + + /** + * Records the purchase and applies whatever the provider says about it. + * + * @param input - Who bought what + * @returns Whether the badge is granted as a result + * + * @remarks + * When the provider cannot confirm the purchase - no adapter configured + * yet, or Google momentarily unreachable - the link is still written, as + * `PENDING`. That grants nothing, and it is what lets the nightly + * reconcile finish the job later: without the row, nothing would ever + * connect this token to this account again. + */ + async execute(input: RegisterPlayPurchaseInput): Promise<{ + isVerified: boolean; + }> { + const state = await this.billingService.fetchSubscription( + input.purchaseToken, + ); + + if (!state) { + this.logger.warn( + { + userId: input.currentUserId, + productId: input.productId, + }, + "Play purchase could not be confirmed; linking it as pending", + ); + } + + const result = await this.syncSubscriptionUseCase.execute({ + userId: input.currentUserId, + provider: BillingProvider.GOOGLE_PLAY, + state: state ?? { + providerSubscriptionId: input.purchaseToken, + status: SubscriptionStatus.PENDING, + eventAt: new Date(), + }, + }); + + return { isVerified: result.verifiedUntil !== null }; + } +} diff --git a/src/http/controllers/play-billing.controller.ts b/src/http/controllers/play-billing.controller.ts new file mode 100644 index 0000000..bf04e35 --- /dev/null +++ b/src/http/controllers/play-billing.controller.ts @@ -0,0 +1,89 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { UnauthorizedError } from "@core/errors"; +import type { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase"; +import type { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service"; +import type { + PlayNotificationQuery, + RegisterPlayPurchaseBody, +} from "@typings/schemas/billing/play.schema"; + +/** + * Controller for the Google Play billing endpoints. + */ +export class PlayBillingController { + /** + * Creates a new PlayBillingController instance. + * + * @param registerPlayPurchaseUseCase - Attaches a purchase to an account + * @param playNotificationService - Handles what Google pushes + * @param config - Environment configuration, for the push secret + */ + constructor( + private readonly registerPlayPurchaseUseCase: RegisterPlayPurchaseUseCase, + private readonly playNotificationService: PlayNotificationService, + private readonly config: FastifyInstance["config"], + ) {} + + /** + * Takes a completed purchase from the app and verifies it. + * + * @param request - The authenticated request carrying the purchase token + * @param reply - The reply to send + */ + async registerPurchase( + request: FastifyRequest<{ Body: RegisterPlayPurchaseBody }>, + reply: FastifyReply, + ): Promise { + const result = await this.registerPlayPurchaseUseCase.execute({ + currentUserId: request.user!.id, + purchaseToken: request.body.purchaseToken, + productId: request.body.productId, + }); + + reply.status(200).send({ + data: result, + meta: { timestamp: new Date().toISOString() }, + }); + } + + /** + * Receives a subscription notification pushed by Google. + * + * Answers 204 for everything it understood, including a duplicate and a + * purchase no account claims. Pub/Sub retries anything that is not a 2xx, + * and retrying either of those forever would achieve nothing - the first + * is already applied and the second needs the app's own call, not another + * delivery. + * + * The genuine retry case is an exception escaping this handler: that + * reaches the error handler as a 5xx, which is exactly the answer that + * makes Google try again. + * + * @param request - The push request from Pub/Sub + * @param reply - The reply to send + * + * @throws UnauthorizedError - When the push secret does not match + */ + async notifications( + request: FastifyRequest<{ Querystring: PlayNotificationQuery }>, + reply: FastifyReply, + ): Promise { + const expected = this.config.PLAY_NOTIFICATIONS_TOKEN; + + // No secret configured means the endpoint is not wired up yet. Closed + // rather than open: an unauthenticated endpoint that writes billing + // state is not something to leave ajar by default. + if (!expected || request.query.token !== expected) { + throw new UnauthorizedError(); + } + + const outcome = await this.playNotificationService.handle(request.body); + + request.log.info( + { outcome, context: "PlayNotification" }, + "Handled a Play billing notification", + ); + + reply.status(204).send(); + } +} diff --git a/src/http/plugins/di/controllers.di.ts b/src/http/plugins/di/controllers.di.ts index 0ee8d10..7cf7c1e 100644 --- a/src/http/plugins/di/controllers.di.ts +++ b/src/http/plugins/di/controllers.di.ts @@ -12,6 +12,7 @@ import { ReportController } from "@controllers/report.controller"; import { MetaController } from "@controllers/meta.controller"; import { DeviceController } from "@controllers/device.controller"; import { BillingController } from "@controllers/billing.controller"; +import { PlayBillingController } from "@controllers/play-billing.controller"; import { CommentController } from "@controllers/comment.controller"; import { BookmarkController } from "@controllers/bookmark.controller"; import { TrendController } from "@controllers/trend.controller"; @@ -62,6 +63,7 @@ export const controllersModule = { metaController: asClass(MetaController).singleton(), deviceController: asClass(DeviceController).singleton(), billingController: asClass(BillingController).singleton(), + playBillingController: asClass(PlayBillingController).singleton(), notificationController: asClass(NotificationController).singleton(), postController: asClass(PostController).singleton(), commentController: asClass(CommentController).singleton(), diff --git a/src/http/plugins/di/external.di.ts b/src/http/plugins/di/external.di.ts index 9e28dbe..91d5c07 100644 --- a/src/http/plugins/di/external.di.ts +++ b/src/http/plugins/di/external.di.ts @@ -5,6 +5,7 @@ import { NoopPushService, } from "@infrastructure/external/push/expo-push.service"; import { NoopBillingService } from "@infrastructure/external/billing/noop-billing.service"; +import { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service"; import { GithubAuthService } from "@infrastructure/external/github-auth.service"; import { GoogleAuthService } from "@infrastructure/external/google-auth.service"; import { S3StorageService } from "@infrastructure/external/s3-storage.service"; @@ -37,6 +38,13 @@ export const externalModule = { */ billingService: asClass(NoopBillingService).singleton(), + /** + * Handles what Google pushes about subscriptions. Useful before the + * provider adapter exists: it still records deliveries, spots + * redeliveries and finds the account a purchase belongs to. + */ + playNotificationService: asClass(PlayNotificationService).singleton(), + emailService: asFunction((config, logger) => { return new EmailService( { diff --git a/src/http/plugins/di/persistence.di.ts b/src/http/plugins/di/persistence.di.ts index 0143483..9a978f9 100644 --- a/src/http/plugins/di/persistence.di.ts +++ b/src/http/plugins/di/persistence.di.ts @@ -11,6 +11,7 @@ import { PrismaDigestDeliveryRepository } from "@infrastructure/persistence/repo import { PrismaReportRepository } from "@infrastructure/persistence/repositories/prisma-report.repository"; import { PrismaDeviceTokenRepository } from "@infrastructure/persistence/repositories/prisma-device-token.repository"; import { PrismaSubscriptionRepository } from "@infrastructure/persistence/repositories/prisma-subscription.repository"; +import { PrismaBillingEventRepository } from "@infrastructure/persistence/repositories/prisma-billing-event.repository"; import { PrismaReportDigestDeliveryRepository } from "@infrastructure/persistence/repositories/prisma-report-digest-delivery.repository"; import { PrismaUserInterestRepository } from "@infrastructure/persistence/repositories/prisma-user-interest.repository"; import { PrismaPostRepository } from "@infrastructure/persistence/repositories/prisma-post.repository"; @@ -150,6 +151,7 @@ export const persistenceModule = { * Subscription repository for the paid verification badge */ subscriptionRepository: asClass(PrismaSubscriptionRepository).singleton(), + billingEventRepository: asClass(PrismaBillingEventRepository).singleton(), reportDigestDeliveryRepository: asClass( PrismaReportDigestDeliveryRepository, ).singleton(), diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 5b60413..cefcda2 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -111,6 +111,7 @@ import { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscripti import { RevokeSubscriptionUseCase } from "@core/use-cases/billing/revoke-subscription"; import { GetSubscriptionUseCase } from "@core/use-cases/billing/get-subscription"; import { ReconcileSubscriptionsUseCase } from "@core/use-cases/billing/reconcile-subscriptions"; +import { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase"; import { REPORT_EXCERPT_LENGTH, REPORT_MAX_DETAILS, @@ -252,6 +253,13 @@ export const useCasesModule = { */ getSubscriptionUseCase: asClass(GetSubscriptionUseCase).singleton(), + /** + * Use case that attaches a Play purchase to the account that made it + */ + registerPlayPurchaseUseCase: asClass( + RegisterPlayPurchaseUseCase, + ).singleton(), + /** * Use case that repairs billing state nightly */ diff --git a/src/http/routes/billing.routes.ts b/src/http/routes/billing.routes.ts index 28b5edd..1278ba5 100644 --- a/src/http/routes/billing.routes.ts +++ b/src/http/routes/billing.routes.ts @@ -11,6 +11,13 @@ import { RateLimitPolicies } from "@plugins/rate-limit.plugin"; import { SubscriptionResponseSchema } from "@typings/schemas/billing/subscription.schema"; +import { + PlayNotificationQuerySchema, + RegisterPlayPurchaseBodySchema, + RegisterPlayPurchaseResponseSchema, + type PlayNotificationQuery, + type RegisterPlayPurchaseBody, +} from "@typings/schemas/billing/play.schema"; import type { FastifyInstance } from "fastify"; /** @@ -21,6 +28,8 @@ import type { FastifyInstance } from "fastify"; */ export default function billingRoutes(fastify: FastifyInstance): void { const billingController = fastify.diContainer.cradle.billingController; + const playBillingController = + fastify.diContainer.cradle.playBillingController; fastify.get( "/billing/subscription", @@ -34,4 +43,43 @@ export default function billingRoutes(fastify: FastifyInstance): void { }, billingController.subscription.bind(billingController), ); + + fastify.post<{ Body: RegisterPlayPurchaseBody }>( + "/billing/play/purchases", + { + schema: { + body: RegisterPlayPurchaseBodySchema, + response: { 200: RegisterPlayPurchaseResponseSchema }, + tags: ["Billing"], + }, + onRequest: [fastify.authenticate], + // A retried hand-over is already harmless: the sync writes the + // provider's absolute state onto one row per account, so reporting + // the same purchase twice lands in the same place. It should still + // carry `idempotency: true` once that plugin is on main, to save + // the second verification round trip. + config: { rateLimit: RateLimitPolicies.SENSITIVE }, + }, + playBillingController.registerPurchase.bind(playBillingController), + ); + + /** + * Where Google pushes subscription notifications. + * + * Unauthenticated in the session sense - Pub/Sub carries no account - and + * guarded by a shared secret on the URL instead. It is deliberately exempt + * from the standard rate limit: Google decides how often it calls, and + * answering 429 only makes it call again. + */ + fastify.post<{ Querystring: PlayNotificationQuery }>( + "/billing/play/notifications", + { + schema: { + querystring: PlayNotificationQuerySchema, + tags: ["Billing"], + }, + config: { rateLimit: false }, + }, + playBillingController.notifications.bind(playBillingController), + ); } diff --git a/src/http/types/fastify-awilix.d.ts b/src/http/types/fastify-awilix.d.ts index c3900ee..bd02091 100644 --- a/src/http/types/fastify-awilix.d.ts +++ b/src/http/types/fastify-awilix.d.ts @@ -12,6 +12,7 @@ import type { MetaController } from "@controllers/meta.controller"; import type { DeviceController } from "@controllers/device.controller"; import type { DevicePurgeScheduler } from "@infrastructure/jobs/device/device-purge.scheduler"; import type { BillingController } from "@controllers/billing.controller"; +import type { PlayBillingController } from "@controllers/play-billing.controller"; import type { SubscriptionReconcileScheduler } from "@infrastructure/jobs/billing/subscription-reconcile.scheduler"; import type { ReportDigestScheduler } from "@infrastructure/jobs/report/report-digest.scheduler"; import type { ReportPurgeScheduler } from "@infrastructure/jobs/report/report-purge.scheduler"; @@ -100,6 +101,9 @@ declare module "@fastify/awilix" { /** Controller for the subscription endpoint */ billingController: BillingController; + /** Controller for the Google Play billing endpoints */ + playBillingController: PlayBillingController; + /** Scheduler that repairs billing state nightly */ subscriptionReconcileScheduler: SubscriptionReconcileScheduler; diff --git a/src/http/types/schemas/billing/play.schema.ts b/src/http/types/schemas/billing/play.schema.ts new file mode 100644 index 0000000..b3eae3d --- /dev/null +++ b/src/http/types/schemas/billing/play.schema.ts @@ -0,0 +1,51 @@ +import { type Static, Type } from "@fastify/type-provider-typebox"; +import { ResponseSchema } from "../create-response-schema"; + +/** + * A purchase the app has just completed, handed over for verification. + * + * The token is Google's, produced by the billing library on the device. It is + * not a claim to anything on its own - the server asks Google what it is - but + * this request is the only place the account behind it is known, because it is + * the only one carrying a session. + */ +export const RegisterPlayPurchaseBodySchema = Type.Object({ + purchaseToken: Type.String({ minLength: 1, maxLength: 4096 }), + productId: Type.String({ minLength: 1, maxLength: 200 }), +}); + +export type RegisterPlayPurchaseBody = Static< + typeof RegisterPlayPurchaseBodySchema +>; + +export const RegisterPlayPurchaseResponseSchema = ResponseSchema( + Type.Object({ + /** + * Whether the badge is granted as a result. + * + * False is not a failure: a purchase Google has not confirmed yet is + * linked to the account and left pending, and the nightly reconcile + * finishes it. + */ + isVerified: Type.Boolean(), + }), +); + +export type RegisterPlayPurchaseResponse = Static< + typeof RegisterPlayPurchaseResponseSchema +>; + +/** + * The shared secret Pub/Sub appends to the push URL. + * + * Checked as a query parameter because that is what a Pub/Sub push + * subscription can carry without any Google library on this side. It is a + * bearer secret and nothing more; verifying the OIDC token Google can also + * send is the stronger option and arrives with the rest of the Google + * integration. + */ +export const PlayNotificationQuerySchema = Type.Object({ + token: Type.Optional(Type.String({ maxLength: 200 })), +}); + +export type PlayNotificationQuery = Static; diff --git a/src/http/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index b0ca9a1..d119b8c 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -272,6 +272,11 @@ export const EnvSchema = Type.Object({ minimum: 1, }), + // Shared secret Pub/Sub appends to the Play notification push URL. Empty + // - the default - closes that endpoint entirely rather than leaving an + // unauthenticated route that writes billing state open by default. + PLAY_NOTIFICATIONS_TOKEN: Type.String({ default: "" }), + // --- Mobile clients --- // A web client is whatever was served this morning; an app version lives on // phones for months. These let the API tell a build that it is too old to diff --git a/src/infrastructure/external/billing/play/play-notification.service.ts b/src/infrastructure/external/billing/play/play-notification.service.ts new file mode 100644 index 0000000..a8f6f8b --- /dev/null +++ b/src/infrastructure/external/billing/play/play-notification.service.ts @@ -0,0 +1,117 @@ +import { BillingProvider } from "@core/domain/enums"; +import type { IBillingEventRepository } from "@core/ports/repositories/billing-event.repository"; +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; +import { parsePlayNotification } from "./play-notification"; + +/** + * What handling a notification came to. + * + * Reported rather than logged and forgotten because the caller has to turn it + * into a status code, and the difference between "we could not use this" and + * "try again later" is the difference between Google giving up and Google + * retrying. + */ +export type PlayNotificationOutcome = + "applied" | "duplicate" | "unknown-purchase" | "ignored" | "test"; + +/** + * Handles the notifications Google pushes about subscriptions. + * + * The notification is treated as a *nudge*, never as state. It says a purchase + * changed; what it changed to is then read from the Play Developer API, and + * that answer is what gets stored. Notifications arrive out of order and are + * redelivered, so believing their contents would mean reinstating subscriptions + * that have ended. + */ +export class PlayNotificationService { + /** + * Creates a new instance of PlayNotificationService. + * + * @param billingService - Asks Google what is true now + * @param subscriptionRepository - Finds the account a purchase belongs to + * @param billingEventRepository - Spots a redelivery, and keeps the trail + * @param syncSubscriptionUseCase - The one door billing state enters by + * @param logger - Records what could not be handled + */ + constructor( + private readonly billingService: BillingPort, + private readonly subscriptionRepository: ISubscriptionRepository, + private readonly billingEventRepository: IBillingEventRepository, + private readonly syncSubscriptionUseCase: SyncSubscriptionUseCase, + private readonly logger: LoggerPort, + ) {} + + /** + * Handles one pushed notification. + * + * @param body - The raw Pub/Sub push body + * @returns What became of it + */ + async handle(body: unknown): Promise { + const parsed = parsePlayNotification(body); + + if (parsed.kind === "test") return "test"; + + if (parsed.kind === "ignored") { + this.logger.warn( + { messageId: parsed.messageId, reason: parsed.reason }, + "Ignored a Play notification", + ); + + return "ignored"; + } + + const { notification } = parsed; + + const isNew = await this.billingEventRepository.recordIfNew({ + id: notification.messageId, + provider: BillingProvider.GOOGLE_PLAY, + type: notification.typeName, + providerSubscriptionId: notification.purchaseToken, + }); + + if (!isNew) return "duplicate"; + + const subscription = + await this.subscriptionRepository.findByProviderSubscriptionId( + notification.purchaseToken, + ); + + // A purchase nobody has claimed. It happens legitimately: Google can + // push the purchase notification before the app's own call arrives. + // There is no account to apply it to, and inventing one is not an + // option - the app's authenticated call is the only thing that knows + // whose purchase this is, and it will sync the state itself. + if (!subscription) { + this.logger.warn( + { + messageId: notification.messageId, + type: notification.typeName, + }, + "Play notification for a purchase no account claims", + ); + + return "unknown-purchase"; + } + + const state = await this.billingService.fetchSubscription( + notification.purchaseToken, + ); + + // The provider cannot say. Left alone rather than guessed at: the + // expiry already on the row retires the badge if the subscription is + // really over, and the nightly reconcile will ask again. + if (!state) return "unknown-purchase"; + + await this.syncSubscriptionUseCase.execute({ + userId: subscription.userId, + provider: BillingProvider.GOOGLE_PLAY, + state: { ...state, eventAt: state.eventAt ?? notification.eventAt }, + }); + + return "applied"; + } +} diff --git a/src/infrastructure/external/billing/play/play-notification.ts b/src/infrastructure/external/billing/play/play-notification.ts new file mode 100644 index 0000000..51058c1 --- /dev/null +++ b/src/infrastructure/external/billing/play/play-notification.ts @@ -0,0 +1,210 @@ +import { SubscriptionStatus } from "@core/domain/enums"; + +/** + * What Google says happened to a subscription. + * + * The numbers are Google's, and they are the whole reason this table exists: + * a notification arrives as `notificationType: 13` and nothing else. + * + * Note that none of them are trusted as *state*. Every one of them means the + * same thing to us - "ask Google what is true now" - because notifications + * arrive out of order and can be redelivered, while a fetch answers with the + * present. The names are kept for the audit trail and the log line. + */ +export const PLAY_NOTIFICATION_TYPES: Record = { + 1: "SUBSCRIPTION_RECOVERED", + 2: "SUBSCRIPTION_RENEWED", + 3: "SUBSCRIPTION_CANCELED", + 4: "SUBSCRIPTION_PURCHASED", + 5: "SUBSCRIPTION_ON_HOLD", + 6: "SUBSCRIPTION_IN_GRACE_PERIOD", + 7: "SUBSCRIPTION_RESTARTED", + 8: "SUBSCRIPTION_PRICE_CHANGE_CONFIRMED", + 9: "SUBSCRIPTION_DEFERRED", + 10: "SUBSCRIPTION_PAUSED", + 11: "SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED", + 12: "SUBSCRIPTION_REVOKED", + 13: "SUBSCRIPTION_EXPIRED", + 20: "SUBSCRIPTION_PENDING_PURCHASE_CANCELED", +}; + +/** + * Google's own name for where a subscription stands. + * + * Returned by the Play Developer API, not by the notification. + */ +export type PlaySubscriptionState = + | "SUBSCRIPTION_STATE_PENDING" + | "SUBSCRIPTION_STATE_ACTIVE" + | "SUBSCRIPTION_STATE_PAUSED" + | "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" + | "SUBSCRIPTION_STATE_ON_HOLD" + | "SUBSCRIPTION_STATE_CANCELED" + | "SUBSCRIPTION_STATE_EXPIRED"; + +/** + * One subscription notification, once the envelope is off. + */ +export interface PlaySubscriptionNotification { + /** Google's identifier for this delivery, used to spot a redelivery. */ + messageId: string; + + /** The purchase this concerns. Our `providerSubscriptionId`. */ + purchaseToken: string; + + /** The subscription product. */ + subscriptionId: string; + + /** Google's numeric type. */ + notificationType: number; + + /** That type as a name, for the audit trail. */ + typeName: string; + + /** When Google says the event happened. */ + eventAt: Date; +} + +/** + * What arrived, once the envelope is off. + * + * A test notification is a real thing Google sends when somebody presses "Send + * test notification" in the console, and it names no subscription. Recognised + * rather than treated as malformed, because it is the first thing anybody does + * when wiring this up and answering it with a 400 makes it look broken. + */ +export type ParsedPlayNotification = + | { kind: "subscription"; notification: PlaySubscriptionNotification } + | { kind: "test"; messageId: string } + | { kind: "ignored"; messageId: string; reason: string }; + +/** + * Turns Google's subscription state into ours. + * + * `PAUSED` maps to `CANCELED` deliberately: a paused subscription is not being + * paid for, and the badge is a thing you have while paying. It comes back on + * its own when Google reports the subscription active again. + * + * `ON_HOLD` maps to `CANCELED` for the same reason - Google has already + * stopped the entitlement by then - while `IN_GRACE_PERIOD` maps to + * `IN_GRACE`, which keeps the badge: the user paid for the period they are in + * and Google is still retrying the next payment. + * + * @param state - Google's state, whatever it sent + * @returns The status to store + */ +export function mapPlayState(state: string): SubscriptionStatus { + switch (state) { + case "SUBSCRIPTION_STATE_ACTIVE": + return SubscriptionStatus.ACTIVE; + case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD": + return SubscriptionStatus.IN_GRACE; + case "SUBSCRIPTION_STATE_PENDING": + return SubscriptionStatus.PENDING; + case "SUBSCRIPTION_STATE_PAUSED": + case "SUBSCRIPTION_STATE_ON_HOLD": + case "SUBSCRIPTION_STATE_CANCELED": + case "SUBSCRIPTION_STATE_EXPIRED": + return SubscriptionStatus.CANCELED; + default: + // An unknown state is not an active one. Google adds values over + // time, and the safe reading of "I do not recognise this" is that + // the badge is not granted rather than that it is. + return SubscriptionStatus.CANCELED; + } +} + +/** + * The shape Pub/Sub pushes: the notification, base64, inside an envelope. + */ +interface PubSubPushBody { + message?: { + data?: string; + messageId?: string; + message_id?: string; + publishTime?: string; + }; +} + +interface DeveloperNotification { + version?: string; + packageName?: string; + eventTimeMillis?: string; + subscriptionNotification?: { + notificationType?: number; + purchaseToken?: string; + subscriptionId?: string; + }; + testNotification?: { version?: string }; + voidedPurchaseNotification?: { purchaseToken?: string }; + oneTimeProductNotification?: unknown; +} + +/** + * Unwraps a Pub/Sub push into the notification inside it. + * + * Total rather than throwing: this runs on an endpoint Google calls, and every + * outcome other than "understood and acted on" has to be reported as something + * Google will not retry forever. A malformed body is `ignored`, not an error. + * + * @param body - The raw request body + * @returns What arrived, and enough to act on it + */ +export function parsePlayNotification(body: unknown): ParsedPlayNotification { + const envelope = body as PubSubPushBody | undefined; + const message = envelope?.message; + const messageId = message?.messageId ?? message?.message_id ?? ""; + + if (!message?.data) { + return { kind: "ignored", messageId, reason: "no message data" }; + } + + let decoded: DeveloperNotification; + + try { + decoded = JSON.parse( + Buffer.from(message.data, "base64").toString("utf8"), + ) as DeveloperNotification; + } catch { + return { kind: "ignored", messageId, reason: "undecodable payload" }; + } + + if (decoded.testNotification) { + return { kind: "test", messageId }; + } + + const subscription = decoded.subscriptionNotification; + + if (!subscription?.purchaseToken || !subscription.notificationType) { + return { + kind: "ignored", + messageId, + reason: decoded.voidedPurchaseNotification + ? "voided purchase, handled by the next fetch" + : "not a subscription notification", + }; + } + + const eventMillis = Number(decoded.eventTimeMillis); + + return { + kind: "subscription", + notification: { + // Falling back to the purchase token keeps the dedupe key + // meaningful even if Pub/Sub ever omits the id: a redelivery of + // the same event then still collides. + messageId: + messageId || + `${subscription.purchaseToken}:${decoded.eventTimeMillis ?? ""}`, + purchaseToken: subscription.purchaseToken, + subscriptionId: subscription.subscriptionId ?? "", + notificationType: subscription.notificationType, + typeName: + PLAY_NOTIFICATION_TYPES[subscription.notificationType] ?? + `UNKNOWN_${subscription.notificationType}`, + eventAt: Number.isFinite(eventMillis) + ? new Date(eventMillis) + : new Date(), + }, + }; +} diff --git a/src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts b/src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts new file mode 100644 index 0000000..e22c007 --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts @@ -0,0 +1,49 @@ +import type { + BillingEventRecord, + IBillingEventRepository, +} from "@core/ports/repositories/billing-event.repository"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import { Prisma } from "@generated/prisma/client"; +import type { BillingProvider as PrismaBillingProvider } from "@generated/prisma/client"; + +/** + * Prisma implementation of the billing event repository. + */ +export class PrismaBillingEventRepository implements IBillingEventRepository { + /** + * @param prisma - Prisma client, possibly scoped to a transaction + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Records a notification, unless it has already been recorded. + * + * @param event - The notification to record. + * @returns True when this caller recorded it. + */ + async recordIfNew(event: BillingEventRecord): Promise { + try { + await this.prisma.billingEvent.create({ + data: { + id: event.id, + provider: event.provider as PrismaBillingProvider, + type: event.type, + providerSubscriptionId: + event.providerSubscriptionId ?? null, + }, + }); + + return true; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + // A redelivery. Already recorded, already applied. + return false; + } + + throw error; + } + } +} diff --git a/tests/e2e/billing/play.test.ts b/tests/e2e/billing/play.test.ts new file mode 100644 index 0000000..8dd330b --- /dev/null +++ b/tests/e2e/billing/play.test.ts @@ -0,0 +1,129 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for the Google Play endpoints. + * + * There is no provider behind them yet - `NoopBillingService` confirms nothing - + * so what is testable is the half that does not need Google: who may call + * these, what a purchase nobody can confirm does, and that the notification + * endpoint is closed to anybody without the push secret. + */ +describe("Play billing", () => { + const ts = Date.now(); + const user = { + email: `play-${ts}@test.com`, + password: "password123", + username: `play${ts}`, + }; + + let token = ""; + + beforeAll(async () => { + await request({ method: "POST", url: "/auth/register", payload: user }); + + const loggedIn = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + + token = parseBody<{ data: { accessToken: string } }>(loggedIn).data + .accessToken; + }); + + describe("POST /billing/play/purchases", () => { + it("should link an unconfirmable purchase without granting a badge", async () => { + const response = await authRequest(token, { + method: "POST", + url: "/billing/play/purchases", + payload: { + purchaseToken: `tok-${ts}`, + productId: "verified_monthly", + }, + }); + + expect(response.statusCode).toBe(200); + // Nothing confirmed it, so nothing is granted - but the account is + // now attached to the token, which is the part only this + // authenticated call can know. + expect( + parseBody<{ data: { isVerified: boolean } }>(response).data + .isVerified, + ).toBe(false); + }); + + it("should show up as a pending subscription", async () => { + const response = await authRequest(token, { + method: "GET", + url: "/billing/subscription", + }); + + const data = parseBody<{ + data: { status: string | null; isVerified: boolean }; + }>(response).data; + + expect(data.status).toBe("PENDING"); + expect(data.isVerified).toBe(false); + }); + + it("should require a session", async () => { + const response = await request({ + method: "POST", + url: "/billing/play/purchases", + payload: { purchaseToken: "tok-x", productId: "p" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject an empty purchase token", async () => { + const response = await authRequest(token, { + method: "POST", + url: "/billing/play/purchases", + payload: { purchaseToken: "", productId: "verified_monthly" }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("POST /billing/play/notifications", () => { + const body = { + message: { + messageId: `msg-${ts}`, + data: Buffer.from( + JSON.stringify({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: `tok-${ts}`, + }, + }), + ).toString("base64"), + }, + }; + + it("should refuse a caller with no push secret", async () => { + // The endpoint writes billing state and carries no session; with + // no secret configured it stays closed rather than open. + const response = await request({ + method: "POST", + url: "/billing/play/notifications", + payload: body, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should refuse a wrong secret", async () => { + const response = await request({ + method: "POST", + url: "/billing/play/notifications?token=guessed", + payload: body, + }); + + expect(response.statusCode).toBe(401); + }); + }); +}); diff --git a/tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts b/tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts new file mode 100644 index 0000000..75de531 --- /dev/null +++ b/tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase"; +import { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; + +const PERIOD_END = new Date("2026-12-01T00:00:00Z"); + +describe("RegisterPlayPurchaseUseCase", () => { + let billing: BillingPort; + let sync: Pick; + let logger: LoggerPort; + let useCase: RegisterPlayPurchaseUseCase; + + const input = { + currentUserId: "user-1", + purchaseToken: "tok-1", + productId: "verified_monthly", + }; + + beforeEach(() => { + billing = { + fetchSubscription: vi.fn().mockResolvedValue({ + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + }), + cancelSubscription: vi.fn(), + }; + sync = { + execute: vi + .fn() + .mockResolvedValue({ applied: true, verifiedUntil: PERIOD_END }), + }; + logger = { error: vi.fn(), warn: vi.fn() } as unknown as LoggerPort; + + useCase = new RegisterPlayPurchaseUseCase( + billing, + sync as SyncSubscriptionUseCase, + logger, + ); + }); + + it("should verify the purchase with the provider before granting anything", async () => { + const result = await useCase.execute(input); + + expect(billing.fetchSubscription).toHaveBeenCalledWith("tok-1"); + expect(result.isVerified).toBe(true); + }); + + it("should store what the provider says, not what the client sent", async () => { + await useCase.execute(input); + + expect(sync.execute).toHaveBeenCalledWith({ + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + state: expect.objectContaining({ + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + }), + }); + }); + + it("should link an unconfirmable purchase as pending rather than drop it", async () => { + // No adapter configured yet, or Google momentarily unreachable. The + // link still has to be written: this request is the only thing that + // knows whose purchase this is. + vi.mocked(billing.fetchSubscription).mockResolvedValue(null); + vi.mocked(sync.execute).mockResolvedValue({ + applied: true, + verifiedUntil: null, + }); + + const result = await useCase.execute(input); + + expect(result.isVerified).toBe(false); + expect(sync.execute).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.PENDING, + }), + }), + ); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("should report no badge when the sync grants none", async () => { + vi.mocked(sync.execute).mockResolvedValue({ + applied: false, + reason: "claimed-by-another-account", + verifiedUntil: null, + }); + + await expect(useCase.execute(input)).resolves.toEqual({ + isVerified: false, + }); + }); +}); diff --git a/tests/unit/infrastructure/billing/play-notification.test.ts b/tests/unit/infrastructure/billing/play-notification.test.ts new file mode 100644 index 0000000..3b43053 --- /dev/null +++ b/tests/unit/infrastructure/billing/play-notification.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + mapPlayState, + parsePlayNotification, +} from "@infrastructure/external/billing/play/play-notification"; +import { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service"; +import { Subscription } from "@core/domain/entities/subscription.entity"; +import { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; +import type { IBillingEventRepository } from "@core/ports/repositories/billing-event.repository"; +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; + +/** + * Wraps a developer notification the way Pub/Sub pushes it. + */ +function push(payload: unknown, messageId = "msg-1"): unknown { + return { + message: { + messageId, + data: Buffer.from(JSON.stringify(payload)).toString("base64"), + }, + }; +} + +describe("parsePlayNotification", () => { + it("should unwrap a subscription notification", () => { + const parsed = parsePlayNotification( + push({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "tok-1", + subscriptionId: "verified_monthly", + }, + }), + ); + + expect(parsed.kind).toBe("subscription"); + + if (parsed.kind !== "subscription") return; + + expect(parsed.notification.purchaseToken).toBe("tok-1"); + expect(parsed.notification.typeName).toBe("SUBSCRIPTION_RENEWED"); + expect(parsed.notification.eventAt).toEqual(new Date(1767225600000)); + }); + + it("should name a type Google has not documented here", () => { + const parsed = parsePlayNotification( + push({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 99, + purchaseToken: "tok-1", + }, + }), + ); + + if (parsed.kind !== "subscription") throw new Error("expected one"); + + expect(parsed.notification.typeName).toBe("UNKNOWN_99"); + }); + + it("should recognise the console's test notification", () => { + // The first thing anybody does when wiring this up. Answering it as + // malformed makes the integration look broken when it is not. + expect( + parsePlayNotification(push({ testNotification: { version: "1.0" } })) + .kind, + ).toBe("test"); + }); + + it("should ignore rather than throw on anything unusable", () => { + for (const body of [ + undefined, + {}, + { message: {} }, + { message: { data: "not base64 json" } }, + push({ oneTimeProductNotification: {} }), + push({ voidedPurchaseNotification: { purchaseToken: "t" } }), + ]) { + expect(parsePlayNotification(body).kind).toBe("ignored"); + } + }); + + it("should fall back to a key of its own when Pub/Sub sends no id", () => { + const parsed = parsePlayNotification({ + message: { + data: Buffer.from( + JSON.stringify({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "tok-1", + }, + }), + ).toString("base64"), + }, + }); + + if (parsed.kind !== "subscription") throw new Error("expected one"); + + // A redelivery of the same event still collides on this. + expect(parsed.notification.messageId).toBe("tok-1:1767225600000"); + }); +}); + +describe("mapPlayState", () => { + it("should entitle only active and grace", () => { + expect(mapPlayState("SUBSCRIPTION_STATE_ACTIVE")).toBe( + SubscriptionStatus.ACTIVE, + ); + expect(mapPlayState("SUBSCRIPTION_STATE_IN_GRACE_PERIOD")).toBe( + SubscriptionStatus.IN_GRACE, + ); + }); + + it("should treat paused and on-hold as not paying", () => { + // Neither is being paid for, and the badge is a thing you have while + // paying. Both come back on their own when Google says active again. + expect(mapPlayState("SUBSCRIPTION_STATE_PAUSED")).toBe( + SubscriptionStatus.CANCELED, + ); + expect(mapPlayState("SUBSCRIPTION_STATE_ON_HOLD")).toBe( + SubscriptionStatus.CANCELED, + ); + }); + + it("should read a state it does not know as not entitling", () => { + // Google adds values over time; the safe reading of "I do not + // recognise this" is that the badge is off, not on. + expect(mapPlayState("SUBSCRIPTION_STATE_SOMETHING_NEW")).toBe( + SubscriptionStatus.CANCELED, + ); + }); +}); + +describe("PlayNotificationService", () => { + let billing: BillingPort; + let subscriptions: Pick< + ISubscriptionRepository, + "findByProviderSubscriptionId" + >; + let events: IBillingEventRepository; + let sync: Pick; + let service: PlayNotificationService; + + const stored = Subscription.with({ + id: "sub-1", + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.ACTIVE, + }); + + const body = push({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "tok-1", + }, + }); + + beforeEach(() => { + billing = { + fetchSubscription: vi.fn().mockResolvedValue({ + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: new Date("2026-12-01T00:00:00Z"), + }), + cancelSubscription: vi.fn(), + }; + subscriptions = { + findByProviderSubscriptionId: vi.fn().mockResolvedValue(stored), + }; + events = { recordIfNew: vi.fn().mockResolvedValue(true) }; + sync = { execute: vi.fn().mockResolvedValue({ applied: true }) }; + + service = new PlayNotificationService( + billing, + subscriptions as ISubscriptionRepository, + events, + sync as SyncSubscriptionUseCase, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerPort, + ); + }); + + it("should ask Google what is true rather than believe the notification", async () => { + await expect(service.handle(body)).resolves.toBe("applied"); + + // The notification said "renewed"; what got stored is what the fetch + // returned. Notifications arrive out of order and are redelivered, so + // their contents are a nudge, never state. + expect(billing.fetchSubscription).toHaveBeenCalledWith("tok-1"); + expect(sync.execute).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("should do nothing twice for a redelivery", async () => { + vi.mocked(events.recordIfNew).mockResolvedValue(false); + + await expect(service.handle(body)).resolves.toBe("duplicate"); + expect(billing.fetchSubscription).not.toHaveBeenCalled(); + expect(sync.execute).not.toHaveBeenCalled(); + }); + + it("should not invent an account for a purchase nobody claims", async () => { + // Google can push the purchase notification before the app's own + // authenticated call arrives; that call is the only thing that knows + // whose purchase it is. + vi.mocked(subscriptions.findByProviderSubscriptionId).mockResolvedValue( + null, + ); + + await expect(service.handle(body)).resolves.toBe("unknown-purchase"); + expect(sync.execute).not.toHaveBeenCalled(); + }); + + it("should leave the row alone when Google cannot say", async () => { + vi.mocked(billing.fetchSubscription).mockResolvedValue(null); + + await expect(service.handle(body)).resolves.toBe("unknown-purchase"); + expect(sync.execute).not.toHaveBeenCalled(); + }); + + it("should record the delivery before acting on it", async () => { + await service.handle(body); + + expect(events.recordIfNew).toHaveBeenCalledWith({ + id: "msg-1", + provider: BillingProvider.GOOGLE_PLAY, + type: "SUBSCRIPTION_RENEWED", + providerSubscriptionId: "tok-1", + }); + }); + + it("should pass a test notification through untouched", async () => { + await expect( + service.handle(push({ testNotification: {} })), + ).resolves.toBe("test"); + + expect(events.recordIfNew).not.toHaveBeenCalled(); + }); +});