diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/002-mobile-ios-cancel-control.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/002-mobile-ios-cancel-control.png new file mode 100644 index 000000000000..698399fc2eca Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/002-mobile-ios-cancel-control.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/003-mobile-ios-cancel-after.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/003-mobile-ios-cancel-after.png new file mode 100644 index 000000000000..c1b9922556c8 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/003-mobile-ios-cancel-after.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/004-mobile-ios-engaged-before.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/004-mobile-ios-engaged-before.png new file mode 100644 index 000000000000..7e398a8b08e1 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/004-mobile-ios-engaged-before.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/005-mobile-ios-engaged-after.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/005-mobile-ios-engaged-after.png new file mode 100644 index 000000000000..67b9e57daa56 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/005-mobile-ios-engaged-after.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/006-mobile-ios-price-preview.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/006-mobile-ios-price-preview.png new file mode 100644 index 000000000000..b07dcc4d14d5 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/006-mobile-ios-price-preview.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/007-mobile-ios-payment-declined.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/007-mobile-ios-payment-declined.png new file mode 100644 index 000000000000..fce0bfe2aa63 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/007-mobile-ios-payment-declined.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/008-mobile-ios-head-preview-normal.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/008-mobile-ios-head-preview-normal.png new file mode 100644 index 000000000000..2d9f459d1bf1 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/008-mobile-ios-head-preview-normal.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/009-mobile-ios-head-preview-large.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/009-mobile-ios-head-preview-large.png new file mode 100644 index 000000000000..c7b347d2a949 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/009-mobile-ios-head-preview-large.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/010-mobile-ios-merged-preview-normal.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/010-mobile-ios-merged-preview-normal.png new file mode 100644 index 000000000000..2d9f459d1bf1 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/010-mobile-ios-merged-preview-normal.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/011-mobile-ios-merged-preview-large.png b/.agent-artifacts/ui-evidence/yearly-retention-20261115/011-mobile-ios-merged-preview-large.png new file mode 100644 index 000000000000..00674074fabb Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention-20261115/011-mobile-ios-merged-preview-large.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/PACKET.md b/.agent-artifacts/ui-evidence/yearly-retention-20261115/PACKET.md new file mode 100644 index 000000000000..52872c2e6b1c --- /dev/null +++ b/.agent-artifacts/ui-evidence/yearly-retention-20261115/PACKET.md @@ -0,0 +1,29 @@ +SCA-1808 candidate render supplement, November 15, 2026. + +The ten synthetic iOS widget rasters are byte-identical to the 05:07Z packet +returned on SCA-1808. Upstream `cf2cb6b0ce` stacks yearly-switch fact values +below their labels above 130% text size. The normal-size isolated previews are +byte-identical before/after; the 150% previews show that layout difference. +Numbered rasters exercise the actual cancellation modal and the engaged +placement in a minimal Home host. Unnumbered previews isolate sheet content. + +Nine tests passed at the repaired merge candidate. The execution command was: + +```sh +cd app +flutter test --no-pub --reporter expanded \ + --dart-define=YEARLY_RETENTION_RENDER_OUTPUT=../.agent-artifacts/sca-1808-repair/renders \ + test/widgets/yearly_retention_test.dart \ + test/widgets/sca1808_upstream_preview_test.dart +``` + +The supplemental fixture and source snapshots are in the issue attachment +`sca-1808-fresh-renders-20261115.zip`. No product code was changed to render it. +The original seven-image baseline and exact localized copy remain in the +adjacent `yearly-retention` packet. Yearly/cancellation English copy is unchanged. +Prices are synthetic fixture values. These are not device or deployed captures. + +The owner authorized retaining this upstream accessibility layout for source +preparation; David has not approved this final packet. Approval/UI-author and +authorized rollout gates remain required. Live verification returns WAITING +(exit 2); no production reads/writes, activation, or measured lift is claimed. diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/evidence.json b/.agent-artifacts/ui-evidence/yearly-retention-20261115/evidence.json new file mode 100644 index 000000000000..3a6d6432e750 --- /dev/null +++ b/.agent-artifacts/ui-evidence/yearly-retention-20261115/evidence.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "images": [ + { + "file": "002-mobile-ios-cancel-control.png", + "platform": "mobile-ios", + "description": "Current cancellation control/no yearly offer; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "003-mobile-ios-cancel-after.png", + "platform": "mobile-ios", + "description": "Production cancellation, too_expensive treatment; yearly before discount; Cancel pinned; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "004-mobile-ios-engaged-before.png", + "platform": "mobile-ios", + "description": "Minimal Home host before/flag-off: no new placement; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "005-mobile-ios-engaged-after.png", + "platform": "mobile-ios", + "description": "Production engaged placement in minimal Home host, not full Home shell; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "006-mobile-ios-price-preview.png", + "platform": "mobile-ios", + "description": "Production existing yearly preview, exact synthetic charge and annual total; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "007-mobile-ios-payment-declined.png", + "platform": "mobile-ios", + "description": "Production switch decline, monthly retained; dismiss sheet to Cancel subscription; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "008-mobile-ios-head-preview-normal.png", + "platform": "mobile-ios", + "description": "Synthetic isolated yearly-switch preview; normal text; original PR head; not a device capture", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "009-mobile-ios-head-preview-large.png", + "platform": "mobile-ios", + "description": "Synthetic isolated yearly-switch preview; 150% text; original PR head; not a device capture", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "010-mobile-ios-merged-preview-normal.png", + "platform": "mobile-ios", + "description": "Synthetic isolated yearly-switch preview; normal text; local merged candidate; not a device capture", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "011-mobile-ios-merged-preview-large.png", + "platform": "mobile-ios", + "description": "Synthetic isolated yearly-switch preview; 150% text; local merged candidate; not a device capture", + "captured_by": "agent", + "source": "visual-audit" + } + ] +} diff --git a/.agent-artifacts/ui-evidence/yearly-retention-20261115/render-manifest.json b/.agent-artifacts/ui-evidence/yearly-retention-20261115/render-manifest.json new file mode 100644 index 000000000000..0f937dbdb0de --- /dev/null +++ b/.agent-artifacts/ui-evidence/yearly-retention-20261115/render-manifest.json @@ -0,0 +1,30 @@ +{ + "pr": 25158, + "merged_main": "9cc1565d383de4565b8bc492479b9d917731370b", + "upstream_layout_commit": "cf2cb6b0ce", + "state": "source repair candidate; approval and live outcomes unaccepted", + "text_scales": [ + 1.0, + 1.5 + ], + "viewport": "430x932 logical px; pixelRatio 2; iOS widget variant", + "fixtures": "synthetic monthly subscription and preview; no production access", + "tests": "9 passed", + "normal_preview_identical": true, + "large_preview_identical": false, + "yearly_and_cancel_english_copy_changed": false, + "sha256": { + "004-mobile-ios-engaged-before.png": "c8a533f4e69db77bad22161f65d451011ab375083d1caa8dc28e67aaa7febe6d", + "002-mobile-ios-cancel-control.png": "18639044ae7e31c48f7b32b6e6785629e2c0343528946e2bfbdf98523843d5dc", + "011-mobile-ios-merged-preview-large.png": "26c6b7e1181505bbdbb7a4a7675ce68597d329ce4ac103b4e4073b144cfbd7a2", + "009-mobile-ios-head-preview-large.png": "8174c7bed0f6ca03287dee44a3985d6aac5bfed3686064b160548cfc639904fa", + "010-mobile-ios-merged-preview-normal.png": "4b6fdec8350f3c32746ae48c36c2a679a990a3aa943f3e09866596741654416b", + "008-mobile-ios-head-preview-normal.png": "4b6fdec8350f3c32746ae48c36c2a679a990a3aa943f3e09866596741654416b", + "007-mobile-ios-payment-declined.png": "5980c2d1eb80abe6c5b44dc8db88a3130a54ad895e88e8a61e814be4e83f189a", + "003-mobile-ios-cancel-after.png": "d87a81584b757d67c6e656853f6f34726843feb023419d0e39e3aa87190d4539", + "006-mobile-ios-price-preview.png": "023cfe9927cbf884783bf71f86598eba22faa1180ed4664db4b368e9d149de57", + "005-mobile-ios-engaged-after.png": "d4c3d860cd9a7d1562c6d7cb892bf3211f9f618fe770bcb00c30e69503e24154" + }, + "base_pr_head": "dd41b1354cb9f38f0dcdb2e182bf960023dd8a1f", + "source_head": "9efe186c259ff93b556a75d8357bed63e5f97d03" +} diff --git a/.agent-artifacts/ui-evidence/yearly-retention/001-mobile-ios-cancel-before.png b/.agent-artifacts/ui-evidence/yearly-retention/001-mobile-ios-cancel-before.png new file mode 100644 index 000000000000..698399fc2eca Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/001-mobile-ios-cancel-before.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/002-mobile-ios-cancel-control.png b/.agent-artifacts/ui-evidence/yearly-retention/002-mobile-ios-cancel-control.png new file mode 100644 index 000000000000..698399fc2eca Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/002-mobile-ios-cancel-control.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/003-mobile-ios-cancel-after.png b/.agent-artifacts/ui-evidence/yearly-retention/003-mobile-ios-cancel-after.png new file mode 100644 index 000000000000..c1b9922556c8 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/003-mobile-ios-cancel-after.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/004-mobile-ios-engaged-before.png b/.agent-artifacts/ui-evidence/yearly-retention/004-mobile-ios-engaged-before.png new file mode 100644 index 000000000000..7e398a8b08e1 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/004-mobile-ios-engaged-before.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/005-mobile-ios-engaged-after.png b/.agent-artifacts/ui-evidence/yearly-retention/005-mobile-ios-engaged-after.png new file mode 100644 index 000000000000..67b9e57daa56 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/005-mobile-ios-engaged-after.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/006-mobile-ios-price-preview.png b/.agent-artifacts/ui-evidence/yearly-retention/006-mobile-ios-price-preview.png new file mode 100644 index 000000000000..b07dcc4d14d5 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/006-mobile-ios-price-preview.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/007-mobile-ios-payment-declined.png b/.agent-artifacts/ui-evidence/yearly-retention/007-mobile-ios-payment-declined.png new file mode 100644 index 000000000000..fce0bfe2aa63 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yearly-retention/007-mobile-ios-payment-declined.png differ diff --git a/.agent-artifacts/ui-evidence/yearly-retention/copy.json b/.agent-artifacts/ui-evidence/yearly-retention/copy.json new file mode 100644 index 000000000000..705a4bdaa1da --- /dev/null +++ b/.agent-artifacts/ui-evidence/yearly-retention/copy.json @@ -0,0 +1,31 @@ +{ + "approval": "PENDING; no authorization or activation", + "locale": "en", + "strings": { + "yearlySwitchTitle": "Switch to Yearly", + "yearlySwitchHomeTitle": "Save {amount} a year with yearly", + "yearlySwitchHomeSubtitle": "Same plan, billed once a year", + "yearlySwitchPlanValue": "{plan}, {price} a year", + "yearlySwitchDueToday": "Due today", + "yearlySwitchRenews": "Renews", + "yearlySwitchCreditNote": "Includes credit for the rest of this month.", + "yearlySwitchDeclined": "Payment declined. You're still on monthly.", + "yearlySwitchFailed": "Couldn't switch. You're still on monthly.", + "yearlySwitchPreviewFailed": "Couldn't load your price.", + "yearlySwitchUnavailable": "Yearly billing isn't available for your plan right now.", + "yearlySwitchDoneTitle": "You're on yearly", + "yearlySwitchDoneBody": "{amount} charged today. Renews {date}.", + "cancelSubscription": "Cancel Subscription", + "retentionOfferBody": "Stay and pay {price} a month for the next {months} months.", + "retentionOfferAccept": "Stay for {percent}% off", + "cancel": "Cancel" + }, + "fixture_amounts": { + "monthly_minor": 2999, + "yearly_minor": 26991, + "savings_minor": 8997, + "due_today_minor": 24492, + "currency": "USD" + }, + "note": "Existing list-price yearly billing, not a new discount. Rendered amounts are synthetic, never verified live prices." +} diff --git a/.agent-artifacts/ui-evidence/yearly-retention/evidence.json b/.agent-artifacts/ui-evidence/yearly-retention/evidence.json new file mode 100644 index 000000000000..30cbf4ac8a9c --- /dev/null +++ b/.agent-artifacts/ui-evidence/yearly-retention/evidence.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "images": [ + { + "file": "001-mobile-ios-cancel-before.png", + "platform": "mobile-ios", + "description": "Historical production cancellation source at ac02feb05f0d6389f473e08a25980397b1348b0f; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "002-mobile-ios-cancel-control.png", + "platform": "mobile-ios", + "description": "Current cancellation control/no yearly offer; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "003-mobile-ios-cancel-after.png", + "platform": "mobile-ios", + "description": "Production cancellation, too_expensive treatment; yearly before discount; Cancel pinned; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "004-mobile-ios-engaged-before.png", + "platform": "mobile-ios", + "description": "Minimal Home host before/flag-off: no new placement; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "005-mobile-ios-engaged-after.png", + "platform": "mobile-ios", + "description": "Production engaged placement in minimal Home host, not full Home shell; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "006-mobile-ios-price-preview.png", + "platform": "mobile-ios", + "description": "Production existing yearly preview, exact synthetic charge and annual total; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + }, + { + "file": "007-mobile-ios-payment-declined.png", + "platform": "mobile-ios", + "description": "Production switch decline, monthly retained; dismiss sheet to Cancel subscription; synthetic widget raster, not a device screenshot", + "captured_by": "agent", + "source": "visual-audit" + } + ] +} diff --git a/.agent-artifacts/ui-evidence/yearly-retention/render-manifest.json b/.agent-artifacts/ui-evidence/yearly-retention/render-manifest.json new file mode 100644 index 000000000000..41e1abeface1 --- /dev/null +++ b/.agent-artifacts/ui-evidence/yearly-retention/render-manifest.json @@ -0,0 +1,60 @@ +{ + "viewport_logical": [ + 430, + 932 + ], + "pixel_ratio": 2, + "theme": "light", + "locale": "en", + "synthetic": true, + "approval": "PENDING", + "historical_source": "ac02feb05f0d6389f473e08a25980397b1348b0f", + "renders": [ + { + "file": "001-mobile-ios-cancel-before.png", + "sha256": "18639044ae7e31c48f7b32b6e6785629e2c0343528946e2bfbdf98523843d5dc", + "role": "Historical production cancellation source at ac02feb05f0d6389f473e08a25980397b1348b0f" + }, + { + "file": "002-mobile-ios-cancel-control.png", + "sha256": "18639044ae7e31c48f7b32b6e6785629e2c0343528946e2bfbdf98523843d5dc", + "role": "Current cancellation control/no yearly offer" + }, + { + "file": "003-mobile-ios-cancel-after.png", + "sha256": "d87a81584b757d67c6e656853f6f34726843feb023419d0e39e3aa87190d4539", + "role": "Production cancellation, too_expensive treatment; yearly before discount; Cancel pinned" + }, + { + "file": "004-mobile-ios-engaged-before.png", + "sha256": "c8a533f4e69db77bad22161f65d451011ab375083d1caa8dc28e67aaa7febe6d", + "role": "Minimal Home host before/flag-off: no new placement" + }, + { + "file": "005-mobile-ios-engaged-after.png", + "sha256": "d4c3d860cd9a7d1562c6d7cb892bf3211f9f618fe770bcb00c30e69503e24154", + "role": "Production engaged placement in minimal Home host, not full Home shell" + }, + { + "file": "006-mobile-ios-price-preview.png", + "sha256": "023cfe9927cbf884783bf71f86598eba22faa1180ed4664db4b368e9d149de57", + "role": "Production existing yearly preview, exact synthetic charge and annual total" + }, + { + "file": "007-mobile-ios-payment-declined.png", + "sha256": "5980c2d1eb80abe6c5b44dc8db88a3130a54ad895e88e8a61e814be4e83f189a", + "role": "Production switch decline, monthly retained; dismiss sheet to Cancel subscription" + } + ], + "limitations": [ + "Widget rasters, not device/system-safe-area or full Home-shell acceptance.", + "No task-owned agent-flutter debug app was available; device/debugger verification not claimed.", + "No actual Stripe price, account, deployment, approval, activation or lift is represented." + ], + "source_files_sha256": { + "app/lib/pages/settings/widgets/cancel_subscription_sheet.dart": "a049f3a5bda156086529967dc03c9ba6b437f0633b2d43328a4a8692aa00927f", + "app/lib/pages/settings/widgets/yearly_retention_placement.dart": "76a9256d3ac1a022c705d751aea568b1e78b3f6c8e464da618cbe8460f6133dc", + "app/lib/pages/settings/widgets/yearly_switch.dart": "a6490fdd2c338edaef0667ddfa84dc17a26b03a3c6d93463664e35eec3c9e4e0", + "app/test/widgets/yearly_retention_test.dart": "9a3edebfbccf1755e2168159910b50326d3243f8926e91aa3ca1764a29ca438c" + } +} diff --git a/app/changelog/unreleased/20261112-yearly-retention.json b/app/changelog/unreleased/20261112-yearly-retention.json new file mode 100644 index 000000000000..dcbdbdb7f3a5 --- /dev/null +++ b/app/changelog/unreleased/20261112-yearly-retention.json @@ -0,0 +1 @@ +{"change":"Prepare the existing-price yearly switch before the cancellation discount and once for engaged monthly subscribers, behind approval, rollout, and stable holdout gates."} diff --git a/app/lib/backend/http/api/yearly_switch_retention.dart b/app/lib/backend/http/api/yearly_switch_retention.dart new file mode 100644 index 000000000000..e16a322f02d8 --- /dev/null +++ b/app/lib/backend/http/api/yearly_switch_retention.dart @@ -0,0 +1,23 @@ +// Owns the optional yearly-retention admission/config transport. +// Preserve typed HTTP failures; only UI callers decide to hide an optional offer. +import 'dart:convert'; + +import 'package:omi/backend/http/api_result.dart'; +import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire; +import 'package:omi/env/env.dart'; + +Future> claimYearlyRetention(String placement, {ApiSend? send}) { + if (!const {'cancel', 'engaged'}.contains(placement)) throw ArgumentError.value(placement, 'placement'); + return executeApi( + request: + ApiRequest(url: '${Env.apiBaseUrl}v1/payments/yearly-switch-retention?placement=$placement', method: 'POST'), + send: send, + decode: (body) => wire.GeneratedYearlySwitchResponse.fromJson(jsonDecode(body) as Map), + ); +} + +Future> yearlyRetentionStatus({ApiSend? send}) => executeApi( + request: ApiRequest(url: '${Env.apiBaseUrl}v1/payments/yearly-switch-retention', method: 'GET'), + send: send, + decode: (body) => wire.GeneratedYearlySwitchResponse.fromJson(jsonDecode(body) as Map).eligible, + ); diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index f223a48ccad3..5d67dac0a9b6 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -1,3 +1,5 @@ +import 'package:omi/pages/settings/widgets/yearly_retention_placement.dart'; +import 'package:omi/pages/settings/widgets/yearly_switch.dart'; // The home page: capture start/stop entry point, permission gating, // foreground-service lifecycle, and on-device transcription wiring. // @@ -935,6 +937,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ); }, ), + if (onHome) const YearlyRetentionPlacement(surface: YearlySwitchSurface.engaged), // Home shows an active call in its capture row; Tasks gets the slim bar. if (!onHome) const ActiveCallTopBar(), Expanded( diff --git a/app/lib/pages/settings/widgets/cancel_subscription_sheet.dart b/app/lib/pages/settings/widgets/cancel_subscription_sheet.dart index e5e87bc42e16..6ed3f274e21a 100644 --- a/app/lib/pages/settings/widgets/cancel_subscription_sheet.dart +++ b/app/lib/pages/settings/widgets/cancel_subscription_sheet.dart @@ -7,6 +7,10 @@ import 'package:provider/provider.dart'; import 'package:omi/backend/http/api/payment.dart' as payment_api; import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire; import 'package:omi/pages/settings/widgets/leave_flow_widgets.dart'; +import 'package:omi/pages/settings/widgets/yearly_retention_placement.dart'; +import 'package:omi/pages/settings/widgets/yearly_switch.dart'; +import 'package:omi/utils/analytics/registry/events.g.dart'; +import 'package:omi/utils/analytics/registry/typed_events.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -20,11 +24,17 @@ const _stepCount = 3; /// step is its own route, so the iOS edge swipe and system back step back one step. This widget /// is step 1 and owns the flow; [show] resolves `true` when the subscription was cancelled. class CancelSubscriptionFlow extends StatefulWidget { - const CancelSubscriptionFlow({super.key, this.loadOffer}); + const CancelSubscriptionFlow( + {super.key, this.loadOffer, this.yearlyClaim, this.yearlyEnabled, this.yearlyPreview, this.yearlyApply}); /// Hermetic preview/test seam; production reads the retention endpoint. final Future Function()? loadOffer; + final Future Function(String)? yearlyClaim; + final Future Function()? yearlyEnabled; + final YearlySwitchLoader? yearlyPreview; + final YearlySwitchApplier? yearlyApply; + static Future show(BuildContext context) { return Navigator.of(context).push(omiPageRoute(builder: (_) => const CancelSubscriptionFlow())); } @@ -35,7 +45,11 @@ class CancelSubscriptionFlow extends StatefulWidget { /// What the reader has entered so far, shared by the three steps. class _CancelFlow { - _CancelFlow({this.loadOffer}); + _CancelFlow({this.loadOffer, this.yearlyClaim, this.yearlyEnabled, this.yearlyPreview, this.yearlyApply}); + final Future Function(String)? yearlyClaim; + final Future Function()? yearlyEnabled; + final YearlySwitchLoader? yearlyPreview; + final YearlySwitchApplier? yearlyApply; final Future Function()? loadOffer; final exit = LeaveFlowExit(); final details = TextEditingController(); @@ -45,7 +59,12 @@ class _CancelFlow { } class _CancelSubscriptionFlowState extends State { - late final _flow = _CancelFlow(loadOffer: widget.loadOffer); + late final _flow = _CancelFlow( + loadOffer: widget.loadOffer, + yearlyClaim: widget.yearlyClaim, + yearlyEnabled: widget.yearlyEnabled, + yearlyPreview: widget.yearlyPreview, + yearlyApply: widget.yearlyApply); static const _reasons = [ _Reason('too_expensive', FontAwesomeIcons.wallet), @@ -201,8 +220,10 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> { final offer = await (_flow.loadOffer ?? payment_api.getRetentionOffer)(); if (!mounted || offer == null || !offer.eligible || offer.discountedAmount == null) return; setState(() => _offer = offer); - PlatformManager.instance.analytics - .track('Retention Offer Shown', properties: {'reason': _flow.reason, 'percent_off': offer.percentOff}); + const TypedEvents().emit(RetentionOfferShown( + reason: RetentionOfferShownReason.values + .firstWhere((value) => value.wireName == _flow.reason, orElse: () => RetentionOfferShownReason.other), + percentOff: offer.percentOff)); } String _monthlyPrice(wire.GeneratedRetentionOfferResponse offer) => @@ -212,8 +233,10 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> { final offer = _offer; if (offer == null || _busy) return; setState(() => _isAcceptingOffer = true); - PlatformManager.instance.analytics - .track('Retention Offer Accepted', properties: {'reason': _flow.reason, 'percent_off': offer.percentOff}); + const TypedEvents().emit(RetentionOfferAccepted( + reason: RetentionOfferAcceptedReason.values + .firstWhere((value) => value.wireName == _flow.reason, orElse: () => RetentionOfferAcceptedReason.other), + percentOff: offer.percentOff)); final ok = await payment_api.acceptRetentionOffer(); if (!mounted) return; if (ok) { @@ -277,8 +300,18 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> { title: context.l10n.justAMoment, subtitle: context.l10n.cancelConsequencesSubtitle, canPop: !_busy, - body: Column( + body: ListView( children: [ + YearlyRetentionPlacement( + surface: YearlySwitchSurface.cancel, + emphasized: _flow.reason == 'too_expensive', + busy: _busy, + claim: _flow.yearlyClaim, + readEnabled: _flow.yearlyEnabled, + loadPreview: _flow.yearlyPreview, + apply: _flow.yearlyApply, + onSwitched: () => _flow.exit.close(context, false), + ), if (offer != null) ...[ LeaveFlowNotice( icon: FontAwesomeIcons.tag, @@ -293,9 +326,9 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> { color: OmiColors.warning, ), const SizedBox(height: OmiSpacing.md), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.xl), + Padding( + padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.xl), + child: Column( children: [ LeaveFlowConsequenceRow(icon: FontAwesomeIcons.infinity, text: context.l10n.cancelConsequenceNoAccess), LeaveFlowConsequenceRow(icon: FontAwesomeIcons.bolt, text: context.l10n.cancelConsequenceBattery), diff --git a/app/lib/pages/settings/widgets/yearly_retention_placement.dart b/app/lib/pages/settings/widgets/yearly_retention_placement.dart new file mode 100644 index 000000000000..8dd4235dad48 --- /dev/null +++ b/app/lib/pages/settings/widgets/yearly_retention_placement.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/yearly_switch_retention.dart' as api; +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/http/api_result.dart'; +import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire; +import 'package:omi/pages/settings/widgets/plans/plan_display_name.dart'; +import 'package:omi/pages/settings/widgets/yearly_switch.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/analytics/registry/events.g.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +/// Rebuilds never retry admission; a later Home presentation can try again. +/// The server's UID enrollment owns admission +/// across hosts, devices, reinstalls and retries; no client preference is truth. +class YearlyRetentionPlacement extends StatefulWidget { + const YearlyRetentionPlacement( + {super.key, + required this.surface, + this.emphasized = false, + this.busy = false, + this.onSwitched, + this.claim, + this.readEnabled, + this.loadPreview, + this.apply}); + + final YearlySwitchSurface surface; + final bool emphasized; + final bool busy; + final VoidCallback? onSwitched; + final Future Function(String)? claim; + final Future Function()? readEnabled; + final YearlySwitchLoader? loadPreview; + final YearlySwitchApplier? apply; + + @override + State createState() => _YearlyRetentionPlacementState(); +} + +class _YearlyRetentionPlacementState extends State with WidgetsBindingObserver { + wire.GeneratedYearlySwitchResponse? _offer; + String? _attemptedFor; + String? _admittedFor; + final Set _pendingClaims = {}; + bool _routeCurrent = false; + bool _wasBackgrounded = false; + Timer? _refresh; + bool _opening = false; + + String? get _candidate { + final candidate = yearlySwitchCandidate(context.read()); + return candidate == null ? null : '${SharedPreferencesUtil().uid}:$candidate'; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final current = ModalRoute.isCurrentOf(context) ?? true; + if (current && !_routeCurrent) _presentHome(); + _routeCurrent = current; + } + + @override + void initState() { + super.initState(); + assert(widget.surface != YearlySwitchSurface.plans); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + _refresh?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) _wasBackgrounded = true; + if (state == AppLifecycleState.resumed) { + if (_offer != null) { + unawaited(_checkEnabled()); + } else if (_wasBackgrounded && _routeCurrent) { + _presentHome(); + } + _wasBackgrounded = false; + } + } + + void _presentHome() { + final candidate = _candidate; + if (widget.surface == YearlySwitchSurface.engaged && + candidate != null && + _attemptedFor == candidate && + _admittedFor != candidate && + !_pendingClaims.contains(candidate)) { + setState(() => _scheduleClaim(candidate)); + } + } + + void _scheduleClaim(String candidate) { + _attemptedFor = candidate; + _offer = null; + _refresh?.cancel(); + _pendingClaims.add(candidate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + if (mounted && _candidate == candidate) await _claim(candidate); + } finally { + _pendingClaims.remove(candidate); + } + }); + } + + Future _checkEnabled() async { + bool enabled; + try { + if (widget.readEnabled != null) { + enabled = await widget.readEnabled!().timeout(const Duration(seconds: 3)); + } else { + final status = await api.yearlyRetentionStatus().timeout(const Duration(seconds: 3)); + enabled = status is ApiSuccess && status.data; + } + } catch (_) { + enabled = false; + } + if (!mounted) return false; + if (!enabled || _candidate != _attemptedFor) { + _refresh?.cancel(); + setState(() => _offer = null); + return false; + } + return true; + } + + Future _claim(String candidate) async { + wire.GeneratedYearlySwitchResponse? offer; + try { + if (widget.claim != null) { + offer = await widget.claim!(widget.surface.name).timeout(const Duration(seconds: 4)); + } else { + final result = await api.claimYearlyRetention(widget.surface.name).timeout(const Duration(seconds: 4)); + if (result is ApiSuccess) offer = result.data; + } + } catch (_) { + return; + } + if (!mounted || _candidate != candidate || offer?.eligible != true) return; + // Admission is consumed even if the flag changes before rendering. + _admittedFor = candidate; + if (offer?.yearlyAmount == null || offer?.savingsAmount == null || offer?.planId == null) { + return; + } + if (!await _checkEnabled() || !mounted || _candidate != candidate || _attemptedFor != candidate) return; + setState(() => _offer = offer); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _candidate == candidate && identical(_offer, offer)) { + observeYearlyRetention(widget.surface, YearlyRetentionObservationAction.shown); + } + }); + _refresh = Timer.periodic(const Duration(seconds: 30), (_) => unawaited(_checkEnabled())); + } + + Future _open() async { + if (_opening || widget.busy) return; + _opening = true; + final candidate = _candidate; + try { + if (!await _checkEnabled() || !mounted || widget.busy || _candidate != candidate) return; + final switched = await showYearlySwitchSheet(context, + surface: widget.surface, load: widget.loadPreview, apply: widget.apply, readEnabled: widget.readEnabled); + if (!mounted) return; + if (switched) { + _refresh?.cancel(); + setState(() => _offer = null); + widget.onSwitched?.call(); + } + } finally { + _opening = false; + } + } + + @override + Widget build(BuildContext context) { + final subscription = context.watch(); + final candidate = yearlySwitchCandidate(subscription) == null ? null : _candidate; + if (candidate == null) return const SizedBox.shrink(); + if (_attemptedFor != candidate) { + _scheduleClaim(candidate); + } + final offer = _offer; + if (offer == null) return const SizedBox.shrink(); + final l10n = context.l10n; + final plan = planTitleForTier(offer.planId!, subscription?.subscription?.availablePlans ?? []); + if (plan == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.md, OmiSpacing.sm, OmiSpacing.md, OmiSpacing.sm), + child: YearlySwitchRow( + key: ValueKey('yearly_retention_${widget.surface.name}'), + title: widget.emphasized || widget.surface == YearlySwitchSurface.engaged + ? l10n.yearlySwitchHomeTitle(yearlySwitchMoney(offer.savingsAmount!, offer.currency)) + : l10n.yearlySwitchTitle, + subtitle: + '${l10n.yearlySwitchPlanValue(plan, yearlySwitchMoney(offer.yearlyAmount!, offer.currency))}\n${l10n.yearlySwitchHomeSubtitle}', + color: widget.emphasized ? OmiColors.surface3 : OmiColors.surface2, + tileColor: OmiColors.surface1, + onTap: widget.busy ? () {} : _open, + onDismiss: widget.surface == YearlySwitchSurface.engaged + ? () { + _refresh?.cancel(); + observeYearlyRetention(widget.surface, YearlyRetentionObservationAction.dismissed); + setState(() => _offer = null); + } + : null, + ), + ); + } +} diff --git a/app/lib/pages/settings/widgets/yearly_switch.dart b/app/lib/pages/settings/widgets/yearly_switch.dart index 2e42611c1740..0c2ada58a090 100644 --- a/app/lib/pages/settings/widgets/yearly_switch.dart +++ b/app/lib/pages/settings/widgets/yearly_switch.dart @@ -6,6 +6,8 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:omi/backend/http/api/payment.dart' as payment_api; +import 'package:omi/backend/http/api_result.dart'; +import 'package:omi/backend/http/api/yearly_switch_retention.dart' as retention_api; import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire; import 'package:omi/models/subscription.dart'; import 'package:omi/pages/settings/widgets/plans/plan_display_name.dart'; @@ -20,7 +22,7 @@ import 'package:omi/utils/l10n_extensions.dart'; /// Offered in Plans & Billing. The confirm sheet shows Stripe's preview: what is charged today, the /// plan and the renewal date. -enum YearlySwitchSurface { plans } +enum YearlySwitchSurface { plans, cancel, engaged } typedef YearlySwitchLoader = Future Function({bool preview}); @@ -65,15 +67,41 @@ String? yearlySwitchCandidate(UsageProvider? provider) { /// [minor] units of [currency], exact and unambiguous like every purchase surface ("US$89.97"). String yearlySwitchMoney(int minor, String? currency) => formatBillingPrice(minor, currency ?? 'usd'); -void _emitShown(YearlySwitchSurface surface) => const TypedEvents() - .emit(SubscriptionYearlySwitchShown(surface: SubscriptionYearlySwitchShownSurface.values.byName(surface.name))); +/// Separate event vocabulary preserves released plans/home event shapes. +void observeYearlyRetention(YearlySwitchSurface surface, YearlyRetentionObservationAction action) { + const TypedEvents().emit(YearlyRetentionObservation( + placement: YearlyRetentionObservationPlacement.values.byName(surface.name), + action: action, + variant: YearlyRetentionObservationVariant.treatment, + experimentVersion: 1, + )); +} -void _emitOpened(YearlySwitchSurface surface) => const TypedEvents() - .emit(SubscriptionYearlySwitchOpened(surface: SubscriptionYearlySwitchOpenedSurface.values.byName(surface.name))); +void _emitShown(YearlySwitchSurface surface) { + if (surface != YearlySwitchSurface.plans) { + observeYearlyRetention(surface, YearlyRetentionObservationAction.shown); + } else { + const TypedEvents().emit(const SubscriptionYearlySwitchShown(surface: SubscriptionYearlySwitchShownSurface.plans)); + } +} -void _emitFailed(YearlySwitchSurface surface, SubscriptionYearlySwitchFailedReason reason) => - const TypedEvents().emit(SubscriptionYearlySwitchFailed( - surface: SubscriptionYearlySwitchFailedSurface.values.byName(surface.name), reason: reason)); +void _emitOpened(YearlySwitchSurface surface) { + if (surface != YearlySwitchSurface.plans) { + observeYearlyRetention(surface, YearlyRetentionObservationAction.opened); + } else { + const TypedEvents() + .emit(const SubscriptionYearlySwitchOpened(surface: SubscriptionYearlySwitchOpenedSurface.plans)); + } +} + +void _emitFailed(YearlySwitchSurface surface, SubscriptionYearlySwitchFailedReason reason) { + if (surface != YearlySwitchSurface.plans) { + observeYearlyRetention(surface, YearlyRetentionObservationAction.failed); + } else { + const TypedEvents() + .emit(SubscriptionYearlySwitchFailed(surface: SubscriptionYearlySwitchFailedSurface.plans, reason: reason)); + } +} /// Opens the confirm sheet; resolves true once the reader is on yearly. Future showYearlySwitchSheet( @@ -81,13 +109,15 @@ Future showYearlySwitchSheet( required YearlySwitchSurface surface, YearlySwitchLoader? load, YearlySwitchApplier? apply, + Future Function()? readEnabled, }) async { _emitOpened(surface); var switched = false; await showOmiSheet( context: context, title: context.l10n.yearlySwitchTitle, - builder: (_) => YearlySwitchSheet(surface: surface, load: load, apply: apply, onSwitched: () => switched = true), + builder: (_) => YearlySwitchSheet( + surface: surface, load: load, apply: apply, readEnabled: readEnabled, onSwitched: () => switched = true), ); return switched; } @@ -96,12 +126,13 @@ Future showYearlySwitchSheet( /// previewed amount only, so the charge is the one on screen. A declined card changes nothing: the /// reader stays on monthly. After any failure the next tap re-previews before it charges. class YearlySwitchSheet extends StatefulWidget { - const YearlySwitchSheet({super.key, required this.surface, this.load, this.apply, this.onSwitched}); + const YearlySwitchSheet({super.key, required this.surface, this.load, this.apply, this.onSwitched, this.readEnabled}); final YearlySwitchSurface surface; final YearlySwitchLoader? load; final YearlySwitchApplier? apply; final VoidCallback? onSwitched; + final Future Function()? readEnabled; @override State createState() => _YearlySwitchSheetState(); @@ -123,7 +154,29 @@ class _YearlySwitchSheetState extends State { _load(); } + Future _enabled() async { + if (widget.surface == YearlySwitchSurface.plans) return true; + try { + if (widget.readEnabled != null) return await widget.readEnabled!().timeout(const Duration(seconds: 3)); + final status = await retention_api.yearlyRetentionStatus().timeout(const Duration(seconds: 3)); + return status is ApiSuccess && status.data; + } catch (_) { + return false; + } + } + Future _load() async { + if (!await _enabled()) { + if (mounted) { + setState(() { + _loading = false; + _unavailable = true; + _offer = null; + }); + } + return; + } + if (!mounted) return; setState(() { _loading = true; _error = null; @@ -150,9 +203,26 @@ class _YearlySwitchSheetState extends State { Future _switch() async { var offer = _offer; if (offer == null || _switching) return; + // Latch before the asynchronous flag read so a double tap cannot charge twice. + setState(() => _switching = true); + if (!await _enabled()) { + if (mounted) { + setState(() { + _switching = false; + _unavailable = true; + _offer = null; + }); + } + return; + } + if (!mounted) return; HapticFeedback.mediumImpact(); - const TypedEvents().emit(SubscriptionYearlySwitchConfirmed( - surface: SubscriptionYearlySwitchConfirmedSurface.values.byName(widget.surface.name))); + if (widget.surface == YearlySwitchSurface.plans) { + const TypedEvents() + .emit(const SubscriptionYearlySwitchConfirmed(surface: SubscriptionYearlySwitchConfirmedSurface.plans)); + } else { + observeYearlyRetention(widget.surface, YearlyRetentionObservationAction.confirmed); + } setState(() { _switching = true; _error = null; @@ -209,8 +279,12 @@ class _YearlySwitchSheetState extends State { } final l10n = context.l10n; if (outcome == _Outcome.switched) { - const TypedEvents().emit(SubscriptionYearlySwitchSucceeded( - surface: SubscriptionYearlySwitchSucceededSurface.values.byName(widget.surface.name))); + if (widget.surface == YearlySwitchSurface.plans) { + const TypedEvents() + .emit(const SubscriptionYearlySwitchSucceeded(surface: SubscriptionYearlySwitchSucceededSurface.plans)); + } else { + observeYearlyRetention(widget.surface, YearlyRetentionObservationAction.succeeded); + } widget.onSwitched?.call(); final usage = context.read(); if (usage != null) { diff --git a/app/lib/utils/analytics/registry/events.g.dart b/app/lib/utils/analytics/registry/events.g.dart index 2d315533b53d..57f875ca3f94 100644 --- a/app/lib/utils/analytics/registry/events.g.dart +++ b/app/lib/utils/analytics/registry/events.g.dart @@ -3509,3 +3509,84 @@ final class SubscriptionPaymentIssueMonthlyFailed extends RegisteredEvent { @override Map get properties => {"reason": reason.wireName}; } + +enum RetentionOfferShownReason { + tooExpensive("too_expensive"), + notUsingEnough("not_using_enough"), + missingFeatures("missing_features"), + audioQuality("audio_quality"), + batteryDrain("battery_drain"), + foundAlternative("found_alternative"), + other("other"); + const RetentionOfferShownReason(this.wireName); + final String wireName; +} + +final class RetentionOfferShown extends RegisteredEvent { + const RetentionOfferShown({required this.reason, required this.percentOff}); + final RetentionOfferShownReason reason; + final int percentOff; + @override + String get wireName => "Retention Offer Shown"; + @override + Map get properties => {"reason": reason.wireName, "percent_off": percentOff}; +} + +enum RetentionOfferAcceptedReason { + tooExpensive("too_expensive"), + notUsingEnough("not_using_enough"), + missingFeatures("missing_features"), + audioQuality("audio_quality"), + batteryDrain("battery_drain"), + foundAlternative("found_alternative"), + other("other"); + const RetentionOfferAcceptedReason(this.wireName); + final String wireName; +} + +final class RetentionOfferAccepted extends RegisteredEvent { + const RetentionOfferAccepted({required this.reason, required this.percentOff}); + final RetentionOfferAcceptedReason reason; + final int percentOff; + @override + String get wireName => "Retention Offer Accepted"; + @override + Map get properties => {"reason": reason.wireName, "percent_off": percentOff}; +} + +enum YearlyRetentionObservationPlacement { + cancel("cancel"), + engaged("engaged"); + const YearlyRetentionObservationPlacement(this.wireName); + final String wireName; +} + +enum YearlyRetentionObservationAction { + shown("shown"), + opened("opened"), + confirmed("confirmed"), + succeeded("succeeded"), + failed("failed"), + dismissed("dismissed"); + const YearlyRetentionObservationAction(this.wireName); + final String wireName; +} + +enum YearlyRetentionObservationVariant { + control("control"), + treatment("treatment"); + const YearlyRetentionObservationVariant(this.wireName); + final String wireName; +} + +final class YearlyRetentionObservation extends RegisteredEvent { + const YearlyRetentionObservation({required this.placement, required this.action, required this.variant, required this.experimentVersion}); + final YearlyRetentionObservationPlacement placement; + final YearlyRetentionObservationAction action; + final YearlyRetentionObservationVariant variant; + final int experimentVersion; + @override + String get wireName => "Yearly Retention Observation"; + @override + Map get properties => {"placement": placement.wireName, "action": action.wireName, "variant": variant.wireName, "experiment_version": experimentVersion}; +} diff --git a/app/test/widgets/render_yearly_retention_baseline.sh b/app/test/widgets/render_yearly_retention_baseline.sh new file mode 100644 index 000000000000..a00e0349096d --- /dev/null +++ b/app/test/widgets/render_yearly_retention_baseline.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Render the actual pre-change cancellation source with the same synthetic fixture. +set -euo pipefail +cd "$(dirname "$0")/../.." +fixture_dir="$(mktemp -d test/widgets/yearly_baseline.XXXXXX)" +trap 'rm -rf "$fixture_dir"' EXIT +git show ac02feb05f0d6389f473e08a25980397b1348b0f:app/lib/pages/settings/widgets/cancel_subscription_sheet.dart > "$fixture_dir/before.dart" +cat > "$fixture_dir/baseline_test.dart" <<'DART' +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/platform/platform_manager.dart'; +import 'package:omi/utils/analytics/analytics_manager.dart'; +import '../../support/recording_analytics.dart'; +import '../yearly_retention_test.dart' as fixture; +import 'before.dart' as before; +void main() { + setUp(() async { + OmiColors.active = OmiPalette.light; + SharedPreferences.setMockInitialValues({'uid':'synthetic-uid'}); + await SharedPreferencesUtil.init(); + PlatformManager.initializeForLocalHarness(); + final analytics = RecordingAnalytics(); + AnalyticsManager.configure(analytics.adapter); + await AnalyticsManager.init(); + }); + tearDown(AnalyticsManager.resetForTesting); + fixture.iosTestWidgets('historical cancellation baseline', (tester) async { + await tester.runAsync(fixture.fonts); + await fixture.pump(tester, before.CancelSubscriptionFlow(loadOffer: () async => + wire.GeneratedRetentionOfferResponse(eligible:true, percentOff:50, durationMonths:3, + regularAmount:2999, discountedAmount:1500, currency:'usd'))); + await fixture.confirmStep(tester); + await fixture.screenshot(tester, '001-mobile-ios-cancel-before'); + }); +} +DART +flutter test --no-pub --dart-define=YEARLY_RETENTION_RENDER_OUTPUT=../.agent-artifacts/ui-evidence/yearly-retention "$fixture_dir/baseline_test.dart" diff --git a/app/test/widgets/yearly_retention_test.dart b/app/test/widgets/yearly_retention_test.dart new file mode 100644 index 000000000000..3ac3734236dc --- /dev/null +++ b/app/test/widgets/yearly_retention_test.dart @@ -0,0 +1,424 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/models/subscription.dart'; +import 'package:omi/pages/settings/widgets/cancel_subscription_sheet.dart'; +import 'package:omi/pages/settings/widgets/yearly_retention_placement.dart'; +import 'package:omi/pages/settings/widgets/yearly_switch.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/analytics/analytics_manager.dart'; +import 'package:omi/utils/platform/platform_manager.dart'; + +import '../helpers/sfpro_font_fixtures.dart'; +import '../support/recording_analytics.dart'; + +const output = String.fromEnvironment('YEARLY_RETENTION_RENDER_OUTPUT'); +final captureKey = GlobalKey(); +final offer = wire.GeneratedYearlySwitchResponse( + eligible: true, + reason: 'eligible', + planId: 'unlimited_v2', + priceId: 'synthetic_yearly', + currency: 'usd', + monthlyAmount: 2999, + yearlyAmount: 26991, + savingsAmount: 8997, + amountDueToday: 24492, + renewsAt: DateTime.utc(2027, 11, 12).millisecondsSinceEpoch ~/ 1000, + prorationDate: DateTime.utc(2026, 11, 12).millisecondsSinceEpoch ~/ 1000); + +class Usage extends UsageProvider { + int cancellations = 0; + Completer? cancelResult; + @override + Future fetchSubscription() async {} + @override + Future loadAvailablePlans() async {} + @override + Future cancelUserSubscription({String? reason, String? reasonDetails}) async { + cancellations++; + return cancelResult == null ? false : await cancelResult!.future; + } +} + +Future pump(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final usage = Usage(); + usage.debugSetSubscription(UserSubscriptionResponse( + subscription: Subscription( + plan: PlanType.unlimitedV2, + status: SubscriptionStatus.active, + stripeSubscriptionId: 'synthetic_monthly', + currentPriceId: 'synthetic_month', + currentPeriodEnd: DateTime.utc(2026, 12, 12).millisecondsSinceEpoch ~/ 1000), + transcriptionSecondsUsed: 0, + transcriptionSecondsLimit: 0, + wordsTranscribedUsed: 0, + wordsTranscribedLimit: 0, + insightsGainedUsed: 0, + insightsGainedLimit: 0)); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: usage, + child: RepaintBoundary( + key: captureKey, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildOmiTheme(brightness: Brightness.light), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child)))); + await tester.pumpAndSettle(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + usage.dispose(); + }); + return usage; +} + +Future confirmStep(WidgetTester tester) async { + final l10n = AppLocalizations.of(tester.element(find.byType(Scaffold).first)); + await tester.tap(find.text(l10n.cancelReasonTooExpensive)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.continueButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.continueButton)); + await tester.pumpAndSettle(); +} + +Future screenshot(WidgetTester tester, String name) async { + if (output.isEmpty) return; + await tester.runAsync(() async { + final boundary = captureKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(output).create(recursive: true); + await File('$output/$name.png').writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); +} + +Future fonts() async { + final manifest = jsonDecode(await rootBundle.loadString('FontManifest.json')) as List; + for (final entry in manifest) { + final loader = FontLoader(entry['family'] as String); + for (final font in entry['fonts'] as List) { + loader.addFont(rootBundle.load(font['asset'] as String)); + } + await loader.load(); + } + for (final family in [ + 'Roboto', + 'CupertinoSystemText', + 'CupertinoSystemDisplay', + '.SF UI Text', + '.SF UI Display', + '.SF Pro Text', + '.SF Pro Display', + 'FlutterTest' + ]) { + final loader = FontLoader(family); + for (final weight in ['REGULAR', 'MEDIUM', 'BOLD']) { + loader.addFont(sfProFont('SFPRODISPLAY$weight.OTF')); + } + await loader.load(); + } +} + +void iosTestWidgets(String description, WidgetTesterCallback callback) => + testWidgets(description, callback, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + +void main() { + late RecordingAnalytics analytics; + setUp(() async { + OmiColors.active = OmiPalette.light; + SharedPreferences.setMockInitialValues({'uid': 'synthetic-uid'}); + await SharedPreferencesUtil.init(); + PlatformManager.initializeForLocalHarness(); + analytics = RecordingAnalytics(); + AnalyticsManager.configure(analytics.adapter); + await AnalyticsManager.init(); + }); + tearDown(() { + AnalyticsManager.resetForTesting(); + }); + + for (final treatment in [false, true]) { + iosTestWidgets('cancel ${treatment ? 'treatment' : 'control'} renders and Cancel stays reachable', (tester) async { + if (output.isNotEmpty) await tester.runAsync(fonts); + final usage = await pump( + tester, + CancelSubscriptionFlow( + loadOffer: () async => const wire.GeneratedRetentionOfferResponse( + eligible: true, + percentOff: 50, + durationMonths: 3, + regularAmount: 2999, + discountedAmount: 1500, + currency: 'usd'), + yearlyClaim: (_) async => treatment ? offer : null, + yearlyEnabled: () async => true, + yearlyPreview: ({bool preview = false}) async => offer, + yearlyApply: ({required priceId, required prorationDate, required expectedAmountDue}) async => + {'error': true, 'status_code': 402})); + await confirmStep(tester); + final key = find.byKey(const ValueKey('yearly_retention_cancel')); + expect(key, treatment ? findsOneWidget : findsNothing); + final l10n = AppLocalizations.of(tester.element(find.byType(Scaffold).first)); + expect(find.text(l10n.cancelSubscription).hitTestable(), findsOneWidget); + analytics.expectSingle('Retention Offer Shown', {'reason': 'too_expensive', 'percent_off': 50}); + await screenshot(tester, treatment ? '003-mobile-ios-cancel-after' : '002-mobile-ios-cancel-control'); + if (treatment) { + await tester.tap(key); + await tester.pumpAndSettle(); + expect(find.text('US\$244.92'), findsOneWidget); + await screenshot(tester, '006-mobile-ios-price-preview'); + await tester.tap(find.byKey(const Key('yearly_switch_confirm'))); + await tester.pumpAndSettle(); + expect(find.text(l10n.yearlySwitchDeclined), findsOneWidget); + await screenshot(tester, '007-mobile-ios-payment-declined'); + await tester.tap(find.byKey(const Key('yearly_switch_cancel'))); + await tester.pumpAndSettle(); + expect(find.text(l10n.cancelSubscription).hitTestable(), findsOneWidget); + } + await tester.tap(find.text(l10n.cancelSubscription)); + await tester.pumpAndSettle(); + expect(usage.cancellations, 1); + expect(tester.takeException(), isNull); + }); + } + + iosTestWidgets('pending admission never blocks cancellation', (tester) async { + final pending = Completer(); + final usage = await pump( + tester, + CancelSubscriptionFlow( + loadOffer: () async => null, yearlyClaim: (_) => pending.future, yearlyEnabled: () async => true)); + await confirmStep(tester); + final l10n = AppLocalizations.of(tester.element(find.byType(Scaffold).first)); + await tester.tap(find.text(l10n.cancelSubscription)); + await tester.pumpAndSettle(); + expect(usage.cancellations, 1); + pending.complete(null); + await tester.pumpAndSettle(); + }); + + iosTestWidgets('cancellation becoming busy during flag read prevents yearly confirmation', (tester) async { + final flag = Completer(); + var reads = 0; + var previews = 0; + final usage = await pump( + tester, + CancelSubscriptionFlow( + loadOffer: () async => null, + yearlyClaim: (_) async => offer, + yearlyEnabled: () { + reads++; + return reads == 2 ? flag.future : Future.value(true); + }, + yearlyPreview: ({bool preview = false}) async { + previews++; + return offer; + })); + usage.cancelResult = Completer(); + await confirmStep(tester); + expect(reads, 1); + await tester.tap(find.byKey(const ValueKey('yearly_retention_cancel'))); + await tester.pump(); + expect(reads, 2); + final l10n = AppLocalizations.of(tester.element(find.byType(Scaffold).first)); + await tester.tap(find.text(l10n.cancelSubscription)); + await tester.pump(); + expect(usage.cancellations, 1); + flag.complete(true); + // Cancellation intentionally remains pending, so its spinner cannot settle. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(previews, 0); + expect(find.byKey(const Key('yearly_switch_confirm')), findsNothing); + usage.cancelResult!.complete(false); + await tester.pumpAndSettle(); + // The abandoned open releases its guard after the competing action finishes. + await tester.tap(find.byKey(const ValueKey('yearly_retention_cancel'))); + await tester.pumpAndSettle(); + expect(previews, 1); + expect(find.byKey(const Key('yearly_switch_confirm')), findsOneWidget); + }); + + iosTestWidgets('engaged placement renders once through rebuilds; flag off hides it', (tester) async { + if (output.isNotEmpty) await tester.runAsync(fonts); + var enabled = true; + var claims = 0; + final usage = await pump( + tester, + Scaffold( + appBar: AppBar(title: Text('Home', style: OmiType.headline.copyWith(fontFamily: 'Roboto'))), + body: ListView(children: [ + YearlyRetentionPlacement( + surface: YearlySwitchSurface.engaged, + claim: (_) async { + claims++; + return offer; + }, + readEnabled: () async => enabled) + ]))); + expect(claims, 1); + await screenshot(tester, '005-mobile-ios-engaged-after'); + usage.notifyListeners(); + await tester.pumpAndSettle(); + expect(claims, 1); + analytics.expectSingle('Yearly Retention Observation', + {'placement': 'engaged', 'action': 'shown', 'variant': 'treatment', 'experiment_version': 1}); + enabled = false; + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('yearly_retention_engaged')), findsNothing); + await screenshot(tester, '004-mobile-ios-engaged-before'); + enabled = true; + usage.notifyListeners(); + await tester.pumpAndSettle(); + expect(claims, 1); + }); + + iosTestWidgets('ineligible mounted Home retries on return, not rebuilds, and displays only once', (tester) async { + var qualified = false; + var claims = 0; + final placementKey = GlobalKey(); + final usage = await pump( + tester, + Scaffold( + body: YearlyRetentionPlacement( + key: placementKey, + surface: YearlySwitchSurface.engaged, + claim: (_) async { + claims++; + return qualified + ? offer + : const wire.GeneratedYearlySwitchResponse(eligible: false, reason: 'not_engaged'); + }, + readEnabled: () async => true))); + final mountedState = placementKey.currentState; + final row = find.byKey(const ValueKey('yearly_retention_engaged')); + expect(claims, 1); + expect(row, findsNothing); + qualified = true; + usage.notifyListeners(); + await tester.pumpAndSettle(); + await tester.pump(const Duration(minutes: 1)); + expect(claims, 1); + final navigator = Navigator.of(placementKey.currentContext!); + navigator.push(MaterialPageRoute(builder: (_) => const Scaffold(body: Text('Other page')))); + await tester.pumpAndSettle(); + // Foregrounding another route does not present Home. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + usage.notifyListeners(); + await tester.pumpAndSettle(); + expect(claims, 1); + navigator.pop(); + await tester.pumpAndSettle(); + expect(placementKey.currentState, same(mountedState)); + expect(claims, 2); + expect(row, findsOneWidget); + usage.notifyListeners(); + await tester.pumpAndSettle(); + expect(claims, 2); + await tester.tap(find.descendant(of: row, matching: find.byIcon(Icons.close))); + await tester.pumpAndSettle(); + navigator.push(MaterialPageRoute(builder: (_) => const Scaffold())); + await tester.pumpAndSettle(); + navigator.pop(); + await tester.pumpAndSettle(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(claims, 2); + expect(row, findsNothing); + final observations = analytics.propertiesOf('Yearly Retention Observation').toList(); + expect(observations.map((event) => event['action']), ['shown', 'dismissed']); + for (final event in observations) { + expect(event, containsPair('placement', 'engaged')); + expect(event, containsPair('variant', 'treatment')); + expect(event, containsPair('experiment_version', 1)); + } + }); + + iosTestWidgets('Home foreground retries non-admission without overlapping pending claims', (tester) async { + final pending = Completer(); + var claims = 0; + final usage = await pump( + tester, + Scaffold( + body: YearlyRetentionPlacement( + surface: YearlySwitchSurface.engaged, + claim: (_) { + claims++; + return claims == 1 ? pending.future : Future.value(offer); + }, + readEnabled: () async => true))); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(claims, 1); + pending.complete(const wire.GeneratedYearlySwitchResponse(eligible: false, reason: 'not_engaged')); + await tester.pumpAndSettle(); + usage.notifyListeners(); + await tester.pumpAndSettle(); + // An inactive/resumed transition (e.g. losing focus) is not a new visit. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(claims, 1); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(claims, 2); + expect(find.byKey(const ValueKey('yearly_retention_engaged')), findsOneWidget); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + usage.notifyListeners(); + await tester.pumpAndSettle(); + expect(claims, 2); + analytics.expectSingle('Yearly Retention Observation', + {'placement': 'engaged', 'action': 'shown', 'variant': 'treatment', 'experiment_version': 1}); + }); + + iosTestWidgets('flag disabled between preview and confirm prevents payment', (tester) async { + var enabled = true; + var charges = 0; + await pump( + tester, + Scaffold( + body: YearlyRetentionPlacement( + surface: YearlySwitchSurface.engaged, + claim: (_) async => offer, + readEnabled: () async => enabled, + loadPreview: ({bool preview = false}) async => offer, + apply: ({required priceId, required prorationDate, required expectedAmountDue}) async { + charges++; + return {'status': 'success'}; + }))); + await tester.tap(find.byKey(const ValueKey('yearly_retention_engaged'))); + await tester.pumpAndSettle(); + enabled = false; + await tester.tap(find.byKey(const Key('yearly_switch_confirm'))); + await tester.pumpAndSettle(); + expect(charges, 0); + expect(find.byKey(const Key('yearly_switch_confirm')), findsNothing); + }); +} diff --git a/backend/database/user_data_deletion.py b/backend/database/user_data_deletion.py index 84096958f5d9..7a3971218ccf 100644 --- a/backend/database/user_data_deletion.py +++ b/backend/database/user_data_deletion.py @@ -13,6 +13,7 @@ from database.memory_collections import RELEASED_MEMORY_CHANGE_HEAD from database.people_generation import generation_ref from database.staged_promote_cache import invalidate_around_write +from database.yearly_switch_retention import erase_assignment from config.list_projection import STATE_COLLECTION, ROW_COLLECTIONS from database.apps import apps_collection, PUBLIC_APPROVED_APPS_CACHE_KEY, search_apps_db from database.redis_db import delete_app_cache_by_id, delete_generic_cache @@ -79,4 +80,7 @@ def purge_user_subtree(uid: str, client: Any, delete_collection: Callable) -> di logger.info(f"Deleting user document: {uid}") user_ref.delete() stop_quiet_checks_for_account(uid, firestore_client=client) + # EXP-006 uses the existing top-level UID experiment roster. Its row is + # outside the user subtree; erase explicitly on every wipe/retry. + erase_assignment(uid, client) return {'status': 'ok', 'message': 'Account deleted successfully'} diff --git a/backend/database/yearly_switch_retention.py b/backend/database/yearly_switch_retention.py new file mode 100644 index 000000000000..9508831237c2 --- /dev/null +++ b/backend/database/yearly_switch_retention.py @@ -0,0 +1,11 @@ +"""UID experiment roster erasure, shared by admission fencing and account wipes. + +Delete failures propagate so the existing account-wipe retry remains authoritative. +This module owns no client construction, enrollment or billing decisions. +""" + +EXPERIMENT_ID = 'EXP-006-yearly-switch-retention' + + +def erase_assignment(uid: str, client) -> None: + client.collection('experiments').document(EXPERIMENT_ID).collection('assignments').document(uid).delete() diff --git a/backend/docs/experiments/EXP-006-yearly-switch-retention.md b/backend/docs/experiments/EXP-006-yearly-switch-retention.md new file mode 100644 index 000000000000..49ea571a36f6 --- /dev/null +++ b/backend/docs/experiments/EXP-006-yearly-switch-retention.md @@ -0,0 +1,185 @@ +# EXP-006 — mobile yearly-switch retention + +Status: source proposal, dark pending David's exact render/copy approval and an +authorized rollout. Neither merging nor an event establishes live lift. This +implements SCA-1808's corrected UID-first scope; no payer ledger is authorized. + +## Assignment and admission + +Use `utils.experiments.enroll`, experiment id +`EXP-006-yearly-switch-retention`, its existing deterministic UID assignment and +50/50 treatment/control split. Do not introduce another identity key or salt. +Both arms are enrolled through the same first-eligible-request path. The +existing assignment document's atomic `create` is also the admission claim. +Only `newly_enrolled && is_treatment` permits a placement. An existing UID, +including an unexposed treatment or control UID, can never claim again. + +The first eligible placement wins; its `source` and frozen eligibility snapshot +identify cancel versus engaged. A lost HTTP response consumes the opportunity; +retrying must not redeliver. This is at-most-once admission, not guaranteed +exactly-once rendering. Intent-to-treat includes those unobserved exposures. +The two placements do not overlap for the same UID. The settings switch remains +available to both arms, so the contrast is the incremental placements, not all +yearly switching versus no yearly switching. + +Existing yearly-switch eligibility is authoritative: paid, renewing, supported +monthly plan/currency, without trial, discount, scheduled change/cancellation, +or other existing refusals. No prices, plans, ceilings or billing mutation are +added. The original invoice preview and price/amount/proration-pinned +`upgrade-subscription(switch_now=true)` apply the switch. + +Engagement predicate v1: **at least eight completed, non-discarded, visible saved +conversations created in the preceding 14 days**, counted with the existing +`get_conversations_count(statuses=['completed'], start_date=now-14d, end_date=now)`. +Its existing visibility exclusions apply. This is eight captures, **not eight +distinct active days**. No empty in-progress session is counted. The count, +window, predicate version and placement are frozen at enrollment. Cancellation +does not require engagement. Engagement is checked on each new Home presentation; +a UID that qualifies after its first presentation can qualify on its next visit. +For a mounted Home, returning from another route or foregrounding from a hidden/ +paused app while Home is current starts a new presentation. Rebuilds, elapsed +time and focus-only inactive/resumed transitions do not retry admission. A +pending claim cannot overlap another claim for that candidate; a successful +admission stays consumed even after dismissal or a subsequent flag-off check. + +## Flags and privacy + +`YEARLY_SWITCH_RETENTION_ENABLED` defaults on; only the literal `false` +(case/whitespace normalized) disables it. Admission additionally requires a +nonempty `YEARLY_SWITCH_RETENTION_APPROVAL_REF` and +`YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED=true`. Both are absent by default. +An authorized operator must first record David's exact approval on SCA-1808. +Do not activate from this PR. Setting the kill switch false stops new admissions; +mounted clients refresh on resume/every 30 seconds and before opening/confirming +the new switch sheet. Config failures hide the optional placement. Existing +cancellation and settings switch continue. Stored controls survive toggles. + +The existing experiment assignment is the only new persisted per-UID record. +`purge_user_subtree` explicitly deletes this experiment's assignment, including +root-missing wipe retries; failures propagate. Admission checks the durable +deletion status before and after enrollment, erasing an in-flight assignment +if deletion started. No other experiment's erasure policy changes. No content, +audio, customer ID, device ID or raw UID is added to metric properties. Existing +analytics consent/identity/erasure transport remains authoritative. + +The two legacy retention-offer wire names are registered without renaming: +Shown means offer loaded into the cancellation step; Accepted means discount +accept intent, not billing success. The new `Yearly Retention Observation` +contains only closed placement/action/variant and numeric experiment version. +Shown is a post-frame observation, not attention or guaranteed ingestion. +Succeeded uses the existing switch confirmation/readback semantics; final +take-up still requires authorized billing association. Presence SQL is an +executable registry consumer, not an outcome estimator or deployed dashboard. + +## Measurement and limits + +Analyze the enrolled UID roster by randomized arm, including controls without +events; do not build the denominator from Shown/Accepted/Succeeded. Stratify +first placement from the frozen snapshot; aggregate both arms consistently. +Estimate yearly take-up, effective paid ends by day 30 and day 60, and revenue +per enrolled subscriber, with uncertainty and cohort maturity. Cancellation +scheduled is not a paid end. Annual renewal retention is not observed in a +60-day window. Annual upfront cash is not recurring-revenue lift. + +UID randomization permits linked-account/duplicate-customer contamination. +Do not call this payer-clustered. Existing billing associations may be missing +or ambiguous: mark those unknown/excluded and report protected coverage. +Overall Stripe totals and list-price MRR cannot establish arm-specific effect. +Do not infer identity from email/device, add an exporter/store, or access +production credentials to fill a gap. The bounded next measurement step is an +authorized verifier assessing which existing associations actually cover the +enrolled arms and returning the missing metric/coverage if insufficient. + +The read-only acceptance gate is: + +```sh +python3 backend/scripts/verify_yearly_switch_retention.py --read-only --require-live --require-mature-holdout +``` + +Live mode always exits 2/WAITING: **authenticated receipt provenance is not +available in this implementation**. `YEARLY_SWITCH_RETENTION_RECEIPT` (a local +protected receipt file) or `--receipt PATH` supplies untrusted assertions only. +The literal authority name, evidence URLs, an approval name, synthetic/live +labels or a caller-supplied signature cannot establish trust. Even a complete, +fresh, mature schema-valid receipt remains WAITING with all metrics withheld. +The command never queries a service or writes a file. + +Bounded measurement gap: an authorized m5 verifier/owner must identify an existing +trusted mechanism to authenticate the receipt producer and independently resolve +its exact serving release, David approval, operational checks and protected +aggregate evidence. An owner-authorized adapter and regression tests at that +boundary are required before this entry point can ever return LIVE/exit 0. No +such adapter, new signing key, payer ledger, exporter, credential access or +production read is authorized by this PR. Source/render review may proceed; +this gap cannot be counted as live acceptance or retention lift. + +Fixtures can separately exercise the schema, chronology, coverage and suppression +rules with: + +```sh +python3 backend/scripts/verify_yearly_switch_retention.py --read-only --schema-only --receipt PATH +``` + +Only schema mode may exit 0/SCHEMA_VALID, and it emits `live_acceptance: false` +with no metric table. It cannot be combined with either live/maturity flag. +SCHEMA_VALID means only that the supplied assertions satisfy the rules; it is +never evidence of deployment, approval, authorization or observed outcomes. + +The receipt vocabulary and positive/negative examples are executable in +`backend/tests/unit/test_verify_yearly_switch_retention.py`. Required fields: + +- `experiment`, `unit=uid`, `authority=authorized_aggregate_verifier`, + `evidence_ref`, `generated_at` (within 24h). +- `release`: exact current `backend_commit`, `client_commit`, `client_build`, + `deployed_at`, and `serving_evidence_ref`. This conservative gate requires both + release commits to equal the checked-out implementation commit. +- `approval`: `approved_by=David Zhang`, matching `source_commit`, + `issue_comment_ref`, and exact SHA256 of the checked-in `render-manifest.json` + and `copy.json` in `.agent-artifacts/ui-evidence/yearly-retention`. +- `gates`: `enabled`, `rollout_authorized`, `stable_uid_holdout`, `once_only`, + `non_overlap`, plus `evidence_ref` for the operational checks. +- `cohort_start`, `cohort_end`, `observed_through`: timezone-aware, after release, + no future observation, at least 60 days after the final enrollment. +- `measurement`: `revenue_basis=realized_net_revenue_per_enrolled_uid`, + `refunds=included`, `currency=USD`, `fx_policy=exclude_non_usd`, + `billing_linkage_evidence_ref`, `uncertainty_method`. +- `arms.control`/`arms.treatment`: `enrolled`, mutually exclusive coverage + categories `linked`, `missing_linkage`, `ambiguous_linkage`, `non_usd`, + `censored`; `take_up`/`effective_ends_30`/`effective_ends_60` with `count` and + `ci95`; `revenue_per_uid` with `mean_usd` and `ci95`. +- `comparison_ci95`: intervals for `take_up_difference`, + `effective_ends_30_difference`, `effective_ends_60_difference`, + `revenue_per_uid_difference`, supplied by the authorized aggregate analysis. + +The gate is deliberately conservative about an enrolled-UID denominator: +incomplete linkage produces WAITING rather than dividing partial revenue by all +enrollees or presenting a covered-only estimate as intention-to-treat. It +suppresses the entire metric table when any count or recoverable complement is +below ten, coverage is incomplete, or live/maturity evidence is missing. This +may withhold an underpowered reading; it is not a requirement for a new ledger +or a prerequisite for preparing this PR and approval packet. Revenue intervals +and missingness/FX sensitivity must come from authorized analysis; no attribution +is invented here. Direct churn credit remains 0pp until measured. + +## Review and reproduction + +The approval packet uses production widgets and synthetic prices. Cancellation +before is rendered from historical source `ac02feb05f0d6389f473e08a25980397b1348b0f`; +Home evidence shows the production placement in a minimal synthetic host, not a +full Home feed/device capture. The existing yearly-switch row and sheet are +reused. No David approval or workflow Kimi SECOND-LOOK is implied by local tests. + +PR #25095 (inspected `c5c7557dd063251304cc16b6ee1d696a33af4d00`) overlaps the +yearly-switch widget and analytics registry. Its payment changes are untouched. +PR #25106 (inspected `4ccd6536f86f2f1920d2b0d2ec6484f170dbb360`) overlaps Home, +flags and generated API assets. Neither head was integrated or approved by this +PR. Reconcile those files at integration; do not overwrite either owner's work. +SCA-1753 plan-picker defaults and SCA-1809 web/macOS parity are untouched except +mechanically regenerated client transport methods. + +```sh +backend/.venv/bin/python -m pytest backend/tests/unit/test_yearly_switch_retention.py backend/tests/unit/test_verify_yearly_switch_retention.py backend/tests/unit/test_yearly_switch.py backend/tests/unit/test_batch_pipeline_privacy.py backend/tests/unit/test_experiments.py -q +cd app +flutter test --no-pub --dart-define=YEARLY_RETENTION_RENDER_OUTPUT=../.agent-artifacts/ui-evidence/yearly-retention test/widgets/yearly_retention_test.dart test/widgets/yearly_switch_test.dart +bash test/widgets/render_yearly_retention_baseline.sh +``` diff --git a/backend/docs/feature-flag-registry.md b/backend/docs/feature-flag-registry.md index 3f3513c66934..905cb042b926 100644 --- a/backend/docs/feature-flag-registry.md +++ b/backend/docs/feature-flag-registry.md @@ -1,4 +1,4 @@ - + # Feature-flag authority registry @@ -120,7 +120,7 @@ entries are exempt: they are queued for removal, not running. ## Overdue for a decision -None as of 2026-11-11. +None as of 2026-11-12. ## Flags @@ -688,6 +688,7 @@ and an explicit empty literal renders as `''`. | `TRANSLATION_ONOPEN_ENABLED` | Serve bounded on-open translation for opt-in detail GETs | backend | env | open | true | true (backend-listen (chart), cloud_run/backend, gke/backend-listen) | true (backend-listen (chart), cloud_run/backend, gke/backend-listen) | — | keep | keep | — | auditfix | | `VERTEX_EMBEDDING_DEDUPE_ENABLED` | Default on; same-owner exact-input Vertex vector reuse, keyed by complete text, task, title and endpoint. Successful vectors only, 600s TTL, 512 entries and 262144 scalar values per process. False bypasses batch and cross-request reuse and clears cache. | backend | env | open | — | — | — | — | keep | keep | — | auditfix | | `WAKE_WORD_ADJUDICATION_ENABLED` | Incident stop for wake-word adjudication | backend | env | open | true | true (backend-listen (chart), cloud_run/backend, gke/backend-listen) | true (backend-listen (chart), cloud_run/backend, gke/backend-listen) | — | keep | keep | — | unowned | +| `YEARLY_SWITCH_RETENTION_ENABLED` | Default on after approval/authorized rollout; explicit false stops both mobile placements while preserving UID controls and existing settings billing. | backend | env | open | — | — | — | — | keep | keep | — | churn | | `client_telemetry_posthog_retirement` | Kill switch that stops moved infrastructure telemetry on PostHog after parity; desktop_health_event and connection-balance events are structurally exempt | macos | posthog | inverted | — | — | — | absent (enable) | pending | keep | — | auditfix | | `desktop-rating-prompt-disabled` | Stop the rating prompt | macos | posthog | inverted | — | — | — | expected (kill) | keep | keep | — | unowned | | `free-tier-kill-switch-v1` | Remote free-tier stop | macos | posthog | inverted | — | — | — | expected (kill) | keep | keep | — | unowned | @@ -912,6 +913,8 @@ and an explicit empty literal renders as `''`. | `VITE_ENABLE_GMAIL_SESSION` | Keep shipped Gmail session integration enabled | windows | build_define | open | — | — | — | — | keep | keep | — | unowned | | `X-Omi-Memory-Default-Delete-Supported` | Indicate default-memory deletion support | backend, macos | server_capability | closed | — | — | — | — | keep | keep | — | unowned | | `X-Omi-Memory-Device-Scope-Supported` | Indicate device-scoped memory query support | backend, macos, windows | server_capability | closed | — | — | — | — | keep | keep | — | unowned | +| `YEARLY_SWITCH_RETENTION_APPROVAL_REF` | Empty by default: requires exact David render/copy approval reference before admission. | backend | env | closed | — | — | — | — | keep | keep | — | churn | +| `YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED` | False by default; separately authorized rollout gate, never set by implementer. | backend | env | closed | — | — | — | — | keep | keep | — | churn | | `mobile-chat-apps-all-channels` | Omi store only: private chat channel endpoints for David; prevents the public Telegram rule from exposing iMessage/WhatsApp. Default off, explicit allowlist only; backend channel admission remains authoritative | mobile | posthog | closed | — | — | — | absent (enable) | keep | keep | — | auditfix | | `upload_end_marker_off` | True stops the phone sending the end-of-recording marker; absent, false or unreadable sends it | mobile | posthog | open | — | — | — | absent (kill) | keep | keep | — | lifecycle-owner | | `watch_copy_dedupe_off` | True keeps and uploads Watch recovery copies; false deduplicates copies covered at least 95% by server-reachable live custody | mobile | posthog | closed | — | — | — | expected (kill) | keep | keep | — | uicleanup | @@ -1503,6 +1506,9 @@ their code default (`fail` tells you which way a missing value resolves). - `TRANSCRIPTION_FINAL_PASS_VAD_COMPACTION_ENABLED` — Default-off local speech VAD compaction for stored-audio ASR and pyannote final passes; sample-exact original clocks; failures retain original audio (fail: closed) - `TYPESENSE_CONVERSATION_INDEX_WRITES` — Write conversations to Typesense index (fail: closed) - `VERTEX_EMBEDDING_DEDUPE_ENABLED` — Default on; same-owner exact-input Vertex vector reuse, keyed by complete text, task, title and endpoint. Successful vectors only, 600s TTL, 512 entries and 262144 scalar values per process. False bypasses batch and cross-request reuse and clears cache. (fail: open) +- `YEARLY_SWITCH_RETENTION_APPROVAL_REF` — Empty by default: requires exact David render/copy approval reference before admission. (fail: closed) +- `YEARLY_SWITCH_RETENTION_ENABLED` — Default on after approval/authorized rollout; explicit false stops both mobile placements while preserving UID controls and existing settings billing. (fail: open) +- `YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED` — False by default; separately authorized rollout gate, never set by implementer. (fail: closed) ## Not feature flags diff --git a/backend/docs/people-generation-writers.json b/backend/docs/people-generation-writers.json index ad5143eecb9f..826838582d3d 100644 --- a/backend/docs/people-generation-writers.json +++ b/backend/docs/people-generation-writers.json @@ -54,7 +54,7 @@ "backend/database/speaker_learning_jobs.py:finish_job": "b1e032311338e69209098f5caa213b3329777cf6fd31ce0d1561facd734396ed", "backend/database/speaker_learning_jobs.py:finish_job.body": "c45f27f274329a99657f41ff964e8db55aa7a8ab5697522f273ff579b3b0652e", "backend/database/speaker_learning_jobs.py:prepare_assignment_jobs": "412ede10470cb5abbd5a34df70bf76b35d69f6ced3198add04fb273bb258db2d", - "backend/database/user_data_deletion.py:purge_user_subtree": "5d6867dea9814853f1e75b885f3e1f9c16a7fa7538e1c8d7405ff262701b99e0", + "backend/database/user_data_deletion.py:purge_user_subtree": "5ae51a54d3c813a5caf5b39a76c400071db93cdf743535df7e291d27c9a996f7", "backend/database/users.py:_invalidate_speech_profile_transaction": "6de957a4ede90a320a4c1e9b3c602e9d1eb39cd980eaa0549c7bd5d39d4ed495", "backend/database/users.py:_remove_sample_transaction": "7e33932de87ec44695f710dfbfd82bf1adf03e9313d557bddf30bd21383ee36c", "backend/database/users.py:_replace_speech_profile_transaction": "316a5a72d62e40db783749fa0d77ba05a8bd4eda8b224205943997a10fc83c1c", diff --git a/backend/docs/runbooks/people-scan-reuse.md b/backend/docs/runbooks/people-scan-reuse.md index 63df78e06016..6ad694367fb1 100644 --- a/backend/docs/runbooks/people-scan-reuse.md +++ b/backend/docs/runbooks/people-scan-reuse.md @@ -69,6 +69,23 @@ writer and inventory suites passed 17 tests, and loopback emulator, including review apply/undo/redo, enrollment, evidence backfill, rollback and concurrent increments. The emulator was stopped after verification. +## Yearly retention erasure audit 2026-11-15 + +SCA-1808 adds `erase_assignment(uid, client)` at the end of +`purge_user_subtree` for the existing top-level UID experiment row. The People +subcollection wipe and final `generation_ref(user_ref).delete()` are unchanged; +the added erase propagates failures for retry. No People writer or generation +helper was added or removed. Re-pinned only this purge scope with Python 3.11 +after the strict generation/erasure tests and all 31 owned loopback emulator +checks passed. The inventory check also passes; no production data was accessed. + +The independent second-look correction moves that same UID erase into +`database/yearly_switch_retention.py` and imports it at module scope. Its target, +ordering, propagated failure and retry semantics are unchanged. Re-audited the +purge fingerprint after this import-only scope change: the strict generation/ +erasure suites and all 31 owned loopback emulator checks pass again; People +subtree and generation-head erasure remain unchanged. + ## Measure before enabling `people_scan_dedupe` remains **0**. The closed-label counter diff --git a/backend/route_policy_manifest.yaml b/backend/route_policy_manifest.yaml index 5ded613f04c3..d5492f5cd956 100644 --- a/backend/route_policy_manifest.yaml +++ b/backend/route_policy_manifest.yaml @@ -84,6 +84,8 @@ routes: - {route_type: http, method: GET, path: /v1/payments/retention-offer,policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: billing, deprecation: {state: active}, owner: backend}} - {route_type: http, method: POST, path: /v1/payments/retention-offer, policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: billing, deprecation: {state: active}, owner: backend}} - {route_type: http, method: GET, path: /v1/payments/yearly-switch, policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: billing, deprecation: {state: active}, owner: backend}} + - {route_type: http, method: GET, path: /v1/payments/yearly-switch-retention, policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: billing, deprecation: {state: active}, owner: backend}} + - {route_type: http, method: POST, path: /v1/payments/yearly-switch-retention, policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: billing, deprecation: {state: active}, owner: backend}} - {route_type: http, method: POST, path: /v1/payments/payment-issue/monthly-switch, policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: billing, deprecation: {state: active}, owner: backend}} - {route_type: http, method: POST, path: "/v1/messaging/webhooks/{adapter_id}", policy: {review_status: reviewed, auth: {mechanisms: [webhook_signature], placement: handler, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: webhook, visibility: internal, data_domain: chat, deprecation: {state: active}, owner: backend}} - {route_type: http, method: POST, path: /v2/files/video, policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token, admin_key_uid_prefix], placement: dependency, scopes: []}, byok: validated_when_headers_present, rate_limit: {policy_name: "file:upload", key_subject: uid, enforcement: fail_open, placement: dependency}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: chat, deprecation: {state: active}, owner: backend}} diff --git a/backend/routers/yearly_switch.py b/backend/routers/yearly_switch.py index 8676a4efc232..2078080d1b45 100644 --- a/backend/routers/yearly_switch.py +++ b/backend/routers/yearly_switch.py @@ -1,13 +1,17 @@ """Monthly to yearly switch offer (see utils.yearly_switch). The switch itself is POST /v1/payments/upgrade-subscription with ``switch_now``.""" -from typing import Optional +from datetime import datetime, timedelta, timezone +from typing import Literal, Optional from fastapi import APIRouter, Depends, Query from pydantic import BaseModel import database.users as users_db -from utils import yearly_switch +from database.account_deletion_policy import account_deletion_blocks_access +from database.conversations import get_conversations_count +from utils import yearly_switch, yearly_switch_retention +from utils.observability.fallback import record_fallback from utils.other import endpoints as auth router = APIRouter() @@ -39,3 +43,81 @@ def get_yearly_switch_endpoint( subscription = users_db.get_user_subscription(uid) offer = yearly_switch.get_offer(subscription.stripe_subscription_id if subscription else None, preview=preview) return YearlySwitchResponse(**offer.__dict__) + + +@router.get('/v1/payments/yearly-switch-retention', response_model=YearlySwitchResponse) +def retention_status(uid: str = Depends(auth.get_current_user_uid)): + """Config only: never enroll or preview a charge. Clients refresh on resume.""" + return YearlySwitchResponse(eligible=yearly_switch_retention.enabled(), reason='retention_status') + + +@router.post('/v1/payments/yearly-switch-retention', response_model=YearlySwitchResponse) +def admit_retention(placement: Literal['cancel', 'engaged'], uid: str = Depends(auth.get_current_user_uid)): + """Consume one UID admission, for either placement; never applies a switch.""" + if not yearly_switch_retention.enabled(): + return YearlySwitchResponse(eligible=False, reason='retention_disabled') + + def blocked(): + return account_deletion_blocks_access(users_db.get_user_deletion_wipe_status(uid)) + + if blocked(): + return YearlySwitchResponse(eligible=False, reason='unavailable') + try: + # A durable assignment already decided both arms and consumed this UID's + # single opportunity. Do not repeat Stripe or capture-count reads on Home. + if yearly_switch_retention.assignment_exists(uid): + return YearlySwitchResponse(eligible=False, reason='not_admitted') + + subscription = users_db.get_user_subscription(uid) + subscription_id = subscription.stripe_subscription_id if subscription else None + current_price_id = getattr(subscription, 'current_price_id', None) if subscription else None + subscription_status = getattr(subscription, 'status', None) if subscription else None + subscription_status = getattr(subscription_status, 'value', subscription_status) + known_monthly_price = ( + bool(subscription_id) + and subscription_status == 'active' + and not bool(getattr(subscription, 'cancel_at_period_end', True)) + and isinstance(current_price_id, str) + and yearly_switch.supports_monthly_switch_price(current_price_id) + ) + + # When the stored subscription snapshot identifies a configured monthly + # price, apply engagement before Stripe. For older snapshots without a + # price id, retain Stripe as the eligibility authority before reading captures. + offer = None + if placement != 'engaged' or not known_monthly_price: + offer = yearly_switch.get_offer(subscription_id) + if not offer.eligible: + return YearlySwitchResponse(**offer.__dict__) + + # Engagement is evaluated only for a plausible monthly subscriber. + now = None + capture_count = 0 + if placement == 'engaged': + now = datetime.now(timezone.utc) + start = now - timedelta(days=yearly_switch_retention.WINDOW_DAYS) + capture_count = get_conversations_count(uid, statuses=['completed'], start_date=start, end_date=now) + if capture_count < yearly_switch_retention.ENGAGED_CAPTURES: + return YearlySwitchResponse(eligible=False, reason='not_engaged') + + if offer is None: + offer = yearly_switch.get_offer(subscription_id) + if not offer.eligible: + return YearlySwitchResponse(**offer.__dict__) + + admitted = yearly_switch_retention.admit( + uid, + placement, + count_captures=lambda _start, _end: capture_count, + blocked=blocked, + now=now, + ) + except Exception: + # This optional placement must never block the existing cancel path. + record_fallback(component='other', from_mode='none', to_mode='none', reason='other', outcome='degraded') + return YearlySwitchResponse(eligible=False, reason='unavailable') + return ( + YearlySwitchResponse(**offer.__dict__) + if admitted + else YearlySwitchResponse(eligible=False, reason='not_admitted') + ) diff --git a/backend/scripts/verify_yearly_switch_retention.py b/backend/scripts/verify_yearly_switch_retention.py new file mode 100644 index 000000000000..f3cffeab08a9 --- /dev/null +++ b/backend/scripts/verify_yearly_switch_retention.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Validate an untrusted aggregate receipt; never contact a service or write data. + +Receipt fields and evidence references are untrusted assertions. No authorized +provenance adapter is configured here: live acceptance always returns WAITING. +Schema-only validation can exercise fixtures, never deployment or causal lift. +No raw receipt content, IDs, small cells or recoverable complements are printed. +""" + +import argparse +import hashlib +from datetime import datetime, timedelta, timezone +import json +import math +import os +from pathlib import Path +import subprocess + +EXPERIMENT = 'EXP-006-yearly-switch-retention' + + +def timestamp(value): + if not isinstance(value, str): + raise ValueError('timestamp must be a string') + parsed = datetime.fromisoformat(value.replace('Z', '+00:00')) + if parsed.tzinfo is None: + raise ValueError('timezone required') + return parsed.astimezone(timezone.utc) + + +def interval(value): + return ( + isinstance(value, list) + and len(value) == 2 + and all(type(x) in (int, float) and math.isfinite(x) for x in value) + and value[0] <= value[1] + ) + + +def validate_schema(receipt, *, source_revision, now=None, render_digest=None, copy_digest=None): + """Validate untrusted assertions for fixture/schema checks, never live acceptance.""" + now = now or datetime.now(timezone.utc) + waiting = [] + + def require(condition, name): + if not condition: + waiting.append(name) + + try: + packet = Path(__file__).resolve().parents[2] / '.agent-artifacts/ui-evidence/yearly-retention' + if render_digest is None: + manifest_bytes = (packet / 'render-manifest.json').read_bytes() + manifest = json.loads(manifest_bytes) + require(bool(manifest['renders']), 'render packet') + for render in manifest['renders']: + name = render['file'] + if Path(name).name != name or not name.endswith('.png'): + raise ValueError('invalid render path') + require( + hashlib.sha256((packet / name).read_bytes()).hexdigest() == render['sha256'], 'render integrity' + ) + render_digest = hashlib.sha256(manifest_bytes).hexdigest() + if copy_digest is None: + copy_digest = hashlib.sha256((packet / 'copy.json').read_bytes()).hexdigest() + require(receipt['experiment'] == EXPERIMENT and receipt['unit'] == 'uid', 'UID experiment') + require( + receipt['authority'] == 'authorized_aggregate_verifier' and bool(receipt['evidence_ref']), + 'receipt authority', + ) + generated = timestamp(receipt['generated_at']) + require(now - timedelta(hours=24) <= generated <= now, 'fresh receipt') + release = receipt['release'] + require( + release['backend_commit'] == source_revision == release['client_commit'] + and bool(release['client_build']) + and bool(release['serving_evidence_ref']), + 'exact backend/client serving release', + ) + approval = receipt['approval'] + require( + approval['approved_by'] == 'David Zhang' + and approval['source_commit'] == source_revision + and approval['issue_comment_ref'] + and approval['render_sha256'] == render_digest + and approval['copy_sha256'] == copy_digest, + 'exact render/copy approval', + ) + gates = receipt['gates'] + require( + all( + gates[k] is True + for k in ('enabled', 'rollout_authorized', 'stable_uid_holdout', 'once_only', 'non_overlap') + ) + and bool(gates['evidence_ref']), + 'flags/admission/holdout evidence', + ) + start = timestamp(receipt['cohort_start']) + end = timestamp(receipt['cohort_end']) + observed = timestamp(receipt['observed_through']) + require(timestamp(release['deployed_at']) <= start <= end <= observed <= generated, 'cohort/release chronology') + require(end + timedelta(days=60) <= observed, 'day-60 maturity') + measurement = receipt['measurement'] + require( + measurement['revenue_basis'] == 'realized_net_revenue_per_enrolled_uid' + and measurement['refunds'] == 'included' + and measurement['currency'] == 'USD' + and measurement['fx_policy'] == 'exclude_non_usd' + and bool(measurement['billing_linkage_evidence_ref']) + and bool(measurement['uncertainty_method']), + 'arm-linked revenue definition/authority', + ) + arms = receipt['arms'] + require(set(arms) == {'control', 'treatment'}, 'both arms') + for arm in ('control', 'treatment'): + values = arms[arm] + n = values['enrolled'] + require(type(n) is int and n >= 10, 'protected enrollment') + counts = [values[k] for k in ('linked', 'missing_linkage', 'ambiguous_linkage', 'non_usd', 'censored')] + require(all(type(x) is int and x >= 0 for x in counts) and sum(counts) == n, 'coverage accounting') + # Complete by-arm outcomes are needed for final acceptance. Missing + # linkage remains unknown: never put excluded UIDs in a zero bucket. + require(counts == [n, 0, 0, 0, 0], 'complete authorized outcome linkage') + for metric in ('take_up', 'effective_ends_30', 'effective_ends_60'): + value = values[metric] + count = value['count'] + cell_safe = type(count) is int and type(n) is int and 10 <= count <= n - 10 + require(cell_safe, f'protected {metric}') + ci = value['ci95'] + require(interval(ci) and 0 <= ci[0] <= count / n <= ci[1] <= 1, f'{metric} uncertainty') + require( + values['effective_ends_30']['count'] <= values['effective_ends_60']['count'], 'cumulative paid ends' + ) + require( + values['effective_ends_60']['count'] - values['effective_ends_30']['count'] >= 10, + 'protected day-31-to-60 recoverable complement', + ) + revenue = values['revenue_per_uid'] + mean = revenue['mean_usd'] + require( + type(mean) in (int, float) + and math.isfinite(mean) + and interval(revenue['ci95']) + and revenue['ci95'][0] <= mean <= revenue['ci95'][1], + 'revenue uncertainty', + ) + # Inter-arm uncertainty is supplied by the authorized aggregate analysis, + # including UID linkage sensitivity; the parser does not invent intervals. + require( + all( + interval(receipt['comparison_ci95'][key]) + for key in ( + 'take_up_difference', + 'effective_ends_30_difference', + 'effective_ends_60_difference', + 'revenue_per_uid_difference', + ) + ), + 'between-arm uncertainty', + ) + except (OSError, KeyError, TypeError, ValueError, ZeroDivisionError, OverflowError): + waiting.append('missing or malformed authorized receipt fields') + if waiting: + return {'status': 'WAITING', 'waiting': sorted(set(waiting)), 'metrics': 'SUPPRESSED_OR_UNAVAILABLE'} + # No aggregate values are emitted for untrusted schema inputs. + return {'status': 'SCHEMA_VALID', 'live_acceptance': False, 'metrics': 'SUPPRESSED_OR_UNAVAILABLE'} + + +def verify(receipt, *, source_revision): + """Live gate. Self-declared authority, URLs, signatures and flags confer no trust. + + No existing authorized mechanism authenticates this receipt and its referenced + serving/approval/aggregate evidence. Until an owner-authorized adapter checks + those facts independently, *every* local receipt is unverifiable. Do not add + a receipt field, CLI override or fixture bypass to promote it to live evidence. + """ + result = validate_schema(receipt, source_revision=source_revision) + waiting = result.get('waiting', []) + return { + 'status': 'WAITING', + 'waiting': sorted(set(waiting + ['authenticated receipt provenance unavailable'])), + 'metrics': 'SUPPRESSED_OR_UNAVAILABLE', + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--read-only', action='store_true', required=True) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument('--require-live', action='store_true') + mode.add_argument('--schema-only', action='store_true', help='validate assertions only; never live acceptance') + parser.add_argument('--require-mature-holdout', action='store_true') + parser.add_argument('--receipt', type=Path, default=os.getenv('YEARLY_SWITCH_RETENTION_RECEIPT')) + args = parser.parse_args() + if args.require_live != args.require_mature_holdout: + parser.error('live mode requires --require-live --require-mature-holdout; schema mode accepts neither') + if args.receipt is None: + print( + json.dumps( + { + 'status': 'WAITING', + 'waiting': ['authorized serving, approval and mature arm-linked aggregate receipt'], + } + ) + ) + return 2 + try: + revision = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], cwd=Path(__file__).resolve().parents[2], text=True + ).strip() + dirty = subprocess.check_output( + ['git', 'status', '--porcelain'], cwd=Path(__file__).resolve().parents[2], text=True + ).strip() + receipt = json.loads(args.receipt.read_text()) + result = ( + {'status': 'WAITING', 'waiting': ['clean exact source checkout']} + if dirty + else (validate_schema if args.schema_only else verify)(receipt, source_revision=revision) + ) + except (OSError, ValueError, subprocess.SubprocessError): + result = {'status': 'WAITING', 'waiting': ['readable authorized aggregate receipt']} + print(json.dumps(result, sort_keys=True, allow_nan=False)) + return 0 if args.schema_only and result['status'] == 'SCHEMA_VALID' else 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/backend/tests/support/firestore_caller_witnesses.py b/backend/tests/support/firestore_caller_witnesses.py index 54cc8bef1f05..dc0068226509 100644 --- a/backend/tests/support/firestore_caller_witnesses.py +++ b/backend/tests/support/firestore_caller_witnesses.py @@ -542,6 +542,18 @@ def _run_wrapped(monkeypatch: pytest.MonkeyPatch, capture: HelperCapture) -> Non trial(capture, wrapped_2025.generate_wrapped_2025, 'u1') +def _run_yearly_retention(monkeypatch: pytest.MonkeyPatch, capture: HelperCapture) -> None: + import routers.yearly_switch as yearly_router + from tests.unit.fixtures.generic_firestore_fake import FakeFirestore + + _stub(monkeypatch, yearly_router.yearly_switch_retention, 'enabled', lambda: True) + _stub(monkeypatch, users_db, 'get_user_deletion_wipe_status', lambda uid: None) + _stub(monkeypatch, users_db, 'get_user_subscription', lambda uid: SimpleNamespace(stripe_subscription_id='fixture')) + _stub(monkeypatch, yearly_router.yearly_switch, 'get_offer', lambda _: SimpleNamespace(eligible=True)) + _stub(monkeypatch, yearly_router.yearly_switch_retention, 'get_firestore_client', lambda: FakeFirestore()) + trial(capture, yearly_router.admit_retention, placement='engaged', uid='u1') + + WITNESSES: dict[str, CallerWitness] = { witness.key: witness for witness in ( @@ -561,6 +573,14 @@ def _run_wrapped(monkeypatch: pytest.MonkeyPatch, capture: HelperCapture) -> Non 1, _run_main_list, ), + CallerWitness( + 'routers/yearly_switch.py:admit_retention:database.conversations.get_conversations_count', + 'database.conversations.get_conversations_count', + 'routers.yearly_switch.get_conversations_count', + ('yearly-retention-engaged-count',), + 1, + _run_yearly_retention, + ), CallerWitness( 'routers/conversations.py:get_conversations_count:database.conversations.get_conversations_count', 'database.conversations.get_conversations_count', diff --git a/backend/tests/support/firestore_conversation_profiles.py b/backend/tests/support/firestore_conversation_profiles.py index be2078a8ade8..b4cdfdcca1b9 100644 --- a/backend/tests/support/firestore_conversation_profiles.py +++ b/backend/tests/support/firestore_conversation_profiles.py @@ -52,6 +52,9 @@ def _scan_profile(name: str, **domains) -> CallerProfile: _ROUTE_SOURCES = [[], ['omi'], ['friend', 'omi']] COUNT_PROFILES = ( + _profile( + 'yearly-retention-engaged-count', statuses=[['completed']], start_date=[FROZEN_NOW], end_date=[FROZEN_LATER] + ), _profile('main-count-all-statuses', statuses=[[]], sources=_ROUTE_SOURCES, **_DATES, **_SELECTORS), _profile('main-count-single-status', statuses=[['completed']], sources=_ROUTE_SOURCES, **_DATES, **_SELECTORS), _profile('main-count-multi-status', statuses=_MULTI_STATUS, sources=[[], ['omi']], **_DATES, **_SELECTORS), diff --git a/backend/tests/unit/test_batch_pipeline_privacy.py b/backend/tests/unit/test_batch_pipeline_privacy.py index 9a603b24561b..5e2ede90a2c0 100644 --- a/backend/tests/unit/test_batch_pipeline_privacy.py +++ b/backend/tests/unit/test_batch_pipeline_privacy.py @@ -176,6 +176,9 @@ def test_account_purge_erases_cohort_membership_and_retries_failures(monkeypatch monkeypatch.setattr('database.user_data_deletion.get_data_plane_firestore_client', lambda: data_plane) client.collection.return_value.document.return_value.get.return_value.exists = False assert purge_user_subtree('synthetic', client, recursive)['status'] == 'ok' + client.collection.assert_any_call('experiments') + experiment = client.collection.return_value.document.return_value.collection.return_value + experiment.document.assert_any_call('synthetic') data_plane.collection.assert_any_call('rollout_cohorts') data_plane.collection.return_value.document.return_value.delete.assert_called_once_with() client.collection.assert_any_call('users') @@ -191,7 +194,10 @@ def test_account_purge_removes_creator_owned_public_apps_and_nested_data(monkeyp user_ref = MagicMock() user_ref.get.return_value.exists = False user_ref.collections.return_value = [] - client.collection.side_effect = lambda name: {'plugins_data': plugins, 'users': users}[name] + experiments = MagicMock() + client.collection.side_effect = lambda name: {'plugins_data': plugins, 'users': users, 'experiments': experiments}[ + name + ] users.document.return_value = user_ref app_ref = MagicMock() @@ -222,6 +228,16 @@ def test_account_purge_removes_creator_owned_public_apps_and_nested_data(monkeyp app_ref.delete.assert_called_once_with() delete_app_cache.assert_called_once_with('creator-app') delete_generic_cache.assert_called_once() + experiments.document.assert_called_once_with('EXP-006-yearly-switch-retention') + experiments.document.return_value.collection.return_value.document.assert_called_once_with('creator') + experiments.document.return_value.collection.return_value.document.return_value.delete.assert_called_once_with() + assignment_delete = experiments.document.return_value.collection.return_value.document.return_value.delete + assignment_delete.side_effect = RuntimeError('assignment erase failed') + with pytest.raises(RuntimeError, match='assignment erase failed'): + purge_user_subtree('creator', client, recursive) + assignment_delete.side_effect = None + assert purge_user_subtree('creator', client, recursive)['status'] == 'ok' + assert assignment_delete.call_count == 3 def test_from_segments_ttl_policy_is_separately_reviewable_and_defaults_do_not_expand_batch_scope(): diff --git a/backend/tests/unit/test_verify_yearly_switch_retention.py b/backend/tests/unit/test_verify_yearly_switch_retention.py new file mode 100644 index 000000000000..e4e5f98b9ea0 --- /dev/null +++ b/backend/tests/unit/test_verify_yearly_switch_retention.py @@ -0,0 +1,253 @@ +import copy +from datetime import datetime, timedelta, timezone +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + 'verify_retention', Path(__file__).parents[2] / 'scripts/verify_yearly_switch_retention.py' +) +verifier = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verifier) +NOW = datetime(2027, 2, 1, tzinfo=timezone.utc) + + +def receipt(): + arm = { + 'enrolled': 100, + 'linked': 100, + 'missing_linkage': 0, + 'ambiguous_linkage': 0, + 'non_usd': 0, + 'censored': 0, + 'take_up': {'count': 20, 'ci95': [0.12, 0.28]}, + 'effective_ends_30': {'count': 20, 'ci95': [0.12, 0.28]}, + 'effective_ends_60': {'count': 30, 'ci95': [0.21, 0.39]}, + 'revenue_per_uid': {'mean_usd': 50.0, 'ci95': [40.0, 60.0]}, + } + return { + 'experiment': verifier.EXPERIMENT, + 'unit': 'uid', + 'authority': 'authorized_aggregate_verifier', + 'evidence_ref': 'synthetic', + 'generated_at': NOW.isoformat(), + 'release': { + 'backend_commit': 'fixture', + 'client_commit': 'fixture', + 'client_build': 'fixture', + 'serving_evidence_ref': 'fixture', + 'deployed_at': '2026-11-01T00:00:00Z', + }, + 'approval': { + 'approved_by': 'David Zhang', + 'source_commit': 'fixture', + 'issue_comment_ref': 'fixture', + 'render_sha256': 'fixture', + 'copy_sha256': 'fixture', + }, + 'gates': dict.fromkeys( + ['enabled', 'rollout_authorized', 'stable_uid_holdout', 'once_only', 'non_overlap', 'evidence_ref'], True + ), + 'cohort_start': '2026-11-02T00:00:00Z', + 'cohort_end': '2026-11-03T00:00:00Z', + 'observed_through': NOW.isoformat(), + 'measurement': { + 'revenue_basis': 'realized_net_revenue_per_enrolled_uid', + 'refunds': 'included', + 'currency': 'USD', + 'fx_policy': 'exclude_non_usd', + 'billing_linkage_evidence_ref': 'fixture', + 'uncertainty_method': 'fixture', + }, + 'arms': {'control': copy.deepcopy(arm), 'treatment': copy.deepcopy(arm)}, + 'comparison_ci95': dict.fromkeys( + [ + 'take_up_difference', + 'effective_ends_30_difference', + 'effective_ends_60_difference', + 'revenue_per_uid_difference', + ], + [-0.1, 0.1], + ), + } + + +def test_complete_synthetic_receipt_only_validates_schema(): + assert ( + verifier.validate_schema( + receipt(), source_revision='fixture', now=NOW, render_digest='fixture', copy_digest='fixture' + )['status'] + == 'SCHEMA_VALID' + ) + + +@pytest.mark.parametrize( + 'mutation', + [ + lambda r: r['release'].update(client_commit='wrong'), + lambda r: r['approval'].update(issue_comment_ref=''), + lambda r: r['gates'].update(non_overlap=False), + lambda r: r.update(cohort_end='2027-01-31T00:00:00Z'), + lambda r: r['arms']['control'].update(linked=99, missing_linkage=1), + lambda r: r['arms']['control']['take_up'].update(count=99, ci95=[0.9, 1]), + lambda r: r['arms']['control']['take_up'].update(count=1, ci95=[0, 0.1]), + lambda r: r['arms']['control']['revenue_per_uid'].update(mean_usd=float('nan')), + lambda r: r['measurement'].update(billing_linkage_evidence_ref=''), + lambda r: r['measurement'].update(revenue_basis='overall_stripe_total'), + lambda r: r['arms']['control']['effective_ends_60'].update(count=21), + ], +) +def test_missing_immature_ambiguous_or_recoverable_cells_fail_closed(mutation): + data = receipt() + mutation(data) + result = verifier.validate_schema( + data, source_revision='fixture', now=NOW, render_digest='fixture', copy_digest='fixture' + ) + assert result['status'] == 'WAITING' + assert 'arms' not in result + + +def test_empty_receipt_is_waiting_not_zero(): + assert ( + verifier.validate_schema( + {}, source_revision='fixture', now=NOW, render_digest='fixture', copy_digest='fixture' + )['status'] + == 'WAITING' + ) + + +@pytest.fixture(scope='module') +def clean_checkout(tmp_path_factory): + # Run the real CLI from a clean committed checkout so dirty-source or missing + # packet gates cannot conceal a provenance bypass. No services or credentials. + root = tmp_path_factory.mktemp('verifier-checkout') + script = root / 'backend/scripts/verify_yearly_switch_retention.py' + script.parent.mkdir(parents=True) + shutil.copyfile(Path(verifier.__file__), script) + packet = '.agent-artifacts/ui-evidence/yearly-retention' + shutil.copytree(Path(__file__).parents[3] / packet, root / packet) + for command in ( + ['git', 'init', '-q'], + ['git', 'add', '.'], + [ + 'git', + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '-qm', + 'fixture', + ], + ): + subprocess.run(command, cwd=root, check=True, capture_output=True) + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() + return script, revision + + +def ready_schema(revision): + data = receipt() + now = datetime.now(timezone.utc) + data['generated_at'] = now.isoformat() + data['observed_through'] = now.isoformat() + data['cohort_start'] = (now - timedelta(days=90)).isoformat() + data['cohort_end'] = (now - timedelta(days=80)).isoformat() + data['release'].update( + backend_commit=revision, client_commit=revision, deployed_at=(now - timedelta(days=100)).isoformat() + ) + data['approval']['source_commit'] = revision + packet = Path(__file__).parents[3] / '.agent-artifacts/ui-evidence/yearly-retention' + for key, name in [('render_sha256', 'render-manifest.json'), ('copy_sha256', 'copy.json')]: + data['approval'][key] = verifier.hashlib.sha256((packet / name).read_bytes()).hexdigest() + return data + + +@pytest.mark.parametrize('provenance', ['synthetic', 'missing', 'forged', 'unknown']) +def test_unverifiable_receipts_never_pass_live_cli(clean_checkout, tmp_path, provenance): + script, revision = clean_checkout + data = ready_schema(revision) + if provenance == 'missing': + data.pop('authority') + data.pop('evidence_ref') + elif provenance == 'forged': + # Plausible refs and self-declared signatures/approval are still untrusted. + data.update( + evidence_ref='https://evidence.example.invalid/receipts/123', + synthetic=False, + live=True, + provenance_verified=True, + provenance={'issuer': 'authorized_aggregate_verifier', 'signature': 'forged', 'verified': True}, + ) + data['approval']['issue_comment_ref'] = 'https://example.invalid/comments/approved' + data['release']['serving_evidence_ref'] = 'https://example.invalid/serving/release' + data['gates']['evidence_ref'] = 'https://example.invalid/gates' + data['measurement']['billing_linkage_evidence_ref'] = 'https://example.invalid/aggregates' + elif provenance == 'unknown': + data['evidence_ref'] = 'unknown-receipt-987' + data['provenance'] = {'issuer': 'unknown', 'verified': True} + path = tmp_path / 'receipt.json' + path.write_text(json.dumps(data)) + schema = verifier.validate_schema(data, source_revision=revision) + if provenance != 'missing': + assert schema['status'] == 'SCHEMA_VALID' + result = verifier.verify(data, source_revision=revision) + assert result['status'] == 'WAITING' + assert 'authenticated receipt provenance unavailable' in result['waiting'] + assert 'arms' not in result + process = subprocess.run( + [ + sys.executable, + str(script), + '--read-only', + '--require-live', + '--require-mature-holdout', + '--receipt', + str(path), + ], + capture_output=True, + text=True, + ) + assert process.returncode == 2, process.stderr + output = json.loads(process.stdout) + assert output['status'] == 'WAITING' + assert 'authenticated receipt provenance unavailable' in output['waiting'] + assert output['metrics'] == 'SUPPRESSED_OR_UNAVAILABLE' + assert 'arms' not in output and 'comparison_ci95' not in output + assert 'evidence.example' not in process.stdout + assert 'clean exact source checkout' not in output['waiting'] + + +def test_schema_cli_success_is_never_live_acceptance(clean_checkout, tmp_path): + script, revision = clean_checkout + path = tmp_path / 'receipt.json' + path.write_text(json.dumps(ready_schema(revision))) + command = [sys.executable, str(script), '--read-only', '--schema-only', '--receipt', str(path)] + process = subprocess.run(command, capture_output=True, text=True) + assert process.returncode == 0, process.stderr + assert json.loads(process.stdout) == { + 'status': 'SCHEMA_VALID', + 'live_acceptance': False, + 'metrics': 'SUPPRESSED_OR_UNAVAILABLE', + } + for incompatible in [['--require-live'], ['--require-mature-holdout']]: + process = subprocess.run(command + incompatible, capture_output=True, text=True) + assert process.returncode == 2 + assert 'SCHEMA_VALID' not in process.stdout + + +@pytest.mark.parametrize('value', [None, False, 123, [], {}]) +def test_malformed_timestamp_fails_closed(value): + data = receipt() + data['generated_at'] = value + result = verifier.validate_schema( + data, source_revision='fixture', now=NOW, render_digest='fixture', copy_digest='fixture' + ) + assert result['status'] == 'WAITING' + assert 'arms' not in result diff --git a/backend/tests/unit/test_yearly_switch_retention.py b/backend/tests/unit/test_yearly_switch_retention.py new file mode 100644 index 000000000000..4de6dc1d40ac --- /dev/null +++ b/backend/tests/unit/test_yearly_switch_retention.py @@ -0,0 +1,255 @@ +from datetime import datetime, timezone +from unittest.mock import Mock + +import pytest + +from tests.unit.fixtures.generic_firestore_fake import FakeFirestore +from database.yearly_switch_retention import erase_assignment +from utils import experiments, yearly_switch_retention as retention + + +@pytest.fixture(autouse=True) +def enabled(monkeypatch): + monkeypatch.setenv('YEARLY_SWITCH_RETENTION_APPROVAL_REF', 'synthetic-approval') + monkeypatch.setenv('YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED', 'true') + monkeypatch.delenv('YEARLY_SWITCH_RETENTION_ENABLED', raising=False) + monkeypatch.setenv('POSTHOG_PRODUCT_CAPTURE_TOKEN', 'disabled') + + +def uid_for(arm): + return next( + f'synthetic-{n}' + for n in range(100) + if experiments.assigned_variant(retention.EXPERIMENT_ID, f'synthetic-{n}') == arm + ) + + +def admit(db, uid, placement='cancel', count=8, blocked=lambda: False): + return retention.admit(uid, placement, count_captures=lambda *_: count, blocked=blocked, client=db) + + +@pytest.mark.parametrize('first,second', [('cancel', 'engaged'), ('engaged', 'cancel')]) +def test_once_only_and_cross_placement_exclusion(first, second): + db = FakeFirestore() + uid = uid_for('treatment') + assert admit(db, uid, first) + assert not admit(db, uid, first) + assert not admit(db, uid, second) + assert len(db.docs) == 1 + assert list(db.docs.values())[0]['eligibility']['placement'] == first + + +def test_control_enrolled_without_either_offer_and_survives_toggles(monkeypatch): + db = FakeFirestore() + uid = uid_for('control') + assert not admit(db, uid) + record = list(db.docs.values())[0].copy() + monkeypatch.setenv('YEARLY_SWITCH_RETENTION_ENABLED', 'false') + assert not admit(db, uid, 'engaged') + monkeypatch.setenv('YEARLY_SWITCH_RETENTION_ENABLED', 'true') + assert not admit(db, uid, 'engaged') + assert list(db.docs.values())[0] == record + + +@pytest.mark.parametrize('key', ['YEARLY_SWITCH_RETENTION_APPROVAL_REF', 'YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED']) +def test_missing_approval_or_rollout_has_no_roster_write(monkeypatch, key): + monkeypatch.delenv(key) + db = FakeFirestore() + assert not admit(db, uid_for('treatment')) + assert db.docs == {} + + +def test_engagement_threshold_and_frozen_window(): + db = FakeFirestore() + uid = uid_for('treatment') + assert not admit(db, uid, 'engaged', count=7) + assert db.docs == {} + count = Mock(return_value=8) + now = datetime(2026, 11, 12, tzinfo=timezone.utc) + assert retention.admit(uid, 'engaged', count_captures=count, blocked=lambda: False, client=db, now=now) + start, end = count.call_args.args + assert (end - start).days == 14 and end == now + assert list(db.docs.values())[0]['eligibility']['completed_saved_captures'] == 8 + + +def test_cancel_does_not_require_engagement(): + assert admit(FakeFirestore(), uid_for('treatment'), count=0) + + +def test_default_firestore_client_is_requested_only_at_call_time(monkeypatch): + client = Mock(return_value=FakeFirestore()) + monkeypatch.setattr(retention, 'get_firestore_client', client) + monkeypatch.setenv('YEARLY_SWITCH_RETENTION_ENABLED', 'false') + assert not retention.admit(uid_for('treatment'), 'cancel', count_captures=Mock(), blocked=lambda: False) + client.assert_not_called() + monkeypatch.setenv('YEARLY_SWITCH_RETENTION_ENABLED', 'true') + assert retention.admit(uid_for('treatment'), 'cancel', count_captures=Mock(), blocked=lambda: False) + client.assert_called_once_with() + assert retention.assignment_exists(uid_for('treatment')) + assert client.call_count == 2 + + +def test_failed_enrollment_cannot_deliver(monkeypatch): + monkeypatch.setattr(experiments, 'enroll', lambda **_: None) + assert not admit(FakeFirestore(), uid_for('treatment')) + + +def test_deletion_during_enrollment_erases_and_excludes(): + db = FakeFirestore() + calls = iter([False, True]) + assert not admit(db, uid_for('treatment'), blocked=lambda: next(calls)) + assert db.docs == {} + + +def test_erase_is_retryable_uid_scoped_and_failure_propagates(): + db = FakeFirestore() + for uid in ('synthetic-a', 'synthetic-b'): + experiments.enroll(experiment_id=retention.EXPERIMENT_ID, uid=uid, firestore_client=db) + erase_assignment('synthetic-a', db) + erase_assignment('synthetic-a', db) + assert len(db.docs) == 1 and next(iter(db.docs)).endswith('/synthetic-b') + bad = Mock() + bad.collection.return_value.document.return_value.collection.return_value.document.return_value.delete.side_effect = RuntimeError( + 'erase failed' + ) + with pytest.raises(RuntimeError, match='erase failed'): + erase_assignment('synthetic-b', bad) + + +def test_concurrent_eligible_hosts_only_one_delivery(monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from threading import Lock + from tests.unit.fixtures.generic_firestore_fake import FakeDocumentReference + + lock = Lock() + create = FakeDocumentReference.create + + def atomic_create(ref, value): + with lock: + return create(ref, value) + + monkeypatch.setattr(FakeDocumentReference, 'create', atomic_create) + db = FakeFirestore() + uid = uid_for('treatment') + with ThreadPoolExecutor(max_workers=8) as pool: + delivered = list(pool.map(lambda i: admit(db, uid, 'cancel' if i % 2 else 'engaged'), range(32))) + assert sum(delivered) == 1 and len(db.docs) == 1 + + +@pytest.fixture +def retention_http(monkeypatch): + # Cold router imports and ASGI startup are setup, not admission CPU work. + from fastapi import FastAPI + from fastapi.testclient import TestClient + import routers.yearly_switch as routes + from utils.yearly_switch import YearlySwitch + + db = FakeFirestore() + original_admit = retention.admit + monkeypatch.setattr(retention, 'admit', lambda *a, **kw: original_admit(*a, **kw, client=db)) + monkeypatch.setattr(routes.users_db, 'get_user_deletion_wipe_status', lambda _: None) + monkeypatch.setattr(routes.users_db, 'get_user_subscription', lambda _: Mock(stripe_subscription_id='synthetic')) + monkeypatch.setattr( + retention, + 'assignment_exists', + lambda uid: db.collection('experiments') + .document(retention.EXPERIMENT_ID) + .collection('assignments') + .document(uid) + .get() + .exists, + ) + monkeypatch.setattr( + routes.yearly_switch, 'get_offer', lambda _: YearlySwitch(True, 'eligible', yearly_amount=26991) + ) + count = Mock(return_value=8) + monkeypatch.setattr(routes, 'get_conversations_count', count) + app = FastAPI() + app.include_router(routes.router) + app.dependency_overrides[routes.auth.get_current_user_uid] = lambda: uid_for('treatment') + with TestClient(app) as http: + yield http, db, count, routes + + +def test_http_admission_and_config_execute_real_routes_with_local_fixtures(retention_http): + http, db, count, _routes = retention_http + assert http.get('/v1/payments/yearly-switch-retention').json()['eligible'] is True + assert not db.docs # Config reads never consume an opportunity. + assert http.post('/v1/payments/yearly-switch-retention?placement=engaged').json()['eligible'] is True + assert http.post('/v1/payments/yearly-switch-retention?placement=engaged').json()['eligible'] is False + assert http.post('/v1/payments/yearly-switch-retention?placement=cancel').json()['eligible'] is False + assert http.post('/v1/payments/yearly-switch-retention?placement=other').status_code == 422 + assert count.call_args.kwargs['statuses'] == ['completed'] + + +@pytest.mark.parametrize('arm', ['treatment', 'control']) +def test_repeat_home_presentation_skips_billing_and_capture_reads(retention_http, monkeypatch, arm): + http, db, count, routes = retention_http + uid = uid_for(arm) + http.app.dependency_overrides[routes.auth.get_current_user_uid] = lambda: uid + + first = http.post('/v1/payments/yearly-switch-retention?placement=engaged').json() + assert first['eligible'] is (arm == 'treatment') + assert len(db.docs) == 1 + + count.reset_mock() + subscription_read = Mock(side_effect=AssertionError('existing enrollment must short-circuit billing reads')) + stripe_read = Mock(side_effect=AssertionError('existing enrollment must short-circuit billing reads')) + monkeypatch.setattr(routes.users_db, 'get_user_subscription', subscription_read) + monkeypatch.setattr(routes.yearly_switch, 'get_offer', stripe_read) + + second = http.post('/v1/payments/yearly-switch-retention?placement=engaged').json() + assert second['eligible'] is False + subscription_read.assert_not_called() + stripe_read.assert_not_called() + count.assert_not_called() + assert len(db.docs) == 1 + + +def test_not_yet_engaged_home_presentation_skips_billing_lookup(retention_http, monkeypatch): + http, db, count, routes = retention_http + count.return_value = 7 + stripe_read = Mock(side_effect=AssertionError('engagement gate must precede billing reads')) + monkeypatch.setattr( + routes.users_db, + 'get_user_subscription', + Mock( + return_value=Mock( + stripe_subscription_id='synthetic', + current_price_id='configured-monthly', + status='active', + cancel_at_period_end=False, + ) + ), + ) + monkeypatch.setattr(routes.yearly_switch, 'supports_monthly_switch_price', lambda _: True) + monkeypatch.setattr(routes.yearly_switch, 'get_offer', stripe_read) + + result = http.post('/v1/payments/yearly-switch-retention?placement=engaged').json() + assert result['eligible'] is False and result['reason'] == 'not_engaged' + stripe_read.assert_not_called() + assert count.call_count == 1 + assert db.docs == {} + + +def test_billing_ineligible_home_presentation_skips_engagement_read(retention_http, monkeypatch): + http, db, count, routes = retention_http + subscription_read = Mock( + return_value=Mock( + stripe_subscription_id='synthetic', + current_price_id='configured-non-monthly', + status='active', + cancel_at_period_end=False, + ) + ) + stripe_read = Mock(return_value=routes.yearly_switch.YearlySwitch(False, 'not_monthly')) + monkeypatch.setattr(routes.users_db, 'get_user_subscription', subscription_read) + monkeypatch.setattr(routes.yearly_switch, 'supports_monthly_switch_price', lambda _: False) + monkeypatch.setattr(routes.yearly_switch, 'get_offer', stripe_read) + + result = http.post('/v1/payments/yearly-switch-retention?placement=engaged').json() + assert result['eligible'] is False and result['reason'] == 'not_monthly' + subscription_read.assert_called_once_with(uid_for('treatment')) + stripe_read.assert_called_once_with('synthetic') + count.assert_not_called() + assert db.docs == {} diff --git a/backend/utils/yearly_switch.py b/backend/utils/yearly_switch.py index cb91636e89aa..83316f74ea4d 100644 --- a/backend/utils/yearly_switch.py +++ b/backend/utils/yearly_switch.py @@ -89,6 +89,11 @@ def _monthly_target(price_id: Optional[str]) -> Optional[tuple[str, str]]: return None +def supports_monthly_switch_price(price_id: Optional[str]) -> bool: + """Whether a stored price id is a configured monthly price for this offer.""" + return _monthly_target(price_id) is not None + + def _target(sub: Any) -> tuple[str, str]: """(plan_id, yearly price id) of a subscription that passed refusal().""" price = _get(_get(_get(sub, 'items'), 'data')[0], 'price') diff --git a/backend/utils/yearly_switch_retention.py b/backend/utils/yearly_switch_retention.py new file mode 100644 index 000000000000..ebeb038e743d --- /dev/null +++ b/backend/utils/yearly_switch_retention.py @@ -0,0 +1,66 @@ +"""UID-first, at-most-once admission. Existing enrollment is the only roster. + +An enrollment is consumed even if its response is lost. Never retry delivery: +intention-to-treat includes admitted readers who never rendered the placement. +No payer inference, new identity key, collection, or billing mutation lives here. +""" + +import os +from datetime import datetime, timedelta, timezone + +from database._client import get_firestore_client +from database.yearly_switch_retention import EXPERIMENT_ID, erase_assignment +from utils import experiments + +ENGAGED_CAPTURES = 8 +WINDOW_DAYS = 14 + + +def enabled() -> bool: + """Default-on kill switch, behind explicit approval and rollout gates.""" + return ( + os.getenv('YEARLY_SWITCH_RETENTION_ENABLED', 'true').strip().lower() != 'false' + and bool(os.getenv('YEARLY_SWITCH_RETENTION_APPROVAL_REF', '').strip()) + and os.getenv('YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED', '').strip().lower() == 'true' + ) + + +def assignment_exists(uid: str, client=None) -> bool: + """Read the existing roster before billing or eligibility work on repeat visits.""" + if client is None: + client = get_firestore_client() + snapshot = client.collection('experiments').document(EXPERIMENT_ID).collection('assignments').document(uid).get() + return bool(snapshot.exists) + + +def admit(uid: str, placement: str, *, count_captures, blocked, client=None, now=None) -> bool: + if placement not in {'cancel', 'engaged'} or not enabled() or blocked(): + return False + if client is None: + client = get_firestore_client() + now = now or datetime.now(timezone.utc) + start = now - timedelta(days=WINDOW_DAYS) + count = None + if placement == 'engaged': + count = count_captures(start, now) + if count < ENGAGED_CAPTURES: + return False + enrollment = experiments.enroll( + experiment_id=EXPERIMENT_ID, + uid=uid, + eligibility={ + 'placement': placement, + 'predicate_version': 1, + 'completed_saved_captures': count, + 'window_start': start, + 'window_end': now, + }, + source=placement, + firestore_client=client, + ) + # Fence a deletion that began while enrollment was in flight. Its marker + # survives user-subtree erasure. Do not recreate an erased roster row. + if blocked(): + erase_assignment(uid, client) + return False + return bool(enrollment and enrollment.newly_enrolled and enrollment.is_treatment and enabled()) diff --git a/config/feature-flags.yaml b/config/feature-flags.yaml index 7dc7688a5336..6eec8340846c 100644 --- a/config/feature-flags.yaml +++ b/config/feature-flags.yaml @@ -1,4 +1,7 @@ flags: + - {"key": "YEARLY_SWITCH_RETENTION_ENABLED", "aliases": [], "kind": "env", "lifecycle": "ops_kill", "surfaces": ["backend"], "summary": "Default on after approval/authorized rollout; explicit false stops both mobile placements while preserving UID controls and existing settings billing.", "fail": "open", "owner": "churn", "created": "2026-11-12", "terminal": "keep", "decision": "keep", "notes": "SCA-1808 / EXP-006. No production changes. Enabled gate checks every admission and clients refresh every 30s/resume and before confirming a switch."} + - {"key": "YEARLY_SWITCH_RETENTION_APPROVAL_REF", "aliases": [], "kind": "env", "lifecycle": "config_switch", "surfaces": ["backend"], "summary": "Empty by default: requires exact David render/copy approval reference before admission.", "fail": "closed", "owner": "churn", "created": "2026-11-12", "terminal": "keep", "decision": "keep", "notes": "SCA-1808 / EXP-006. No production changes. Enabled gate checks every admission and clients refresh every 30s/resume and before confirming a switch."} + - {"key": "YEARLY_SWITCH_RETENTION_ROLLOUT_AUTHORIZED", "aliases": [], "kind": "env", "lifecycle": "config_switch", "surfaces": ["backend"], "summary": "False by default; separately authorized rollout gate, never set by implementer.", "fail": "closed", "owner": "churn", "created": "2026-11-12", "terminal": "keep", "decision": "keep", "notes": "SCA-1808 / EXP-006. No production changes. Enabled gate checks every admission and clients refresh every 30s/resume and before confirming a switch."} - {"key": "HOME_SETTLED_EMPTY_WIRE_ENABLED", "aliases": [], "kind": "env", "lifecycle": "ops_kill", "surfaces": ["backend"], "summary": "Default on; a neutral terminal title stops released clients re-inferring transcription from affirmative settled-empty noise", "fail": "open", "owner": "quality-lead", "created": "2026-11-15", "terminal": "keep", "decision": "keep", "notes": "Off restores the old empty-title wire. Adds No summary only to empty-title affirmative completed noise on conversation list and episode metadata reads; retains home_step=noise, no invented summary, transcript, status change or stored mutation. Both surfaces are required because old clients merge by ID. See backend/utils/conversations/legacy_empty_wire.py."} - {"key": "EPISODE_EMPTY_MEMBER_CLEANUP_ENABLED", "aliases": [], "kind": "env", "lifecycle": "config_switch", "surfaces": ["backend"], "summary": "Default off; opt-in atomic episode-reference removal during existing guarded empty-live deletion", "fail": "closed", "owner": "quality-lead", "created": "2026-11-15", "terminal": "keep", "decision": "keep", "notes": "Only transactional episode/fence/reverse-link removal is gated. Dangling-member read tolerance and registered-audio protection are unconditional. Quality lead ramps 1% then 100% of cleanup-serving traffic within 48 hours of this image serving on listen and pusher, watching attach transaction aborts/409 lock timeouts. See backend/docs/listen_pusher_pipeline/changes/2026-11-15-episode-wait-repairs.md."} - {"key": "EPISODE_COMPOSE_ANCHORED_WAIT_ENABLED", "aliases": [], "kind": "env", "lifecycle": "ops_kill", "surfaces": ["backend"], "summary": "Default on; preserve each transcript blocker first observation across membership changes", "fail": "open", "owner": "quality-lead", "created": "2026-11-15", "terminal": "keep", "decision": "keep", "notes": "Off restores aggregate wait-start resets and ignores additive compose_transcript_wait_observations. Blocker observations are generation-fenced by pending_at, seeded from legacy starts. Old writers may rewrite aggregate fields without extending a new readers per-blocker cap. See backend/utils/episodes/compose_wait.py."} diff --git a/contracts/analytics/TRACKING_PLAN.md b/contracts/analytics/TRACKING_PLAN.md index 01ee57fdf7f3..a681155fdce6 100644 --- a/contracts/analytics/TRACKING_PLAN.md +++ b/contracts/analytics/TRACKING_PLAN.md @@ -222,6 +222,9 @@ Source: events.json. Presence is not task success. Existing SDK provenance/ident | subscriptionPaymentIssueMonthlyTapped | Subscription Payment Issue Monthly Tapped | none | active | subscription-payment-recovery | | subscriptionPaymentIssueMonthlySwitched | Subscription Payment Issue Monthly Switched | none | active | subscription-payment-recovery | | subscriptionPaymentIssueMonthlyFailed | Subscription Payment Issue Monthly Failed | reason | active | subscription-payment-recovery | +| retentionOfferShown | Retention Offer Shown | reason, percent_off | active | yearly-switch-retention | +| retentionOfferAccepted | Retention Offer Accepted | reason, percent_off | active | yearly-switch-retention | +| yearlyRetentionObservation | Yearly Retention Observation | placement, action, variant, experiment_version | active | yearly-switch-retention | ## App Review Opportunity decisions diff --git a/contracts/analytics/consumers/yearly-switch-retention.json b/contracts/analytics/consumers/yearly-switch-retention.json new file mode 100644 index 000000000000..368551b0176f --- /dev/null +++ b/contracts/analytics/consumers/yearly-switch-retention.json @@ -0,0 +1,17 @@ +{ + "id": "yearly-switch-retention", + "kind": "question", + "owner": "Churn", + "question": "Which registered offer observations arrive in an exact namespace/build? Descriptive only; UID intent-to-treat denominators come from EXP-006 enrollment, not event presence. Accepted is intent, not a paid switch.", + "query": "contracts/analytics/consumers/yearly-switch-retention.sql", + "events": [ + "retentionOfferShown", + "retentionOfferAccepted", + "yearlyRetentionObservation" + ], + "namespace_property": "$app_namespace", + "dimensions": [ + "build_number" + ], + "zero_policy": "No rows means no observed event, not zero take-up or churn. Missing billing linkage and maturity remain WAITING. No attribution from overall Stripe totals." +} diff --git a/contracts/analytics/consumers/yearly-switch-retention.sql b/contracts/analytics/consumers/yearly-switch-retention.sql new file mode 100644 index 000000000000..599b5f332e37 --- /dev/null +++ b/contracts/analytics/consumers/yearly-switch-retention.sql @@ -0,0 +1 @@ +SELECT event, count(*) FROM events WHERE app_namespace = :app_namespace AND build_number = :build_number AND event IN ('Retention Offer Shown', 'Retention Offer Accepted', 'Yearly Retention Observation') GROUP BY event; diff --git a/contracts/analytics/events.json b/contracts/analytics/events.json index 15b9953b6daa..42dcb2f203a1 100644 --- a/contracts/analytics/events.json +++ b/contracts/analytics/events.json @@ -5221,6 +5221,114 @@ "subscription-payment-recovery" ], "correlation": null + }, + { + "id": "retentionOfferShown", + "wire_name": "Retention Offer Shown", + "status": "active", + "replaced_by": null, + "phase": "point", + "intent": null, + "properties": { + "reason": { + "type": "enum", + "wire_name": "reason", + "values": [ + "too_expensive", + "not_using_enough", + "missing_features", + "audio_quality", + "battery_drain", + "found_alternative", + "other" + ] + }, + "percentOff": { + "type": "int", + "wire_name": "percent_off" + } + }, + "consumers": [ + "yearly-switch-retention" + ], + "correlation": null + }, + { + "id": "retentionOfferAccepted", + "wire_name": "Retention Offer Accepted", + "status": "active", + "replaced_by": null, + "phase": "point", + "intent": null, + "properties": { + "reason": { + "type": "enum", + "wire_name": "reason", + "values": [ + "too_expensive", + "not_using_enough", + "missing_features", + "audio_quality", + "battery_drain", + "found_alternative", + "other" + ] + }, + "percentOff": { + "type": "int", + "wire_name": "percent_off" + } + }, + "consumers": [ + "yearly-switch-retention" + ], + "correlation": null + }, + { + "id": "yearlyRetentionObservation", + "wire_name": "Yearly Retention Observation", + "status": "active", + "replaced_by": null, + "phase": "point", + "intent": null, + "properties": { + "placement": { + "type": "enum", + "wire_name": "placement", + "values": [ + "cancel", + "engaged" + ] + }, + "action": { + "type": "enum", + "wire_name": "action", + "values": [ + "shown", + "opened", + "confirmed", + "succeeded", + "failed", + "dismissed" + ] + }, + "variant": { + "type": "enum", + "wire_name": "variant", + "values": [ + "control", + "treatment" + ] + }, + "experimentVersion": { + "type": "int", + "wire_name": "experiment_version" + } + }, + "consumers": [ + "yearly-switch-retention" + ], + "correlation": null } ], "transport_dispositions": { diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index 79b8c0a94a70..d196e56061a8 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -15594,6 +15594,57 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } + public static func retentionStatusV1PaymentsYearlySwitchRetentionGet(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> OmiAnyCodable { + let _path = "/v1/payments/yearly-switch-retention" + guard let components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "GET" + for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) } + if let token = client.token { + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } + if let authorization { req.setValue(String(authorization), forHTTPHeaderField: "authorization") } + if let xAppPlatform { req.setValue(String(xAppPlatform), forHTTPHeaderField: "X-App-Platform") } + if let xDeviceIdHash { req.setValue(String(xDeviceIdHash), forHTTPHeaderField: "X-Device-Id-Hash") } + if let xAppVersion { req.setValue(String(xAppVersion), forHTTPHeaderField: "X-App-Version") } + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL } + guard (200..<300).contains(http.statusCode) else { + throw OmiApiError.httpError(status: http.statusCode, data: data) + } + return try JSONDecoder().decode(OmiAnyCodable.self, from: data) + } + + public static func admitRetentionV1PaymentsYearlySwitchRetentionPost(client: OmiApiClient, placement: String, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> OmiAnyCodable { + let _path = "/v1/payments/yearly-switch-retention" + guard var components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + var queryItems: [URLQueryItem] = [] + queryItems.append(URLQueryItem(name: "placement", value: String(placement))) + if !queryItems.isEmpty { components.queryItems = queryItems } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) } + if let token = client.token { + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } + if let authorization { req.setValue(String(authorization), forHTTPHeaderField: "authorization") } + if let xAppPlatform { req.setValue(String(xAppPlatform), forHTTPHeaderField: "X-App-Platform") } + if let xDeviceIdHash { req.setValue(String(xDeviceIdHash), forHTTPHeaderField: "X-Device-Id-Hash") } + if let xAppVersion { req.setValue(String(xAppVersion), forHTTPHeaderField: "X-App-Version") } + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL } + guard (200..<300).contains(http.statusCode) else { + throw OmiApiError.httpError(status: http.statusCode, data: data) + } + return try JSONDecoder().decode(OmiAnyCodable.self, from: data) + } + public static func getPaypalPaymentDetailsEndpointV1PaypalPaymentDetailsGet(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> OmiAnyCodable { let _path = "/v1/paypal/payment-details" guard let components = URLComponents(string: client.baseURL + _path) else { @@ -21601,5 +21652,5 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - // Total: 550 Swift client methods generated. + // Total: 552 Swift client methods generated. } diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index b9d5536a82b8..b1cc8a56a55f 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -10876,6 +10876,24 @@ export interface OmiApiPaths { }; }; }; + "/v1/payments/yearly-switch-retention": { + get: { + operationId: "retention_status_v1_payments_yearly_switch_retention_get"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + post: { + operationId: "admit_retention_v1_payments_yearly_switch_retention_post"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/paypal/payment-details": { get: { operationId: "get_paypal_payment_details_endpoint_v1_paypal_payment_details_get"; @@ -19485,6 +19503,47 @@ export async function get_yearly_switch_endpoint_v1_payments_yearly_switch_get(q return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function retention_status_v1_payments_yearly_switch_retention_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function admit_retention_v1_payments_yearly_switch_retention_post(query: { placement: "cancel" | "engaged" }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_paypal_payment_details_endpoint_v1_paypal_payment_details_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/paypal/payment-details`; @@ -24105,4 +24164,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 550 client methods generated. +// Total: 552 client methods generated. diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 17fe0c88edcb..e0e4f01ce24c 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -70064,6 +70064,167 @@ "summary": "Get Yearly Switch Endpoint" } }, + "/v1/payments/yearly-switch-retention": { + "get": { + "description": "Config only: never enroll or preview a charge. Clients refresh on resume.", + "operationId": "retention_status_v1_payments_yearly_switch_retention_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "title": "Authorization", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Platform", + "required": false, + "schema": { + "title": "X-App-Platform", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Device-Id-Hash", + "required": false, + "schema": { + "title": "X-Device-Id-Hash", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Version", + "required": false, + "schema": { + "title": "X-App-Version", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/YearlySwitchResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Retention Status" + }, + "post": { + "description": "Consume one UID admission, for either placement; never applies a switch.", + "operationId": "admit_retention_v1_payments_yearly_switch_retention_post", + "parameters": [ + { + "in": "query", + "name": "placement", + "required": true, + "schema": { + "enum": [ + "cancel", + "engaged" + ], + "title": "Placement", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "title": "Authorization", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Platform", + "required": false, + "schema": { + "title": "X-App-Platform", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Device-Id-Hash", + "required": false, + "schema": { + "title": "X-Device-Id-Hash", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Version", + "required": false, + "schema": { + "title": "X-App-Version", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/YearlySwitchResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Admit Retention" + } + }, "/v1/paypal/payment-details": { "get": { "description": "Get the PayPal payment details for the user", diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index b9d5536a82b8..b1cc8a56a55f 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -10876,6 +10876,24 @@ export interface OmiApiPaths { }; }; }; + "/v1/payments/yearly-switch-retention": { + get: { + operationId: "retention_status_v1_payments_yearly_switch_retention_get"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + post: { + operationId: "admit_retention_v1_payments_yearly_switch_retention_post"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/paypal/payment-details": { get: { operationId: "get_paypal_payment_details_endpoint_v1_paypal_payment_details_get"; @@ -19485,6 +19503,47 @@ export async function get_yearly_switch_endpoint_v1_payments_yearly_switch_get(q return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function retention_status_v1_payments_yearly_switch_retention_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function admit_retention_v1_payments_yearly_switch_retention_post(query: { placement: "cancel" | "engaged" }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_paypal_payment_details_endpoint_v1_paypal_payment_details_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/paypal/payment-details`; @@ -24105,4 +24164,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 550 client methods generated. +// Total: 552 client methods generated. diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index b9d5536a82b8..b1cc8a56a55f 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -10876,6 +10876,24 @@ export interface OmiApiPaths { }; }; }; + "/v1/payments/yearly-switch-retention": { + get: { + operationId: "retention_status_v1_payments_yearly_switch_retention_get"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + post: { + operationId: "admit_retention_v1_payments_yearly_switch_retention_post"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/paypal/payment-details": { get: { operationId: "get_paypal_payment_details_endpoint_v1_paypal_payment_details_get"; @@ -19485,6 +19503,47 @@ export async function get_yearly_switch_endpoint_v1_payments_yearly_switch_get(q return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function retention_status_v1_payments_yearly_switch_retention_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function admit_retention_v1_payments_yearly_switch_retention_post(query: { placement: "cancel" | "engaged" }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_paypal_payment_details_endpoint_v1_paypal_payment_details_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/paypal/payment-details`; @@ -24105,4 +24164,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 550 client methods generated. +// Total: 552 client methods generated. diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index b9d5536a82b8..b1cc8a56a55f 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -10876,6 +10876,24 @@ export interface OmiApiPaths { }; }; }; + "/v1/payments/yearly-switch-retention": { + get: { + operationId: "retention_status_v1_payments_yearly_switch_retention_get"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + post: { + operationId: "admit_retention_v1_payments_yearly_switch_retention_post"; + responses: { + "200": YearlySwitchResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/paypal/payment-details": { get: { operationId: "get_paypal_payment_details_endpoint_v1_paypal_payment_details_get"; @@ -19485,6 +19503,47 @@ export async function get_yearly_switch_endpoint_v1_payments_yearly_switch_get(q return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function retention_status_v1_payments_yearly_switch_retention_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function admit_retention_v1_payments_yearly_switch_retention_post(query: { placement: "cancel" | "engaged" }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/payments/yearly-switch-retention`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_paypal_payment_details_endpoint_v1_paypal_payment_details_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/paypal/payment-details`; @@ -24105,4 +24164,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 550 client methods generated. +// Total: 552 client methods generated.