mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57545b1a4 | ||
|
|
e490be2f35 | ||
|
|
ab07fd23cf | ||
|
|
c1fd2234b8 | ||
|
|
3af43b3366 | ||
|
|
0e470f532e | ||
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 | ||
|
|
f708586c59 | ||
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 | ||
|
|
4a93b677af | ||
|
|
d79cd99050 | ||
|
|
167862a8a6 | ||
|
|
48b569b9d4 | ||
|
|
75be6aa492 | ||
|
|
9fe65d5036 | ||
|
|
bfa9bf221d | ||
|
|
184eeb0846 | ||
|
|
0eff26a1c9 | ||
|
|
d729f641ff | ||
|
|
044a2c101d | ||
|
|
38e2c8db3e | ||
|
|
1b22d14f3d |
@@ -525,6 +525,7 @@ jobs:
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -602,7 +603,9 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -660,3 +663,8 @@ jobs:
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
@@ -448,6 +448,9 @@ jobs:
|
||||
- name: Check formatting and lint
|
||||
run: pnpm exec biome ci .
|
||||
|
||||
- name: Lint JSX for browser-translation safety
|
||||
run: pnpm exec eslint . --max-warnings 0
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
|
||||
+23
-1
@@ -84,7 +84,18 @@
|
||||
},
|
||||
"useConst": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off"
|
||||
"noParameterAssign": "off",
|
||||
"noRestrictedImports": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"paths": {
|
||||
"@lingui/react": {
|
||||
"importNames": ["I18nProvider"],
|
||||
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true,
|
||||
@@ -116,10 +127,21 @@
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["fluxer_app/src/**/*.tsx"],
|
||||
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
|
||||
},
|
||||
{
|
||||
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
|
||||
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
|
||||
],
|
||||
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
|
||||
@@ -117,6 +117,60 @@ FLUXER_IMAGE_TAG=v1
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# The stack ships its own Postgres and its own object store, and points at both
|
||||
# by service name. Set these to run either one outside the stack. Leave them
|
||||
# unset and the bundled services are used. Taking a service out of the stack
|
||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
||||
# store up belongs to whoever runs it.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
#FLUXER_POSTGRES_USERNAME=fluxer
|
||||
#FLUXER_POSTGRES_SSL=true
|
||||
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||
#FLUXER_S3_REGION=eu-central-1
|
||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
# Bucket names. The bundled object store creates whichever names these hold, so
|
||||
# the two stay in step. An object store outside the stack needs the buckets to
|
||||
# exist already.
|
||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
||||
# line unset and the service in the stack is used. Taking a service out of the
|
||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
||||
# replaces docker-compose.yml.
|
||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
|
||||
# Voice off. The livekit service still runs until an override file takes it out.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless the instance is configured for it.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# The client address. Set the header name a proxy in front actually writes, and
|
||||
# turn the trust off when nothing sits in front, because a trusted header an
|
||||
# attacker can set is a spoofed client address.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
||||
# service names the object storage endpoint and its addressing at info on start,
|
||||
# so a bucket that answers 404 is visible without raising this.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
|
||||
@@ -311,10 +365,16 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
# users and messages routers and their shards. Leave it unset and each service
|
||||
# uses its own built-in default, which is what the numbers below describe. Set it
|
||||
# and the one value replaces the built-in default on all four, so size it for the
|
||||
# busiest of them rather than for the smallest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them, and the api
|
||||
# turns that rejection into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
|
||||
@@ -2,59 +2,60 @@ name: fluxer
|
||||
|
||||
x-fluxer-postgres-env: &fluxer-postgres-env
|
||||
FLUXER_DATABASE_BACKEND: postgres
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_POSTGRES_PORT: "5432"
|
||||
FLUXER_POSTGRES_DATABASE: fluxer
|
||||
FLUXER_POSTGRES_USERNAME: fluxer
|
||||
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
|
||||
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
|
||||
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
|
||||
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
|
||||
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
FLUXER_POSTGRES_SSL: "false"
|
||||
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
|
||||
|
||||
x-fluxer-env: &fluxer-env
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_ENV: production
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_SVC_SHARD_COUNT: "1"
|
||||
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_URL: http://meilisearch:7700
|
||||
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
|
||||
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
|
||||
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_REGION: us-east-1
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "true"
|
||||
FLUXER_S3_BUCKET_CDN: fluxer
|
||||
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
|
||||
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
|
||||
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED: "true"
|
||||
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
|
||||
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
@@ -72,16 +73,16 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
|
||||
|
||||
FLUXER_SMS_ENABLED: "false"
|
||||
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_ENABLED: "false"
|
||||
FLUXER_NCMEC_ENABLED: "false"
|
||||
FLUXER_CLAMAV_ENABLED: "false"
|
||||
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
|
||||
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
|
||||
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
@@ -288,11 +289,16 @@ services:
|
||||
environment:
|
||||
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
@@ -559,7 +565,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -577,7 +583,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -627,7 +633,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -645,7 +651,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'**/*.generated.*',
|
||||
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['fluxer_app/src/**/*.tsx'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './fluxer_app/tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {'react-google-translate': reactGoogleTranslate},
|
||||
rules: {
|
||||
'react-google-translate/no-conditional-text-nodes-with-siblings': [
|
||||
'error',
|
||||
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
|
||||
],
|
||||
'react-google-translate/no-return-text-nodes': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -560,6 +560,8 @@ fn traits_form(
|
||||
}
|
||||
}
|
||||
|
||||
const DERIVED_TRAITS: [&str; 1] = ["premium"];
|
||||
|
||||
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
|
||||
limit_config
|
||||
.map(|response| {
|
||||
@@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| !DERIVED_TRAITS.contains(value))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
@@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|trait_name| !trait_definitions.contains(trait_name))
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -81,6 +81,22 @@ function withUploadRelaySecret(master: MasterConfig, secretBase64: string): Mast
|
||||
};
|
||||
}
|
||||
|
||||
function withStripeLegacyPrices(
|
||||
master: MasterConfig,
|
||||
legacyPrices: Record<string, Array<string> | undefined> | undefined,
|
||||
): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
stripe: {
|
||||
...master.integrations.stripe,
|
||||
legacy_prices: legacyPrices,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
@@ -111,3 +127,42 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('carries the retired stripe price map from master config onto the api config', () => {
|
||||
const legacyPrices = {
|
||||
monthly_brl: ['price_retired_monthly_brl'],
|
||||
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
|
||||
monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up'],
|
||||
};
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, legacyPrices)).stripe.legacyPrices).toEqual(
|
||||
legacyPrices,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the retired price map even when no live prices are configured', () => {
|
||||
const withoutPrices: MasterConfig = {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
stripe: {
|
||||
...master.integrations.stripe,
|
||||
prices: undefined,
|
||||
legacy_prices: {monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']},
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = buildAPIConfigFromMaster(withoutPrices);
|
||||
expect(config.stripe.prices).toBeUndefined();
|
||||
expect(config.stripe.legacyPrices).toEqual({monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']});
|
||||
});
|
||||
|
||||
it('leaves the retired price map undefined when master config does not set one', () => {
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
|
||||
monthlyEur: master.integrations.stripe.prices.monthly_eur,
|
||||
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
|
||||
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
|
||||
monthlyInr: master.integrations.stripe.prices.monthly_inr,
|
||||
monthlyNok: master.integrations.stripe.prices.monthly_nok,
|
||||
monthlyPln: master.integrations.stripe.prices.monthly_pln,
|
||||
monthlySek: master.integrations.stripe.prices.monthly_sek,
|
||||
monthlyTry: master.integrations.stripe.prices.monthly_try,
|
||||
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
|
||||
yearlyEur: master.integrations.stripe.prices.yearly_eur,
|
||||
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
|
||||
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
|
||||
yearlyInr: master.integrations.stripe.prices.yearly_inr,
|
||||
yearlyNok: master.integrations.stripe.prices.yearly_nok,
|
||||
yearlyPln: master.integrations.stripe.prices.yearly_pln,
|
||||
yearlySek: master.integrations.stripe.prices.yearly_sek,
|
||||
yearlyTry: master.integrations.stripe.prices.yearly_try,
|
||||
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
|
||||
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
|
||||
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
|
||||
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
|
||||
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
|
||||
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
|
||||
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
|
||||
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
|
||||
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
|
||||
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
|
||||
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
|
||||
@@ -395,6 +407,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
gift1YearTry: master.integrations.stripe.prices.gift_1_year_try,
|
||||
}
|
||||
: undefined,
|
||||
legacyPrices: master.integrations.stripe.legacy_prices,
|
||||
},
|
||||
bunny: {
|
||||
purgeEnabled: master.integrations.bunny.purge_enabled,
|
||||
|
||||
@@ -229,14 +229,11 @@ export class AdminMessageService {
|
||||
hitsPerPage: limit,
|
||||
page: 1,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const resolvedMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
|
||||
);
|
||||
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
|
||||
const messageResponses = await createMessageResponseDataService().buildMessages({
|
||||
userId: createUserID(0n),
|
||||
messages: result.messages,
|
||||
access: await this.getMessageResponseAccessForAdmin(channelId),
|
||||
});
|
||||
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
||||
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
||||
const adminMessages = messageResponses.map((message) =>
|
||||
@@ -282,19 +279,6 @@ export class AdminMessageService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getMessageResponseForAdmin(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
): Promise<MessageResponse | null> {
|
||||
const access = await this.getMessageResponseAccessForAdmin(channelId);
|
||||
return createMessageResponseDataService().getMessage({
|
||||
userId: createUserID(0n),
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
});
|
||||
}
|
||||
|
||||
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
|
||||
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
|
||||
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
|
||||
|
||||
@@ -45,6 +45,7 @@ import {Logger} from '../../Logger';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
import {resolveAssignedTraits} from '../../user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
@@ -383,7 +384,8 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const traitSet = data.traits.length > 0 ? new Set(data.traits) : null;
|
||||
const assigned = resolveAssignedTraits(user.traits ?? [], data.traits);
|
||||
const traitSet = assigned.size > 0 ? assigned : null;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function createMessageSnapshotsForForward(
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message, ownKeys: ReadonlySet<string>): Array<string> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
@@ -316,7 +316,7 @@ function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string
|
||||
return;
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
if (key.startsWith('attachments/')) {
|
||||
if (ownKeys.has(key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
@@ -342,6 +342,11 @@ export async function purgeMessageAttachments(
|
||||
): Promise<void> {
|
||||
const cdnKeys = new Set<string>();
|
||||
const cdnUrls: Array<string> = [];
|
||||
const ownedCdnKeys = new Set<string>(
|
||||
collectMessageAttachments(message).map((attachment) =>
|
||||
makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
|
||||
),
|
||||
);
|
||||
for (const attachment of collectMessageAttachments(message)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
if (cdnKeys.has(cdnKey)) {
|
||||
@@ -352,7 +357,7 @@ export async function purgeMessageAttachments(
|
||||
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
|
||||
}
|
||||
}
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message, ownedCdnKeys)) {
|
||||
if (cdnKeys.has(embedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageResponse, MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../../../BrandedTypes';
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
@@ -192,29 +191,19 @@ export class MessageRetrievalService {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const access = {
|
||||
sourceGuildId: channel.guildId,
|
||||
messageHistoryCutoff: !hasReadHistory ? (authChannel.guild?.message_history_cutoff ?? null) : null,
|
||||
canReadMessageHistory: hasReadHistory,
|
||||
};
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const foundMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) =>
|
||||
responseDataService.getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
}),
|
||||
),
|
||||
const builtMessages = await createMessageResponseDataService().buildMessages({
|
||||
userId,
|
||||
messages: result.messages,
|
||||
access,
|
||||
});
|
||||
const messageResponses = builtMessages.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const messageResponses = foundMessages
|
||||
.filter((message): message is MessageResponse => message !== null)
|
||||
.map(({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage);
|
||||
return {
|
||||
channels: messageResponses.length > 0 ? [await this.mapSearchChannelResponse(channel, userId, requestCache)] : [],
|
||||
messages: messageResponses,
|
||||
|
||||
@@ -226,17 +226,29 @@ export interface APIConfig {
|
||||
monthlyUsd?: string;
|
||||
monthlyEur?: string;
|
||||
monthlyBrl?: string;
|
||||
monthlyDkk?: string;
|
||||
monthlyInr?: string;
|
||||
monthlyNok?: string;
|
||||
monthlyPln?: string;
|
||||
monthlySek?: string;
|
||||
monthlyTry?: string;
|
||||
yearlyUsd?: string;
|
||||
yearlyEur?: string;
|
||||
yearlyBrl?: string;
|
||||
yearlyDkk?: string;
|
||||
yearlyInr?: string;
|
||||
yearlyNok?: string;
|
||||
yearlyPln?: string;
|
||||
yearlySek?: string;
|
||||
yearlyTry?: string;
|
||||
gift1MonthUsd?: string;
|
||||
gift1MonthEur?: string;
|
||||
gift1MonthSek?: string;
|
||||
gift1YearSek?: string;
|
||||
gift1MonthDkk?: string;
|
||||
gift1YearDkk?: string;
|
||||
gift1MonthNok?: string;
|
||||
gift1YearNok?: string;
|
||||
gift1MonthBrl?: string;
|
||||
gift1MonthInr?: string;
|
||||
gift1MonthPln?: string;
|
||||
@@ -248,6 +260,7 @@ export interface APIConfig {
|
||||
gift1YearPln?: string;
|
||||
gift1YearTry?: string;
|
||||
};
|
||||
legacyPrices?: Record<string, Array<string> | undefined>;
|
||||
};
|
||||
bunny: {
|
||||
purgeEnabled: boolean;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
StorageObjectRangeNotSatisfiableError,
|
||||
} from '../infrastructure/IStorageService';
|
||||
import {Logger} from '../Logger';
|
||||
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
|
||||
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
|
||||
|
||||
type ListedDesktopVersion = {
|
||||
version: string;
|
||||
pub_date: Date;
|
||||
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
|
||||
};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
@@ -216,41 +225,28 @@ export class DownloadService {
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
|
||||
if (release.kind === 'untracked') {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (release.kind === 'unpublished') {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
throw new Error(
|
||||
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
|
||||
);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
@@ -269,18 +265,22 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const entry = manifest.files[params.format];
|
||||
if (!entry) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const filename = this.extractFilename(entry);
|
||||
if (filename.trim().length === 0) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const resolvedFilename = await this.resolveManifestFilename({
|
||||
channel: params.channel,
|
||||
@@ -291,7 +291,11 @@ export class DownloadService {
|
||||
test: params.test,
|
||||
});
|
||||
if (!resolvedFilename) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
|
||||
if (parsed && parsed.version !== manifest.version) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
@@ -302,7 +306,7 @@ export class DownloadService {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -320,10 +324,14 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
|
||||
if (result) {
|
||||
@@ -331,11 +339,11 @@ export class DownloadService {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
|
||||
async listDesktopVersions(params: {
|
||||
@@ -351,158 +359,17 @@ export class DownloadService {
|
||||
versions: Array<VersionInfo>;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return {versions: [], hasMore: false};
|
||||
let listedVersions = await this.listDesktopVersionFiles(params);
|
||||
if (params.before) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
const versionMap = new Map<
|
||||
string,
|
||||
{
|
||||
pub_date: Date;
|
||||
files: Map<
|
||||
DesktopFormat,
|
||||
{
|
||||
filename: string;
|
||||
sha256Key: string | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
|
||||
let filteredVersions = sortedVersions;
|
||||
if (params.before) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
|
||||
}
|
||||
if (params.after) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
|
||||
}
|
||||
const hasMore = filteredVersions.length > params.limit;
|
||||
const paginatedVersions = filteredVersions.slice(0, params.limit);
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return {versions, hasMore};
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
throw error;
|
||||
if (params.after) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
|
||||
}
|
||||
return {
|
||||
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
|
||||
hasMore: listedVersions.length > params.limit,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveVersionedDesktopKey(params: {
|
||||
@@ -558,10 +425,10 @@ export class DownloadService {
|
||||
}): Promise<DesktopChecksumFile | null> {
|
||||
const version = await this.getLatestDesktopVersion(params);
|
||||
const file = version?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
const key = await this.resolveLatestDesktopKey(params);
|
||||
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
@@ -586,8 +453,16 @@ export class DownloadService {
|
||||
if (objectSha256) {
|
||||
return this.buildDesktopChecksumFile(key, filename, objectSha256);
|
||||
}
|
||||
const latest = await this.getLatestDesktopVersion(params);
|
||||
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
|
||||
const prefix = desktopArtifactPrefix(params);
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
|
||||
const versionInfo =
|
||||
isDesktopManifest(manifest) && manifest.version === params.version
|
||||
? await this.getLatestDesktopVersionFromManifest(params, manifest)
|
||||
: null;
|
||||
const file = versionInfo?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
@@ -768,6 +643,77 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
|
||||
const [descriptorText, markerText] = await Promise.all([
|
||||
this.readOptionalTextFromStorage(descriptorKey),
|
||||
this.readOptionalTextFromStorage(markerKey),
|
||||
]);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'untracked'};
|
||||
}
|
||||
if (markerText == null) {
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== channel ||
|
||||
readiness.version !== version ||
|
||||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
|
||||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
|
||||
) {
|
||||
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
return {kind: 'published', descriptorText};
|
||||
}
|
||||
|
||||
private async isReleasableDesktopVersion(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
version: string,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<boolean> {
|
||||
if (params.test || Config.instance.selfHosted) {
|
||||
return true;
|
||||
}
|
||||
const checked = releasability.get(version);
|
||||
if (checked !== undefined) {
|
||||
return checked;
|
||||
}
|
||||
let releasable = true;
|
||||
try {
|
||||
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
|
||||
} catch (error) {
|
||||
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
|
||||
}
|
||||
releasability.set(version, releasable);
|
||||
return releasable;
|
||||
}
|
||||
|
||||
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
candidates: ReadonlyArray<T>,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<T | null> {
|
||||
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
|
||||
for (const candidate of newestCandidates) {
|
||||
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const [newest] = newestCandidates;
|
||||
if (!newest) {
|
||||
return null;
|
||||
}
|
||||
Logger.error(
|
||||
{channel: params.channel, version: newest.version},
|
||||
'No recent desktop version has a published release',
|
||||
);
|
||||
return newest;
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
return /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
@@ -789,7 +735,13 @@ export class DownloadService {
|
||||
) {
|
||||
return manifestFilename;
|
||||
}
|
||||
return this.findLatestFilenameForRequestedArch(params);
|
||||
for (const entry of await this.listDesktopVersionFiles(params)) {
|
||||
const file = entry.files.get(params.format);
|
||||
if (file) {
|
||||
return file.filename;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
|
||||
@@ -826,44 +778,145 @@ export class DownloadService {
|
||||
return params.filename.toLowerCase().endsWith('.exe');
|
||||
}
|
||||
|
||||
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
private async listDesktopVersionFiles(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
test?: boolean;
|
||||
}): Promise<Array<ListedDesktopVersion>> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
const versionMap = new Map<string, ListedDesktopVersion>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
version,
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let latestFilename: string | null = null;
|
||||
let latestVersion: string | null = null;
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
filename.includes('/') ||
|
||||
filename.endsWith('.sha256') ||
|
||||
filename.endsWith('.blockmap') ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename === 'manifest.json' ||
|
||||
filename === 'RELEASES.json' ||
|
||||
filename === 'releases.json'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed || parsed.format !== params.format) {
|
||||
continue;
|
||||
}
|
||||
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
|
||||
latestVersion = parsed.version;
|
||||
latestFilename = filename;
|
||||
}
|
||||
|
||||
private async buildListedDesktopVersions(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
listedVersions: ReadonlyArray<ListedDesktopVersion>,
|
||||
): Promise<Array<VersionInfo>> {
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const entry of listedVersions) {
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return latestFilename;
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const entry of listedVersions) {
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version: entry.version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
@@ -1030,16 +1083,25 @@ export class DownloadService {
|
||||
return key;
|
||||
}
|
||||
|
||||
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
const filename = await this.findLatestFilenameForRequestedArch(params);
|
||||
if (!filename) {
|
||||
private async resolveLatestDesktopKeyFromObjects(
|
||||
params: LatestFilenameLookupParams,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<string | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(
|
||||
params,
|
||||
listedVersions.filter((entry) => entry.files.has(params.format)),
|
||||
releasability,
|
||||
);
|
||||
const file = latest?.files.get(params.format);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
filename,
|
||||
filename: file.filename,
|
||||
test: params.test,
|
||||
});
|
||||
}
|
||||
@@ -1189,22 +1251,23 @@ export class DownloadService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getLatestDesktopVersionFromObjects(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
}): Promise<VersionInfo | null> {
|
||||
const {versions} = await this.listDesktopVersions({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
limit: 1,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
});
|
||||
return versions[0] ?? null;
|
||||
private async getLatestDesktopVersionFromObjects(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<VersionInfo | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
|
||||
return versionInfo ?? null;
|
||||
}
|
||||
|
||||
private async resolveDesktopFileSha256(params: {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getConfig} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
|
||||
const RELEASES_PREFIX = 'desktop/canary/github-releases';
|
||||
const SOURCE_SHA = 'b'.repeat(40);
|
||||
const V904 = '2026.904.135113';
|
||||
const V908 = '2026.908.173325';
|
||||
const V909 = '2026.909.202036';
|
||||
|
||||
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
|
||||
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
|
||||
|
||||
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
|
||||
['darwin', 'arm64', 4],
|
||||
['darwin', 'x64', 4],
|
||||
['linux', 'arm64', 4],
|
||||
['linux', 'x64', 4],
|
||||
['win32', 'arm64', 6],
|
||||
['win32', 'x64', 6],
|
||||
];
|
||||
|
||||
type StoredObjects = Map<string, string>;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
|
||||
const prefix = options.prefix ?? PREFIX;
|
||||
const filename = appImageFilename(version);
|
||||
objects.set(`${prefix}/${filename}`, filename);
|
||||
if (options.checksum !== false) {
|
||||
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
|
||||
}
|
||||
objects.set(
|
||||
`${prefix}/manifest.json`,
|
||||
JSON.stringify({
|
||||
channel: 'canary',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
version,
|
||||
pub_date: '2026-09-08T18:06:00Z',
|
||||
files: {appimage: {filename, sha256: sha256Hex(filename)}},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
|
||||
const assets = routes.flatMap(([plat, arch, count]) =>
|
||||
Array.from({length: count}, (_, index) => {
|
||||
const filename =
|
||||
plat === 'linux' && arch === 'x64' && index === 0
|
||||
? appImageFilename(version)
|
||||
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
|
||||
return {
|
||||
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
|
||||
release_asset: filename,
|
||||
sha256: sha256Hex(filename),
|
||||
size: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const descriptor = JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
assets,
|
||||
});
|
||||
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
|
||||
objects.set(
|
||||
`${RELEASES_PREFIX}/${version}.ready.json`,
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
descriptor_sha256: sha256Hex(descriptor),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function releaseBuild(objects: StoredObjects, version: string) {
|
||||
const descriptor = publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
publishMarker(objects, version, descriptor);
|
||||
}
|
||||
|
||||
function incidentObjects(): StoredObjects {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
return objects;
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
|
||||
const reads: Array<string> = [];
|
||||
const listings: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
onRead?.(params.key);
|
||||
const body = objects.get(params.key);
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) => {
|
||||
listings.push(params.prefix);
|
||||
return Array.from(objects.keys())
|
||||
.filter((key) => key.startsWith(params.prefix))
|
||||
.sort()
|
||||
.map((key) => ({key}));
|
||||
},
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads, listings};
|
||||
}
|
||||
|
||||
async function resolveLatest(service: DownloadService, test?: boolean) {
|
||||
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
|
||||
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
|
||||
return {version: metadata?.version, key, checksum: checksum?.body};
|
||||
}
|
||||
|
||||
function latestOf(version: string, prefix = PREFIX) {
|
||||
const filename = appImageFilename(version);
|
||||
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
|
||||
}
|
||||
|
||||
describe('desktop release readiness', () => {
|
||||
it('offers a published manifest version after reading only its release state', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
releaseBuild(objects, V909);
|
||||
const {service, reads, listings} = createService(objects);
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.ready.json`,
|
||||
]);
|
||||
expect(listings).toEqual([]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
});
|
||||
|
||||
it('falls back to the newest published version while the manifest version awaits its release', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
|
||||
const {service, reads, listings} = createService(incidentObjects());
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.ready.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.ready.json`,
|
||||
`${PREFIX}/${appImageFilename(V904)}.sha256`,
|
||||
]);
|
||||
expect(listings).toEqual([`${PREFIX}/`]);
|
||||
});
|
||||
|
||||
it('offers a manifest version that has no release descriptor', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
uploadBuild(objects, V904);
|
||||
uploadBuild(objects, V908);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, 'another descriptor');
|
||||
const {service} = createService(objects);
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const descriptor = publishDescriptor(
|
||||
objects,
|
||||
V908,
|
||||
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
|
||||
);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, descriptor);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
|
||||
'Invalid GitHub desktop release descriptor',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
|
||||
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
|
||||
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('still resolves the unpublished version through versioned routes', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {checksum: false});
|
||||
const {service} = createService(objects);
|
||||
const params = {...APPIMAGE_PARAMS, version: V908};
|
||||
const filename = appImageFilename(V908);
|
||||
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
|
||||
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
|
||||
sha256: sha256Hex(filename),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps offering the manifest version on self-hosted instances', async () => {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
const {service, reads} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps offering the newest test build', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
|
||||
const objects = incidentObjects();
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
publishMarker(objects, V909, descriptor);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
|
||||
kind: 'ready',
|
||||
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the newest version when ten unpublished versions hide a published one', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const newest = '2026.908.170009';
|
||||
for (let build = 0; build < 10; build++) {
|
||||
const version = `2026.908.${170000 + build}`;
|
||||
publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
|
||||
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
|
||||
});
|
||||
|
||||
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
|
||||
const objects = incidentObjects();
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
let manifestReads = 0;
|
||||
const {service} = createService(objects, (key) => {
|
||||
if (key !== `${PREFIX}/manifest.json`) {
|
||||
return;
|
||||
}
|
||||
manifestReads += 1;
|
||||
if (manifestReads === 2) {
|
||||
publishMarker(objects, V909, descriptor);
|
||||
}
|
||||
});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
|
||||
expect(checksum?.body).toBe(latestOf(V904).checksum);
|
||||
});
|
||||
|
||||
it('lists an unpublished version while latest skips it', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
|
||||
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const BASE_URL = 'https://api.example.test';
|
||||
const V1 = '2026.901.100000';
|
||||
const V2 = '2026.902.100000';
|
||||
const V3 = '2026.903.100000';
|
||||
const V4 = '2026.904.100000';
|
||||
const V5 = '2026.905.100000';
|
||||
|
||||
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
|
||||
|
||||
type StoredObject = {body?: string; lastModified?: Date};
|
||||
type StoredObjects = Map<string, StoredObject>;
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function debFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-amd64.deb`;
|
||||
}
|
||||
|
||||
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
|
||||
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
|
||||
if (options.sha256 !== undefined) {
|
||||
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
|
||||
}
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects) {
|
||||
const reads: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
const object = objects.get(params.key);
|
||||
if (object?.body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(object.body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) =>
|
||||
Array.from(objects.entries())
|
||||
.filter(([key]) => key.startsWith(params.prefix))
|
||||
.sort(([left], [right]) => (left < right ? -1 : 1))
|
||||
.map(([key, object]) => ({key, lastModified: object.lastModified})),
|
||||
getObjectMetadata: async () => null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads};
|
||||
}
|
||||
|
||||
function versionNumbers(versions: Array<{version: string}>): Array<string> {
|
||||
return versions.map((entry) => entry.version);
|
||||
}
|
||||
|
||||
describe('desktop version listing', () => {
|
||||
it('lists versions newest first with the files of each version', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
|
||||
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
|
||||
expect(listed.hasMore).toBe(false);
|
||||
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
|
||||
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
|
||||
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
|
||||
});
|
||||
|
||||
it('excludes names that are not artefacts for the requested coordinate', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
|
||||
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
|
||||
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
|
||||
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V3]);
|
||||
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
|
||||
});
|
||||
|
||||
it('pages with limit, before and after and reports whether more remain', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version));
|
||||
}
|
||||
const {service} = createService(objects);
|
||||
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
|
||||
expect(firstPage.hasMore).toBe(true);
|
||||
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
|
||||
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
|
||||
expect(olderPage.hasMore).toBe(false);
|
||||
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
|
||||
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
|
||||
expect(newerPage.hasMore).toBe(false);
|
||||
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
|
||||
expect(versionNumbers(between.versions)).toEqual([V4]);
|
||||
expect(between.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
|
||||
const hash = 'b'.repeat(64);
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: hash});
|
||||
addArtifact(objects, appImageFilename(V2));
|
||||
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(listed.versions[0].files.appimage).toEqual({
|
||||
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
|
||||
sha256: hash,
|
||||
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
|
||||
});
|
||||
expect(listed.versions[1].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a checksum only for the versions it returns', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
|
||||
});
|
||||
});
|
||||
@@ -156,7 +156,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
@@ -238,7 +238,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -143,6 +143,32 @@ describe('StorageService.getPresignedUploadURL', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('gives both clients the configured addressing rather than pinning one to path style', async () => {
|
||||
await withS3Config(
|
||||
{
|
||||
endpoint: 'https://s3.example.test',
|
||||
presignedUrlBase: '',
|
||||
forcePathStyle: false,
|
||||
region: 'eu-central-1',
|
||||
accessKeyId: 'fluxer',
|
||||
secretAccessKey: 'fluxer-secret',
|
||||
buckets: {uploads: 'fluxer-uploads'},
|
||||
},
|
||||
async () => {
|
||||
const service = new StorageService();
|
||||
const probe = service as unknown as {
|
||||
client: {config: {forcePathStyle?: unknown}};
|
||||
presignClient: {config: {forcePathStyle?: unknown}};
|
||||
};
|
||||
const resolve = async (value: unknown): Promise<unknown> =>
|
||||
typeof value === 'function' ? await (value as () => Promise<unknown>)() : value;
|
||||
|
||||
expect(await resolve(probe.client.config.forcePathStyle)).toBe(false);
|
||||
expect(await resolve(probe.presignClient.config.forcePathStyle)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageService.copyObjectWithMetadataStripping', () => {
|
||||
|
||||
@@ -127,7 +127,7 @@ export class StorageService implements IStorageService {
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
this.presignClient = buildPooledS3Client({
|
||||
endpoint: this.resolvePresignEndpoint(),
|
||||
@@ -136,6 +136,15 @@ export class StorageService implements IStorageService {
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
Logger.info(
|
||||
{
|
||||
endpoint: this.provider.endpoint,
|
||||
presignEndpoint: this.resolvePresignEndpoint(),
|
||||
region: this.provider.region,
|
||||
addressing: this.provider.forcePathStyle ? 'path' : 'virtual-host',
|
||||
},
|
||||
'Object storage client ready',
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePresignEndpoint(): string {
|
||||
|
||||
@@ -13342,13 +13342,10 @@
|
||||
"name": "country_code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
|
||||
},
|
||||
{
|
||||
"name": "pricing_mode",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/PricingModeEnum"}
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13623,11 +13620,81 @@
|
||||
"name": "country_code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/premium/switch-to-list-price": {
|
||||
"post": {
|
||||
"operationId": "switch_subscription_to_list_price",
|
||||
"summary": "Switch subscription to the current list price",
|
||||
"tags": ["Premium"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SwitchToListPriceResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Switch subscription to the current list price"}},
|
||||
"description": "Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
|
||||
"security": [{"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/premium/visionary/rejoin": {
|
||||
"post": {
|
||||
"operationId": "rejoin_visionary_guild",
|
||||
@@ -31997,9 +32064,9 @@
|
||||
"description": "The amount the user is actually charged, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of the charged amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
@@ -32194,6 +32261,7 @@
|
||||
"pending_subscription_change": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PendingSubscriptionChangeResponse"}, {"type": "null"}]
|
||||
},
|
||||
"list_price_switch": {"$ref": "#/components/schemas/ListPriceSwitchState"},
|
||||
"subscription": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PremiumBillingSubscriptionResponse"}, {"type": "null"}]
|
||||
},
|
||||
@@ -32209,6 +32277,7 @@
|
||||
"stripe_customer_id",
|
||||
"current_subscription_price",
|
||||
"pending_subscription_change",
|
||||
"list_price_switch",
|
||||
"subscription",
|
||||
"invoices",
|
||||
"invoices_has_more",
|
||||
@@ -32241,6 +32310,12 @@
|
||||
"type": "string",
|
||||
"description": "Stripe subscription schedule ID managing the pending change"
|
||||
},
|
||||
"change_kind": {
|
||||
"enum": ["billing_cycle", "price"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["billing_cycle", "price"],
|
||||
"description": "Whether the pending change moves the billing cycle or only the price within the same cycle"
|
||||
},
|
||||
"current_billing_cycle": {
|
||||
"anyOf": [
|
||||
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
|
||||
@@ -32263,12 +32338,16 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Stripe price ID that will be used after the change"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Unit amount of the price that will be used after the change, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -32289,11 +32368,13 @@
|
||||
},
|
||||
"required": [
|
||||
"schedule_id",
|
||||
"change_kind",
|
||||
"current_billing_cycle",
|
||||
"target_billing_cycle",
|
||||
"effective_at",
|
||||
"current_price_id",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"currency",
|
||||
"initial_amount_minor",
|
||||
"recurring_amount_minor",
|
||||
@@ -32303,6 +32384,104 @@
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"ListPriceSwitchState": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the authenticated user can move their subscription down to the current list price right now"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/ListPriceSwitchIneligibilityReason"}, {"type": "null"}],
|
||||
"description": "Why the switch is unavailable, when available is false"
|
||||
},
|
||||
"pending": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a switch to the current list price is already scheduled"
|
||||
},
|
||||
"current_price_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Stripe price ID the subscription is billed against today"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Amount the subscription is billed today, in the currency minor unit"
|
||||
},
|
||||
"list_price_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Current list Stripe price ID for the same cycle and currency"
|
||||
},
|
||||
"list_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Current list price for the same cycle and currency, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Currency of both the current and the list amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
"anyOf": [
|
||||
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Recurring billing cycle the switch applies to"
|
||||
},
|
||||
"effective_at": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO timestamp the switch takes effect, which is the end of the current billing period"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"available",
|
||||
"reason",
|
||||
"pending",
|
||||
"current_price_id",
|
||||
"current_amount_minor",
|
||||
"list_price_id",
|
||||
"list_amount_minor",
|
||||
"currency",
|
||||
"billing_cycle",
|
||||
"effective_at"
|
||||
]
|
||||
},
|
||||
"ListPriceSwitchIneligibilityReason": {
|
||||
"enum": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enumNames": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
]
|
||||
},
|
||||
"PremiumBillingSubscriptionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32443,13 +32622,9 @@
|
||||
"localized": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
|
||||
"description": "Localized checkout prices resolved from mirrored billing data"
|
||||
},
|
||||
"base": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
|
||||
"description": "Standard USD/EUR checkout prices resolved from mirrored billing data"
|
||||
}
|
||||
},
|
||||
"required": ["country_code", "localized", "base"]
|
||||
"required": ["country_code", "localized"]
|
||||
},
|
||||
"PriceIdsResponse": {
|
||||
"type": "object",
|
||||
@@ -32487,15 +32662,15 @@
|
||||
"description": "Gift 1 year price amount in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for the prices"
|
||||
},
|
||||
"gift_currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for gift prices"
|
||||
}
|
||||
},
|
||||
@@ -32508,7 +32683,6 @@
|
||||
},
|
||||
"required": ["disabled"]
|
||||
},
|
||||
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
|
||||
"SelfServeRefundResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32544,6 +32718,134 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"SwitchToListPriceResponse": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/components/schemas/ScheduledSwitchToListPriceResponse"},
|
||||
{"$ref": "#/components/schemas/AlreadyScheduledSwitchToListPriceResponse"},
|
||||
{"$ref": "#/components/schemas/IneligibleSwitchToListPriceResponse"}
|
||||
]
|
||||
},
|
||||
"ScheduledSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["scheduled"],
|
||||
"description": "The switch was scheduled for the end of the current billing period"
|
||||
},
|
||||
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
|
||||
"target_price_id": {
|
||||
"type": "string",
|
||||
"description": "Stripe price ID the subscription will be billed against after the switch"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed after the switch, in the currency minor unit"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"effective_at",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"current_amount_minor",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"AlreadyScheduledSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["already_scheduled"],
|
||||
"description": "The switch was already scheduled by an earlier request"
|
||||
},
|
||||
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
|
||||
"target_price_id": {
|
||||
"type": "string",
|
||||
"description": "Stripe price ID the subscription will be billed against after the switch"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed after the switch, in the currency minor unit"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"effective_at",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"current_amount_minor",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"IneligibleSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["ineligible"],
|
||||
"description": "The subscription cannot be moved to the current list price"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enumNames": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"description": "Why the switch was refused"
|
||||
}
|
||||
},
|
||||
"required": ["status", "reason"]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -33709,7 +34011,10 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"price_id": {"type": "string", "description": "The Stripe price ID for the subscription plan"},
|
||||
"country_code": {"type": "string", "description": "Two-letter country code used for regional pricing"},
|
||||
"country_code": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code used for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
},
|
||||
"client_geoip_country_code": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code observed by the client GeoIP store before checkout"
|
||||
@@ -33718,7 +34023,6 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout"
|
||||
},
|
||||
"pricing_mode": {"$ref": "#/components/schemas/PricingModeEnum"},
|
||||
"payment_method": {"$ref": "#/components/schemas/CheckoutPaymentMethodEnum"},
|
||||
"is_business": {
|
||||
"type": "boolean",
|
||||
@@ -36529,7 +36833,7 @@
|
||||
"description": "Controls who sees the full profile: all guild members, only small-guild members, or only friends"
|
||||
},
|
||||
"synced_preferences": {
|
||||
"anyOf": [{"type": "string", "maxLength": 349528}, {"type": "null"}],
|
||||
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}],
|
||||
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoApp} from '../types/HonoEnv';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {Validator} from '../Validator';
|
||||
|
||||
export function PremiumController(app: HonoApp) {
|
||||
@@ -32,7 +33,8 @@ export function PremiumController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const {country_code} = ctx.req.valid('query');
|
||||
const state = await ctx.get('stripeService').getPremiumState(userId, country_code);
|
||||
const geoip = await lookupGeoip(ctx.req.raw);
|
||||
const state = await ctx.get('stripeService').getPremiumState(userId, geoip.countryCode ?? country_code);
|
||||
return ctx.json(state);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -253,7 +253,7 @@ export class GlobalSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
MessageResponse,
|
||||
MessageSearchResultsResponse,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserID} from '../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../BrandedTypes';
|
||||
import {createChannelID} from '../BrandedTypes';
|
||||
import {mapChannelToResponse} from '../channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '../channel/IChannelRepository';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
messageResponseAccessForChannel,
|
||||
} from '../channel/services/message/MessageResponseDataService';
|
||||
import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../models/Channel';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
|
||||
const CHANNEL_LOOKUP_CONCURRENCY = 16;
|
||||
|
||||
export class MessageSearchResponseMapper {
|
||||
constructor(
|
||||
@@ -23,71 +21,46 @@ export class MessageSearchResponseMapper {
|
||||
) {}
|
||||
|
||||
async mapSearchResultToResponses(
|
||||
result: {
|
||||
hits: Array<{
|
||||
channelId: string;
|
||||
id: string;
|
||||
}>;
|
||||
total: number;
|
||||
},
|
||||
messages: Array<Message>,
|
||||
userId: UserID,
|
||||
requestCache: RequestCache,
|
||||
): Promise<{
|
||||
messages: Array<MessageSearchResultsResponse['messages'][number]>;
|
||||
channels: Array<MessageSearchResultsResponse['channels'][number]>;
|
||||
}> {
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const orderedChannelIds = new Set<string>();
|
||||
for (const entry of messageEntries) {
|
||||
orderedChannelIds.add(entry.channelId.toString());
|
||||
}
|
||||
const channels = await Promise.all(
|
||||
Array.from(orderedChannelIds).map((channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
),
|
||||
const orderedChannelIds = Array.from(new Set(messages.map((message) => message.channelId.toString())));
|
||||
const channels = await mapWithConcurrency(orderedChannelIds, CHANNEL_LOOKUP_CONCURRENCY, (channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
);
|
||||
const validChannels = channels.filter((channel): channel is Channel => channel !== null);
|
||||
const channelById = new Map(validChannels.map((channel) => [channel.id.toString(), channel] as const));
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const messageResponsesWithEntries = await Promise.all(
|
||||
messageEntries.map(async (entry) => {
|
||||
const channel = channelById.get(entry.channelId.toString());
|
||||
if (!channel) return null;
|
||||
const message = await responseDataService.getMessage({
|
||||
userId,
|
||||
channelId: entry.channelId,
|
||||
messageId: entry.messageId,
|
||||
access: messageResponseAccessForChannel(channel),
|
||||
});
|
||||
return message ? {message, channelId: entry.channelId.toString()} : null;
|
||||
}),
|
||||
const channelById = new Map(
|
||||
channels
|
||||
.filter((channel): channel is Channel => channel !== null)
|
||||
.map((channel) => [channel.id.toString(), channel] as const),
|
||||
);
|
||||
const validMessageResponseEntries = messageResponsesWithEntries.filter(
|
||||
(entry): entry is {message: MessageResponse; channelId: string} => entry !== null,
|
||||
);
|
||||
const messageResponses = validMessageResponseEntries.map((entry) => {
|
||||
const {referenced_message: _referencedMessage, ...searchMessage} = entry.message;
|
||||
return searchMessage;
|
||||
const renderableMessages = messages.filter((message) => channelById.has(message.channelId.toString()));
|
||||
const messageResponses = await createMessageResponseDataService().buildMessagesForChannels({
|
||||
userId,
|
||||
messages: renderableMessages,
|
||||
channelById,
|
||||
});
|
||||
const orderedResponseChannelIds = new Set(validMessageResponseEntries.map((entry) => entry.channelId));
|
||||
const orderedChannels = Array.from(orderedResponseChannelIds)
|
||||
const searchMessages = messageResponses.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const respondedChannelIds = new Set(searchMessages.map((message) => message.channel_id));
|
||||
const orderedChannels = orderedChannelIds
|
||||
.filter((channelId) => respondedChannelIds.has(channelId))
|
||||
.map((channelId) => channelById.get(channelId))
|
||||
.filter((channel): channel is Channel => channel !== undefined);
|
||||
const channelResponses = await Promise.all(
|
||||
orderedChannels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
const channelResponses = await mapWithConcurrency(orderedChannels, CHANNEL_LOOKUP_CONCURRENCY, (channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
messages: messageResponses,
|
||||
messages: searchMessages,
|
||||
channels: channelResponses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {type ChannelID, createChannelID, createMessageID, type MessageID} from '
|
||||
import type {IMessageRepository} from '../channel/repositories/IMessageRepository';
|
||||
import {Logger} from '../Logger';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
import {deleteMessageSearchDocuments} from './MessageSearchIndexCleanup';
|
||||
|
||||
@@ -13,11 +14,16 @@ const RECONCILE_BATCH_SIZE = 250;
|
||||
const MAX_RECONCILE_PAGES = 40;
|
||||
const MAX_STALE_DELETE_ABSOLUTE = 250;
|
||||
const MAX_STALE_DELETE_RATIO = 0.5;
|
||||
const HIT_LOOKUP_CONCURRENCY = 32;
|
||||
|
||||
interface MessageLookupRepository {
|
||||
readonly messages: Pick<IMessageRepository, 'getMessage'>;
|
||||
}
|
||||
|
||||
interface MessageSearchLookupResult extends SearchResult<SearchableMessage> {
|
||||
messages: Array<Message>;
|
||||
}
|
||||
|
||||
interface SearchExistingMessagesParams {
|
||||
searchService: IMessageSearchService;
|
||||
messageRepository: MessageLookupRepository;
|
||||
@@ -28,12 +34,21 @@ interface SearchExistingMessagesParams {
|
||||
cursor?: Array<string>;
|
||||
}
|
||||
|
||||
interface ValidatedHit {
|
||||
hit: SearchableMessage;
|
||||
message: Message | null;
|
||||
}
|
||||
|
||||
interface ValidatedHits {
|
||||
validHits: Array<SearchableMessage>;
|
||||
validHits: Array<ValidatedHit>;
|
||||
staleMessageIds: Array<MessageID>;
|
||||
lookupErrorCount: number;
|
||||
}
|
||||
|
||||
function resolvedMessages(validHits: Array<ValidatedHit>): Array<Message> {
|
||||
return validHits.map((entry) => entry.message).filter((message): message is Message => message !== null);
|
||||
}
|
||||
|
||||
export async function searchExistingMessages({
|
||||
searchService,
|
||||
messageRepository,
|
||||
@@ -42,7 +57,7 @@ export async function searchExistingMessages({
|
||||
hitsPerPage,
|
||||
page,
|
||||
cursor,
|
||||
}: SearchExistingMessagesParams): Promise<SearchResult<SearchableMessage>> {
|
||||
}: SearchExistingMessagesParams): Promise<MessageSearchLookupResult> {
|
||||
const result = await searchService.searchMessages(query, filters, {
|
||||
hitsPerPage,
|
||||
page: cursor?.length ? undefined : page,
|
||||
@@ -50,7 +65,7 @@ export async function searchExistingMessages({
|
||||
});
|
||||
const validated = await validateSearchHits(messageRepository, result.hits);
|
||||
if (validated.staleMessageIds.length === 0) {
|
||||
return result;
|
||||
return {...result, messages: resolvedMessages(validated.validHits)};
|
||||
}
|
||||
if (cursor?.length) {
|
||||
if (validated.lookupErrorCount === 0) {
|
||||
@@ -58,8 +73,9 @@ export async function searchExistingMessages({
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
hits: validated.validHits,
|
||||
hits: validated.validHits.map((entry) => entry.hit),
|
||||
total: Math.max(validated.validHits.length, result.total - validated.staleMessageIds.length),
|
||||
messages: resolvedMessages(validated.validHits),
|
||||
};
|
||||
}
|
||||
return reconcileOffsetSearchResult({
|
||||
@@ -79,9 +95,9 @@ async function reconcileOffsetSearchResult({
|
||||
filters,
|
||||
hitsPerPage,
|
||||
page,
|
||||
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<SearchResult<SearchableMessage>> {
|
||||
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<MessageSearchLookupResult> {
|
||||
const requestedOffset = (page - 1) * hitsPerPage;
|
||||
const pageHits: Array<SearchableMessage> = [];
|
||||
const pageHits: Array<ValidatedHit> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
let lookupErrorCount = 0;
|
||||
let examinedCount = 0;
|
||||
@@ -102,9 +118,9 @@ async function reconcileOffsetSearchResult({
|
||||
lookupErrorCount += validated.lookupErrorCount;
|
||||
examinedCount += result.hits.length;
|
||||
staleMessageIds.push(...validated.staleMessageIds);
|
||||
for (const hit of validated.validHits) {
|
||||
for (const entry of validated.validHits) {
|
||||
if (validTotal >= requestedOffset && pageHits.length < hitsPerPage) {
|
||||
pageHits.push(hit);
|
||||
pageHits.push(entry);
|
||||
}
|
||||
validTotal += 1;
|
||||
}
|
||||
@@ -121,8 +137,9 @@ async function reconcileOffsetSearchResult({
|
||||
await deleteStaleSearchDocuments(searchService, staleMessageIds, examinedCount);
|
||||
}
|
||||
return {
|
||||
hits: pageHits,
|
||||
hits: pageHits.map((entry) => entry.hit),
|
||||
total: Math.max(pageHits.length, corpusTotal - staleMessageIds.length),
|
||||
messages: resolvedMessages(pageHits),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,38 +147,36 @@ async function validateSearchHits(
|
||||
messageRepository: MessageLookupRepository,
|
||||
hits: Array<SearchableMessage>,
|
||||
): Promise<ValidatedHits> {
|
||||
const checked = await Promise.all(
|
||||
hits.map(async (hit) => {
|
||||
let channelId: ChannelID;
|
||||
let messageId: MessageID;
|
||||
try {
|
||||
channelId = createChannelID(BigInt(hit.channelId));
|
||||
messageId = createMessageID(BigInt(hit.id));
|
||||
} catch (_invalidId) {
|
||||
return {hit: null, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
let message: Message | null;
|
||||
try {
|
||||
message = await messageRepository.messages.getMessage(channelId, messageId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, messageId: hit.id, channelId: hit.channelId},
|
||||
'Search read repair lookup failed; keeping document',
|
||||
);
|
||||
return {hit, staleMessageId: null, lookupError: true};
|
||||
}
|
||||
if (message && message.channelId.toString() === hit.channelId) {
|
||||
return {hit, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
return {hit: null, staleMessageId: messageId, lookupError: false};
|
||||
}),
|
||||
);
|
||||
const validHits: Array<SearchableMessage> = [];
|
||||
const checked = await mapWithConcurrency(hits, HIT_LOOKUP_CONCURRENCY, async (hit) => {
|
||||
let channelId: ChannelID;
|
||||
let messageId: MessageID;
|
||||
try {
|
||||
channelId = createChannelID(BigInt(hit.channelId));
|
||||
messageId = createMessageID(BigInt(hit.id));
|
||||
} catch (_invalidId) {
|
||||
return {entry: null, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
let message: Message | null;
|
||||
try {
|
||||
message = await messageRepository.messages.getMessage(channelId, messageId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, messageId: hit.id, channelId: hit.channelId},
|
||||
'Search read repair lookup failed; keeping document',
|
||||
);
|
||||
return {entry: {hit, message: null}, staleMessageId: null, lookupError: true};
|
||||
}
|
||||
if (message && message.channelId.toString() === hit.channelId) {
|
||||
return {entry: {hit, message}, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
return {entry: null, staleMessageId: messageId, lookupError: false};
|
||||
});
|
||||
const validHits: Array<ValidatedHit> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
let lookupErrorCount = 0;
|
||||
for (const item of checked) {
|
||||
if (item.hit) {
|
||||
validHits.push(item.hit);
|
||||
if (item.entry) {
|
||||
validHits.push(item.entry);
|
||||
}
|
||||
if (item.staleMessageId) {
|
||||
staleMessageIds.push(item.staleMessageId);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import type {Currency} from '../utils/CurrencyUtils';
|
||||
|
||||
export enum ProductType {
|
||||
@@ -22,10 +23,69 @@ export interface ProductInfo {
|
||||
billingCycle?: RecurringBillingCycle;
|
||||
}
|
||||
|
||||
const LEGACY_SLOT_SHAPES: Record<string, Omit<ProductInfo, 'currency'> | undefined> = {
|
||||
monthly: {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
billingCycle: 'monthly',
|
||||
},
|
||||
yearly: {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
billingCycle: 'yearly',
|
||||
},
|
||||
gift_1_month: {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
},
|
||||
gift_1_year: {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
|
||||
usd: 'USD',
|
||||
eur: 'EUR',
|
||||
brl: 'BRL',
|
||||
dkk: 'DKK',
|
||||
inr: 'INR',
|
||||
nok: 'NOK',
|
||||
pln: 'PLN',
|
||||
sek: 'SEK',
|
||||
try: 'TRY',
|
||||
};
|
||||
|
||||
function parseLegacySlot(slot: string): ProductInfo | null {
|
||||
const separatorIndex = slot.lastIndexOf('_');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
const shape = LEGACY_SLOT_SHAPES[slot.slice(0, separatorIndex)];
|
||||
const currency = LEGACY_SLOT_CURRENCIES[slot.slice(separatorIndex + 1).toLowerCase()];
|
||||
if (!shape || !currency) {
|
||||
return null;
|
||||
}
|
||||
return {...shape, currency};
|
||||
}
|
||||
|
||||
export class ProductRegistry {
|
||||
private products = new Map<string, ProductInfo>();
|
||||
|
||||
constructor() {
|
||||
this.registerConfiguredProducts();
|
||||
this.registerLegacyProducts();
|
||||
}
|
||||
|
||||
private registerConfiguredProducts(): void {
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) return;
|
||||
this.registerProduct(prices.monthlyUsd, {
|
||||
@@ -52,6 +112,14 @@ export class ProductRegistry {
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyDkk, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyInr, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -60,6 +128,14 @@ export class ProductRegistry {
|
||||
currency: 'INR',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyNok, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyPln, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -68,6 +144,14 @@ export class ProductRegistry {
|
||||
currency: 'PLN',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlySek, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyTry, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -100,6 +184,14 @@ export class ProductRegistry {
|
||||
currency: 'BRL',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyDkk, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyInr, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -108,6 +200,14 @@ export class ProductRegistry {
|
||||
currency: 'INR',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyNok, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyPln, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -116,6 +216,14 @@ export class ProductRegistry {
|
||||
currency: 'PLN',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlySek, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyTry, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -152,6 +260,48 @@ export class ProductRegistry {
|
||||
isGift: true,
|
||||
currency: 'INR',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthDkk, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearDkk, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthNok, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearNok, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthSek, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearSek, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthPln, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -210,6 +360,32 @@ export class ProductRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
private registerLegacyProducts(): void {
|
||||
const legacyPrices = Config.stripe.legacyPrices;
|
||||
if (!legacyPrices) return;
|
||||
if (typeof legacyPrices !== 'object' || Array.isArray(legacyPrices)) {
|
||||
Logger.warn({}, 'Ignoring legacy Stripe price configuration that is not an object of slot names to price ids');
|
||||
return;
|
||||
}
|
||||
for (const [slot, priceIds] of Object.entries(legacyPrices)) {
|
||||
if (!Array.isArray(priceIds)) {
|
||||
Logger.warn({slot}, 'Ignoring legacy Stripe price slot that is not a list of price IDs');
|
||||
continue;
|
||||
}
|
||||
const info = parseLegacySlot(slot);
|
||||
if (!info) {
|
||||
Logger.warn({slot}, 'Ignoring legacy Stripe price slot with an unrecognised name or currency');
|
||||
continue;
|
||||
}
|
||||
for (const priceId of priceIds) {
|
||||
if (!priceId || this.products.has(priceId)) {
|
||||
continue;
|
||||
}
|
||||
this.products.set(priceId, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerProduct(priceId: string | undefined, info: ProductInfo): void {
|
||||
if (priceId) {
|
||||
this.products.set(priceId, info);
|
||||
@@ -236,12 +412,21 @@ export class ProductRegistry {
|
||||
if (normalizedCurrency === 'brl') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'try') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
|
||||
}
|
||||
@@ -266,6 +451,15 @@ export class ProductRegistry {
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PriceIdsResponse,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
SwitchToListPriceResponse,
|
||||
UrlResponse,
|
||||
WebhookReceivedResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
@@ -91,7 +92,6 @@ export function StripeController(app: HonoApp) {
|
||||
country_code,
|
||||
client_geoip_country_code,
|
||||
eu_withdrawal_waiver_accepted,
|
||||
pricing_mode,
|
||||
payment_method,
|
||||
is_business,
|
||||
} = ctx.req.valid('json');
|
||||
@@ -104,7 +104,6 @@ export function StripeController(app: HonoApp) {
|
||||
clientGeoipCountryCode: client_geoip_country_code,
|
||||
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
|
||||
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
|
||||
pricingMode: pricing_mode,
|
||||
paymentMethod: payment_method,
|
||||
isBusiness: is_business,
|
||||
});
|
||||
@@ -128,14 +127,8 @@ export function StripeController(app: HonoApp) {
|
||||
}),
|
||||
Validator('json', CreateCheckoutSessionRequest),
|
||||
async (ctx) => {
|
||||
const {
|
||||
price_id,
|
||||
country_code,
|
||||
client_geoip_country_code,
|
||||
eu_withdrawal_waiver_accepted,
|
||||
pricing_mode,
|
||||
is_business,
|
||||
} = ctx.req.valid('json');
|
||||
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
|
||||
ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
const checkoutUrl = await ctx.get('stripeService').createLocalizedCardPreapprovalSession({
|
||||
userId,
|
||||
@@ -144,7 +137,6 @@ export function StripeController(app: HonoApp) {
|
||||
clientGeoipCountryCode: client_geoip_country_code,
|
||||
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
|
||||
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
|
||||
pricingMode: pricing_mode,
|
||||
isBusiness: is_business,
|
||||
});
|
||||
return ctx.json({url: checkoutUrl});
|
||||
@@ -186,14 +178,8 @@ export function StripeController(app: HonoApp) {
|
||||
}),
|
||||
Validator('json', CreateCheckoutSessionRequest),
|
||||
async (ctx) => {
|
||||
const {
|
||||
price_id,
|
||||
country_code,
|
||||
client_geoip_country_code,
|
||||
eu_withdrawal_waiver_accepted,
|
||||
pricing_mode,
|
||||
is_business,
|
||||
} = ctx.req.valid('json');
|
||||
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
|
||||
ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
const checkoutUrl = await ctx.get('stripeService').createCheckoutSession({
|
||||
userId,
|
||||
@@ -203,7 +189,6 @@ export function StripeController(app: HonoApp) {
|
||||
clientGeoipCountryCode: client_geoip_country_code,
|
||||
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
|
||||
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
|
||||
pricingMode: pricing_mode,
|
||||
isBusiness: is_business,
|
||||
});
|
||||
return ctx.json({url: checkoutUrl});
|
||||
@@ -300,8 +285,9 @@ export function StripeController(app: HonoApp) {
|
||||
tags: 'Premium',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {country_code, pricing_mode} = ctx.req.valid('query');
|
||||
const priceIds = await ctx.get('stripeService').getPriceIds(country_code, pricing_mode);
|
||||
const {country_code} = ctx.req.valid('query');
|
||||
const geoipCountryCode = await getPurchaseGeoipCountryCode(ctx.req.raw);
|
||||
const priceIds = await ctx.get('stripeService').getPriceIds(geoipCountryCode ?? country_code);
|
||||
return ctx.json(priceIds);
|
||||
},
|
||||
);
|
||||
@@ -494,6 +480,27 @@ export function StripeController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/premium/switch-to-list-price',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'switch_subscription_to_list_price',
|
||||
summary: 'Switch subscription to the current list price',
|
||||
description:
|
||||
"Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
|
||||
responseSchema: SwitchToListPriceResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Premium',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const result = await ctx.get('stripeService').switchSubscriptionToCurrentListPrice(userId);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/premium/cancel-pending-subscription-change',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
|
||||
@@ -4,9 +4,9 @@ import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/Pr
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
PremiumStateResponse,
|
||||
PricingMode,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import Stripe from 'stripe';
|
||||
@@ -143,7 +143,6 @@ export class StripeService {
|
||||
| 'euWithdrawalWaiverAccepted'
|
||||
| 'isBusiness'
|
||||
| 'priceId'
|
||||
| 'pricingMode'
|
||||
| 'purchaseGeoipCountryCode'
|
||||
| 'userId'
|
||||
>,
|
||||
@@ -159,10 +158,7 @@ export class StripeService {
|
||||
return this.checkoutService.createCustomerPortalSession(userId);
|
||||
}
|
||||
|
||||
async getPriceIds(
|
||||
countryCode?: string,
|
||||
pricingMode: PricingMode = 'localized',
|
||||
): Promise<{
|
||||
async getPriceIds(countryCode?: string): Promise<{
|
||||
monthly: string | null;
|
||||
yearly: string | null;
|
||||
gift_1_month: string | null;
|
||||
@@ -174,7 +170,7 @@ export class StripeService {
|
||||
gift_1_month_amount_minor: number | null;
|
||||
gift_1_year_amount_minor: number | null;
|
||||
}> {
|
||||
return this.checkoutService.getPriceIds(countryCode, pricingMode);
|
||||
return this.checkoutService.getPriceIds(countryCode);
|
||||
}
|
||||
|
||||
async getCurrentSubscriptionPrice(userId: UserID): Promise<CurrentSubscriptionPriceResponse> {
|
||||
@@ -201,6 +197,10 @@ export class StripeService {
|
||||
return this.subscriptionService.changeBillingCycle(userId, billingCycle, effectiveAt);
|
||||
}
|
||||
|
||||
async switchSubscriptionToCurrentListPrice(userId: UserID): Promise<SwitchToListPriceResponse> {
|
||||
return this.subscriptionService.switchToCurrentListPrice(userId);
|
||||
}
|
||||
|
||||
async cancelPendingSubscriptionChange(userId: UserID): Promise<void> {
|
||||
return this.subscriptionService.cancelPendingSubscriptionChange(userId);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstant
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
ListPriceSwitchState,
|
||||
PendingSubscriptionChangeKind,
|
||||
PendingSubscriptionChangeResponse,
|
||||
PremiumBillingInvoiceResponse,
|
||||
PremiumBillingPaymentMethodResponse,
|
||||
@@ -11,7 +13,6 @@ import type {
|
||||
PremiumPricingState,
|
||||
PremiumStateResponse,
|
||||
PriceIdsResponse,
|
||||
PricingMode,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundIneligibilityReason,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
@@ -31,13 +32,7 @@ import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {checkHasActivePaidPremium} from '../../user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '../../user/UserMappers';
|
||||
import {
|
||||
type Currency,
|
||||
getBaseCurrencyPreferences,
|
||||
getBaseGiftCurrencyPreferences,
|
||||
getCurrencyPreferences,
|
||||
getGiftCurrencyPreferences,
|
||||
} from '../../utils/CurrencyUtils';
|
||||
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
|
||||
import type {RecurringBillingCycle} from '../ProductRegistry';
|
||||
import {ProductRegistry} from '../ProductRegistry';
|
||||
import {getPrimarySubscriptionItem} from '../StripeSubscriptionPeriod';
|
||||
@@ -109,8 +104,11 @@ function normalizeCurrency(value: string | null | undefined): Currency | null {
|
||||
currency === 'USD' ||
|
||||
currency === 'EUR' ||
|
||||
currency === 'BRL' ||
|
||||
currency === 'DKK' ||
|
||||
currency === 'INR' ||
|
||||
currency === 'NOK' ||
|
||||
currency === 'PLN' ||
|
||||
currency === 'SEK' ||
|
||||
currency === 'TRY'
|
||||
) {
|
||||
return currency;
|
||||
@@ -264,6 +262,9 @@ export class PremiumStateService {
|
||||
this.resolvePricing(countryCode),
|
||||
]);
|
||||
const refundEligibilityState = await this.resolveRefundEligibility(user, invoices.allRows);
|
||||
const listPriceSwitch = this.resolveListPriceSwitch(subscription, subscriptionPrice, pendingSubscriptionChange);
|
||||
const pendingBillingCycleChange =
|
||||
pendingSubscriptionChange?.change_kind === 'billing_cycle' ? pendingSubscriptionChange : null;
|
||||
const activePaidPremium = checkHasActivePaidPremium(user);
|
||||
const isEffectivePremium = user.isPremium();
|
||||
const actualPremiumEndAt = user.effectivePremiumUntil;
|
||||
@@ -298,7 +299,8 @@ export class PremiumStateService {
|
||||
billing: {
|
||||
stripe_customer_id: customerIds[0] ?? user.stripeCustomerId ?? null,
|
||||
current_subscription_price: subscriptionPrice,
|
||||
pending_subscription_change: pendingSubscriptionChange,
|
||||
pending_subscription_change: pendingBillingCycleChange,
|
||||
list_price_switch: listPriceSwitch,
|
||||
subscription: subscription ? await this.mapSubscription(subscription) : null,
|
||||
invoices: invoices.rows.map(mapInvoice),
|
||||
invoices_has_more: invoices.hasMore,
|
||||
@@ -576,8 +578,16 @@ export class PremiumStateService {
|
||||
const targetItem = futurePhase.items[0] ?? null;
|
||||
const targetPriceDetails = await this.resolveStripePriceDetails(targetItem?.price ?? null);
|
||||
const metadataTargetBillingCycle = normalizeBillingCycle(schedule.metadata?.pending_billing_cycle);
|
||||
const targetBillingCycle = targetPriceDetails.billingCycle ?? metadataTargetBillingCycle;
|
||||
if (!targetBillingCycle || targetBillingCycle === currentBillingCycle) {
|
||||
const targetBillingCycle = targetPriceDetails.billingCycle ?? metadataTargetBillingCycle ?? currentBillingCycle;
|
||||
if (!targetBillingCycle) {
|
||||
return null;
|
||||
}
|
||||
const changeKind: PendingSubscriptionChangeKind =
|
||||
targetBillingCycle === currentBillingCycle ? 'price' : 'billing_cycle';
|
||||
if (
|
||||
changeKind === 'price' &&
|
||||
(targetPriceDetails.priceId == null || targetPriceDetails.priceId === currentPriceDetails.priceId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const quantity = targetItem?.quantity ?? currentItem?.quantity ?? 1;
|
||||
@@ -598,11 +608,13 @@ export class PremiumStateService {
|
||||
const creditAmountMinor = firstInvoiceAdjustmentTotal < 0 ? -firstInvoiceAdjustmentTotal : null;
|
||||
return {
|
||||
schedule_id: schedule.id,
|
||||
change_kind: changeKind,
|
||||
current_billing_cycle: currentBillingCycle,
|
||||
target_billing_cycle: targetBillingCycle,
|
||||
effective_at: new Date(futurePhase.start_date * 1000).toISOString(),
|
||||
current_price_id: currentPriceDetails.priceId,
|
||||
target_price_id: targetPriceDetails.priceId,
|
||||
target_amount_minor: targetPriceDetails.amountMinor,
|
||||
currency: targetPriceDetails.currency,
|
||||
initial_amount_minor: initialAmountMinor,
|
||||
recurring_amount_minor: recurringAmountMinor,
|
||||
@@ -610,6 +622,62 @@ export class PremiumStateService {
|
||||
};
|
||||
}
|
||||
|
||||
private resolveListPriceSwitch(
|
||||
subscription: BillingSubscriptionRow | null,
|
||||
subscriptionPrice: CurrentSubscriptionPriceResponse,
|
||||
pendingChange: PendingSubscriptionChangeResponse,
|
||||
): ListPriceSwitchState {
|
||||
const base = {
|
||||
pending: false,
|
||||
current_price_id: subscriptionPrice?.price_id ?? null,
|
||||
current_amount_minor: subscriptionPrice?.amount_minor ?? null,
|
||||
list_price_id: subscriptionPrice?.list_price_id ?? null,
|
||||
list_amount_minor: subscriptionPrice?.list_amount_minor ?? null,
|
||||
currency: subscriptionPrice?.currency ?? null,
|
||||
billing_cycle: subscriptionPrice?.billing_cycle ?? null,
|
||||
effective_at: toIso(subscription?.current_period_end),
|
||||
};
|
||||
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
|
||||
return {...base, available: false, reason: 'feature_unavailable'};
|
||||
}
|
||||
if (!subscription) {
|
||||
return {...base, available: false, reason: 'no_active_subscription'};
|
||||
}
|
||||
if (!subscriptionPrice) {
|
||||
return {...base, available: false, reason: 'unsupported_subscription'};
|
||||
}
|
||||
if (!subscription.status || !ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
||||
return {...base, available: false, reason: 'subscription_not_chargeable'};
|
||||
}
|
||||
if (subscription.cancel_at != null || subscription.cancel_at_period_end === true) {
|
||||
return {...base, available: false, reason: 'subscription_cancelling'};
|
||||
}
|
||||
if (pendingChange) {
|
||||
const targetsListPrice =
|
||||
subscriptionPrice.list_price_id != null && pendingChange.target_price_id === subscriptionPrice.list_price_id;
|
||||
return {
|
||||
...base,
|
||||
available: false,
|
||||
reason: targetsListPrice ? null : 'conflicting_pending_change',
|
||||
pending: targetsListPrice,
|
||||
effective_at: pendingChange.effective_at,
|
||||
};
|
||||
}
|
||||
if (subscriptionPrice.list_price_id == null || subscriptionPrice.list_amount_minor == null) {
|
||||
return {...base, available: false, reason: 'no_list_price'};
|
||||
}
|
||||
if (subscriptionPrice.list_price_id === subscriptionPrice.price_id) {
|
||||
return {...base, available: false, reason: 'already_on_list_price'};
|
||||
}
|
||||
if (subscriptionPrice.list_amount_minor >= subscriptionPrice.amount_minor) {
|
||||
return {...base, available: false, reason: 'not_a_price_decrease'};
|
||||
}
|
||||
if (base.effective_at == null) {
|
||||
return {...base, available: false, reason: 'missing_period_end'};
|
||||
}
|
||||
return {...base, available: true, reason: null};
|
||||
}
|
||||
|
||||
private async resolveStripePriceDetails(
|
||||
priceRef: Stripe.Price | Stripe.DeletedPrice | string | null | undefined,
|
||||
): Promise<StripePriceDetails> {
|
||||
@@ -671,22 +739,15 @@ export class PremiumStateService {
|
||||
|
||||
private async resolvePricing(countryCode: string | null | undefined): Promise<PremiumPricingState> {
|
||||
const normalizedCountryCode = normalizeCountryCode(countryCode);
|
||||
const [localized, base] = await Promise.all([
|
||||
this.resolvePriceIds(normalizedCountryCode, 'localized'),
|
||||
this.resolvePriceIds(normalizedCountryCode, 'base'),
|
||||
]);
|
||||
const localized = await this.resolvePriceIds(normalizedCountryCode);
|
||||
return {
|
||||
country_code: normalizedCountryCode,
|
||||
localized,
|
||||
base,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolvePriceIds(
|
||||
countryCode: string | null,
|
||||
pricingMode: PricingMode,
|
||||
): Promise<PriceIdsResponse | null> {
|
||||
const resolved = this.resolveConfiguredPriceIds(countryCode, pricingMode);
|
||||
private async resolvePriceIds(countryCode: string | null): Promise<PriceIdsResponse | null> {
|
||||
const resolved = this.resolveConfiguredPriceIds(countryCode);
|
||||
if (!resolved) return null;
|
||||
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
|
||||
resolved.monthly ? this.billingRepository.prices.findById(resolved.monthly) : null,
|
||||
@@ -703,11 +764,9 @@ export class PremiumStateService {
|
||||
};
|
||||
}
|
||||
|
||||
private resolveConfiguredPriceIds(countryCode: string | null, pricingMode: PricingMode): ResolvedPriceIds | null {
|
||||
const recurringCurrencyPreferences =
|
||||
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
|
||||
const giftCurrencyPreferences =
|
||||
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
|
||||
private resolveConfiguredPriceIds(countryCode: string | null): ResolvedPriceIds | null {
|
||||
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
|
||||
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
|
||||
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
|
||||
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
|
||||
if (!recurringPrices || !giftPrices) return null;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
|
||||
import {UnclaimedAccountCannotMakePurchasesError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotMakePurchasesError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {CheckoutPaymentMethod} from '@fluxer/schema/src/domains/premium/GiftCodeSchemas';
|
||||
import type {PricingMode} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
import type Stripe from 'stripe';
|
||||
@@ -25,13 +24,7 @@ import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {
|
||||
type Currency,
|
||||
getBaseCurrencyPreferences,
|
||||
getBaseGiftCurrencyPreferences,
|
||||
getCurrencyPreferences,
|
||||
getGiftCurrencyPreferences,
|
||||
} from '../../utils/CurrencyUtils';
|
||||
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
|
||||
import type {ProductInfo, ProductRegistry} from '../ProductRegistry';
|
||||
import {
|
||||
canProvisionPremiumFromSubscriptionStatus,
|
||||
@@ -86,7 +79,6 @@ export interface CreateCheckoutSessionParams {
|
||||
clientGeoipCountryCode?: string | null;
|
||||
purchaseGeoipCountryCode?: string | null;
|
||||
euWithdrawalWaiverAccepted?: boolean;
|
||||
pricingMode?: PricingMode;
|
||||
paymentMethod?: CheckoutPaymentMethod;
|
||||
isBusiness?: boolean;
|
||||
}
|
||||
@@ -182,7 +174,6 @@ export class StripeCheckoutService {
|
||||
clientGeoipCountryCode,
|
||||
purchaseGeoipCountryCode,
|
||||
euWithdrawalWaiverAccepted,
|
||||
pricingMode = 'localized',
|
||||
paymentMethod = 'card',
|
||||
isBusiness = false,
|
||||
}: CreateCheckoutSessionParams): Promise<string> {
|
||||
@@ -191,7 +182,7 @@ export class StripeCheckoutService {
|
||||
priceId,
|
||||
isGift,
|
||||
countryCode,
|
||||
pricingMode,
|
||||
purchaseGeoipCountryCode,
|
||||
});
|
||||
const isRecurringSubscription = this.productRegistry.isRecurringSubscription(productInfo);
|
||||
const checkoutMode: CheckoutSessionMode = isRecurringSubscription ? 'subscription' : 'payment';
|
||||
@@ -221,7 +212,6 @@ export class StripeCheckoutService {
|
||||
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
|
||||
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
|
||||
eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION,
|
||||
pricing_mode: pricingMode,
|
||||
payment_method: paymentMethod,
|
||||
};
|
||||
const checkoutParams: CheckoutSessionCreateParams = {
|
||||
@@ -301,7 +291,6 @@ export class StripeCheckoutService {
|
||||
clientGeoipCountryCode,
|
||||
purchaseGeoipCountryCode,
|
||||
euWithdrawalWaiverAccepted,
|
||||
pricingMode = 'localized',
|
||||
isBusiness = false,
|
||||
}: Pick<
|
||||
CreateCheckoutSessionParams,
|
||||
@@ -310,31 +299,22 @@ export class StripeCheckoutService {
|
||||
| 'euWithdrawalWaiverAccepted'
|
||||
| 'isBusiness'
|
||||
| 'priceId'
|
||||
| 'pricingMode'
|
||||
| 'purchaseGeoipCountryCode'
|
||||
| 'userId'
|
||||
>): Promise<string> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
const normalizedCountryCode = countryCode?.trim().toUpperCase();
|
||||
const normalizedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
|
||||
if (!normalizedCountryCode) {
|
||||
Logger.error({priceId, userId}, 'Localized card preapproval requires a country code');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
if (pricingMode !== 'localized') {
|
||||
Logger.error(
|
||||
{priceId, userId, pricingMode},
|
||||
'Localized card preapproval requested for non-localized pricing mode',
|
||||
);
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
const {customerId, productInfo} = await this.prepareCheckoutContext({
|
||||
userId,
|
||||
priceId,
|
||||
isGift: false,
|
||||
countryCode: normalizedCountryCode,
|
||||
pricingMode,
|
||||
});
|
||||
if (!this.requiresLocalizedCardPreapproval(productInfo)) {
|
||||
Logger.error(
|
||||
@@ -364,7 +344,6 @@ export class StripeCheckoutService {
|
||||
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
|
||||
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
|
||||
...(waiverContext.required ? {eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION} : {}),
|
||||
pricing_mode: pricingMode,
|
||||
setup_type: 'localized_card_preapproval',
|
||||
localized_card_preapproval_currency: productInfo.currency,
|
||||
localized_card_preapproval_token: token,
|
||||
@@ -560,7 +539,7 @@ export class StripeCheckoutService {
|
||||
priceId,
|
||||
isGift = false,
|
||||
countryCode,
|
||||
pricingMode = 'localized',
|
||||
purchaseGeoipCountryCode,
|
||||
}: CreateCheckoutSessionParams): Promise<{
|
||||
customerId: string;
|
||||
productInfo: ProductInfo;
|
||||
@@ -581,12 +560,13 @@ export class StripeCheckoutService {
|
||||
);
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !countryCode) {
|
||||
const enforcedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
|
||||
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !enforcedCountryCode) {
|
||||
Logger.error({priceId, userId, currency: productInfo.currency}, 'Localized price requested without country code');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
if (countryCode) {
|
||||
this.assertPriceMatchesCountryCatalog({countryCode, priceId, isGift, pricingMode, userId});
|
||||
if (enforcedCountryCode) {
|
||||
this.assertPriceMatchesCountryCatalog({countryCode: enforcedCountryCode, priceId, isGift, userId});
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
@@ -703,20 +683,25 @@ export class StripeCheckoutService {
|
||||
return normalized && /^[A-Z]{2}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
private resolveEnforcedPricingCountryCode({
|
||||
countryCode,
|
||||
purchaseGeoipCountryCode,
|
||||
}: Pick<CreateCheckoutSessionParams, 'countryCode' | 'purchaseGeoipCountryCode'>): string | null {
|
||||
return this.normalizeCountryCode(purchaseGeoipCountryCode) ?? this.normalizeCountryCode(countryCode);
|
||||
}
|
||||
|
||||
private assertPriceMatchesCountryCatalog({
|
||||
countryCode,
|
||||
priceId,
|
||||
isGift,
|
||||
pricingMode = 'localized',
|
||||
userId,
|
||||
}: {
|
||||
countryCode: string;
|
||||
priceId: string;
|
||||
isGift: boolean;
|
||||
pricingMode?: PricingMode;
|
||||
userId: UserID;
|
||||
}): void {
|
||||
const localizedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
|
||||
const localizedPrices = this.resolveConfiguredPriceIds(countryCode);
|
||||
const allowedPriceIds = new Set(
|
||||
(isGift
|
||||
? [localizedPrices.gift_1_month, localizedPrices.gift_1_year]
|
||||
@@ -731,7 +716,6 @@ export class StripeCheckoutService {
|
||||
userId,
|
||||
currency: isGift ? localizedPrices.gift_currency : localizedPrices.currency,
|
||||
isGift,
|
||||
pricingMode,
|
||||
},
|
||||
'Checkout price mismatch for country',
|
||||
);
|
||||
@@ -1022,8 +1006,8 @@ export class StripeCheckoutService {
|
||||
}
|
||||
}
|
||||
|
||||
async getPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): Promise<PriceIdsResponse> {
|
||||
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
|
||||
async getPriceIds(countryCode?: string): Promise<PriceIdsResponse> {
|
||||
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode);
|
||||
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
|
||||
this.getStripePriceSummary(resolvedPrices.monthly),
|
||||
this.getStripePriceSummary(resolvedPrices.yearly),
|
||||
@@ -1057,11 +1041,9 @@ export class StripeCheckoutService {
|
||||
private static readonly PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
|
||||
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
|
||||
|
||||
private resolveConfiguredPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): ResolvedPriceIds {
|
||||
const recurringCurrencyPreferences =
|
||||
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
|
||||
const giftCurrencyPreferences =
|
||||
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
|
||||
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
|
||||
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
|
||||
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
|
||||
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
|
||||
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
|
||||
return {
|
||||
@@ -1126,6 +1108,15 @@ export class StripeCheckoutService {
|
||||
yearly: prices.yearlyBrl,
|
||||
currency,
|
||||
};
|
||||
case 'DKK':
|
||||
if (!prices.monthlyDkk || !prices.yearlyDkk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyDkk,
|
||||
yearly: prices.yearlyDkk,
|
||||
currency,
|
||||
};
|
||||
case 'INR':
|
||||
if (!prices.monthlyInr || !prices.yearlyInr) {
|
||||
return null;
|
||||
@@ -1135,6 +1126,15 @@ export class StripeCheckoutService {
|
||||
yearly: prices.yearlyInr,
|
||||
currency,
|
||||
};
|
||||
case 'NOK':
|
||||
if (!prices.monthlyNok || !prices.yearlyNok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyNok,
|
||||
yearly: prices.yearlyNok,
|
||||
currency,
|
||||
};
|
||||
case 'PLN':
|
||||
if (!prices.monthlyPln || !prices.yearlyPln) {
|
||||
return null;
|
||||
@@ -1144,6 +1144,15 @@ export class StripeCheckoutService {
|
||||
yearly: prices.yearlyPln,
|
||||
currency,
|
||||
};
|
||||
case 'SEK':
|
||||
if (!prices.monthlySek || !prices.yearlySek) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlySek,
|
||||
yearly: prices.yearlySek,
|
||||
currency,
|
||||
};
|
||||
case 'TRY':
|
||||
if (!prices.monthlyTry || !prices.yearlyTry) {
|
||||
return null;
|
||||
@@ -1193,6 +1202,33 @@ export class StripeCheckoutService {
|
||||
gift_1_year: prices.gift1YearInr,
|
||||
gift_currency: 'INR',
|
||||
};
|
||||
case 'DKK':
|
||||
if (!prices.gift1MonthDkk || !prices.gift1YearDkk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthDkk,
|
||||
gift_1_year: prices.gift1YearDkk,
|
||||
gift_currency: 'DKK',
|
||||
};
|
||||
case 'NOK':
|
||||
if (!prices.gift1MonthNok || !prices.gift1YearNok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthNok,
|
||||
gift_1_year: prices.gift1YearNok,
|
||||
gift_currency: 'NOK',
|
||||
};
|
||||
case 'SEK':
|
||||
if (!prices.gift1MonthSek || !prices.gift1YearSek) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthSek,
|
||||
gift_1_year: prices.gift1YearSek,
|
||||
gift_currency: 'SEK',
|
||||
};
|
||||
case 'PLN':
|
||||
if (!prices.gift1MonthPln || !prices.gift1YearPln) {
|
||||
return null;
|
||||
|
||||
@@ -5,11 +5,13 @@ import {PremiumFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import type {IDonationRepository} from '../../donation/IDonationRepository';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
|
||||
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import type {GiftCode} from '../../models/GiftCode';
|
||||
import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
@@ -19,6 +21,8 @@ import {extractId} from '../StripeUtils';
|
||||
import type {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
||||
import type {StripePaymentFraudService} from './StripePaymentFraudService';
|
||||
|
||||
export const REFUND_ALLOWANCE_CLAIM_PREFIX = 'refund-allowance';
|
||||
|
||||
export class StripeDisputeWebhookHandler {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
@@ -105,7 +109,7 @@ export class StripeDisputeWebhookHandler {
|
||||
}
|
||||
Logger.debug(
|
||||
{userId: payment.userId},
|
||||
'User unsuspended after chargeback withdrawal - 30 day purchase block applied',
|
||||
'User unsuspended after chargeback withdrawal - 30 day self-serve refund cooldown applied',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,25 +171,94 @@ export class StripeDisputeWebhookHandler {
|
||||
);
|
||||
user = foundUser;
|
||||
}
|
||||
if (!user.firstRefundAt) {
|
||||
const updatedUser = await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
await this.dispatchUser(updatedUser);
|
||||
const claimKeys = await this.resolveRefundAllowanceClaimKeys(charge);
|
||||
if (claimKeys.length === 0) {
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId},
|
||||
'First refund recorded - 30 day purchase block applied',
|
||||
);
|
||||
} else {
|
||||
const updatedUser = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
{premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.dispatchUser(updatedUser);
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId},
|
||||
'Second refund recorded - permanent purchase block applied',
|
||||
'Refund was issued by Fluxer - not counted against the user refund allowance',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const claimedKeys: Array<string> = [];
|
||||
let alreadyCounted = false;
|
||||
for (const claimKey of claimKeys) {
|
||||
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
|
||||
if (claim === 'claimed') {
|
||||
claimedKeys.push(claimKey);
|
||||
} else {
|
||||
alreadyCounted = true;
|
||||
}
|
||||
}
|
||||
if (alreadyCounted) {
|
||||
for (const claimKey of claimedKeys) {
|
||||
await getBillingRepository().webhookEvents.markProcessed(claimKey);
|
||||
}
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId, claimKeys},
|
||||
'Refund already counted against the user refund allowance',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isFirstRefund = !user.firstRefundAt;
|
||||
const patch: Partial<UserRow> = isFirstRefund
|
||||
? {first_refund_at: new Date()}
|
||||
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
|
||||
let updatedUser: User;
|
||||
try {
|
||||
updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
|
||||
} catch (error) {
|
||||
for (const claimKey of claimedKeys) {
|
||||
await getBillingRepository().webhookEvents.releaseClaim(claimKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const claimKey of claimedKeys) {
|
||||
await getBillingRepository().webhookEvents.markProcessed(claimKey);
|
||||
}
|
||||
await this.dispatchUser(updatedUser);
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId},
|
||||
isFirstRefund
|
||||
? 'First refund recorded - 30 day self-serve refund cooldown applied'
|
||||
: 'Second refund recorded - permanent purchase block applied',
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefundAllowanceClaimKeys(charge: Stripe.Charge): Promise<Array<string>> {
|
||||
const chargeClaimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${charge.id}`;
|
||||
const inlined = charge.refunds?.data ?? [];
|
||||
const refunds = (
|
||||
inlined.length > 0
|
||||
? inlined.map((refund) => ({
|
||||
id: refund.id,
|
||||
createdAtMs: refund.created * 1000,
|
||||
status: refund.status,
|
||||
rejectionReason: refund.metadata?.rejection_reason ?? null,
|
||||
}))
|
||||
: (await getBillingRepository().refunds.listByCharge(charge.id)).map((row) => ({
|
||||
id: row.provider_id,
|
||||
createdAtMs: row.stripe_created_at?.getTime() ?? 0,
|
||||
status: row.status,
|
||||
rejectionReason: row.metadata?.get('rejection_reason') ?? null,
|
||||
}))
|
||||
).filter((refund) => refund.status !== 'failed' && refund.status !== 'canceled');
|
||||
if (refunds.length === 0) {
|
||||
Logger.warn(
|
||||
{chargeId: charge.id},
|
||||
'No refund records found for refunded charge; counting the charge once against the user refund allowance',
|
||||
);
|
||||
return [chargeClaimKey];
|
||||
}
|
||||
const customerRefunds = refunds
|
||||
.filter((refund) => refund.rejectionReason === null)
|
||||
.sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id));
|
||||
const earliest = customerRefunds[0];
|
||||
const latest = customerRefunds[customerRefunds.length - 1];
|
||||
if (!earliest || !latest) {
|
||||
return [];
|
||||
}
|
||||
const latestClaimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${latest.id}`;
|
||||
return latest.id === earliest.id ? [chargeClaimKey, latestClaimKey] : [latestClaimKey];
|
||||
}
|
||||
|
||||
private async handleGiftChargeback(giftCode: GiftCode, dispute: Stripe.Dispute): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {FeatureNotAvailableSelfHostedError} from '@fluxer/errors/src/domains/core/FeatureNotAvailableSelfHostedError';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import {StripeNoPurchaseHistoryError} from '@fluxer/errors/src/domains/payment/StripeNoPurchaseHistoryError';
|
||||
@@ -15,11 +16,13 @@ import type {
|
||||
import type Stripe from 'stripe';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {extractId} from '../StripeUtils';
|
||||
import {REFUND_ALLOWANCE_CLAIM_PREFIX} from './StripeDisputeWebhookHandler';
|
||||
import type {StripeSubscriptionService} from './StripeSubscriptionService';
|
||||
|
||||
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -260,16 +263,35 @@ export class StripeRefundService {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
Logger.error(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
|
||||
'Self-serve refund confirmed but subscription cancellation failed; the subscription is still active and will keep billing until it is cancelled manually',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`;
|
||||
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
|
||||
if (claim !== 'claimed') {
|
||||
Logger.debug(
|
||||
{userId: user.id.toString(), refundId: refund.id, claim},
|
||||
'Self-serve refund already counted against the user refund allowance',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isFirstRefund = !user.firstRefundAt;
|
||||
const patch: Partial<UserRow> = isFirstRefund
|
||||
? {first_refund_at: new Date()}
|
||||
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
|
||||
try {
|
||||
await this.userRepository.patchUpsert(user.id, patch, user.toRow());
|
||||
} catch (error) {
|
||||
await getBillingRepository().webhookEvents.releaseClaim(claimKey);
|
||||
throw error;
|
||||
}
|
||||
await getBillingRepository().webhookEvents.markProcessed(claimKey);
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
|
||||
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null, isFirstRefund},
|
||||
'Self-serve refund confirmed succeeded; refund allowance and cancellation finalized',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
|
||||
import {StripeSubscriptionAlreadyCancelingError} from '@fluxer/errors/src/domains/payment/StripeSubscriptionAlreadyCancelingError';
|
||||
import {StripeSubscriptionNotCancelingError} from '@fluxer/errors/src/domains/payment/StripeSubscriptionNotCancelingError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {CurrentSubscriptionPriceResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
import type Stripe from 'stripe';
|
||||
@@ -25,7 +28,7 @@ import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '../../user/UserMappers';
|
||||
import type {Currency} from '../../utils/CurrencyUtils';
|
||||
import type {RecurringBillingCycle} from '../ProductRegistry';
|
||||
import type {ProductInfo, RecurringBillingCycle} from '../ProductRegistry';
|
||||
import {
|
||||
getPrimarySubscriptionItem,
|
||||
getSubscriptionEntitlementPeriodEndUnix,
|
||||
@@ -36,12 +39,80 @@ import {extractId} from '../StripeUtils';
|
||||
|
||||
type BillingCycleChangeEffectiveAt = 'now' | 'period_end';
|
||||
|
||||
const CHARGEABLE_SUBSCRIPTION_STATUSES = new Set<Stripe.Subscription.Status>(['active', 'trialing']);
|
||||
|
||||
function mapStripeIds(values: Array<string | {id: string}> | null | undefined): Array<string> | null {
|
||||
const ids = (values ?? []).map((value) => extractId(value)).filter((id): id is string => id !== null);
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
function mapSchedulePhaseDiscounts(
|
||||
discounts:
|
||||
| Array<{
|
||||
coupon: string | {id: string} | null;
|
||||
discount: unknown;
|
||||
promotion_code: string | {id: string} | null;
|
||||
}>
|
||||
| null
|
||||
| undefined,
|
||||
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.Discount> | null {
|
||||
const mapped = (discounts ?? [])
|
||||
.map((discount): Stripe.SubscriptionScheduleUpdateParams.Phase.Discount | null => {
|
||||
const coupon = extractId(discount.coupon);
|
||||
if (coupon) {
|
||||
return {coupon};
|
||||
}
|
||||
const promotionCode = extractId(discount.promotion_code);
|
||||
if (promotionCode) {
|
||||
return {promotion_code: promotionCode};
|
||||
}
|
||||
return typeof discount.discount === 'string' ? {discount: discount.discount} : null;
|
||||
})
|
||||
.filter((discount): discount is Stripe.SubscriptionScheduleUpdateParams.Phase.Discount => discount !== null);
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
function mapSchedulePhaseAddInvoiceItems(
|
||||
addInvoiceItems: Array<Stripe.SubscriptionSchedule.Phase.AddInvoiceItem> | null | undefined,
|
||||
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem> | null {
|
||||
const mapped = (addInvoiceItems ?? [])
|
||||
.map((addInvoiceItem) => {
|
||||
const price = extractId(addInvoiceItem.price);
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
const mappedItem: Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem = {
|
||||
price,
|
||||
quantity: addInvoiceItem.quantity ?? 1,
|
||||
period: addInvoiceItem.period,
|
||||
};
|
||||
const taxRates = mapStripeIds(addInvoiceItem.tax_rates);
|
||||
if (taxRates) {
|
||||
mappedItem.tax_rates = taxRates;
|
||||
}
|
||||
const discounts = mapSchedulePhaseDiscounts(addInvoiceItem.discounts);
|
||||
if (discounts) {
|
||||
mappedItem.discounts = discounts;
|
||||
}
|
||||
if (addInvoiceItem.metadata) {
|
||||
mappedItem.metadata = addInvoiceItem.metadata;
|
||||
}
|
||||
return mappedItem;
|
||||
})
|
||||
.filter(
|
||||
(addInvoiceItem): addInvoiceItem is Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem =>
|
||||
addInvoiceItem !== null,
|
||||
);
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
export class StripeSubscriptionService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private userRepository: IUserRepository,
|
||||
private productRegistry: {
|
||||
getRecurringSubscriptionPriceId: (billingCycle: RecurringBillingCycle, currency: string) => string | null;
|
||||
getProduct: (priceId: string) => ProductInfo | null;
|
||||
},
|
||||
private cacheService: ICacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
@@ -251,7 +322,7 @@ export class StripeSubscriptionService {
|
||||
return;
|
||||
}
|
||||
await this.stripe.subscriptionSchedules.release(schedule.id, {
|
||||
preserve_cancel_date: false,
|
||||
preserve_cancel_date: Boolean(subscription.cancel_at || subscription.cancel_at_period_end),
|
||||
});
|
||||
const releasedSubscription = await this.stripe.subscriptions.retrieve(subscription.id, {
|
||||
expand: ['items.data.price'],
|
||||
@@ -268,7 +339,7 @@ export class StripeSubscriptionService {
|
||||
);
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
premium_will_cancel: false,
|
||||
premium_will_cancel: Boolean(releasedSubscription.cancel_at || releasedSubscription.cancel_at_period_end),
|
||||
};
|
||||
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(releasedSubscription);
|
||||
if (computedPremiumUntil) {
|
||||
@@ -433,6 +504,142 @@ export class StripeSubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async switchToCurrentListPrice(userId: UserID): Promise<SwitchToListPriceResponse> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeNoActiveSubscriptionError();
|
||||
}
|
||||
const lockKey = `list_price_switch_lock:${user.id}`;
|
||||
const lockToken = await this.cacheService.acquireLock(
|
||||
lockKey,
|
||||
StripeSubscriptionService.LIST_PRICE_SWITCH_LOCK_TTL_SECONDS,
|
||||
);
|
||||
if (!lockToken) {
|
||||
Logger.debug(
|
||||
{userId, subscriptionId: user.stripeSubscriptionId, lockKey},
|
||||
'List price switch skipped because another switch is already in flight',
|
||||
);
|
||||
return {status: 'ineligible', reason: 'switch_in_progress'};
|
||||
}
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId, {
|
||||
expand: ['items.data.price', 'schedule'],
|
||||
});
|
||||
const item = getPrimarySubscriptionItem(subscription);
|
||||
if (!item?.id || !item.price?.recurring || !item.price.currency || item.price.unit_amount == null) {
|
||||
return {status: 'ineligible', reason: 'unsupported_subscription'};
|
||||
}
|
||||
if (!CHARGEABLE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
||||
return {status: 'ineligible', reason: 'subscription_not_chargeable'};
|
||||
}
|
||||
if (subscription.cancel_at != null || subscription.cancel_at_period_end) {
|
||||
return {status: 'ineligible', reason: 'subscription_cancelling'};
|
||||
}
|
||||
const billingCycle = this.getBillingCycleFromInterval(item.price.recurring.interval);
|
||||
if (!billingCycle) {
|
||||
return {status: 'ineligible', reason: 'unsupported_subscription'};
|
||||
}
|
||||
const currency = item.price.currency.toUpperCase() as Currency;
|
||||
const targetPriceId = this.productRegistry.getRecurringSubscriptionPriceId(billingCycle, currency);
|
||||
if (!targetPriceId) {
|
||||
return {status: 'ineligible', reason: 'no_list_price'};
|
||||
}
|
||||
if (targetPriceId === item.price.id) {
|
||||
return {status: 'ineligible', reason: 'already_on_list_price'};
|
||||
}
|
||||
const targetProduct = this.productRegistry.getProduct(targetPriceId);
|
||||
if (!targetProduct || targetProduct.currency !== currency) {
|
||||
return {status: 'ineligible', reason: 'no_list_price'};
|
||||
}
|
||||
const targetAmountMinor = await this.getListPriceAmountMinor(targetPriceId);
|
||||
const currentAmountMinor = item.price.unit_amount;
|
||||
if (targetAmountMinor == null) {
|
||||
return {status: 'ineligible', reason: 'no_list_price'};
|
||||
}
|
||||
if (targetAmountMinor >= currentAmountMinor) {
|
||||
return {status: 'ineligible', reason: 'not_a_price_decrease'};
|
||||
}
|
||||
const periodEnd = getSubscriptionEntitlementPeriodEndUnix(subscription, item);
|
||||
if (!periodEnd || periodEnd <= Math.floor(Date.now() / 1000)) {
|
||||
return {status: 'ineligible', reason: 'missing_period_end'};
|
||||
}
|
||||
const switched = {
|
||||
effective_at: new Date(periodEnd * 1000).toISOString(),
|
||||
target_price_id: targetPriceId,
|
||||
target_amount_minor: targetAmountMinor,
|
||||
current_amount_minor: currentAmountMinor,
|
||||
currency,
|
||||
};
|
||||
const schedule = await this.loadSubscriptionSchedule(subscription.schedule);
|
||||
const pendingSchedule =
|
||||
schedule && (schedule.status === 'active' || schedule.status === 'not_started') ? schedule : null;
|
||||
if (pendingSchedule) {
|
||||
if (this.subscriptionScheduleHasFutureTargetPrice(pendingSchedule, targetPriceId)) {
|
||||
Logger.debug(
|
||||
{userId, subscriptionId: subscription.id, scheduleId: pendingSchedule.id, targetPriceId},
|
||||
'List price switch already scheduled for period end',
|
||||
);
|
||||
return {status: 'already_scheduled', ...switched};
|
||||
}
|
||||
if (pendingSchedule.end_behavior === 'cancel') {
|
||||
return {status: 'ineligible', reason: 'cancellation_managed_by_schedule'};
|
||||
}
|
||||
if (this.subscriptionScheduleHasPendingBillingCycleChange(pendingSchedule, subscription)) {
|
||||
return {status: 'ineligible', reason: 'conflicting_pending_change'};
|
||||
}
|
||||
}
|
||||
await this.scheduleBillingCycleChangeAtPeriodEnd({
|
||||
user,
|
||||
subscription,
|
||||
item,
|
||||
currentBillingCycle: billingCycle,
|
||||
targetBillingCycle: billingCycle,
|
||||
targetPriceId,
|
||||
clearCancellation: false,
|
||||
});
|
||||
Logger.debug(
|
||||
{
|
||||
userId,
|
||||
subscriptionId: subscription.id,
|
||||
billingCycle,
|
||||
currency,
|
||||
currentPriceId: item.price.id,
|
||||
targetPriceId,
|
||||
currentAmountMinor,
|
||||
targetAmountMinor,
|
||||
periodEnd,
|
||||
},
|
||||
'Subscription switch to current list price scheduled for period end',
|
||||
);
|
||||
return {status: 'scheduled', ...switched};
|
||||
} catch (error: unknown) {
|
||||
Logger.error(
|
||||
{error, userId, subscriptionId: user.stripeSubscriptionId},
|
||||
'Failed to switch subscription to the current list price',
|
||||
);
|
||||
if (error instanceof StripeError || error instanceof StripeInvalidProductConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to switch subscription to the list price';
|
||||
throw new StripeError(message);
|
||||
} finally {
|
||||
try {
|
||||
const released = await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
if (!released) {
|
||||
Logger.warn({userId, lockKey}, 'List price switch lock token no longer matched on release');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, lockKey}, 'Failed to release list price switch lock');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async scheduleBillingCycleChangeAtPeriodEnd({
|
||||
user,
|
||||
subscription,
|
||||
@@ -440,6 +647,7 @@ export class StripeSubscriptionService {
|
||||
currentBillingCycle,
|
||||
targetBillingCycle,
|
||||
targetPriceId,
|
||||
clearCancellation = true,
|
||||
}: {
|
||||
user: User;
|
||||
subscription: Stripe.Subscription;
|
||||
@@ -447,6 +655,7 @@ export class StripeSubscriptionService {
|
||||
currentBillingCycle: RecurringBillingCycle;
|
||||
targetBillingCycle: RecurringBillingCycle;
|
||||
targetPriceId: string;
|
||||
clearCancellation?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
@@ -456,7 +665,7 @@ export class StripeSubscriptionService {
|
||||
throw new StripeError('Subscription is missing a future period end for scheduled billing cycle change');
|
||||
}
|
||||
let currentSubscription = subscription;
|
||||
if (subscription.cancel_at || subscription.cancel_at_period_end) {
|
||||
if (clearCancellation && (subscription.cancel_at || subscription.cancel_at_period_end)) {
|
||||
currentSubscription = await this.stripe.subscriptions.update(
|
||||
subscription.id,
|
||||
this.getClearCancellationUpdateParams(subscription) ?? {proration_behavior: 'none'},
|
||||
@@ -491,7 +700,6 @@ export class StripeSubscriptionService {
|
||||
firstInvoiceCredit?.price_data?.unit_amount != null ? -firstInvoiceCredit.price_data.unit_amount : null;
|
||||
const targetPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
start_date: periodEnd,
|
||||
billing_cycle_anchor: 'phase_start',
|
||||
items: [
|
||||
{
|
||||
price: targetPriceId,
|
||||
@@ -500,6 +708,9 @@ export class StripeSubscriptionService {
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
};
|
||||
if (currentBillingCycle !== targetBillingCycle) {
|
||||
targetPhase.billing_cycle_anchor = 'phase_start';
|
||||
}
|
||||
if (firstInvoiceCredit) {
|
||||
targetPhase.add_invoice_items = [firstInvoiceCredit];
|
||||
targetPhase.metadata = {
|
||||
@@ -508,7 +719,7 @@ export class StripeSubscriptionService {
|
||||
};
|
||||
}
|
||||
await this.stripe.subscriptionSchedules.update(schedule.id, {
|
||||
end_behavior: 'release',
|
||||
end_behavior: clearCancellation ? 'release' : schedule.end_behavior,
|
||||
proration_behavior: 'none',
|
||||
metadata: {
|
||||
user_id: user.id.toString(),
|
||||
@@ -516,14 +727,33 @@ export class StripeSubscriptionService {
|
||||
},
|
||||
phases: [currentPhase, targetPhase],
|
||||
});
|
||||
let scheduledSubscription = currentSubscription;
|
||||
if (!clearCancellation) {
|
||||
scheduledSubscription = await this.stripe.subscriptions.retrieve(currentSubscription.id, {
|
||||
expand: ['items.data.price'],
|
||||
});
|
||||
try {
|
||||
await getBillingRepository().subscriptions.upsertFromStripe(scheduledSubscription, {
|
||||
knownUserId: user.id,
|
||||
snapshotCapturedAt: new Date(),
|
||||
});
|
||||
} catch (mirrorErr) {
|
||||
Logger.error(
|
||||
{mirrorErr, subId: scheduledSubscription.id},
|
||||
'Mirror upsert failed after scheduling a period-end price change; reconciler will heal',
|
||||
);
|
||||
}
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
premium_will_cancel: false,
|
||||
premium_will_cancel: clearCancellation
|
||||
? false
|
||||
: Boolean(scheduledSubscription.cancel_at || scheduledSubscription.cancel_at_period_end),
|
||||
};
|
||||
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(currentSubscription);
|
||||
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(scheduledSubscription);
|
||||
if (computedPremiumUntil) {
|
||||
patch['premium_until'] = computedPremiumUntil;
|
||||
}
|
||||
const updatedCustomerId = extractId(currentSubscription.customer);
|
||||
const updatedCustomerId = extractId(scheduledSubscription.customer);
|
||||
if (updatedCustomerId && updatedCustomerId !== user.stripeCustomerId) {
|
||||
patch['stripe_customer_id'] = updatedCustomerId;
|
||||
}
|
||||
@@ -532,7 +762,7 @@ export class StripeSubscriptionService {
|
||||
Logger.debug(
|
||||
{
|
||||
userId: user.id,
|
||||
subscriptionId: currentSubscription.id,
|
||||
subscriptionId: scheduledSubscription.id,
|
||||
scheduleId: schedule.id,
|
||||
fromBillingCycle: currentBillingCycle,
|
||||
toBillingCycle: targetBillingCycle,
|
||||
@@ -742,12 +972,28 @@ export class StripeSubscriptionService {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
const mappedItem: Stripe.SubscriptionScheduleUpdateParams.Phase.Item = {
|
||||
price,
|
||||
quantity: phaseItem.quantity ?? 1,
|
||||
};
|
||||
const itemTaxRates = mapStripeIds(phaseItem.tax_rates);
|
||||
if (itemTaxRates) {
|
||||
mappedItem.tax_rates = itemTaxRates;
|
||||
}
|
||||
const itemDiscounts = mapSchedulePhaseDiscounts(phaseItem.discounts);
|
||||
if (itemDiscounts) {
|
||||
mappedItem.discounts = itemDiscounts;
|
||||
}
|
||||
if (phaseItem.metadata) {
|
||||
mappedItem.metadata = phaseItem.metadata;
|
||||
}
|
||||
if (phaseItem.billing_thresholds?.usage_gte != null) {
|
||||
mappedItem.billing_thresholds = {usage_gte: phaseItem.billing_thresholds.usage_gte};
|
||||
}
|
||||
return mappedItem;
|
||||
})
|
||||
.filter((phaseItem): phaseItem is {price: string; quantity: number} => phaseItem !== null) ?? [];
|
||||
.filter((phaseItem): phaseItem is Stripe.SubscriptionScheduleUpdateParams.Phase.Item => phaseItem !== null) ??
|
||||
[];
|
||||
const currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
start_date: phase?.start_date ?? subscription.start_date ?? subscription.created,
|
||||
end_date: periodEnd,
|
||||
@@ -762,6 +1008,99 @@ export class StripeSubscriptionService {
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
};
|
||||
if (phase) {
|
||||
const phaseDiscounts = mapSchedulePhaseDiscounts(phase.discounts);
|
||||
if (phaseDiscounts) {
|
||||
currentPhase.discounts = phaseDiscounts;
|
||||
}
|
||||
const defaultTaxRates = mapStripeIds(phase.default_tax_rates);
|
||||
if (defaultTaxRates) {
|
||||
currentPhase.default_tax_rates = defaultTaxRates;
|
||||
}
|
||||
if (phase.metadata) {
|
||||
currentPhase.metadata = phase.metadata;
|
||||
}
|
||||
if (phase.currency) {
|
||||
currentPhase.currency = phase.currency;
|
||||
}
|
||||
if (phase.description != null) {
|
||||
currentPhase.description = phase.description;
|
||||
}
|
||||
if (phase.collection_method) {
|
||||
currentPhase.collection_method = phase.collection_method;
|
||||
}
|
||||
if (phase.application_fee_percent != null) {
|
||||
currentPhase.application_fee_percent = phase.application_fee_percent;
|
||||
}
|
||||
const defaultPaymentMethod = extractId(phase.default_payment_method);
|
||||
if (defaultPaymentMethod) {
|
||||
currentPhase.default_payment_method = defaultPaymentMethod;
|
||||
}
|
||||
const onBehalfOf = extractId(phase.on_behalf_of);
|
||||
if (onBehalfOf) {
|
||||
currentPhase.on_behalf_of = onBehalfOf;
|
||||
}
|
||||
if (phase.automatic_tax) {
|
||||
const automaticTax: Stripe.SubscriptionScheduleUpdateParams.Phase.AutomaticTax = {
|
||||
enabled: phase.automatic_tax.enabled,
|
||||
};
|
||||
if (phase.automatic_tax.liability) {
|
||||
automaticTax.liability = {type: phase.automatic_tax.liability.type};
|
||||
const liabilityAccount = extractId(phase.automatic_tax.liability.account);
|
||||
if (liabilityAccount) {
|
||||
automaticTax.liability.account = liabilityAccount;
|
||||
}
|
||||
}
|
||||
currentPhase.automatic_tax = automaticTax;
|
||||
}
|
||||
if (phase.invoice_settings) {
|
||||
const invoiceSettings: Stripe.SubscriptionScheduleUpdateParams.Phase.InvoiceSettings = {};
|
||||
const accountTaxIds = mapStripeIds(phase.invoice_settings.account_tax_ids);
|
||||
if (accountTaxIds) {
|
||||
invoiceSettings.account_tax_ids = accountTaxIds;
|
||||
}
|
||||
if (phase.invoice_settings.days_until_due != null) {
|
||||
invoiceSettings.days_until_due = phase.invoice_settings.days_until_due;
|
||||
}
|
||||
if (phase.invoice_settings.issuer) {
|
||||
invoiceSettings.issuer = {type: phase.invoice_settings.issuer.type};
|
||||
const issuerAccount = extractId(phase.invoice_settings.issuer.account);
|
||||
if (issuerAccount) {
|
||||
invoiceSettings.issuer.account = issuerAccount;
|
||||
}
|
||||
}
|
||||
if (Object.keys(invoiceSettings).length > 0) {
|
||||
currentPhase.invoice_settings = invoiceSettings;
|
||||
}
|
||||
}
|
||||
if (phase.billing_thresholds) {
|
||||
const billingThresholds: Stripe.SubscriptionScheduleUpdateParams.Phase.BillingThresholds = {};
|
||||
if (phase.billing_thresholds.amount_gte != null) {
|
||||
billingThresholds.amount_gte = phase.billing_thresholds.amount_gte;
|
||||
}
|
||||
if (phase.billing_thresholds.reset_billing_cycle_anchor != null) {
|
||||
billingThresholds.reset_billing_cycle_anchor = phase.billing_thresholds.reset_billing_cycle_anchor;
|
||||
}
|
||||
if (Object.keys(billingThresholds).length > 0) {
|
||||
currentPhase.billing_thresholds = billingThresholds;
|
||||
}
|
||||
}
|
||||
if (phase.transfer_data) {
|
||||
const destination = extractId(phase.transfer_data.destination);
|
||||
if (destination) {
|
||||
currentPhase.transfer_data = {destination};
|
||||
if (phase.transfer_data.amount_percent != null) {
|
||||
currentPhase.transfer_data.amount_percent = phase.transfer_data.amount_percent;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase.start_date > now) {
|
||||
const addInvoiceItems = mapSchedulePhaseAddInvoiceItems(phase.add_invoice_items);
|
||||
if (addInvoiceItems) {
|
||||
currentPhase.add_invoice_items = addInvoiceItems;
|
||||
}
|
||||
}
|
||||
}
|
||||
const trialEnd = subscription.trial_end;
|
||||
if (trialEnd != null && trialEnd > now) {
|
||||
if (trialEnd >= periodEnd) {
|
||||
@@ -770,6 +1109,10 @@ export class StripeSubscriptionService {
|
||||
currentPhase.trial_end = trialEnd;
|
||||
}
|
||||
}
|
||||
const phaseIsTrial = currentPhase.trial === true || currentPhase.trial_end != null;
|
||||
if (phase?.billing_cycle_anchor && !(phaseIsTrial && phase.billing_cycle_anchor === 'phase_start')) {
|
||||
currentPhase.billing_cycle_anchor = phase.billing_cycle_anchor;
|
||||
}
|
||||
return currentPhase;
|
||||
}
|
||||
|
||||
@@ -1026,6 +1369,7 @@ export class StripeSubscriptionService {
|
||||
private static readonly CURRENT_PRICE_CACHE_TTL_SECONDS = seconds('5 minutes');
|
||||
private static readonly LIST_PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
|
||||
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
|
||||
private static readonly LIST_PRICE_SWITCH_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly USER_TRIAL_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly USER_TRIAL_LOCK_MAX_WAIT_MS = 15000;
|
||||
private static readonly USER_TRIAL_LOCK_RETRY_DELAY_MS = 100;
|
||||
|
||||
@@ -65,6 +65,14 @@ export class StripeSubscriptionWebhookHandler {
|
||||
Logger.error({invoiceId: invoice.id, billingReason}, 'No subscription ID found in subscription invoice');
|
||||
throw new StripeError('Invoice missing subscription id');
|
||||
}
|
||||
const donor = await this.donationRepository.findDonorByStripeSubscriptionId(subscriptionId);
|
||||
if (donor) {
|
||||
Logger.debug(
|
||||
{invoiceId: invoice.id, eventId, subscriptionId, donorEmail: donor.email},
|
||||
'Skipping invoice payment for donation subscription',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isSubscriptionUpdateInvoice(invoice)) {
|
||||
Logger.debug(
|
||||
{
|
||||
|
||||
@@ -309,6 +309,10 @@ export class StripeWebhookService {
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.paymentMethods.markDetached(pm.id, new Date()));
|
||||
break;
|
||||
}
|
||||
case 'mandate.updated': {
|
||||
await this.handleMandateUpdated(event.data.object as Stripe.Mandate);
|
||||
break;
|
||||
}
|
||||
case 'payment_intent.created':
|
||||
case 'payment_intent.processing':
|
||||
case 'payment_intent.succeeded':
|
||||
@@ -369,6 +373,18 @@ export class StripeWebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMandateUpdated(mandate: Stripe.Mandate): Promise<void> {
|
||||
if (mandate.status !== 'inactive') {
|
||||
return;
|
||||
}
|
||||
const paymentMethodId =
|
||||
typeof mandate.payment_method === 'string' ? mandate.payment_method : (mandate.payment_method?.id ?? null);
|
||||
Logger.warn(
|
||||
{mandateId: mandate.id, paymentMethodId, status: mandate.status},
|
||||
'Stripe mandate is no longer active; recurring payments on this payment method will fail',
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
|
||||
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
|
||||
if (chargeId !== null) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import Stripe from 'stripe';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {Config} from '../../Config';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {STRIPE_API_VERSION} from '../StripeApiVersion';
|
||||
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_state_pricing_monthly_usd',
|
||||
yearlyUsd: 'price_state_pricing_yearly_usd',
|
||||
monthlyBrl: 'price_state_pricing_monthly_brl',
|
||||
yearlyBrl: 'price_state_pricing_yearly_brl',
|
||||
gift1MonthUsd: 'price_state_pricing_gift_1_month_usd',
|
||||
gift1YearUsd: 'price_state_pricing_gift_1_year_usd',
|
||||
gift1MonthBrl: 'price_state_pricing_gift_1_month_brl',
|
||||
gift1YearBrl: 'price_state_pricing_gift_1_year_brl',
|
||||
};
|
||||
|
||||
const MOCK_PRICE_SEEDS = {
|
||||
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
|
||||
[MOCK_PRICES.gift1MonthUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
|
||||
[MOCK_PRICES.gift1YearUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
|
||||
[MOCK_PRICES.gift1MonthBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
|
||||
[MOCK_PRICES.gift1YearBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
|
||||
};
|
||||
|
||||
describe('PremiumStatePricing', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
|
||||
async function mirrorPrices(): Promise<void> {
|
||||
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
|
||||
const price = await stripe.prices.retrieve(priceId);
|
||||
await getBillingRepository().prices.upsertFromStripe(price);
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.prices = originalPrices;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
await mirrorPrices();
|
||||
});
|
||||
|
||||
test('resolves the localized BRL catalog for a Brazilian request', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state?country_code=BR')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.pricing.country_code).toBe('BR');
|
||||
expect(state.pricing.localized?.currency).toBe('BRL');
|
||||
expect(state.pricing.localized?.monthly_amount_minor).toBe(1890);
|
||||
expect(state.pricing.localized?.yearly_amount_minor).toBe(18900);
|
||||
expect(state.pricing.localized?.monthly).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(state.pricing.localized?.yearly).toBe(MOCK_PRICES.yearlyBrl);
|
||||
});
|
||||
|
||||
test('resolves the USD catalog when the request declares no country', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.pricing.country_code).toBeNull();
|
||||
expect(state.pricing.localized?.currency).toBe('USD');
|
||||
expect(state.pricing.localized?.monthly_amount_minor).toBe(499);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, describe, expect, test} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import {ProductRegistry, ProductType} from '../ProductRegistry';
|
||||
|
||||
// Mirrors the live BRL/TRY configuration after the repricing: the BRL slots point at the new list
|
||||
// prices, TRY is not configured at all (production has no TRY prices), and every retired price id
|
||||
// lives only in the legacy map.
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_monthly_usd',
|
||||
monthlyBrl: 'price_1TMbqsFPC94Os7FdfElrIIaZ',
|
||||
yearlyUsd: 'price_yearly_usd',
|
||||
yearlyBrl: 'price_1TMbqtFPC94Os7Fd7VJqmDyt',
|
||||
gift1MonthBrl: 'price_gift_1_month_brl',
|
||||
gift1YearBrl: 'price_gift_1_year_brl',
|
||||
};
|
||||
|
||||
const LEGACY_MONTHLY_BRL = 'price_legacy_monthly_brl';
|
||||
const LEGACY_YEARLY_BRL = 'price_legacy_yearly_brl';
|
||||
const LEGACY_GIFT_1_MONTH_BRL = 'price_legacy_gift_1_month_brl';
|
||||
const LEGACY_GIFT_1_YEAR_BRL = 'price_legacy_gift_1_year_brl';
|
||||
const LEGACY_MONTHLY_TRY = 'price_1TMYpdFPC94Os7FdZVRx98Up';
|
||||
|
||||
const MOCK_LEGACY_PRICES: Record<string, Array<string> | undefined> = {
|
||||
monthly_brl: [LEGACY_MONTHLY_BRL],
|
||||
yearly_brl: [LEGACY_YEARLY_BRL],
|
||||
gift_1_month_brl: [LEGACY_GIFT_1_MONTH_BRL],
|
||||
gift_1_year_brl: [LEGACY_GIFT_1_YEAR_BRL],
|
||||
monthly_try: [LEGACY_MONTHLY_TRY],
|
||||
};
|
||||
|
||||
describe('ProductRegistry - legacy prices', () => {
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
let originalLegacyPrices: typeof Config.stripe.legacyPrices | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
originalLegacyPrices = Config.stripe.legacyPrices;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Config.stripe.prices = originalPrices;
|
||||
Config.stripe.legacyPrices = originalLegacyPrices;
|
||||
});
|
||||
|
||||
function buildRegistry(
|
||||
prices: typeof Config.stripe.prices,
|
||||
legacyPrices: typeof Config.stripe.legacyPrices,
|
||||
): ProductRegistry {
|
||||
Config.stripe.prices = prices;
|
||||
Config.stripe.legacyPrices = legacyPrices;
|
||||
return new ProductRegistry();
|
||||
}
|
||||
|
||||
describe('slot shapes', () => {
|
||||
test('a legacy monthly slot resolves to a monthly recurring product in its currency', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy yearly slot resolves to a yearly recurring product in its currency', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)).toEqual({
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy gift_1_month slot resolves to a one-month gift with no billing cycle', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const info = registry.getProduct(LEGACY_GIFT_1_MONTH_BRL);
|
||||
expect(info).toEqual({
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(info?.billingCycle).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a legacy gift_1_year slot resolves to a one-year gift with no billing cycle', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const info = registry.getProduct(LEGACY_GIFT_1_YEAR_BRL);
|
||||
expect(info).toEqual({
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(info?.billingCycle).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the currency comes from the slot suffix, not from the price id', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
// Same shape of id, different slot: only the slot suffix decides the currency.
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_TRY)?.currency).toBe('TRY');
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('every supported currency suffix is recognised', () => {
|
||||
const registry = buildRegistry(
|
||||
{},
|
||||
{
|
||||
monthly_usd: ['legacy_usd'],
|
||||
monthly_eur: ['legacy_eur'],
|
||||
monthly_brl: ['legacy_brl'],
|
||||
monthly_dkk: ['legacy_dkk'],
|
||||
monthly_inr: ['legacy_inr'],
|
||||
monthly_nok: ['legacy_nok'],
|
||||
monthly_pln: ['legacy_pln'],
|
||||
monthly_sek: ['legacy_sek'],
|
||||
monthly_try: ['legacy_try'],
|
||||
},
|
||||
);
|
||||
expect(registry.getProduct('legacy_usd')?.currency).toBe('USD');
|
||||
expect(registry.getProduct('legacy_eur')?.currency).toBe('EUR');
|
||||
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
|
||||
expect(registry.getProduct('legacy_dkk')?.currency).toBe('DKK');
|
||||
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
|
||||
expect(registry.getProduct('legacy_nok')?.currency).toBe('NOK');
|
||||
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
|
||||
expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK');
|
||||
expect(registry.getProduct('legacy_try')?.currency).toBe('TRY');
|
||||
});
|
||||
|
||||
test('an uppercased currency suffix is still recognised', () => {
|
||||
const registry = buildRegistry({}, {monthly_BRL: ['legacy_upper_brl']});
|
||||
expect(registry.getProduct('legacy_upper_brl')?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('a slot may retire more than one price id', () => {
|
||||
const registry = buildRegistry({}, {monthly_brl: ['legacy_one', 'legacy_two', 'legacy_three']});
|
||||
expect(registry.getProduct('legacy_one')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
expect(registry.getProduct('legacy_two')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
expect(registry.getProduct('legacy_three')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
test('legacy prices register even when no prices are configured at all', () => {
|
||||
const registry = buildRegistry(undefined, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)).toBeNull();
|
||||
});
|
||||
|
||||
test('an unknown price id still resolves to null', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct('price_never_seen')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('honoured but unpurchasable', () => {
|
||||
test('a legacy recurring price resolves through getProduct but is never the checkout price', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
|
||||
// Direction 1: the retired price is honoured on renewal.
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)).not.toBeNull();
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)).not.toBeNull();
|
||||
|
||||
// Direction 2: checkout only ever offers the authored list price.
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).not.toBe(LEGACY_MONTHLY_BRL);
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'BRL')).toBe(MOCK_PRICES.yearlyBrl);
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'BRL')).not.toBe(LEGACY_YEARLY_BRL);
|
||||
});
|
||||
|
||||
test('a legacy gift price resolves through getProduct but is never the gift checkout price', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
|
||||
expect(registry.getProduct(LEGACY_GIFT_1_MONTH_BRL)).not.toBeNull();
|
||||
expect(registry.getProduct(LEGACY_GIFT_1_YEAR_BRL)).not.toBeNull();
|
||||
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'BRL')).toBe(MOCK_PRICES.gift1MonthBrl);
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'BRL')).not.toBe(LEGACY_GIFT_1_MONTH_BRL);
|
||||
expect(registry.getGiftPriceId('gift_1_year', 'BRL')).toBe(MOCK_PRICES.gift1YearBrl);
|
||||
expect(registry.getGiftPriceId('gift_1_year', 'BRL')).not.toBe(LEGACY_GIFT_1_YEAR_BRL);
|
||||
});
|
||||
|
||||
test('an archived price in a currency the instance does not configure renews but cannot be bought', () => {
|
||||
// The real production case: one live subscription sits on an archived TRY price that
|
||||
// production never configured. getProduct must resolve it; checkout must still refuse TRY.
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_TRY)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'TRY',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'TRY')).toBeNull();
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'TRY')).toBeNull();
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'TRY')).toBeNull();
|
||||
expect(registry.getGiftPriceId('gift_1_year', 'TRY')).toBeNull();
|
||||
});
|
||||
|
||||
test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? []));
|
||||
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'];
|
||||
|
||||
const offered: Array<string> = [];
|
||||
for (const currency of currencies) {
|
||||
for (const cycle of ['monthly', 'yearly'] as const) {
|
||||
const priceId = registry.getRecurringSubscriptionPriceId(cycle, currency);
|
||||
if (priceId) offered.push(priceId);
|
||||
}
|
||||
for (const duration of ['gift_1_month', 'gift_1_year'] as const) {
|
||||
const priceId = registry.getGiftPriceId(duration, currency);
|
||||
if (priceId) offered.push(priceId);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offered.length).toBeGreaterThan(0);
|
||||
expect(offered.filter((priceId) => legacyIds.has(priceId))).toEqual([]);
|
||||
// And every legacy id is still honoured by the registry.
|
||||
for (const legacyId of legacyIds) {
|
||||
expect(registry.getProduct(legacyId)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy recurring product is still classified as a recurring subscription', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const recurring = registry.getProduct(LEGACY_MONTHLY_BRL);
|
||||
const gift = registry.getProduct(LEGACY_GIFT_1_YEAR_BRL);
|
||||
expect(recurring).not.toBeNull();
|
||||
expect(gift).not.toBeNull();
|
||||
expect(registry.isRecurringSubscription(recurring!)).toBe(true);
|
||||
expect(registry.isRecurringSubscription(gift!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence', () => {
|
||||
test('an authored price wins over a legacy entry claiming the same id', () => {
|
||||
// Deliberate collision: the live monthly BRL list price is also listed as a retired yearly
|
||||
// USD price. The authored ProductInfo must survive.
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
yearly_usd: [MOCK_PRICES.monthlyBrl],
|
||||
});
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
});
|
||||
|
||||
test('an authored gift price wins over a legacy entry claiming the same id', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_try: [MOCK_PRICES.gift1YearBrl],
|
||||
});
|
||||
expect(registry.getProduct(MOCK_PRICES.gift1YearBrl)).toEqual({
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
});
|
||||
|
||||
test('the first legacy slot to claim an id wins over a later one', () => {
|
||||
const registry = buildRegistry(
|
||||
{},
|
||||
{
|
||||
monthly_brl: ['legacy_shared'],
|
||||
yearly_usd: ['legacy_shared'],
|
||||
},
|
||||
);
|
||||
expect(registry.getProduct('legacy_shared')).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed configuration', () => {
|
||||
test('a slot whose value is a bare string is ignored and the rest still registers', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_brl: 'price_legacy_bare_string' as unknown as Array<string>,
|
||||
yearly_brl: [LEGACY_YEARLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('price_legacy_bare_string')).toBeNull();
|
||||
// Not accidentally registered character by character either.
|
||||
expect(registry.getProduct('p')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)?.type).toBe(ProductType.YEARLY_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
test('a slot with an unrecognised name is ignored and the rest still registers', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
weekly_brl: ['legacy_weekly'],
|
||||
visionary_usd: ['legacy_visionary'],
|
||||
brl: ['legacy_no_separator'],
|
||||
_brl: ['legacy_leading_separator'],
|
||||
yearly_brl: [LEGACY_YEARLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('legacy_weekly')).toBeNull();
|
||||
expect(registry.getProduct('legacy_visionary')).toBeNull();
|
||||
expect(registry.getProduct('legacy_no_separator')).toBeNull();
|
||||
expect(registry.getProduct('legacy_leading_separator')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)?.type).toBe(ProductType.YEARLY_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
test('a slot with an unknown currency is ignored and the rest still registers', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_jpy: ['legacy_jpy'],
|
||||
gift_1_year_gbp: ['legacy_gbp'],
|
||||
monthly_brl: [LEGACY_MONTHLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('legacy_jpy')).toBeNull();
|
||||
expect(registry.getProduct('legacy_gbp')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('empty, blank and undefined entries inside a slot are skipped', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_brl: ['', undefined as unknown as string, LEGACY_MONTHLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('an undefined slot value is ignored', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_try: undefined,
|
||||
monthly_brl: [LEGACY_MONTHLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('an array-valued legacy map is ignored without throwing', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, [
|
||||
'price_legacy_array',
|
||||
] as unknown as typeof Config.stripe.legacyPrices);
|
||||
expect(registry.getProduct('price_legacy_array')).toBeNull();
|
||||
// The authored prices are untouched.
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).toBe(MOCK_PRICES.monthlyBrl);
|
||||
});
|
||||
|
||||
test('a string-valued legacy map is ignored without throwing', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, 'nonsense' as unknown as typeof Config.stripe.legacyPrices);
|
||||
expect(registry.getProduct('nonsense')).toBeNull();
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('a number-valued legacy map is ignored without throwing', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, 42 as unknown as typeof Config.stripe.legacyPrices);
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('an absent or empty legacy map leaves the authored registry intact', () => {
|
||||
expect(buildRegistry(MOCK_PRICES, undefined).getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
expect(buildRegistry(MOCK_PRICES, {}).getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
|
||||
const {lookupGeoipMock} = vi.hoisted(() => ({
|
||||
lookupGeoipMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
|
||||
lookupGeoip: lookupGeoipMock,
|
||||
}));
|
||||
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_enforce_monthly_usd',
|
||||
yearlyUsd: 'price_enforce_yearly_usd',
|
||||
monthlyBrl: 'price_enforce_monthly_brl',
|
||||
yearlyBrl: 'price_enforce_yearly_brl',
|
||||
gift1MonthUsd: 'price_enforce_gift_1_month_usd',
|
||||
gift1YearUsd: 'price_enforce_gift_1_year_usd',
|
||||
gift1MonthBrl: 'price_enforce_gift_1_month_brl',
|
||||
gift1YearBrl: 'price_enforce_gift_1_year_brl',
|
||||
};
|
||||
|
||||
const MOCK_PRICE_SEEDS = {
|
||||
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
|
||||
};
|
||||
|
||||
function geoipCountry(countryCode: string | null): GeoipResult {
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: '203.0.113.10',
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StripeCheckoutCountryEnforcement', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
|
||||
async function createPurchaser(): Promise<string> {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
return account.token;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.prices = originalPrices;
|
||||
});
|
||||
beforeEach(() => {
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
lookupGeoipMock.mockReset();
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
|
||||
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
|
||||
test('rejects a base-currency subscription price when the request geolocates to a localized market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a base-currency subscription price when country_code is omitted entirely', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyUsd})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a localized gift price when the request geolocates outside that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('US'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a localized gift price even from inside that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('accepts the base gift price from inside a localized market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthUsd, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts the localized price for a request that geolocates to that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('falls back to the declared country when the request cannot be geolocated', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
});
|
||||
|
||||
test('resolves price ids from the request geolocation rather than the query parameter', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const priceIds = await createBuilder<{currency: string; monthly: string | null}>(harness, '')
|
||||
.get('/premium/price-ids?country_code=US')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(priceIds.currency).toBe('BRL');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyBrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
PriceIdsResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
|
||||
const {lookupGeoipMock} = vi.hoisted(() => ({
|
||||
lookupGeoipMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
|
||||
lookupGeoip: lookupGeoipMock,
|
||||
}));
|
||||
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_nordic_monthly_usd',
|
||||
yearlyUsd: 'price_nordic_yearly_usd',
|
||||
monthlyEur: 'price_nordic_monthly_eur',
|
||||
yearlyEur: 'price_nordic_yearly_eur',
|
||||
monthlyDkk: 'price_nordic_monthly_dkk',
|
||||
yearlyDkk: 'price_nordic_yearly_dkk',
|
||||
monthlyNok: 'price_nordic_monthly_nok',
|
||||
yearlyNok: 'price_nordic_yearly_nok',
|
||||
monthlyPln: 'price_nordic_monthly_pln',
|
||||
yearlyPln: 'price_nordic_yearly_pln',
|
||||
monthlySek: 'price_nordic_monthly_sek',
|
||||
yearlySek: 'price_nordic_yearly_sek',
|
||||
gift1MonthUsd: 'price_nordic_gift_1_month_usd',
|
||||
gift1YearUsd: 'price_nordic_gift_1_year_usd',
|
||||
gift1MonthEur: 'price_nordic_gift_1_month_eur',
|
||||
gift1YearEur: 'price_nordic_gift_1_year_eur',
|
||||
};
|
||||
|
||||
const MOCK_PRICE_SEEDS = {
|
||||
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyEur]: {unit_amount: 499, currency: 'eur', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyEur]: {unit_amount: 4999, currency: 'eur', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3700, currency: 'dkk', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyDkk]: {unit_amount: 37000, currency: 'dkk', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyNok]: {unit_amount: 5400, currency: 'nok', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyNok]: {unit_amount: 54000, currency: 'nok', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyPln]: {unit_amount: 1900, currency: 'pln', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyPln]: {unit_amount: 19000, currency: 'pln', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlySek]: {unit_amount: 5400, currency: 'sek', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlySek]: {unit_amount: 54000, currency: 'sek', interval: 'year' as const},
|
||||
};
|
||||
|
||||
const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur';
|
||||
|
||||
function geoipCountry(countryCode: string | null): GeoipResult {
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: '203.0.113.10',
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Nordic localized currencies', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
|
||||
async function createPurchaser(): Promise<string> {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
return account.token;
|
||||
}
|
||||
|
||||
function getPriceIds(countryCode: string | null): Promise<PriceIdsResponse> {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(countryCode));
|
||||
return createBuilder<PriceIdsResponse>(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.prices = originalPrices;
|
||||
});
|
||||
beforeEach(() => {
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
lookupGeoipMock.mockReset();
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
|
||||
stripeHandlers = createStripeApiHandlers({
|
||||
prices: MOCK_PRICE_SEEDS,
|
||||
subscriptionsListEmpty: true,
|
||||
subscriptions: {
|
||||
[EUR_SUBSCRIPTION_ID]: {
|
||||
customer: `cus_${EUR_SUBSCRIPTION_ID}`,
|
||||
price_id: MOCK_PRICES.monthlyEur,
|
||||
unit_amount: 499,
|
||||
currency: 'eur',
|
||||
interval: 'month',
|
||||
item_id: `si_${EUR_SUBSCRIPTION_ID}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
|
||||
describe('country routing', () => {
|
||||
test('Sweden resolves to the SEK subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('SE');
|
||||
expect(priceIds.currency).toBe('SEK');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek);
|
||||
});
|
||||
|
||||
test('Denmark resolves to the DKK subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('DK');
|
||||
expect(priceIds.currency).toBe('DKK');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk);
|
||||
});
|
||||
|
||||
test('Norway resolves to the NOK subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('NO');
|
||||
expect(priceIds.currency).toBe('NOK');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok);
|
||||
});
|
||||
|
||||
test('Poland still resolves to the PLN subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('PL');
|
||||
expect(priceIds.currency).toBe('PLN');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln);
|
||||
});
|
||||
|
||||
test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%s still resolves to the EUR subscription catalog', async (country) => {
|
||||
const priceIds = await getPriceIds(country);
|
||||
expect(priceIds.currency).toBe('EUR');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
|
||||
});
|
||||
|
||||
test.each(['SE', 'DK', 'NO'])('%s keeps gifts on the base currency', async (country) => {
|
||||
const priceIds = await getPriceIds(country);
|
||||
expect(priceIds.gift_currency).toBe('EUR');
|
||||
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
|
||||
expect(priceIds.gift_1_year).toBe(MOCK_PRICES.gift1YearEur);
|
||||
});
|
||||
|
||||
test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => {
|
||||
Config.stripe.prices = {
|
||||
...MOCK_PRICES,
|
||||
gift1MonthEur: undefined,
|
||||
gift1YearEur: undefined,
|
||||
};
|
||||
const priceIds = await getPriceIds('SE');
|
||||
expect(priceIds.currency).toBe('SEK');
|
||||
expect(priceIds.gift_currency).toBe('USD');
|
||||
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthUsd);
|
||||
});
|
||||
|
||||
test('Sweden falls back to EUR while the SEK prices are still unconfigured', async () => {
|
||||
Config.stripe.prices = {
|
||||
...MOCK_PRICES,
|
||||
monthlySek: undefined,
|
||||
yearlySek: undefined,
|
||||
};
|
||||
const priceIds = await getPriceIds('SE');
|
||||
expect(priceIds.currency).toBe('EUR');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
|
||||
expect(priceIds.gift_currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout enforcement', () => {
|
||||
test('rejects the EUR subscription price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyEur, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects the USD subscription price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a neighbouring Nordic price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyDkk, country_code: 'DK'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('accepts the SEK subscription price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts the base gift price from inside Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rejects the SEK subscription price on the gift endpoint', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing subscribers', () => {
|
||||
test('a Swedish customer billed in EUR keeps the EUR price and is not repriced to SEK', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
stripe_subscription_id: EUR_SUBSCRIPTION_ID,
|
||||
premium_type: 1,
|
||||
premium_billing_cycle: 'monthly',
|
||||
premium_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
premium_will_cancel: false,
|
||||
})
|
||||
.execute();
|
||||
const price = await createBuilder<CurrentSubscriptionPriceResponse>(harness, account.token)
|
||||
.get('/premium/current-subscription-price')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(price).toMatchObject({
|
||||
price_id: MOCK_PRICES.monthlyEur,
|
||||
currency: 'EUR',
|
||||
billing_cycle: 'monthly',
|
||||
is_grandfathered: false,
|
||||
list_price_id: MOCK_PRICES.monthlyEur,
|
||||
});
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
@@ -9,10 +11,17 @@ import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {
|
||||
createMockWebhookPayload,
|
||||
createStripeApiHandlers,
|
||||
type StripeWebhookEventData,
|
||||
} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
|
||||
|
||||
const MOCK_CUSTOMER_ID = 'cus_self_serve_refund';
|
||||
const MOCK_SUBSCRIPTION_ID = 'sub_self_serve_refund';
|
||||
@@ -107,6 +116,7 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
|
||||
}
|
||||
|
||||
function refundCreateHandler(opts?: {
|
||||
refundId?: string;
|
||||
status?: 'succeeded' | 'pending' | 'failed';
|
||||
failureReason?: string;
|
||||
onRequest?: (idempotencyKey: string | null) => void;
|
||||
@@ -121,7 +131,7 @@ function refundCreateHandler(opts?: {
|
||||
if (match) metadata[match[1]] = value as string;
|
||||
}
|
||||
return HttpResponse.json({
|
||||
id: 're_test_self_serve',
|
||||
id: opts?.refundId ?? 're_test_self_serve',
|
||||
object: 'refund',
|
||||
amount: Number.parseInt((params.amount as string) ?? '0', 10),
|
||||
currency: 'usd',
|
||||
@@ -156,7 +166,27 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
setupSyncStripeWebhookWorker();
|
||||
});
|
||||
function createWebhookSignature(payload: string, timestamp: number, secret: string): string {
|
||||
const signedPayload = `${timestamp}.${payload}`;
|
||||
const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
|
||||
return `t=${timestamp},v1=${signature}`;
|
||||
}
|
||||
async function sendWebhook(eventData: StripeWebhookEventData): Promise<{
|
||||
received: boolean;
|
||||
}> {
|
||||
const {payload, timestamp} = createMockWebhookPayload(eventData);
|
||||
const signature = createWebhookSignature(payload, timestamp, Config.stripe.webhookSecret!);
|
||||
return createBuilder<{
|
||||
received: boolean;
|
||||
}>(harness, '')
|
||||
.post('/stripe/webhook')
|
||||
.header('stripe-signature', signature)
|
||||
.header('content-type', 'application/json')
|
||||
.body(payload)
|
||||
.execute();
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
@@ -345,6 +375,102 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('counts one self-serve refund once even when charge.refunded arrives afterwards and is retried', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const invoice = buildInvoice({
|
||||
id: 'in_cross_path',
|
||||
paidAtSecondsAgo: SECONDS_PER_DAY,
|
||||
subscriptionId: null,
|
||||
});
|
||||
server.use(invoiceListHandler([invoice]), refundCreateHandler({refundId: 're_cross_path'}));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.refund_id).toBe('re_cross_path');
|
||||
expect(response.status).toBe('succeeded');
|
||||
const userRepository = new UserRepository();
|
||||
const afterSelfServe = await userRepository.findUnique(userId);
|
||||
expect(afterSelfServe!.firstRefundAt).not.toBeNull();
|
||||
expect(afterSelfServe!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: invoice.chargeId,
|
||||
payment_intent: invoice.paymentIntentId,
|
||||
customer: MOCK_CUSTOMER_ID,
|
||||
amount_refunded: 2500,
|
||||
refunds: {
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
url: `/v1/charges/${invoice.chargeId}/refunds`,
|
||||
data: [
|
||||
{
|
||||
id: 're_cross_path',
|
||||
object: 'refund',
|
||||
charge: invoice.chargeId,
|
||||
payment_intent: invoice.paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId,
|
||||
invoice_id: invoice.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook({...chargeEvent, id: 'evt_cross_path_1'});
|
||||
await sendWebhook({...chargeEvent, id: 'evt_cross_path_2'});
|
||||
const afterWebhook = await userRepository.findUnique(userId);
|
||||
expect(afterWebhook!.firstRefundAt!.getTime()).toBe(afterSelfServe!.firstRefundAt!.getTime());
|
||||
expect(afterWebhook!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('counts one self-serve refund once even when refund.updated confirms it after the endpoint already did', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const invoice = buildInvoice({
|
||||
id: 'in_double_confirm',
|
||||
paidAtSecondsAgo: SECONDS_PER_DAY,
|
||||
subscriptionId: null,
|
||||
});
|
||||
server.use(invoiceListHandler([invoice]), refundCreateHandler({refundId: 're_double_confirm'}));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
const userRepository = new UserRepository();
|
||||
const afterSelfServe = await userRepository.findUnique(userId);
|
||||
expect(afterSelfServe!.firstRefundAt).not.toBeNull();
|
||||
await sendWebhook({
|
||||
id: 'evt_double_confirm_1',
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 're_double_confirm',
|
||||
object: 'refund',
|
||||
status: 'succeeded',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId,
|
||||
invoice_id: invoice.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const afterWebhook = await userRepository.findUnique(userId);
|
||||
expect(afterWebhook!.firstRefundAt!.getTime()).toBe(afterSelfServe!.firstRefundAt!.getTime());
|
||||
expect(afterWebhook!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
ListPriceSwitchIneligibilityReason,
|
||||
PremiumStateResponse,
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import Stripe from 'stripe';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createPwnedPasswordsRangeHandler} from '../../test/msw/handlers/PwnedPasswordsHandlers';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {STRIPE_API_VERSION} from '../StripeApiVersion';
|
||||
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_list_monthly_usd',
|
||||
yearlyUsd: 'price_list_yearly_usd',
|
||||
monthlyBrl: 'price_list_monthly_brl',
|
||||
yearlyBrl: 'price_list_yearly_brl',
|
||||
};
|
||||
|
||||
const RETIRED_MONTHLY_BRL = 'price_retired_monthly_brl';
|
||||
const RETIRED_CHEAP_MONTHLY_BRL = 'price_retired_cheap_monthly_brl';
|
||||
const RETIRED_MONTHLY_TRY = 'price_retired_monthly_try';
|
||||
const RETIRED_EQUAL_MONTHLY_BRL = 'price_retired_equal_monthly_brl';
|
||||
|
||||
const LIST_MONTHLY_BRL_MINOR = 1890;
|
||||
const LIST_YEARLY_BRL_MINOR = 18900;
|
||||
const RETIRED_MONTHLY_BRL_MINOR = 2499;
|
||||
const RETIRED_CHEAP_MONTHLY_BRL_MINOR = 990;
|
||||
const RETIRED_MONTHLY_TRY_MINOR = 2999;
|
||||
|
||||
const MOCK_PRICE_SEEDS: Record<
|
||||
string,
|
||||
{
|
||||
unit_amount: number;
|
||||
currency: string;
|
||||
interval: 'month' | 'year';
|
||||
}
|
||||
> = {
|
||||
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month'},
|
||||
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year'},
|
||||
[MOCK_PRICES.monthlyBrl]: {unit_amount: LIST_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
[MOCK_PRICES.yearlyBrl]: {unit_amount: LIST_YEARLY_BRL_MINOR, currency: 'brl', interval: 'year'},
|
||||
[RETIRED_MONTHLY_BRL]: {unit_amount: RETIRED_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
[RETIRED_CHEAP_MONTHLY_BRL]: {unit_amount: RETIRED_CHEAP_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
[RETIRED_MONTHLY_TRY]: {unit_amount: RETIRED_MONTHLY_TRY_MINOR, currency: 'try', interval: 'month'},
|
||||
[RETIRED_EQUAL_MONTHLY_BRL]: {unit_amount: LIST_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
};
|
||||
|
||||
interface SubscriberFixture {
|
||||
subscriptionId: string;
|
||||
priceId: string;
|
||||
status?: 'active' | 'trialing' | 'past_due';
|
||||
cancelAt?: number | null;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
periodStart?: number;
|
||||
periodEnd?: number;
|
||||
}
|
||||
|
||||
interface ResolvedSubscriber {
|
||||
token: string;
|
||||
userId: UserID;
|
||||
subscriptionId: string;
|
||||
priceId: string;
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
}
|
||||
|
||||
describe('StripeSubscriptionListPriceSwitch', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
|
||||
function applyStripeHandlers(fixture: Required<SubscriberFixture>): void {
|
||||
const seed = MOCK_PRICE_SEEDS[fixture.priceId];
|
||||
if (!seed) {
|
||||
throw new Error(`Missing explicit price seed for ${fixture.priceId}`);
|
||||
}
|
||||
stripeHandlers = createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[fixture.subscriptionId]: {
|
||||
customer: `cus_${fixture.subscriptionId}`,
|
||||
price_id: fixture.priceId,
|
||||
unit_amount: seed.unit_amount,
|
||||
currency: seed.currency,
|
||||
interval: seed.interval,
|
||||
item_id: `si_${fixture.subscriptionId}`,
|
||||
current_period_start: fixture.periodStart,
|
||||
current_period_end: fixture.periodEnd,
|
||||
status: fixture.status,
|
||||
cancel_at: fixture.cancelAt,
|
||||
cancel_at_period_end: fixture.cancelAtPeriodEnd,
|
||||
},
|
||||
},
|
||||
prices: MOCK_PRICE_SEEDS,
|
||||
});
|
||||
server.use(...stripeHandlers.handlers, createPwnedPasswordsRangeHandler());
|
||||
}
|
||||
|
||||
async function mirrorStripeState(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {expand: ['items.data.price']});
|
||||
await getBillingRepository().subscriptions.upsertFromStripe(subscription, {
|
||||
knownUserId: userId,
|
||||
snapshotCapturedAt: new Date(),
|
||||
});
|
||||
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
|
||||
const price = await stripe.prices.retrieve(priceId);
|
||||
await getBillingRepository().prices.upsertFromStripe(price);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSubscriber(fixture: SubscriberFixture): Promise<ResolvedSubscriber> {
|
||||
const periodStart = fixture.periodStart ?? Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = fixture.periodEnd ?? periodStart + 30 * 24 * 60 * 60;
|
||||
const resolved: Required<SubscriberFixture> = {
|
||||
subscriptionId: fixture.subscriptionId,
|
||||
priceId: fixture.priceId,
|
||||
status: fixture.status ?? 'active',
|
||||
cancelAt: fixture.cancelAt ?? null,
|
||||
cancelAtPeriodEnd: fixture.cancelAtPeriodEnd ?? false,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
const account = await createTestAccount(harness);
|
||||
applyStripeHandlers(resolved);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
stripe_subscription_id: resolved.subscriptionId,
|
||||
premium_type: 1,
|
||||
premium_billing_cycle: MOCK_PRICE_SEEDS[resolved.priceId]?.interval === 'year' ? 'yearly' : 'monthly',
|
||||
premium_until: new Date(periodEnd * 1000).toISOString(),
|
||||
premium_will_cancel: Boolean(resolved.cancelAt) || resolved.cancelAtPeriodEnd,
|
||||
})
|
||||
.execute();
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await mirrorStripeState(userId, resolved.subscriptionId);
|
||||
return {
|
||||
token: account.token,
|
||||
userId,
|
||||
subscriptionId: resolved.subscriptionId,
|
||||
priceId: resolved.priceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function switchToListPrice(subscriber: ResolvedSubscriber): Promise<SwitchToListPriceResponse> {
|
||||
return createBuilder<SwitchToListPriceResponse>(harness, subscriber.token)
|
||||
.post('/premium/switch-to-list-price')
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function getPremiumState(subscriber: ResolvedSubscriber): Promise<PremiumStateResponse> {
|
||||
return createBuilder<PremiumStateResponse>(harness, subscriber.token).get('/premium/state').expect(200).execute();
|
||||
}
|
||||
|
||||
function expectNoStripeWrites(): void {
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.releasedSubscriptionSchedules).toHaveLength(0);
|
||||
}
|
||||
|
||||
async function expectRefusal(
|
||||
subscriber: ResolvedSubscriber,
|
||||
reason: ListPriceSwitchIneligibilityReason,
|
||||
): Promise<void> {
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result).toEqual({status: 'ineligible', reason});
|
||||
const state = await getPremiumState(subscriber);
|
||||
expect(state.billing.list_price_switch.available).toBe(false);
|
||||
expect(state.billing.list_price_switch.reason).toBe(reason);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
harness = await createApiTestHarness();
|
||||
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.prices = originalPrices;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
stripeHandlers.resetAll();
|
||||
server.use(...stripeHandlers.handlers, createPwnedPasswordsRangeHandler());
|
||||
});
|
||||
|
||||
describe('POST /premium/switch-to-list-price', () => {
|
||||
test('schedules a grandfathered BRL monthly subscriber onto the current list price at period end', async () => {
|
||||
const moneyMoves: Array<string> = [];
|
||||
server.use(
|
||||
http.post('https://api.stripe.com/v1/invoices', () => {
|
||||
moneyMoves.push('invoices.create');
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('https://api.stripe.com/v1/charges', () => {
|
||||
moneyMoves.push('charges.create');
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('https://api.stripe.com/v1/payment_intents', () => {
|
||||
moneyMoves.push('payment_intents.create');
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
);
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_grandfathered',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result).toEqual({
|
||||
status: 'scheduled',
|
||||
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
|
||||
target_price_id: MOCK_PRICES.monthlyBrl,
|
||||
target_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules[0]?.from_subscription).toBe('sub_brl_grandfathered');
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
|
||||
const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0];
|
||||
expect(scheduleUpdate?.params.end_behavior).toBe('release');
|
||||
expect(scheduleUpdate?.params.proration_behavior).toBe('none');
|
||||
expect(scheduleUpdate?.params.phases).toHaveLength(2);
|
||||
expect(scheduleUpdate?.params.phases?.[0]?.end_date).toBe(String(subscriber.periodEnd));
|
||||
expect(scheduleUpdate?.params.phases?.[0]?.items?.[0]?.price).toBe(RETIRED_MONTHLY_BRL);
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(subscriber.periodEnd));
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.proration_behavior).toBe('none');
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBeUndefined();
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.add_invoice_items).toBeUndefined();
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
|
||||
expect(moneyMoves).toEqual([]);
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(false);
|
||||
const state = await getPremiumState(subscriber);
|
||||
expect(state.billing.pending_subscription_change).toBeNull();
|
||||
expect(state.billing.list_price_switch).toEqual(
|
||||
expect.objectContaining({
|
||||
available: false,
|
||||
reason: null,
|
||||
pending: true,
|
||||
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
|
||||
current_price_id: RETIRED_MONTHLY_BRL,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
list_price_id: MOCK_PRICES.monthlyBrl,
|
||||
list_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
currency: 'BRL',
|
||||
billing_cycle: 'monthly',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves the phase-level settings Stripe would otherwise unset on the live subscription', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_phase_settings',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
const scheduleId = 'sub_sched_phase_settings';
|
||||
const richSchedule = {
|
||||
id: scheduleId,
|
||||
object: 'subscription_schedule',
|
||||
status: 'active',
|
||||
subscription: subscriber.subscriptionId,
|
||||
end_behavior: 'release',
|
||||
metadata: {},
|
||||
current_phase: {start_date: subscriber.periodStart, end_date: subscriber.periodEnd},
|
||||
phases: [
|
||||
{
|
||||
start_date: subscriber.periodStart,
|
||||
end_date: subscriber.periodEnd,
|
||||
currency: 'brl',
|
||||
collection_method: 'send_invoice',
|
||||
description: 'Grandfathered Plutonium',
|
||||
metadata: {campaign: 'brl_2026'},
|
||||
discounts: [{coupon: {id: 'co_grandfather_10'}, discount: null, promotion_code: null}],
|
||||
default_tax_rates: [{id: 'txr_br_icms'}],
|
||||
automatic_tax: {enabled: true, disabled_reason: null, liability: {type: 'self'}},
|
||||
invoice_settings: {account_tax_ids: null, days_until_due: 14, issuer: null},
|
||||
billing_cycle_anchor: 'automatic',
|
||||
add_invoice_items: [],
|
||||
items: [
|
||||
{
|
||||
price: RETIRED_MONTHLY_BRL,
|
||||
quantity: 1,
|
||||
metadata: {seat: 'primary'},
|
||||
discounts: [{coupon: null, discount: 'di_existing', promotion_code: null}],
|
||||
tax_rates: [{id: 'txr_br_iss'}],
|
||||
billing_thresholds: null,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
},
|
||||
],
|
||||
livemode: false,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const scheduleUpdates: Array<Record<string, string>> = [];
|
||||
server.use(
|
||||
http.post('https://api.stripe.com/v1/subscription_schedules', () => HttpResponse.json(richSchedule)),
|
||||
http.post(`https://api.stripe.com/v1/subscription_schedules/${scheduleId}`, async ({request}) => {
|
||||
const formData = await request.formData();
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
entries[key] = String(value);
|
||||
}
|
||||
scheduleUpdates.push(entries);
|
||||
return HttpResponse.json(richSchedule);
|
||||
}),
|
||||
);
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result.status).toBe('scheduled');
|
||||
expect(scheduleUpdates).toHaveLength(1);
|
||||
const update = scheduleUpdates[0]!;
|
||||
expect(update['phases[0][items][0][price]']).toBe(RETIRED_MONTHLY_BRL);
|
||||
expect(update['phases[0][discounts][0][coupon]']).toBe('co_grandfather_10');
|
||||
expect(update['phases[0][default_tax_rates][0]']).toBe('txr_br_icms');
|
||||
expect(update['phases[0][items][0][tax_rates][0]']).toBe('txr_br_iss');
|
||||
expect(update['phases[0][items][0][discounts][0][discount]']).toBe('di_existing');
|
||||
expect(update['phases[0][items][0][metadata][seat]']).toBe('primary');
|
||||
expect(update['phases[0][metadata][campaign]']).toBe('brl_2026');
|
||||
expect(update['phases[0][collection_method]']).toBe('send_invoice');
|
||||
expect(update['phases[0][description]']).toBe('Grandfathered Plutonium');
|
||||
expect(update['phases[0][currency]']).toBe('brl');
|
||||
expect(update['phases[0][automatic_tax][enabled]']).toBe('true');
|
||||
expect(update['phases[0][automatic_tax][liability][type]']).toBe('self');
|
||||
expect(update['phases[0][invoice_settings][days_until_due]']).toBe('14');
|
||||
expect(update['phases[0][billing_cycle_anchor]']).toBe('automatic');
|
||||
expect(update['phases[0][end_date]']).toBe(String(subscriber.periodEnd));
|
||||
expect(update['phases[1][items][0][price]']).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(update['phases[1][discounts][0][coupon]']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('refuses to move a subscriber up to a more expensive list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_below_list',
|
||||
priceId: RETIRED_CHEAP_MONTHLY_BRL,
|
||||
});
|
||||
await expectRefusal(subscriber, 'not_a_price_decrease');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a subscriber who is cancelling at period end', async () => {
|
||||
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_cancel_at_period_end',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
cancelAtPeriodEnd: true,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
await expectRefusal(subscriber, 'subscription_cancelling');
|
||||
expectNoStripeWrites();
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('refuses a subscriber who is cancelling on an explicit cancel_at date', async () => {
|
||||
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_cancel_at',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
cancelAt: periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
await expectRefusal(subscriber, 'subscription_cancelling');
|
||||
expectNoStripeWrites();
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('is idempotent and reports an already scheduled switch on the second call', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_idempotent',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
const first = await switchToListPrice(subscriber);
|
||||
expect(first.status).toBe('scheduled');
|
||||
const second = await switchToListPrice(subscriber);
|
||||
expect(second).toEqual({
|
||||
status: 'already_scheduled',
|
||||
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
|
||||
target_price_id: MOCK_PRICES.monthlyBrl,
|
||||
target_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('refuses a retired price that costs exactly the same as the list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_equal_price',
|
||||
priceId: RETIRED_EQUAL_MONTHLY_BRL,
|
||||
});
|
||||
await expectRefusal(subscriber, 'not_a_price_decrease');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('lets a scheduled switch be withdrawn again and restores eligibility', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_withdrawn',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
expect((await switchToListPrice(subscriber)).status).toBe('scheduled');
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/cancel-pending-subscription-change')
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.releasedSubscriptionSchedules).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.releasedSubscriptionSchedules[0]?.params.preserve_cancel_date).toBe('false');
|
||||
const state = await getPremiumState(subscriber);
|
||||
expect(state.billing.pending_subscription_change).toBeNull();
|
||||
expect(state.billing.list_price_switch).toEqual(
|
||||
expect.objectContaining({
|
||||
available: true,
|
||||
reason: null,
|
||||
pending: false,
|
||||
current_price_id: RETIRED_MONTHLY_BRL,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
list_price_id: MOCK_PRICES.monthlyBrl,
|
||||
list_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a subscriber who is already on the current list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_on_list_price',
|
||||
priceId: MOCK_PRICES.monthlyBrl,
|
||||
});
|
||||
await expectRefusal(subscriber, 'already_on_list_price');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a currency that has no configured list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_try_unconfigured',
|
||||
priceId: RETIRED_MONTHLY_TRY,
|
||||
});
|
||||
await expectRefusal(subscriber, 'no_list_price');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a subscription that is not chargeable', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_past_due',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
status: 'past_due',
|
||||
});
|
||||
await expectRefusal(subscriber, 'subscription_not_chargeable');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a subscriber whose cancellation is managed by a subscription schedule', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_schedule_cancel',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/change-subscription')
|
||||
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, subscriber.token).post('/premium/cancel-subscription').expect(204).execute();
|
||||
stripeHandlers.spies.updatedSubscriptions.length = 0;
|
||||
stripeHandlers.spies.createdSubscriptionSchedules.length = 0;
|
||||
stripeHandlers.spies.updatedSubscriptionSchedules.length = 0;
|
||||
stripeHandlers.spies.releasedSubscriptionSchedules.length = 0;
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result).toEqual({status: 'ineligible', reason: 'subscription_cancelling'});
|
||||
expectNoStripeWrites();
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('refuses when a billing cycle change is already pending', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_pending_cycle_change',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/change-subscription')
|
||||
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
stripeHandlers.spies.updatedSubscriptions.length = 0;
|
||||
stripeHandlers.spies.createdSubscriptionSchedules.length = 0;
|
||||
stripeHandlers.spies.updatedSubscriptionSchedules.length = 0;
|
||||
await expectRefusal(subscriber, 'conflicting_pending_change');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('rejects a user without an active subscription and mirrors that in premium state', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/premium/switch-to-list-price')
|
||||
.expect(400, APIErrorCodes.STRIPE_NO_ACTIVE_SUBSCRIPTION)
|
||||
.execute();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(state.billing.list_price_switch.available).toBe(false);
|
||||
expect(state.billing.list_price_switch.reason).toBe('no_active_subscription');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /premium/change-subscription (unchanged behaviour)', () => {
|
||||
test('still clears a pending cancellation and re-anchors billing when the cycle actually changes', async () => {
|
||||
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_cycle_change',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
cancelAt: periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/change-subscription')
|
||||
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
|
||||
const clearCancelUpdate = stripeHandlers.spies.updatedSubscriptions[0];
|
||||
expect(clearCancelUpdate?.id).toBe('sub_brl_cycle_change');
|
||||
expect(clearCancelUpdate?.params.cancel_at).toBe('');
|
||||
expect(clearCancelUpdate?.params.proration_behavior).toBe('none');
|
||||
expect(clearCancelUpdate?.params.items).toBeUndefined();
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
|
||||
const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0];
|
||||
expect(scheduleUpdate?.params.end_behavior).toBe('release');
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(periodEnd));
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBe('phase_start');
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.yearlyBrl);
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,14 @@ import crypto from 'node:crypto';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import {
|
||||
createInvoiceFinalizationFailedEvent,
|
||||
createInvoicePaidEvent,
|
||||
@@ -41,16 +43,24 @@ const MOCK_PRICES = {
|
||||
gift1YearEur: 'price_gift_1_year_eur',
|
||||
};
|
||||
|
||||
const LEGACY_MONTHLY_BRL_PRICE = 'price_legacy_monthly_brl';
|
||||
const LEGACY_YEARLY_BRL_PRICE = 'price_legacy_yearly_brl';
|
||||
const UNMAPPED_PRICE = 'price_retired_unmapped_brl';
|
||||
const MANDATE_REVOKED_WARNING =
|
||||
'Stripe mandate is no longer active; recurring payments on this payment method will fail';
|
||||
|
||||
describe('Stripe Webhook - Invoice Events', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalWebhookSecret: string | undefined;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
let originalLegacyPrices: typeof Config.stripe.legacyPrices | undefined;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
setupSyncStripeWebhookWorker();
|
||||
originalWebhookSecret = Config.stripe.webhookSecret;
|
||||
originalPrices = Config.stripe.prices;
|
||||
originalLegacyPrices = Config.stripe.legacyPrices;
|
||||
Config.stripe.webhookSecret = 'whsec_test_secret';
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
stripeHandlers = createStripeApiHandlers();
|
||||
@@ -60,9 +70,11 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.webhookSecret = originalWebhookSecret;
|
||||
Config.stripe.prices = originalPrices;
|
||||
Config.stripe.legacyPrices = originalLegacyPrices;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
Config.stripe.legacyPrices = undefined;
|
||||
stripeHandlers.reset();
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
@@ -104,7 +116,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
subscriptionId: string;
|
||||
priceId: string;
|
||||
productType: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
const {userId, subscriptionId, priceId, productType} = params;
|
||||
const checkoutSessionId = `cs_test_${crypto.randomUUID()}`;
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
@@ -127,6 +139,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
currency: 'usd',
|
||||
completed_at: new Date(),
|
||||
});
|
||||
return checkoutSessionId;
|
||||
}
|
||||
async function setSubscriptionUserState(params: {
|
||||
accountUserId: string;
|
||||
@@ -856,4 +869,365 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
expect(me.premium_until).not.toBeNull();
|
||||
});
|
||||
});
|
||||
describe('renewals on retired prices', () => {
|
||||
test('renews a subscription whose price is only known to the legacy price map', async () => {
|
||||
Config.stripe.legacyPrices = {monthly_brl: [LEGACY_MONTHLY_BRL_PRICE]};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_legacy_brl_renewal';
|
||||
const customerId = 'cus_legacy_brl_renewal';
|
||||
const invoiceId = 'in_legacy_brl_renewal';
|
||||
const checkoutSessionId = await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: LEGACY_MONTHLY_BRL_PRICE,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[subscriptionId]: {
|
||||
customer: customerId,
|
||||
price_id: LEGACY_MONTHLY_BRL_PRICE,
|
||||
currency: 'brl',
|
||||
interval: 'month',
|
||||
item_id: 'si_legacy_brl_renewal',
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodStart + 30 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}).handlers,
|
||||
);
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: invoiceId,
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: customerId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_billing_cycle: string | null;
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_billing_cycle).toBe('monthly');
|
||||
expect(me.premium_until).not.toBeNull();
|
||||
expect(new Date(me.premium_until!).toISOString()).toBe(
|
||||
new Date((currentPeriodStart + 30 * 24 * 60 * 60) * 1000).toISOString(),
|
||||
);
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const payment = await new PaymentRepository().getPaymentByCheckoutSession(checkoutSessionId);
|
||||
expect(payment?.invoiceId).toBe(invoiceId);
|
||||
});
|
||||
test('renews a subscription whose retired price sits in a legacy slot alongside other price ids', async () => {
|
||||
Config.stripe.legacyPrices = {
|
||||
monthly_brl: ['price_legacy_monthly_brl_older', LEGACY_MONTHLY_BRL_PRICE],
|
||||
yearly_brl: [LEGACY_YEARLY_BRL_PRICE],
|
||||
};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_legacy_brl_yearly_renewal';
|
||||
const customerId = 'cus_legacy_brl_yearly_renewal';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: LEGACY_YEARLY_BRL_PRICE,
|
||||
productType: ProductType.YEARLY_SUBSCRIPTION,
|
||||
});
|
||||
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[subscriptionId]: {
|
||||
customer: customerId,
|
||||
price_id: LEGACY_YEARLY_BRL_PRICE,
|
||||
currency: 'brl',
|
||||
interval: 'year',
|
||||
item_id: 'si_legacy_brl_yearly_renewal',
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodStart + 365 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}).handlers,
|
||||
);
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_legacy_brl_yearly_renewal',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: customerId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_billing_cycle: string | null;
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_billing_cycle).toBe('yearly');
|
||||
expect(new Date(me.premium_until!).toISOString()).toBe(
|
||||
new Date((currentPeriodStart + 365 * 24 * 60 * 60) * 1000).toISOString(),
|
||||
);
|
||||
});
|
||||
test('still rejects a renewal whose price is in neither the configured nor the legacy price map', async () => {
|
||||
Config.stripe.legacyPrices = {monthly_brl: [LEGACY_MONTHLY_BRL_PRICE]};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_unmapped_price_renewal';
|
||||
const customerId = 'cus_unmapped_price_renewal';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: UNMAPPED_PRICE,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
stripe_subscription_id: subscriptionId,
|
||||
stripe_customer_id: customerId,
|
||||
})
|
||||
.execute();
|
||||
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[subscriptionId]: {
|
||||
customer: customerId,
|
||||
price_id: UNMAPPED_PRICE,
|
||||
currency: 'brl',
|
||||
interval: 'month',
|
||||
item_id: 'si_unmapped_price_renewal',
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodStart + 30 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}).handlers,
|
||||
);
|
||||
await sendWebhookExpectStripeError({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_unmapped_price_renewal',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: customerId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.NONE);
|
||||
expect(me.premium_until).toBeNull();
|
||||
});
|
||||
test('lets an authored price win over a legacy entry that claims the same price id', async () => {
|
||||
Config.stripe.legacyPrices = {yearly_brl: [MOCK_PRICES.monthlyUsd]};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_authored_wins_over_legacy';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: MOCK_PRICES.monthlyUsd,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_authored_wins_over_legacy',
|
||||
billing_reason: 'subscription_cycle',
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_billing_cycle: string | null;
|
||||
premium_type: number | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_billing_cycle).toBe('monthly');
|
||||
});
|
||||
});
|
||||
describe('donation subscription guard', () => {
|
||||
test('does not extend premium for an invoice on a donation subscription', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_donation_recurring';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: MOCK_PRICES.monthlyUsd,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const {DonationRepository} = await import('../../donation/DonationRepository');
|
||||
await new DonationRepository().createDonor({
|
||||
email: '[email protected]',
|
||||
stripeCustomerId: 'cus_donation_recurring',
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
subscriptionAmountCents: 1000,
|
||||
subscriptionCurrency: 'usd',
|
||||
subscriptionInterval: 'month',
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_donation_recurring',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: 'cus_donation_recurring',
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.NONE);
|
||||
expect(me.premium_until).toBeNull();
|
||||
});
|
||||
test('still renews when only the donor customer id matches and the subscription id does not', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sharedCustomerId = 'cus_donor_and_subscriber';
|
||||
const premiumSubscriptionId = 'sub_premium_beside_donation';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId: premiumSubscriptionId,
|
||||
priceId: MOCK_PRICES.monthlyUsd,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const {DonationRepository} = await import('../../donation/DonationRepository');
|
||||
await new DonationRepository().createDonor({
|
||||
email: '[email protected]',
|
||||
stripeCustomerId: sharedCustomerId,
|
||||
stripeSubscriptionId: 'sub_donation_beside_premium',
|
||||
subscriptionAmountCents: 1000,
|
||||
subscriptionCurrency: 'usd',
|
||||
subscriptionInterval: 'month',
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_premium_beside_donation',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: sharedCustomerId,
|
||||
parent: {subscription_details: {subscription: premiumSubscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_until).not.toBeNull();
|
||||
});
|
||||
});
|
||||
describe('mandate.updated', () => {
|
||||
function captureLoggerWarnings(): {
|
||||
messages: Array<unknown>;
|
||||
restore: () => void;
|
||||
} {
|
||||
const activeLogger = Logger.child({}) as unknown as NoopLogger;
|
||||
expect(activeLogger).toBeInstanceOf(NoopLogger);
|
||||
const messages: Array<unknown> = [];
|
||||
const spy = vi.spyOn(activeLogger, 'warn').mockImplementation((...args: Array<unknown>) => {
|
||||
messages.push(args[args.length - 1]);
|
||||
});
|
||||
return {messages, restore: () => spy.mockRestore()};
|
||||
}
|
||||
function revocationWarnings(messages: Array<unknown>): Array<unknown> {
|
||||
return messages.filter((message) => message === MANDATE_REVOKED_WARNING);
|
||||
}
|
||||
test('logs the revocation warning and leaves premium untouched when a mandate goes inactive', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const premiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
premium_type: UserPremiumTypes.SUBSCRIPTION,
|
||||
premium_until: premiumUntil.toISOString(),
|
||||
stripe_subscription_id: 'sub_pix_mandate',
|
||||
stripe_customer_id: 'cus_pix_mandate',
|
||||
})
|
||||
.execute();
|
||||
const warnings = captureLoggerWarnings();
|
||||
try {
|
||||
const result = await sendWebhook({
|
||||
type: 'mandate.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'mandate_test_inactive',
|
||||
object: 'mandate',
|
||||
status: 'inactive',
|
||||
payment_method: 'pm_pix_mandate',
|
||||
type: 'multi_use',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
expect(revocationWarnings(warnings.messages)).toHaveLength(1);
|
||||
} finally {
|
||||
warnings.restore();
|
||||
}
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_until).toBe(premiumUntil.toISOString());
|
||||
});
|
||||
test('does not log the revocation warning for a pending or still-active mandate', async () => {
|
||||
const warnings = captureLoggerWarnings();
|
||||
try {
|
||||
for (const status of ['pending', 'active'] as const) {
|
||||
const result = await sendWebhook({
|
||||
type: 'mandate.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: `mandate_test_${status}`,
|
||||
object: 'mandate',
|
||||
status,
|
||||
payment_method: {id: 'pm_pix_mandate', object: 'payment_method', type: 'pix'},
|
||||
type: 'multi_use',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
}
|
||||
expect(revocationWarnings(warnings.messages)).toHaveLength(0);
|
||||
} finally {
|
||||
warnings.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,14 +49,14 @@ describe('Stripe Webhook Refund', () => {
|
||||
.body(payload)
|
||||
.execute();
|
||||
}
|
||||
function useRefundListHandler(chargeId: string): void {
|
||||
function useRefundListHandler(chargeId: string, refunds: Array<Record<string, unknown>> = []): void {
|
||||
server.use(
|
||||
http.get(
|
||||
({request}) => request.url === `https://api.stripe.com/v1/refunds?charge=${chargeId}&limit=100`,
|
||||
() =>
|
||||
HttpResponse.json({
|
||||
object: 'list',
|
||||
data: [],
|
||||
data: refunds,
|
||||
has_more: false,
|
||||
url: '/v1/refunds',
|
||||
}),
|
||||
@@ -155,6 +155,366 @@ describe('Stripe Webhook Refund', () => {
|
||||
expect(updatedPayment).not.toBeNull();
|
||||
expect(updatedPayment!.status).toBe('refunded');
|
||||
});
|
||||
test('counts a refund once when the same charge.refunded event is redelivered', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_retry_123';
|
||||
const checkoutSessionId = 'cs_test_refund_retry_123';
|
||||
const chargeId = 'ch_test_refund_retry_123';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
useRefundListHandler(chargeId, [
|
||||
{
|
||||
id: 're_test_refund_retry_123',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_1'});
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_2'});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).not.toBeNull();
|
||||
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('counts a refund once when charge.refunded lands before the refund record exists', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_late_record';
|
||||
const checkoutSessionId = 'cs_test_refund_late_record';
|
||||
const chargeId = 'ch_test_refund_late_record';
|
||||
const refundId = 're_test_refund_late_record';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
const refund = {
|
||||
id: refundId,
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {},
|
||||
};
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
};
|
||||
useRefundListHandler(chargeId);
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_1'});
|
||||
const afterFallback = await userRepository.findUnique(userId);
|
||||
expect(afterFallback!.firstRefundAt).not.toBeNull();
|
||||
expect(afterFallback!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
await sendWebhook({
|
||||
id: 'evt_test_refund_late_record_2',
|
||||
type: 'refund.created',
|
||||
data: {object: refund},
|
||||
});
|
||||
useRefundListHandler(chargeId, [refund]);
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_3'});
|
||||
const afterRecord = await userRepository.findUnique(userId);
|
||||
expect(afterRecord!.firstRefundAt!.getTime()).toBe(afterFallback!.firstRefundAt!.getTime());
|
||||
expect(afterRecord!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('does not count a refund Fluxer issued itself against the refund allowance', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_system_123';
|
||||
const checkoutSessionId = 'cs_test_refund_system_123';
|
||||
const chargeId = 'ch_test_refund_system_123';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
useRefundListHandler(chargeId, [
|
||||
{
|
||||
id: 're_test_refund_system_123',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {rejection_reason: 'duplicate_active_subscription'},
|
||||
},
|
||||
]);
|
||||
await sendWebhook({
|
||||
type: 'charge.refunded',
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('does not count a refund Fluxer issued for a localized card country mismatch', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_card_mismatch';
|
||||
const checkoutSessionId = 'cs_test_refund_card_mismatch';
|
||||
const chargeId = 'ch_test_refund_card_mismatch';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
useRefundListHandler(chargeId, [
|
||||
{
|
||||
id: 're_test_refund_card_mismatch',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {rejection_reason: 'localized_card_country_mismatch'},
|
||||
},
|
||||
]);
|
||||
await sendWebhook({
|
||||
type: 'charge.refunded',
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
const updatedPayment = await userRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
expect(updatedPayment!.status).toBe('refunded');
|
||||
});
|
||||
test('counts only the customer refund when Fluxer issued a later system refund on the same charge', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_mixed';
|
||||
const checkoutSessionId = 'cs_test_refund_mixed';
|
||||
const chargeId = 'ch_test_refund_mixed';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
refunds: {
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
url: `/v1/charges/${chargeId}/refunds`,
|
||||
data: [
|
||||
{
|
||||
id: 're_test_refund_mixed_customer',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 1500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: nowSeconds - 600,
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: 're_test_refund_mixed_system',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 1000,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: nowSeconds,
|
||||
metadata: {rejection_reason: 'duplicate_active_subscription'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_1'});
|
||||
const afterFirst = await userRepository.findUnique(userId);
|
||||
expect(afterFirst!.firstRefundAt).not.toBeNull();
|
||||
expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_2'});
|
||||
const afterRetry = await userRepository.findUnique(userId);
|
||||
expect(afterRetry!.firstRefundAt!.getTime()).toBe(afterFirst!.firstRefundAt!.getTime());
|
||||
expect(afterRetry!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('counts a second, distinct refund against the allowance after the first one was already counted', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_ladder';
|
||||
const checkoutSessionId = 'cs_test_refund_ladder';
|
||||
const chargeId = 'ch_test_refund_ladder';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
function chargeEventWithRefunds(refunds: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
refunds: {
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
url: `/v1/charges/${chargeId}/refunds`,
|
||||
data: refunds,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const firstRefund = {
|
||||
id: 're_test_refund_ladder_1',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 1200,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: nowSeconds - 600,
|
||||
metadata: {},
|
||||
};
|
||||
const secondRefund = {
|
||||
...firstRefund,
|
||||
id: 're_test_refund_ladder_2',
|
||||
amount: 1300,
|
||||
created: nowSeconds,
|
||||
};
|
||||
await sendWebhook({...chargeEventWithRefunds([firstRefund]), id: 'evt_test_refund_ladder_1'});
|
||||
const afterFirst = await userRepository.findUnique(userId);
|
||||
expect(afterFirst!.firstRefundAt).not.toBeNull();
|
||||
expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
await sendWebhook({...chargeEventWithRefunds([firstRefund, secondRefund]), id: 'evt_test_refund_ladder_2'});
|
||||
const afterSecond = await userRepository.findUnique(userId);
|
||||
expect(afterSecond!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(PremiumFlags.PURCHASE_DISABLED);
|
||||
});
|
||||
test('falls back to customer ID when payment intent is not indexed (subscription mode)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
@@ -113,7 +113,10 @@ export function setupSyncStripeWebhookWorker(): void {
|
||||
setInjectedWorkerService(new SyncTaskWorkerService({processStripeWebhook}));
|
||||
}
|
||||
|
||||
let originalWebhookSecretDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
|
||||
originalWebhookSecretDescriptor ??= Object.getOwnPropertyDescriptor(Config.stripe, 'webhookSecret');
|
||||
Object.defineProperty(Config.stripe, 'webhookSecret', {
|
||||
get: () => secret,
|
||||
configurable: true,
|
||||
@@ -121,6 +124,11 @@ export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
|
||||
}
|
||||
|
||||
export function restoreStripeWebhookSecret(): void {
|
||||
if (originalWebhookSecretDescriptor) {
|
||||
Object.defineProperty(Config.stripe, 'webhookSecret', originalWebhookSecretDescriptor);
|
||||
originalWebhookSecretDescriptor = undefined;
|
||||
return;
|
||||
}
|
||||
delete (
|
||||
Config.stripe as {
|
||||
webhookSecret?: string;
|
||||
|
||||
@@ -64,6 +64,14 @@ interface StripeApiMockConfig {
|
||||
paymentMethods?: Record<string, Partial<MockStripePaymentMethod>>;
|
||||
setupIntents?: Record<string, Partial<MockStripeSetupIntent>>;
|
||||
subscriptions?: Record<string, Partial<MockStripeSubscriptionState>>;
|
||||
prices?: Record<string, MockStripePriceOverrides>;
|
||||
}
|
||||
|
||||
interface MockStripePriceOverrides {
|
||||
unit_amount?: number;
|
||||
currency?: string;
|
||||
interval?: 'month' | 'year';
|
||||
product?: string;
|
||||
}
|
||||
|
||||
interface SubscriptionScheduleParams {
|
||||
@@ -302,6 +310,7 @@ interface MockStripeSubscriptionState {
|
||||
customer: string;
|
||||
trial_end: number | null;
|
||||
price_id: string;
|
||||
unit_amount: number;
|
||||
currency: string;
|
||||
interval: 'month' | 'year';
|
||||
item_id: string;
|
||||
@@ -344,6 +353,16 @@ interface MockStripeSubscriptionSchedule {
|
||||
}>;
|
||||
}
|
||||
|
||||
const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const;
|
||||
|
||||
function inferPriceIdCurrency(normalizedPriceId: string): string {
|
||||
return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd';
|
||||
}
|
||||
|
||||
function inferPriceIdInterval(normalizedPriceId: string): 'month' | 'year' {
|
||||
return normalizedPriceId.includes('year') ? 'year' : 'month';
|
||||
}
|
||||
|
||||
function parseFormDataToObject<T extends object = Record<string, unknown>>(formData: FormData): T {
|
||||
const result: Record<string, unknown> = {};
|
||||
const isArrayIndex = (segment: string): boolean => /^\d+$/.test(segment);
|
||||
@@ -788,18 +807,8 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
function inferSubscriptionPriceState(priceId: string): Pick<MockStripeSubscriptionState, 'currency' | 'interval'> {
|
||||
const normalizedPriceId = priceId.toLowerCase();
|
||||
return {
|
||||
currency: normalizedPriceId.includes('eur')
|
||||
? 'eur'
|
||||
: normalizedPriceId.includes('brl')
|
||||
? 'brl'
|
||||
: normalizedPriceId.includes('inr')
|
||||
? 'inr'
|
||||
: normalizedPriceId.includes('pln')
|
||||
? 'pln'
|
||||
: normalizedPriceId.includes('try')
|
||||
? 'try'
|
||||
: 'usd',
|
||||
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
|
||||
currency: inferPriceIdCurrency(normalizedPriceId),
|
||||
interval: inferPriceIdInterval(normalizedPriceId),
|
||||
};
|
||||
}
|
||||
function createDefaultSubscriptionState(): MockStripeSubscriptionState {
|
||||
@@ -808,6 +817,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
customer: 'cus_test_1',
|
||||
trial_end: null,
|
||||
price_id: 'price_test_1',
|
||||
unit_amount: 2500,
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
item_id: 'si_test_1',
|
||||
@@ -856,7 +866,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
price: {
|
||||
id: subState.price_id,
|
||||
object: 'price',
|
||||
unit_amount: 2500,
|
||||
unit_amount: subState.unit_amount,
|
||||
currency: subState.currency,
|
||||
recurring: {
|
||||
interval: subState.interval,
|
||||
@@ -1703,28 +1713,19 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
http.get(`${STRIPE_API_BASE}/v1/prices/:id`, ({params}) => {
|
||||
const {id} = params;
|
||||
const normalizedPriceId = String(id).toLowerCase();
|
||||
const overrides = config.prices?.[String(id)];
|
||||
return HttpResponse.json({
|
||||
id,
|
||||
object: 'price',
|
||||
active: true,
|
||||
currency: normalizedPriceId.includes('eur')
|
||||
? 'eur'
|
||||
: normalizedPriceId.includes('brl')
|
||||
? 'brl'
|
||||
: normalizedPriceId.includes('inr')
|
||||
? 'inr'
|
||||
: normalizedPriceId.includes('pln')
|
||||
? 'pln'
|
||||
: normalizedPriceId.includes('try')
|
||||
? 'try'
|
||||
: 'usd',
|
||||
unit_amount: normalizedPriceId.includes('year') ? 4999 : 499,
|
||||
currency: overrides?.currency ?? inferPriceIdCurrency(normalizedPriceId),
|
||||
unit_amount: overrides?.unit_amount ?? (inferPriceIdInterval(normalizedPriceId) === 'year' ? 4999 : 499),
|
||||
type: 'recurring',
|
||||
recurring: {
|
||||
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
|
||||
interval: overrides?.interval ?? inferPriceIdInterval(normalizedPriceId),
|
||||
interval_count: 1,
|
||||
},
|
||||
product: 'prod_test_1',
|
||||
product: overrides?.product ?? 'prod_test_1',
|
||||
livemode: false,
|
||||
created: Math.floor(Date.now() / 1000) - 365 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isDerivedTrait, isServerManagedTrait, resolveAssignedTraits} from './UserTraits';
|
||||
|
||||
describe('UserTraits', () => {
|
||||
it('treats premium as derived rather than assignable', () => {
|
||||
expect(isDerivedTrait('premium')).toBe(true);
|
||||
expect(isDerivedTrait('beta-tester')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognises every trait shape the server manages', () => {
|
||||
expect(isServerManagedTrait('sso')).toBe(true);
|
||||
expect(isServerManagedTrait('sso:acme')).toBe(true);
|
||||
expect(isServerManagedTrait('sso_provider:0123456789abcdef')).toBe(true);
|
||||
expect(isServerManagedTrait('sso_identity:0123456789abcdef')).toBe(true);
|
||||
expect(isServerManagedTrait('registration_pending_approval')).toBe(true);
|
||||
expect(isServerManagedTrait('registration_rejected')).toBe(true);
|
||||
expect(isServerManagedTrait('beta-tester')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the operator traits it was given', () => {
|
||||
expect([...resolveAssignedTraits([], ['beta-tester', 'experimental'])]).toEqual(['beta-tester', 'experimental']);
|
||||
});
|
||||
|
||||
it('drops premium because nothing reads the stored value', () => {
|
||||
expect([...resolveAssignedTraits([], ['premium'])]).toEqual([]);
|
||||
expect([...resolveAssignedTraits([], ['beta-tester', 'premium'])]).toEqual(['beta-tester']);
|
||||
});
|
||||
|
||||
it('keeps a premium user premium when the operator saves other traits', () => {
|
||||
expect([...resolveAssignedTraits(['premium'], ['beta-tester'])]).toEqual(['beta-tester']);
|
||||
});
|
||||
|
||||
it('preserves server managed traits the operator did not send', () => {
|
||||
const resolved = resolveAssignedTraits(
|
||||
['sso', 'sso:acme', 'sso_identity:0123456789abcdef', 'beta-tester'],
|
||||
['experimental'],
|
||||
);
|
||||
expect([...resolved].sort()).toEqual(['experimental', 'sso', 'sso:acme', 'sso_identity:0123456789abcdef']);
|
||||
});
|
||||
|
||||
it('keeps a pending approval user pending when their traits are cleared', () => {
|
||||
expect([...resolveAssignedTraits(['registration_pending_approval'], [])]).toEqual([
|
||||
'registration_pending_approval',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses to let an operator forge a server managed trait', () => {
|
||||
expect([...resolveAssignedTraits([], ['sso', 'sso_identity:forged'])]).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores empty trait names', () => {
|
||||
expect([...resolveAssignedTraits([], ['', 'beta-tester'])]).toEqual(['beta-tester']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const DERIVED_TRAITS = new Set(['premium']);
|
||||
|
||||
const SERVER_MANAGED_TRAITS = new Set(['sso', 'registration_pending_approval', 'registration_rejected']);
|
||||
|
||||
const SERVER_MANAGED_TRAIT_PREFIXES = ['sso:', 'sso_provider:', 'sso_identity:'];
|
||||
|
||||
export function isDerivedTrait(trait: string): boolean {
|
||||
return DERIVED_TRAITS.has(trait);
|
||||
}
|
||||
|
||||
export function isServerManagedTrait(trait: string): boolean {
|
||||
return SERVER_MANAGED_TRAITS.has(trait) || SERVER_MANAGED_TRAIT_PREFIXES.some((prefix) => trait.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function resolveAssignedTraits(current: Iterable<string>, requested: Iterable<string>): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const trait of requested) {
|
||||
if (!trait || isDerivedTrait(trait) || isServerManagedTrait(trait)) {
|
||||
continue;
|
||||
}
|
||||
next.add(trait);
|
||||
}
|
||||
for (const trait of current) {
|
||||
if (isServerManagedTrait(trait)) {
|
||||
next.add(trait);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -125,12 +125,13 @@ describe('User Settings synced_preferences', () => {
|
||||
});
|
||||
test('reports TOO_LARGE for a snapshot inside the encoded-length bound but over the byte cap', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const baseEntries = Math.floor((SYNCED_PREFERENCES_MAX_BYTES - 8192) / 1006);
|
||||
const build = (tailLength: number) =>
|
||||
encodeSyncedPreferences(
|
||||
create(SyncedPreferencesSchema, {
|
||||
localSpamOverrides: create(LocalUserSpamOverridesSchema, {
|
||||
spammerUserIds: [
|
||||
...Array.from({length: 260}, (_, i) => `${i}-${'x'.repeat(1000)}`),
|
||||
...Array.from({length: baseEntries}, (_, i) => `${i}-${'x'.repeat(1000)}`),
|
||||
'y'.repeat(tailLength),
|
||||
],
|
||||
}),
|
||||
@@ -138,7 +139,13 @@ describe('User Settings synced_preferences', () => {
|
||||
);
|
||||
let tail = 1;
|
||||
let encoded = build(tail);
|
||||
while (encodedSyncedPreferencesByteLength(encoded) < SYNCED_PREFERENCES_MAX_BYTES + 1 && tail < 8000) {
|
||||
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
|
||||
tail += 512;
|
||||
encoded = build(tail);
|
||||
}
|
||||
tail = Math.max(1, tail - 512);
|
||||
encoded = build(tail);
|
||||
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
|
||||
tail += 1;
|
||||
encoded = build(tail);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'BRL' | 'INR' | 'PLN' | 'TRY';
|
||||
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
|
||||
|
||||
export function getCurrency(countryCode: string | null | undefined): Currency {
|
||||
return getCurrencyPreferences(countryCode)[0];
|
||||
@@ -16,12 +16,21 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
|
||||
if (upperCode === 'BR') {
|
||||
return ['BRL', 'USD', 'EUR'];
|
||||
}
|
||||
if (upperCode === 'DK') {
|
||||
return ['DKK', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'IN') {
|
||||
return ['INR', 'USD', 'EUR'];
|
||||
}
|
||||
if (upperCode === 'NO') {
|
||||
return ['NOK', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'PL') {
|
||||
return ['PLN', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'SE') {
|
||||
return ['SEK', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'TR') {
|
||||
return ['TRY', 'USD', 'EUR'];
|
||||
}
|
||||
@@ -31,21 +40,10 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
|
||||
return ['USD', 'EUR'];
|
||||
}
|
||||
|
||||
export function getBaseCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
if (!countryCode) {
|
||||
return ['USD', 'EUR'];
|
||||
}
|
||||
const upperCode = countryCode.toUpperCase();
|
||||
if (isEuEeaCountryCode(upperCode)) {
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
return ['USD', 'EUR'];
|
||||
}
|
||||
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
|
||||
|
||||
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getCurrencyPreferences(countryCode);
|
||||
}
|
||||
|
||||
export function getBaseGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getBaseCurrencyPreferences(countryCode);
|
||||
return getCurrencyPreferences(countryCode).filter(
|
||||
(currency) => currency === 'USD' || currency === 'EUR' || GIFT_ELIGIBLE_LOCALIZED_CURRENCIES.has(currency),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getCurrency} from '../CurrencyUtils';
|
||||
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../CurrencyUtils';
|
||||
|
||||
describe('getCurrency', () => {
|
||||
describe('returns USD for non-EEA countries', () => {
|
||||
@@ -55,11 +55,11 @@ describe('getCurrency', () => {
|
||||
it('returns EUR for Finland', () => {
|
||||
expect(getCurrency('FI')).toBe('EUR');
|
||||
});
|
||||
it('returns EUR for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('EUR');
|
||||
it('returns SEK for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('SEK');
|
||||
});
|
||||
it('returns EUR for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('EUR');
|
||||
it('returns DKK for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('DKK');
|
||||
});
|
||||
it('returns PLN for Poland', () => {
|
||||
expect(getCurrency('PL')).toBe('PLN');
|
||||
@@ -76,8 +76,8 @@ describe('getCurrency', () => {
|
||||
it('returns EUR for Romania', () => {
|
||||
expect(getCurrency('RO')).toBe('EUR');
|
||||
});
|
||||
it('returns EUR for Norway (EEA but not EU)', () => {
|
||||
expect(getCurrency('NO')).toBe('EUR');
|
||||
it('returns NOK for Norway (EEA but not EU)', () => {
|
||||
expect(getCurrency('NO')).toBe('NOK');
|
||||
});
|
||||
it('returns EUR for Iceland (EEA but not EU)', () => {
|
||||
expect(getCurrency('IS')).toBe('EUR');
|
||||
@@ -128,7 +128,6 @@ describe('getCurrency', () => {
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
@@ -147,10 +146,8 @@ describe('getCurrency', () => {
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'IS',
|
||||
'LI',
|
||||
'NO',
|
||||
];
|
||||
for (const country of eeaCountries) {
|
||||
it(`returns EUR for ${country}`, () => {
|
||||
@@ -160,5 +157,45 @@ describe('getCurrency', () => {
|
||||
it('uses local currency for Poland', () => {
|
||||
expect(getCurrency('PL')).toBe('PLN');
|
||||
});
|
||||
it('uses local currency for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('SEK');
|
||||
});
|
||||
it('uses local currency for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('DKK');
|
||||
});
|
||||
it('uses local currency for Norway', () => {
|
||||
expect(getCurrency('NO')).toBe('NOK');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGiftCurrencyPreferences', () => {
|
||||
it('never offers a localized currency that is cheaper than the base price', () => {
|
||||
for (const country of ['BR', 'IN', 'PL', 'TR']) {
|
||||
expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]);
|
||||
}
|
||||
});
|
||||
it('offers the localized currency where it is not cheaper than the base price', () => {
|
||||
expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']);
|
||||
});
|
||||
it('uses EUR for other EEA countries', () => {
|
||||
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('PL')).toEqual(['EUR', 'USD']);
|
||||
});
|
||||
it('uses USD everywhere else', () => {
|
||||
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('IN')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('TR')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('US')).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
it('uses USD when the country is unknown', () => {
|
||||
expect(getGiftCurrencyPreferences(null)).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences(undefined)).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
it('is case insensitive', () => {
|
||||
expect(getGiftCurrencyPreferences('se')).toEqual(['SEK', 'EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('br')).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {type ChannelID, createChannelID, createMessageID, createUserID, type MessageID} from '../../BrandedTypes';
|
||||
import {harvestMessages} from './HarvestUserData';
|
||||
|
||||
const AUTHOR = createUserID(1000000000000000000n);
|
||||
|
||||
function makeRepository(refs: Array<{channelId: ChannelID; messageId: MessageID}>, missing = new Set<string>()) {
|
||||
let pages = 0;
|
||||
return {
|
||||
pages: () => pages,
|
||||
listMessagesByAuthor: async (_userId: typeof AUTHOR, limit: number, lastMessageId?: MessageID) => {
|
||||
pages++;
|
||||
const start = lastMessageId ? refs.findIndex((r) => r.messageId === lastMessageId) + 1 : 0;
|
||||
return refs.slice(start, start + limit);
|
||||
},
|
||||
getMessage: async (_channelId: ChannelID, messageId: MessageID) =>
|
||||
missing.has(messageId.toString()) ? null : {content: `body ${messageId.toString()}`, attachments: undefined},
|
||||
};
|
||||
}
|
||||
|
||||
function refsAcross(channelCount: number, perChannel: number) {
|
||||
const out: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
let id = 1500000000000000000n;
|
||||
for (let c = 0; c < channelCount; c++) {
|
||||
const channelId = createChannelID(2000000000000000000n + BigInt(c));
|
||||
for (let m = 0; m < perChannel; m++) {
|
||||
out.push({channelId, messageId: createMessageID(id)});
|
||||
id += 1n;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('harvestMessages', () => {
|
||||
it('reads past a single page instead of stopping at one query', async () => {
|
||||
const refs = refsAcross(1, 2500);
|
||||
const repo = makeRepository(refs);
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.totalMessages).toBe(2500);
|
||||
expect(repo.pages()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('groups every message under its own channel', async () => {
|
||||
const repo = makeRepository(refsAcross(3, 4));
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.channelMessagesMap.size).toBe(3);
|
||||
for (const messages of result.channelMessagesMap.values()) {
|
||||
expect(messages).toHaveLength(4);
|
||||
}
|
||||
expect(result.totalMessages).toBe(12);
|
||||
});
|
||||
|
||||
it('leaves out a message the repository cannot return', async () => {
|
||||
const refs = refsAcross(1, 5);
|
||||
const repo = makeRepository(refs, new Set([refs[2].messageId.toString()]));
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.totalMessages).toBe(4);
|
||||
});
|
||||
|
||||
it('returns an empty map for an account with no messages', async () => {
|
||||
const repo = makeRepository([]);
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.totalMessages).toBe(0);
|
||||
expect(result.channelMessagesMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -171,6 +171,10 @@ interface ArchiveResult {
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
// A harvest is every message the account wrote, so the read pages to the end of
|
||||
// the account rather than stopping at a count. The page size is what bounds one
|
||||
// query, not what bounds the archive.
|
||||
const HARVEST_MESSAGE_CHUNK_SIZE = 1000;
|
||||
const CONCURRENT_MESSAGE_LIMIT = 10;
|
||||
const INITIAL_PROGRESS = 5;
|
||||
const MESSAGES_PROGRESS_MAX = 55;
|
||||
@@ -226,11 +230,12 @@ interface HarvestMessagesFilterArgs {
|
||||
findChannel: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
}
|
||||
|
||||
async function harvestMessages(
|
||||
export async function harvestMessages(
|
||||
channelRepository: {
|
||||
listMessagesByAuthor: (
|
||||
userId: UserID,
|
||||
limit: number,
|
||||
lastMessageId?: MessageID,
|
||||
) => Promise<
|
||||
Array<{
|
||||
channelId: ChannelID;
|
||||
@@ -250,24 +255,78 @@ async function harvestMessages(
|
||||
filterArgs: HarvestMessagesFilterArgs | null,
|
||||
): Promise<HarvestMessageResult> {
|
||||
const channelMessagesMap = new Map<string, Array<HarvestedMessage>>();
|
||||
const channelEligibility = filterArgs ? new Map<string, boolean>() : null;
|
||||
Logger.debug('Fetching all user messages');
|
||||
const startFetchTime = Date.now();
|
||||
const messageRefs = await channelRepository.listMessagesByAuthor(userId, 100000);
|
||||
Logger.debug(
|
||||
{
|
||||
totalMessages: messageRefs.length,
|
||||
fetchElapsed: Date.now() - startFetchTime,
|
||||
totalElapsed: Date.now() - startTime,
|
||||
},
|
||||
'All messages retrieved',
|
||||
);
|
||||
if (messageRefs.length === 0) {
|
||||
return {channelMessagesMap, totalMessages: 0};
|
||||
}
|
||||
const channelEligibility = filterArgs ? new Map<string, boolean>() : null;
|
||||
const filteredRefs: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
if (filterArgs) {
|
||||
for (const ref of messageRefs) {
|
||||
let lastMessageId: MessageID | undefined;
|
||||
let scannedMessages = 0;
|
||||
let totalMessages = 0;
|
||||
|
||||
const readMessage = async ({
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<ChannelHarvestResult | null> => {
|
||||
try {
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Message not found during harvest',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const timestamp = snowflakeToDate(messageId);
|
||||
const attachments: Array<HarvestedAttachment> = [];
|
||||
if (message.attachments) {
|
||||
for (const attachment of message.attachments) {
|
||||
attachments.push({
|
||||
attachment_id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
size: attachment.size.toString(),
|
||||
content_type: attachment.contentType,
|
||||
content_hash: null,
|
||||
archive_path: null,
|
||||
cdn_url: makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
channelId: channelId.toString(),
|
||||
messageData: {
|
||||
id: messageId.toString(),
|
||||
timestamp: timestamp.toISOString(),
|
||||
content: message.content ?? '',
|
||||
attachments,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Failed to process message during harvest',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const page = await channelRepository.listMessagesByAuthor(userId, HARVEST_MESSAGE_CHUNK_SIZE, lastMessageId);
|
||||
if (page.length === 0) {
|
||||
break;
|
||||
}
|
||||
scannedMessages += page.length;
|
||||
lastMessageId = page[page.length - 1].messageId;
|
||||
|
||||
const pageRefs: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
for (const ref of page) {
|
||||
if (!filterArgs) {
|
||||
pageRefs.push(ref);
|
||||
continue;
|
||||
}
|
||||
const ts = snowflakeToDate(ref.messageId).getTime();
|
||||
if (!isTimestampInWindow(ts, filterArgs.filter)) {
|
||||
continue;
|
||||
@@ -280,76 +339,41 @@ async function harvestMessages(
|
||||
channelEligibility!.set(channelIdStr, eligible);
|
||||
}
|
||||
if (eligible) {
|
||||
filteredRefs.push(ref);
|
||||
pageRefs.push(ref);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredRefs.push(...messageRefs);
|
||||
}
|
||||
if (filteredRefs.length === 0) {
|
||||
return {channelMessagesMap, totalMessages: 0};
|
||||
}
|
||||
const messages: Array<ChannelHarvestResult> = [];
|
||||
for (let i = 0; i < filteredRefs.length; i += CONCURRENT_MESSAGE_LIMIT) {
|
||||
const batch = filteredRefs.slice(i, i + CONCURRENT_MESSAGE_LIMIT);
|
||||
const batchPromises = batch.map(async ({channelId, messageId}): Promise<ChannelHarvestResult | null> => {
|
||||
try {
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Message not found during harvest',
|
||||
);
|
||||
return null;
|
||||
|
||||
for (let i = 0; i < pageRefs.length; i += CONCURRENT_MESSAGE_LIMIT) {
|
||||
const batchResults = await Promise.all(pageRefs.slice(i, i + CONCURRENT_MESSAGE_LIMIT).map(readMessage));
|
||||
for (const result of batchResults) {
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
const timestamp = snowflakeToDate(messageId);
|
||||
const attachments: Array<HarvestedAttachment> = [];
|
||||
if (message.attachments) {
|
||||
for (const attachment of message.attachments) {
|
||||
attachments.push({
|
||||
attachment_id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
size: attachment.size.toString(),
|
||||
content_type: attachment.contentType,
|
||||
content_hash: null,
|
||||
archive_path: null,
|
||||
cdn_url: makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
});
|
||||
}
|
||||
let bucket = channelMessagesMap.get(result.channelId);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
channelMessagesMap.set(result.channelId, bucket);
|
||||
}
|
||||
return {
|
||||
channelId: channelId.toString(),
|
||||
messageData: {
|
||||
id: messageId.toString(),
|
||||
timestamp: timestamp.toISOString(),
|
||||
content: message.content ?? '',
|
||||
attachments,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Failed to process message during harvest',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
for (const result of batchResults) {
|
||||
if (result !== null) {
|
||||
messages.push(result);
|
||||
bucket.push(result.messageData);
|
||||
totalMessages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const {channelId, messageData} of messages) {
|
||||
if (!channelMessagesMap.has(channelId)) {
|
||||
channelMessagesMap.set(channelId, []);
|
||||
|
||||
if (page.length < HARVEST_MESSAGE_CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
channelMessagesMap.get(channelId)!.push(messageData);
|
||||
}
|
||||
return {channelMessagesMap, totalMessages: messages.length};
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
scannedMessages,
|
||||
totalMessages,
|
||||
fetchElapsed: Date.now() - startFetchTime,
|
||||
totalElapsed: Date.now() - startTime,
|
||||
},
|
||||
'All messages retrieved',
|
||||
);
|
||||
return {channelMessagesMap, totalMessages};
|
||||
}
|
||||
|
||||
function buildUserDataJson(params: UserDataJsonParams) {
|
||||
|
||||
@@ -26,6 +26,8 @@ const configuredMaxConcurrency = parseParallelInteger(process.env.API_TEST_MAX_C
|
||||
const MODULE_REGISTRY_TEST_FILES = [
|
||||
'src/api/gif/GifRequestCountry.test.ts',
|
||||
'src/api/risk/__tests__/AccountPolicyService.test.ts',
|
||||
'src/api/stripe/tests/StripeCheckoutCountryEnforcement.test.ts',
|
||||
'src/api/stripe/tests/StripeNordicCurrencies.test.ts',
|
||||
];
|
||||
|
||||
const INSTANCE_POLICY_TEST_FILES = [
|
||||
|
||||
@@ -11,6 +11,7 @@ const JSX_EXTENSIONS = new Set(['.tsx', '.jsx']);
|
||||
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.cache', '.swc']);
|
||||
const GENERATED_FILES = new Set([join(SOURCE_DIR, 'features', 'ui', 'components', 'SVGMasks.tsx')]);
|
||||
const NON_THEMEABLE_IDENTIFIERS = new Set([
|
||||
'AppI18nProvider',
|
||||
'Fragment',
|
||||
'I18nProvider',
|
||||
'Outlet',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
@@ -54,13 +55,11 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
|
||||
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
|
||||
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
|
||||
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
|
||||
import {i18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import {reaction} from 'mobx';
|
||||
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => startScreenSharePiPController(), []);
|
||||
useEffect(() => startMediaDeviceStartupPreload(), []);
|
||||
useServiceWorkerBadge();
|
||||
useKeybindManager(i18n);
|
||||
useDesktopElectronBridges();
|
||||
@@ -239,7 +237,7 @@ export const App = observer((): React.ReactElement => {
|
||||
return () => detach?.();
|
||||
}, []);
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={{color: 'currentColor', weight: 'fill'}}>
|
||||
<PremiumCheckoutReturnWatcher data-flx="app.app.premium-checkout-return-watcher" />
|
||||
<DndContext data-flx="app.app.dnd-context">
|
||||
@@ -250,6 +248,6 @@ export const App = observer((): React.ReactElement => {
|
||||
</RouterProvider>
|
||||
</DndContext>
|
||||
</IconContext.Provider>
|
||||
</I18nProvider>
|
||||
</AppI18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
Vendored
+2
@@ -18,6 +18,8 @@ interface FluxerDebugApi {
|
||||
getClientInfoObjectSync?: () => unknown;
|
||||
getVoiceSubscriptionDebug?: () => Promise<unknown>;
|
||||
getVoiceSubscriptionDebugJson?: () => Promise<string>;
|
||||
getTranslationDomGuardStats?: () => unknown;
|
||||
uninstallTranslationDomGuard?: () => boolean;
|
||||
}
|
||||
|
||||
type FluxerDebugGlobal = Record<string, unknown> & FluxerDebugApi;
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
FREE_VS_PREMIUM_DESCRIPTOR,
|
||||
VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -51,7 +50,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
const locale = LocaleUtils.getCurrentLocale();
|
||||
const mobileLayoutState = MobileLayout;
|
||||
const [isGiftMode, setIsGiftMode] = useState(defaultGiftMode);
|
||||
const [pricingMode, setPricingMode] = useState<PricingMode>('localized');
|
||||
const giftSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const perksSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const countryCode = GeoIP.countryCode;
|
||||
@@ -66,14 +64,11 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
yearlyPrice,
|
||||
giftMonthlyPrice,
|
||||
giftYearlyPrice,
|
||||
hasPricingChoice,
|
||||
localizedCurrency,
|
||||
baseCurrency,
|
||||
currentSubscriptionPrice,
|
||||
currentSubscriptionPriceLabel,
|
||||
currentSubscriptionListPriceLabel,
|
||||
isCurrentSubscriptionGrandfathered,
|
||||
} = usePremiumData(countryCode, pricingMode, {premiumState});
|
||||
} = usePremiumData({premiumState});
|
||||
const {
|
||||
loadingPortal,
|
||||
loadingCancel,
|
||||
@@ -92,15 +87,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(
|
||||
priceIds,
|
||||
countryCode,
|
||||
pricingMode,
|
||||
subscriptionStatus.isGiftSubscription,
|
||||
mobileLayoutState.enabled,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!hasPricingChoice && pricingMode === 'base') {
|
||||
setPricingMode('localized');
|
||||
}
|
||||
}, [hasPricingChoice, pricingMode]);
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) return;
|
||||
void PremiumCommands.refreshPremiumState(countryCode ?? undefined);
|
||||
@@ -175,12 +164,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
<PlutoniumUpsellBanner data-flx="app.plutonium-content.plutonium-upsell-banner" />
|
||||
<GiftSection
|
||||
giftSectionRef={giftSectionRef}
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
giftYearlyPrice={giftYearlyPrice}
|
||||
loadingCheckout={loadingCheckout}
|
||||
@@ -289,12 +272,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
<PricingSection
|
||||
isGiftMode={isGiftMode}
|
||||
setIsGiftMode={setIsGiftMode}
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
monthlyPrice={monthlyPrice}
|
||||
yearlyPrice={yearlyPrice}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
@@ -308,12 +285,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
) : (
|
||||
<GiftSection
|
||||
giftSectionRef={giftSectionRef}
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
giftYearlyPrice={giftYearlyPrice}
|
||||
loadingCheckout={loadingCheckout}
|
||||
@@ -340,12 +311,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
{!subscriptionStatus.isPremium && (
|
||||
<BottomCTASection
|
||||
isGiftMode={isGiftMode}
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
monthlyPrice={monthlyPrice}
|
||||
yearlyPrice={yearlyPrice}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
|
||||
-25
@@ -2,10 +2,8 @@
|
||||
|
||||
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/BottomCTASection.module.css';
|
||||
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
@@ -20,12 +18,6 @@ const CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR = msg({
|
||||
|
||||
interface BottomCTASectionProps {
|
||||
isGiftMode: boolean;
|
||||
countryCode: string | null;
|
||||
pricingMode: PricingMode;
|
||||
setPricingMode: (value: PricingMode) => void;
|
||||
hasPricingChoice: boolean;
|
||||
localizedCurrency: string | null;
|
||||
baseCurrency: string | null;
|
||||
monthlyPrice: string;
|
||||
yearlyPrice: string;
|
||||
giftMonthlyPrice: string;
|
||||
@@ -39,12 +31,6 @@ interface BottomCTASectionProps {
|
||||
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
({
|
||||
isGiftMode,
|
||||
countryCode,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
hasPricingChoice,
|
||||
localizedCurrency,
|
||||
baseCurrency,
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
giftMonthlyPrice,
|
||||
@@ -63,17 +49,6 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
<h2 className={styles.title} data-flx="app.plutonium.bottom-cta-section.title">
|
||||
{isGiftMode ? <Trans>Ready to buy a gift?</Trans> : <Trans>Ready to upgrade?</Trans>}
|
||||
</h2>
|
||||
<PricingContextPanel
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
isGiftMode={isGiftMode}
|
||||
compact
|
||||
data-flx="app.plutonium.bottom-cta-section.pricing-context-panel"
|
||||
/>
|
||||
<div className={styles.buttonContainer} data-flx="app.plutonium.bottom-cta-section.button-container">
|
||||
{!isGiftMode ? (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,6 @@ import gridStyles from '@app/features/app/components/dialogs/components/PricingG
|
||||
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/GiftSection.module.css';
|
||||
import {SectionHeader} from '@app/features/app/components/dialogs/components/plutonium/PlutoniumSectionHeader';
|
||||
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR,
|
||||
VIEW_PREMIUM_PERKS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowDownIcon} from '@phosphor-icons/react';
|
||||
@@ -38,12 +36,6 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
|
||||
|
||||
interface GiftSectionProps {
|
||||
giftSectionRef: React.RefObject<HTMLDivElement | null>;
|
||||
countryCode: string | null;
|
||||
pricingMode: PricingMode;
|
||||
setPricingMode: (value: PricingMode) => void;
|
||||
hasPricingChoice: boolean;
|
||||
localizedCurrency: string | null;
|
||||
baseCurrency: string | null;
|
||||
giftMonthlyPrice: string;
|
||||
giftYearlyPrice: string;
|
||||
loadingCheckout: boolean;
|
||||
@@ -55,12 +47,6 @@ interface GiftSectionProps {
|
||||
export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
({
|
||||
giftSectionRef,
|
||||
countryCode,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
hasPricingChoice,
|
||||
localizedCurrency,
|
||||
baseCurrency,
|
||||
giftMonthlyPrice,
|
||||
giftYearlyPrice,
|
||||
loadingCheckout,
|
||||
@@ -82,16 +68,6 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
})}
|
||||
data-flx="app.plutonium.gift-section.section-header"
|
||||
/>
|
||||
<PricingContextPanel
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
isGiftMode
|
||||
data-flx="app.plutonium.gift-section.pricing-context-panel"
|
||||
/>
|
||||
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.gift-section.div--2">
|
||||
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.gift-section.div--3">
|
||||
<PurchaseDisabledWrapper
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.panelCompact {
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
-490
@@ -1,490 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel.module.css';
|
||||
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
|
||||
import {
|
||||
BLIK_PAYMENT_METHOD,
|
||||
MB_WAY_PAYMENT_METHOD,
|
||||
PAYMENT_PROVIDER_NAME,
|
||||
PIX_PAYMENT_METHOD,
|
||||
UPI_PAYMENT_METHOD,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getCurrencyCodeLabel, type PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
const BRL_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized BRL gift checkout',
|
||||
comment: 'Plutonium gift checkout panel title for buyers in Brazil who selected localized BRL pricing.',
|
||||
});
|
||||
const BRL_GIFT_PIX_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{pixPaymentMethod} can be used for one-time BRL payments when {paymentProviderName} offers it on the checkout page.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Brazil. Explains that Pix is available for one-time gift purchases via the payment provider.',
|
||||
});
|
||||
const BRL_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'If you pay with a card in BRL, it still needs to be issued in Brazil for the purchase to stay eligible.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Brazil. Clarifies that BRL card payments require a Brazil-issued card.',
|
||||
});
|
||||
const INR_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized INR gift checkout',
|
||||
comment: 'Plutonium gift checkout panel title for buyers in India who selected localized INR pricing.',
|
||||
});
|
||||
const INR_GIFT_UPI_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{upiPaymentMethod} can be used for one-time INR payments when {paymentProviderName} offers it on the checkout page.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for India. Explains that UPI is available for one-time gift purchases via the payment provider.',
|
||||
});
|
||||
const INR_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'If you pay with a card in INR, it still needs to be issued in India for the purchase to stay eligible.',
|
||||
comment: 'Plutonium gift checkout guidance for India. Clarifies that INR card payments require an India-issued card.',
|
||||
});
|
||||
const PLN_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized PLN gift checkout',
|
||||
comment: 'Plutonium gift checkout panel title for buyers in Poland who selected localized PLN pricing.',
|
||||
});
|
||||
const PLN_GIFT_BLIK_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{blikPaymentMethod} can be used for one-time PLN gift payments, even though it does not support subscriptions in {paymentProviderName} checkout.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Poland. Explains that BLIK works for gifts but not for recurring subscriptions.',
|
||||
});
|
||||
const PLN_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'If you pay with a card in PLN, it still needs to be issued in Poland for the purchase to stay eligible.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Poland. Clarifies that PLN card payments require a Poland-issued card.',
|
||||
});
|
||||
const TRY_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized TRY gift checkout',
|
||||
comment: 'Plutonium gift checkout panel title for buyers in Türkiye who selected localized TRY pricing.',
|
||||
});
|
||||
const TRY_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'TRY gifts are one-time payments. Cards charged in TRY still need to be issued in Türkiye for the purchase to stay eligible.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Türkiye. Clarifies one-time-only nature and Türkiye card issuance requirement.',
|
||||
});
|
||||
const TRY_GIFT_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'There is no app-based local payment method surfaced here today, so card checkout is the main path.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Türkiye. Notes that no local wallet method is currently available; card is the main option.',
|
||||
});
|
||||
const EUR_GIFT_TITLE_DESCRIPTOR = msg({
|
||||
message: 'EUR gift checkout',
|
||||
comment: 'Plutonium gift checkout panel title for buyers in Portugal paying with EUR.',
|
||||
});
|
||||
const EUR_GIFT_MBWAY_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{mbWayPaymentMethod} can be used for one-time EUR gift payments when {paymentProviderName} offers it on the checkout page.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance for Portugal. Explains that MB WAY can be used for one-time EUR gift purchases.',
|
||||
});
|
||||
const STANDARD_GIFT_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Standard {baseCurrencyLabel} gift checkout',
|
||||
comment:
|
||||
'Plutonium gift checkout panel title when the buyer opted into the standard (non-localized) currency. The currency code is interpolated.',
|
||||
});
|
||||
const STANDARD_GIFT_SWITCH_BACK_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'Switch back to {localizedCurrencyLabel} if you prefer the localized price instead.',
|
||||
comment:
|
||||
'Plutonium gift checkout guidance. Reminds the buyer they can revert to localized pricing. The currency code is interpolated.',
|
||||
});
|
||||
const EUR_SUBSCRIPTION_TITLE_DESCRIPTOR = msg({
|
||||
message: 'EUR subscription checkout',
|
||||
comment: 'Plutonium subscription checkout panel title for subscribers in Portugal paying with EUR.',
|
||||
});
|
||||
const EUR_SUBSCRIPTION_MBWAY_ONE_TIME_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{mbWayPaymentMethod} is a one-time payment method in {paymentProviderName} checkout, so it is not a recurring subscription path here.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Portugal. Explains that MB WAY cannot be used for recurring subscriptions.',
|
||||
});
|
||||
const EUR_SUBSCRIPTION_USE_CARD_OR_GIFT_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'Use a card for the subscription, or buy a gift instead if you want a one-time EUR payment method.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Portugal. Suggests using a card for recurring billing or a gift for one-time EUR payments.',
|
||||
});
|
||||
const STANDARD_PRICING_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Standard {baseCurrencyLabel} pricing',
|
||||
comment:
|
||||
'Plutonium subscription checkout panel title when the subscriber opted into the standard (non-localized) currency. The currency code is interpolated.',
|
||||
});
|
||||
const STANDARD_PRICING_FULL_PRICE_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'You are using the full standard {baseCurrencyLabel} price instead of localized pricing.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance. States that the standard (non-localized) price applies. The currency code is interpolated.',
|
||||
});
|
||||
const STANDARD_PRICING_SKIPS_VERIFICATION_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'This skips localized card verification. Switch back to {localizedCurrencyLabel} if you prefer the local price.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance. Notes that the standard path skips localized verification and offers a way back.',
|
||||
});
|
||||
const BRL_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized BRL subscription checkout',
|
||||
comment: 'Plutonium subscription checkout panel title for subscribers in Brazil with localized BRL pricing.',
|
||||
});
|
||||
const BRL_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Cards can keep BRL pricing after a quick {paymentProviderName} verification, but the card must be issued in Brazil.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Brazil. Explains that BRL card subscriptions need a verification step and a Brazil-issued card.',
|
||||
});
|
||||
const BRL_SUBSCRIPTION_PIX_RECURRING_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{pixPaymentMethod} can skip the card verification step when {paymentProviderName} offers recurring {pixPaymentMethod2} for your checkout.',
|
||||
comment: 'Plutonium subscription checkout guidance for Brazil. Explains that recurring Pix avoids card verification.',
|
||||
});
|
||||
const BRL_SUBSCRIPTION_PIX_FALLBACK_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'If {pixPaymentMethod} is unavailable or you prefer standard pricing, you can switch to USD at any time.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Brazil. Offers USD as a fallback when Pix is not available or standard pricing is preferred.',
|
||||
});
|
||||
const INR_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized INR subscription checkout',
|
||||
comment: 'Plutonium subscription checkout panel title for subscribers in India with localized INR pricing.',
|
||||
});
|
||||
const INR_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Cards can keep INR pricing after a quick {paymentProviderName} verification, but the card must be issued in India.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for India. Explains that INR card subscriptions need a verification step and an India-issued card.',
|
||||
});
|
||||
const INR_SUBSCRIPTION_UPI_RECURRING_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{upiPaymentMethod} can skip the card verification step when {paymentProviderName} offers recurring {upiPaymentMethod2} for your checkout.',
|
||||
comment: 'Plutonium subscription checkout guidance for India. Explains that recurring UPI avoids card verification.',
|
||||
});
|
||||
const INR_SUBSCRIPTION_UPI_FALLBACK_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'If recurring {upiPaymentMethod} is unavailable or you prefer standard pricing, you can switch to USD at any time.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for India. Offers USD as a fallback when recurring UPI is not available.',
|
||||
});
|
||||
const PLN_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized PLN subscription checkout',
|
||||
comment: 'Plutonium subscription checkout panel title for subscribers in Poland with localized PLN pricing.',
|
||||
});
|
||||
const PLN_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Cards can keep PLN pricing after a quick {paymentProviderName} verification, but the card must be issued in Poland.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Poland. Explains that PLN card subscriptions need a verification step and a Poland-issued card.',
|
||||
});
|
||||
const PLN_SUBSCRIPTION_BLIK_UNSUPPORTED_DETAIL_DESCRIPTOR = msg({
|
||||
message: '{blikPaymentMethod} does not support subscriptions in {paymentProviderName} checkout.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Poland. Clarifies that BLIK is not available for recurring subscriptions.',
|
||||
});
|
||||
const PLN_SUBSCRIPTION_BLIK_GIFT_FALLBACK_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'If you want to use {blikPaymentMethod}, buy a PLN gift instead, or switch to standard EUR pricing.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Poland. Suggests a PLN gift or standard EUR if the subscriber wants BLIK.',
|
||||
});
|
||||
const TRY_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Localized TRY subscription checkout',
|
||||
comment: 'Plutonium subscription checkout panel title for subscribers in Türkiye with localized TRY pricing.',
|
||||
});
|
||||
const TRY_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Cards can keep TRY pricing after a quick {paymentProviderName} verification, but the card must be issued in Türkiye.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Türkiye. Explains that TRY card subscriptions need a verification step and a Türkiye-issued card.',
|
||||
});
|
||||
const TRY_SUBSCRIPTION_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR = msg({
|
||||
message:
|
||||
'There is no app-based local subscription method surfaced here today, so local card checkout is the main path.',
|
||||
comment:
|
||||
'Plutonium subscription checkout guidance for Türkiye. Notes that no local wallet method is available; card is the main option.',
|
||||
});
|
||||
const SWITCH_TO_STANDARD_USD_DETAIL_DESCRIPTOR = msg({
|
||||
message: 'If you prefer, you can switch to standard USD pricing instead.',
|
||||
comment: 'Plutonium subscription checkout guidance. Offers USD as a fallback option.',
|
||||
});
|
||||
const USING_LOCALIZED_PRICING_SUMMARY_DESCRIPTOR = msg({
|
||||
message: 'Using localized {localizedCurrencyLabel} pricing.',
|
||||
comment:
|
||||
'Compact summary line shown in Plutonium checkout when localized pricing is active. The currency code is interpolated.',
|
||||
});
|
||||
const USING_STANDARD_PRICING_SUMMARY_DESCRIPTOR = msg({
|
||||
message: 'Using standard {baseCurrencyLabel} pricing.',
|
||||
comment:
|
||||
'Compact summary line shown in Plutonium checkout when standard pricing is active. The currency code is interpolated.',
|
||||
});
|
||||
const PRICING_PREFERENCE_GROUP_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Pricing preference',
|
||||
comment:
|
||||
'Accessible group label for the pricing toggle (localized vs standard) in Plutonium checkout. Not visible on screen.',
|
||||
});
|
||||
|
||||
interface PricingContextPanelProps {
|
||||
countryCode: string | null;
|
||||
pricingMode: PricingMode;
|
||||
setPricingMode: (mode: PricingMode) => void;
|
||||
hasPricingChoice: boolean;
|
||||
localizedCurrency: string | null;
|
||||
baseCurrency: string | null;
|
||||
isGiftMode: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function getCountryContext(countryCode: string | null): 'BR' | 'IN' | 'PL' | 'PT' | 'TR' | 'OTHER' {
|
||||
const upperCountryCode = countryCode?.toUpperCase();
|
||||
switch (upperCountryCode) {
|
||||
case 'BR':
|
||||
case 'IN':
|
||||
case 'PL':
|
||||
case 'PT':
|
||||
case 'TR':
|
||||
return upperCountryCode;
|
||||
default:
|
||||
return 'OTHER';
|
||||
}
|
||||
}
|
||||
|
||||
export const PricingContextPanel: React.FC<PricingContextPanelProps> = observer(
|
||||
({
|
||||
countryCode,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
hasPricingChoice,
|
||||
localizedCurrency,
|
||||
baseCurrency,
|
||||
isGiftMode,
|
||||
compact = false,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const countryContext = getCountryContext(countryCode);
|
||||
const localizedCurrencyLabel = getCurrencyCodeLabel(localizedCurrency);
|
||||
const baseCurrencyLabel = getCurrencyCodeLabel(baseCurrency);
|
||||
const guidance = useMemo(() => {
|
||||
if (isGiftMode) {
|
||||
if (pricingMode === 'localized') {
|
||||
switch (countryContext) {
|
||||
case 'BR':
|
||||
return {
|
||||
title: i18n._(BRL_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(BRL_GIFT_PIX_DETAIL_DESCRIPTOR, {
|
||||
pixPaymentMethod: PIX_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(BRL_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
|
||||
],
|
||||
};
|
||||
case 'IN':
|
||||
return {
|
||||
title: i18n._(INR_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(INR_GIFT_UPI_DETAIL_DESCRIPTOR, {
|
||||
upiPaymentMethod: UPI_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(INR_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
|
||||
],
|
||||
};
|
||||
case 'PL':
|
||||
return {
|
||||
title: i18n._(PLN_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(PLN_GIFT_BLIK_DETAIL_DESCRIPTOR, {
|
||||
blikPaymentMethod: BLIK_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(PLN_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
|
||||
],
|
||||
};
|
||||
case 'TR':
|
||||
return {
|
||||
title: i18n._(TRY_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(TRY_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
|
||||
i18n._(TRY_GIFT_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
if (countryContext === 'PT' && baseCurrency === 'EUR') {
|
||||
return {
|
||||
title: i18n._(EUR_GIFT_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(EUR_GIFT_MBWAY_DETAIL_DESCRIPTOR, {
|
||||
mbWayPaymentMethod: MB_WAY_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (hasPricingChoice && pricingMode === 'base') {
|
||||
return {
|
||||
title: i18n._(STANDARD_GIFT_TITLE_DESCRIPTOR, {baseCurrencyLabel}),
|
||||
items: [i18n._(STANDARD_GIFT_SWITCH_BACK_DETAIL_DESCRIPTOR, {localizedCurrencyLabel})],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (countryContext === 'PT' && baseCurrency === 'EUR') {
|
||||
return {
|
||||
title: i18n._(EUR_SUBSCRIPTION_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(EUR_SUBSCRIPTION_MBWAY_ONE_TIME_DETAIL_DESCRIPTOR, {
|
||||
mbWayPaymentMethod: MB_WAY_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(EUR_SUBSCRIPTION_USE_CARD_OR_GIFT_DETAIL_DESCRIPTOR),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (pricingMode === 'base') {
|
||||
if (hasPricingChoice) {
|
||||
return {
|
||||
title: i18n._(STANDARD_PRICING_TITLE_DESCRIPTOR, {baseCurrencyLabel}),
|
||||
items: [
|
||||
i18n._(STANDARD_PRICING_FULL_PRICE_DETAIL_DESCRIPTOR, {baseCurrencyLabel}),
|
||||
i18n._(STANDARD_PRICING_SKIPS_VERIFICATION_DETAIL_DESCRIPTOR, {localizedCurrencyLabel}),
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
switch (countryContext) {
|
||||
case 'BR':
|
||||
return {
|
||||
title: i18n._(BRL_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(BRL_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(BRL_SUBSCRIPTION_PIX_RECURRING_DETAIL_DESCRIPTOR, {
|
||||
pixPaymentMethod: PIX_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
pixPaymentMethod2: PIX_PAYMENT_METHOD,
|
||||
}),
|
||||
i18n._(BRL_SUBSCRIPTION_PIX_FALLBACK_DETAIL_DESCRIPTOR, {
|
||||
pixPaymentMethod: PIX_PAYMENT_METHOD,
|
||||
}),
|
||||
],
|
||||
};
|
||||
case 'IN':
|
||||
return {
|
||||
title: i18n._(INR_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(INR_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(INR_SUBSCRIPTION_UPI_RECURRING_DETAIL_DESCRIPTOR, {
|
||||
upiPaymentMethod: UPI_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
upiPaymentMethod2: UPI_PAYMENT_METHOD,
|
||||
}),
|
||||
i18n._(INR_SUBSCRIPTION_UPI_FALLBACK_DETAIL_DESCRIPTOR, {
|
||||
upiPaymentMethod: UPI_PAYMENT_METHOD,
|
||||
}),
|
||||
],
|
||||
};
|
||||
case 'PL':
|
||||
return {
|
||||
title: i18n._(PLN_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(PLN_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(PLN_SUBSCRIPTION_BLIK_UNSUPPORTED_DETAIL_DESCRIPTOR, {
|
||||
blikPaymentMethod: BLIK_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(PLN_SUBSCRIPTION_BLIK_GIFT_FALLBACK_DETAIL_DESCRIPTOR, {blikPaymentMethod: BLIK_PAYMENT_METHOD}),
|
||||
],
|
||||
};
|
||||
case 'TR':
|
||||
return {
|
||||
title: i18n._(TRY_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
|
||||
items: [
|
||||
i18n._(TRY_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
i18n._(TRY_SUBSCRIPTION_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR),
|
||||
i18n._(SWITCH_TO_STANDARD_USD_DETAIL_DESCRIPTOR),
|
||||
],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
baseCurrency,
|
||||
baseCurrencyLabel,
|
||||
countryContext,
|
||||
hasPricingChoice,
|
||||
isGiftMode,
|
||||
localizedCurrencyLabel,
|
||||
pricingMode,
|
||||
i18n.locale,
|
||||
]);
|
||||
const summary = useMemo(() => {
|
||||
if (pricingMode === 'localized') {
|
||||
if (hasPricingChoice) {
|
||||
return i18n._(USING_LOCALIZED_PRICING_SUMMARY_DESCRIPTOR, {localizedCurrencyLabel});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (hasPricingChoice) {
|
||||
return i18n._(USING_STANDARD_PRICING_SUMMARY_DESCRIPTOR, {baseCurrencyLabel});
|
||||
}
|
||||
return null;
|
||||
}, [baseCurrencyLabel, hasPricingChoice, localizedCurrencyLabel, pricingMode, i18n.locale]);
|
||||
return (
|
||||
<div className={styles.container} data-flx="app.plutonium.pricing-context-panel.container">
|
||||
{hasPricingChoice && (
|
||||
<div
|
||||
className={styles.toggleContainer}
|
||||
role="group"
|
||||
aria-label={i18n._(PRICING_PREFERENCE_GROUP_LABEL_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-context-panel.toggle-container"
|
||||
>
|
||||
<ToggleButton
|
||||
active={pricingMode === 'localized'}
|
||||
onClick={() => setPricingMode('localized')}
|
||||
label={<Trans>Local {localizedCurrencyLabel}</Trans>}
|
||||
data-flx="app.plutonium.pricing-context-panel.toggle-button.set-pricing-mode"
|
||||
/>
|
||||
<ToggleButton
|
||||
active={pricingMode === 'base'}
|
||||
onClick={() => setPricingMode('base')}
|
||||
label={<Trans>Standard {baseCurrencyLabel}</Trans>}
|
||||
data-flx="app.plutonium.pricing-context-panel.toggle-button.set-pricing-mode--2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{summary && compact && (
|
||||
<p className={styles.summary} data-flx="app.plutonium.pricing-context-panel.summary">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
{guidance && !compact && (
|
||||
<div
|
||||
className={clsx(styles.panel, compact && styles.panelCompact)}
|
||||
data-flx="app.plutonium.pricing-context-panel.panel"
|
||||
>
|
||||
<h3 className={styles.title} data-flx="app.plutonium.pricing-context-panel.title">
|
||||
{guidance.title}
|
||||
</h3>
|
||||
<ul className={styles.list} data-flx="app.plutonium.pricing-context-panel.list">
|
||||
{guidance.items.map((item) => (
|
||||
<li key={item} data-flx="app.plutonium.pricing-context-panel.li">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
-24
@@ -3,7 +3,6 @@
|
||||
import {PricingCard} from '@app/features/app/components/dialogs/components/PricingCard';
|
||||
import gridStyles from '@app/features/app/components/dialogs/components/PricingGrid.module.css';
|
||||
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
|
||||
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingSection.module.css';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
ONE_TIME_PURCHASE_DESCRIPTOR,
|
||||
VIEW_PREMIUM_PERKS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowDownIcon} from '@phosphor-icons/react';
|
||||
@@ -73,12 +71,6 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
|
||||
interface PricingSectionProps {
|
||||
isGiftMode: boolean;
|
||||
setIsGiftMode: (value: boolean) => void;
|
||||
countryCode: string | null;
|
||||
pricingMode: PricingMode;
|
||||
setPricingMode: (value: PricingMode) => void;
|
||||
hasPricingChoice: boolean;
|
||||
localizedCurrency: string | null;
|
||||
baseCurrency: string | null;
|
||||
monthlyPrice: string;
|
||||
yearlyPrice: string;
|
||||
giftMonthlyPrice: string;
|
||||
@@ -93,12 +85,6 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
({
|
||||
isGiftMode,
|
||||
setIsGiftMode,
|
||||
countryCode,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
hasPricingChoice,
|
||||
localizedCurrency,
|
||||
baseCurrency,
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
giftMonthlyPrice,
|
||||
@@ -133,16 +119,6 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
|
||||
/>
|
||||
</div>
|
||||
<PricingContextPanel
|
||||
countryCode={countryCode}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
hasPricingChoice={hasPricingChoice}
|
||||
localizedCurrency={localizedCurrency}
|
||||
baseCurrency={baseCurrency}
|
||||
isGiftMode={isGiftMode}
|
||||
data-flx="app.plutonium.pricing-section.pricing-context-panel"
|
||||
/>
|
||||
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.pricing-section.div">
|
||||
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.pricing-section.div--2">
|
||||
{!isGiftMode ? (
|
||||
|
||||
-6
@@ -49,12 +49,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
|
||||
+1
-7
@@ -19,7 +19,7 @@ interface PurchaseHistorySectionProps {
|
||||
export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = observer(
|
||||
({premiumState, loadingPortal, handleOpenCustomerPortal}) => {
|
||||
const invoices = premiumState?.billing.invoices ?? [];
|
||||
const [invoicesExpanded, setInvoicesExpanded] = useState(invoices.length > 0);
|
||||
const [invoicesExpanded, setInvoicesExpanded] = useState(false);
|
||||
return (
|
||||
<section className={styles.section} data-flx="app.plutonium.purchase-history-section.section">
|
||||
<div className={styles.card} data-flx="app.plutonium.purchase-history-section.card">
|
||||
@@ -28,12 +28,6 @@ export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = obs
|
||||
<h3 className={styles.title} data-flx="app.plutonium.purchase-history-section.title">
|
||||
<Trans>Purchase history</Trans>
|
||||
</h3>
|
||||
<p className={styles.description} data-flx="app.plutonium.purchase-history-section.description">
|
||||
<Trans>
|
||||
Your recent invoices. To change the payment method for your subscription, add or choose one in the
|
||||
billing portal and make it the default.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
+165
-6
@@ -2,6 +2,7 @@
|
||||
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PerksButton} from '@app/features/app/components/dialogs/components/PerksButton';
|
||||
import {useSubscriptionActions} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionActions';
|
||||
import type {GracePeriodInfo} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionStatus';
|
||||
import statusStyles from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistoryStatus.module.css';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard.module.css';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
PREMIUM_PRODUCT_NAME,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {JOIN_COMMUNITY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {
|
||||
CLAIM_ACCOUNT_TO_PURCHASE_OR_REDEEM_PREMIUM_DESCRIPTOR,
|
||||
MANAGE_SUBSCRIPTION_DESCRIPTOR,
|
||||
@@ -21,6 +23,7 @@ import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import type {PendingSubscriptionChangeResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -55,6 +58,14 @@ const SWITCH_TO_MONTHLY_DESCRIPTOR = msg({
|
||||
message: 'Switch to monthly',
|
||||
comment: 'Button confirming a change to monthly subscription billing.',
|
||||
});
|
||||
const SWITCH_TO_THE_NEW_PRICE_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Switch to the new price?',
|
||||
comment: 'Billing confirmation title for moving an active subscription down to the current price.',
|
||||
});
|
||||
const SWITCH_PRICE_DESCRIPTOR = msg({
|
||||
message: 'Switch price',
|
||||
comment: 'Button confirming a move of an active subscription down to the current price.',
|
||||
});
|
||||
const NOT_NOW_DESCRIPTOR = msg({
|
||||
message: 'Not now',
|
||||
comment: 'Button that cancels a subscription billing change.',
|
||||
@@ -171,6 +182,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {loadingSwitchToListPrice, handleSwitchToListPrice} = useSubscriptionActions();
|
||||
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
|
||||
const tooltipText: string | (() => React.ReactNode) =
|
||||
purchaseDisabledTooltip != null
|
||||
@@ -184,9 +196,26 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
const effectiveYearlyPrice =
|
||||
billingCycle === 'yearly' && currentSubscriptionPriceLabel ? currentSubscriptionPriceLabel : yearlyPrice;
|
||||
const hasPendingSubscriptionChange = pendingSubscriptionChange != null && !premiumWillCancel;
|
||||
const currentUserId = Users.currentUser?.id;
|
||||
const listPriceSwitch =
|
||||
currentUserId != null && PremiumState.loadedForUserId === currentUserId
|
||||
? (PremiumState.state?.billing.list_price_switch ?? null)
|
||||
: null;
|
||||
const listPriceNewLabel = listPriceSwitch
|
||||
? formatMinorUnitPrice(listPriceSwitch.list_amount_minor, listPriceSwitch.currency, locale)
|
||||
: null;
|
||||
const listPriceCurrentLabel = listPriceSwitch
|
||||
? formatMinorUnitPrice(listPriceSwitch.current_amount_minor, listPriceSwitch.currency, locale)
|
||||
: null;
|
||||
const listPriceEffectiveDate = listPriceSwitch?.effective_at
|
||||
? getFormattedLongDate(listPriceSwitch.effective_at, locale)
|
||||
: null;
|
||||
const hasPendingListPriceSwitch = listPriceSwitch?.pending === true && !premiumWillCancel;
|
||||
const pendingChangeDate = pendingSubscriptionChange
|
||||
? getFormattedLongDate(new Date(pendingSubscriptionChange.effective_at), locale)
|
||||
: null;
|
||||
: hasPendingListPriceSwitch
|
||||
? listPriceEffectiveDate
|
||||
: null;
|
||||
const pendingInitialPriceLabel = pendingSubscriptionChange
|
||||
? formatMinorUnitPrice(pendingSubscriptionChange.initial_amount_minor, pendingSubscriptionChange.currency, locale)
|
||||
: null;
|
||||
@@ -200,6 +229,26 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
const pendingCreditPriceLabel = pendingSubscriptionChange
|
||||
? formatMinorUnitPrice(pendingSubscriptionChange.credit_amount_minor, pendingSubscriptionChange.currency, locale)
|
||||
: null;
|
||||
const canSwitchToListPrice =
|
||||
listPriceSwitch?.available === true &&
|
||||
!premiumWillCancel &&
|
||||
!hasPendingSubscriptionChange &&
|
||||
listPriceNewLabel != null &&
|
||||
listPriceCurrentLabel != null &&
|
||||
listPriceEffectiveDate != null;
|
||||
const shouldSuggestCancelingPendingChange =
|
||||
listPriceSwitch?.available === false &&
|
||||
listPriceSwitch.reason === 'conflicting_pending_change' &&
|
||||
listPriceSwitch.list_amount_minor != null &&
|
||||
listPriceSwitch.current_amount_minor != null &&
|
||||
listPriceSwitch.list_amount_minor < listPriceSwitch.current_amount_minor;
|
||||
const shouldMentionListPriceWhileCancelling =
|
||||
listPriceSwitch?.available === false &&
|
||||
listPriceSwitch.reason === 'subscription_cancelling' &&
|
||||
listPriceSwitch.list_amount_minor != null &&
|
||||
listPriceSwitch.current_amount_minor != null &&
|
||||
listPriceSwitch.list_amount_minor < listPriceSwitch.current_amount_minor;
|
||||
const pendingTargetPriceLabel = hasPendingListPriceSwitch ? listPriceNewLabel : null;
|
||||
const grandfatheredTooltip =
|
||||
isCurrentSubscriptionGrandfathered && currentSubscriptionListPriceLabel
|
||||
? i18n._(LEGACY_RATE_WITH_PRICE_DESCRIPTOR, {currentSubscriptionListPriceLabel})
|
||||
@@ -314,6 +363,32 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
yearlyAmountMinor,
|
||||
],
|
||||
);
|
||||
const handleConfirmSwitchToListPrice = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(SWITCH_TO_THE_NEW_PRICE_TITLE_DESCRIPTOR)}
|
||||
description={
|
||||
<Trans comment="Billing confirmation body for moving an active subscription down to the current price. {listPriceEffectiveDate} is a date, and {listPriceCurrentLabel} and {listPriceNewLabel} are currency amounts, all already formatted and localized by code; never write a date or an amount into the translation.">
|
||||
On{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.handle-confirm-switch-to-list-price.strong">
|
||||
{listPriceEffectiveDate}
|
||||
</strong>{' '}
|
||||
your subscription moves from {listPriceCurrentLabel} to {listPriceNewLabel}. Nothing is charged today,
|
||||
and the rest of your subscription stays exactly as it is.
|
||||
</Trans>
|
||||
}
|
||||
primaryText={i18n._(SWITCH_PRICE_DESCRIPTOR)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={i18n._(NOT_NOW_DESCRIPTOR)}
|
||||
onPrimary={async () => {
|
||||
await handleSwitchToListPrice();
|
||||
}}
|
||||
data-flx="app.plutonium.subscription-card.handle-confirm-switch-to-list-price.confirm-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, [handleSwitchToListPrice, i18n, listPriceCurrentLabel, listPriceEffectiveDate, listPriceNewLabel]);
|
||||
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
|
||||
disabled ? (
|
||||
<Tooltip key={key} text={tooltipText} data-flx="app.plutonium.subscription-card.wrap-if-disabled.tooltip">
|
||||
@@ -498,8 +573,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
{!isVisionary &&
|
||||
hasPendingSubscriptionChange &&
|
||||
pendingSubscriptionChange &&
|
||||
(hasPendingSubscriptionChange || hasPendingListPriceSwitch) &&
|
||||
pendingChangeDate &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
@@ -508,7 +582,20 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.pending-change-info"
|
||||
>
|
||||
{pendingSubscriptionChange.target_billing_cycle === 'yearly' ? (
|
||||
{hasPendingListPriceSwitch ? (
|
||||
pendingTargetPriceLabel ? (
|
||||
<Trans comment="Plutonium subscription card line shown when a move to a lower price is scheduled. {pendingChangeDate} is a date and {pendingTargetPriceLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.">
|
||||
New price scheduled for{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--13">{pendingChangeDate}</strong>.
|
||||
Renewals will be {pendingTargetPriceLabel} from then on.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Plutonium subscription card line shown when a move to a lower price is scheduled but the new amount is unknown. {pendingChangeDate} is a date already formatted and localized by code; never write a date into the translation.">
|
||||
New price scheduled for{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--13">{pendingChangeDate}</strong>.
|
||||
</Trans>
|
||||
)
|
||||
) : pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
|
||||
pendingInitialPriceLabel && pendingRecurringPriceLabel ? (
|
||||
pendingCreditPriceLabel ? (
|
||||
<Trans>
|
||||
@@ -546,6 +633,59 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
shouldSuggestCancelingPendingChange &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
!isGiftSubscription && (
|
||||
<div
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.list-price-switch-blocked-info"
|
||||
>
|
||||
<Trans comment="Plutonium subscription card hint shown to a subscriber who could move to a lower price but has another billing change already scheduled.">
|
||||
Cancel the scheduled change to move to the current price instead.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
shouldMentionListPriceWhileCancelling &&
|
||||
listPriceNewLabel &&
|
||||
premiumUntil &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
!isGiftSubscription &&
|
||||
(() => {
|
||||
const cancelDate = getFormattedLongDate(premiumUntil, locale);
|
||||
return (
|
||||
<div
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.list-price-switch-cancelling-info"
|
||||
>
|
||||
<Trans comment="Plutonium subscription card line shown to a subscriber on a legacy price whose subscription is already set to cancel. {cancelDate} is a date and {listPriceNewLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.">
|
||||
Your subscription still ends on{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--15">{cancelDate}</strong>. The current
|
||||
price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here.
|
||||
</Trans>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{!isVisionary &&
|
||||
canSwitchToListPrice &&
|
||||
listPriceEffectiveDate &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
!isGiftSubscription && (
|
||||
<div
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.list-price-switch-info"
|
||||
>
|
||||
<Trans comment="Plutonium subscription card line offering a move to a lower price. {listPriceEffectiveDate} is a date already formatted and localized by code; never write a date into the translation.">
|
||||
A lower price is available. Switching takes effect on{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--14">{listPriceEffectiveDate}</strong> and
|
||||
changes nothing else.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
premiumUntil &&
|
||||
!premiumWillCancel &&
|
||||
@@ -628,6 +768,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
{shouldUseChangePlanQuickAction &&
|
||||
targetBillingCycle &&
|
||||
!hasPendingSubscriptionChange &&
|
||||
!hasPendingListPriceSwitch &&
|
||||
wrapIfDisabled(
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -651,7 +792,21 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
'change-plan',
|
||||
purchaseDisabled,
|
||||
)}
|
||||
{hasPendingSubscriptionChange && (
|
||||
{canSwitchToListPrice && listPriceNewLabel && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleConfirmSwitchToListPrice}
|
||||
submitting={loadingSwitchToListPrice}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
data-flx="app.plutonium.subscription-card.action-button.confirm-switch-to-list-price"
|
||||
>
|
||||
<Trans comment="Billing button that opens confirmation to move the subscription down to the current price. {listPriceNewLabel} is the localized new price.">
|
||||
Switch to {listPriceNewLabel}
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{(hasPendingSubscriptionChange || hasPendingListPriceSwitch) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancelPendingSubscriptionChange}
|
||||
@@ -660,7 +815,11 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
className={styles.actionButton}
|
||||
data-flx="app.plutonium.subscription-card.action-button.cancel-pending-subscription-change"
|
||||
>
|
||||
{pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
|
||||
{hasPendingListPriceSwitch ? (
|
||||
<Trans comment="Billing button that cancels a scheduled move to a lower subscription price.">
|
||||
Cancel price change
|
||||
</Trans>
|
||||
) : pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
|
||||
<Trans comment="Billing button that cancels a scheduled yearly upgrade.">
|
||||
Cancel yearly upgrade
|
||||
</Trans>
|
||||
|
||||
+1
-9
@@ -18,7 +18,6 @@ import type {CheckoutPaymentMethod, PriceIds} from '@app/features/premium/comman
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
import {recordPremiumCheckoutReturnIntent} from '@app/features/premium/utils/PremiumCheckoutReturnIntent';
|
||||
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
@@ -246,7 +245,6 @@ function alternativePaymentMethodForCurrency(
|
||||
export const useCheckoutActions = (
|
||||
priceIds: PriceIds | null,
|
||||
countryCode: string | null,
|
||||
pricingMode: PricingMode,
|
||||
isGiftSubscription: boolean,
|
||||
mobileEnabled: boolean,
|
||||
) => {
|
||||
@@ -552,7 +550,6 @@ export const useCheckoutActions = (
|
||||
priceId,
|
||||
countryCode ?? undefined,
|
||||
isGift,
|
||||
pricingMode,
|
||||
paymentMethod,
|
||||
);
|
||||
await openCheckoutUrl(checkoutUrl, {promptKind: 'payment', skipMobilePrompt});
|
||||
@@ -572,11 +569,7 @@ export const useCheckoutActions = (
|
||||
}
|
||||
setLoadingCheckout(true);
|
||||
try {
|
||||
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(
|
||||
priceId,
|
||||
countryCode,
|
||||
pricingMode,
|
||||
);
|
||||
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(priceId, countryCode);
|
||||
await openCheckoutUrl(checkoutUrl, {
|
||||
promptKind: 'localized_card_preapproval',
|
||||
skipMobilePrompt,
|
||||
@@ -641,7 +634,6 @@ export const useCheckoutActions = (
|
||||
getAlternativePaymentMethodPrompt,
|
||||
isGiftSubscription,
|
||||
mobileEnabled,
|
||||
pricingMode,
|
||||
i18n,
|
||||
],
|
||||
);
|
||||
|
||||
+11
-30
@@ -2,7 +2,7 @@
|
||||
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import type {CurrentSubscriptionPrice, PriceIds} from '@app/features/premium/commands/PremiumCommands';
|
||||
import {formatMinorUnitPrice, type PricingMode} from '@app/features/premium/utils/PricingUtils';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {useMemo} from 'react';
|
||||
@@ -35,35 +35,19 @@ export interface PremiumData {
|
||||
yearlyPrice: string;
|
||||
giftMonthlyPrice: string;
|
||||
giftYearlyPrice: string;
|
||||
localizedPriceIds: PriceIds | null;
|
||||
basePriceIds: PriceIds | null;
|
||||
hasPricingChoice: boolean;
|
||||
selectedPricingMode: PricingMode;
|
||||
localizedCurrency: string | null;
|
||||
baseCurrency: string | null;
|
||||
currentSubscriptionPrice: CurrentSubscriptionPrice | null;
|
||||
currentSubscriptionPriceLabel: string | null;
|
||||
currentSubscriptionListPriceLabel: string | null;
|
||||
isCurrentSubscriptionGrandfathered: boolean;
|
||||
}
|
||||
|
||||
export const usePremiumData = (
|
||||
_countryCode: string | null,
|
||||
pricingMode: PricingMode = 'localized',
|
||||
{
|
||||
premiumState = null,
|
||||
}: {
|
||||
premiumState?: PremiumStateResponse | null;
|
||||
} = {},
|
||||
): PremiumData => {
|
||||
export const usePremiumData = ({
|
||||
premiumState = null,
|
||||
}: {
|
||||
premiumState?: PremiumStateResponse | null;
|
||||
} = {}): PremiumData => {
|
||||
const locale = LocaleUtils.getCurrentLocale();
|
||||
const localizedPriceIds = premiumState?.pricing.localized ?? null;
|
||||
const basePriceIds = premiumState?.pricing.base ?? null;
|
||||
const hasPricingChoice =
|
||||
typeof localizedPriceIds?.currency === 'string' &&
|
||||
typeof basePriceIds?.currency === 'string' &&
|
||||
localizedPriceIds.currency !== basePriceIds.currency;
|
||||
const priceIds = pricingMode === 'base' && hasPricingChoice ? basePriceIds : localizedPriceIds;
|
||||
const priceIds = premiumState?.pricing.localized ?? null;
|
||||
const monthlyPrice = useMemo(() => {
|
||||
return formatPriceLabel(priceIds?.monthly_amount_minor, priceIds?.currency, locale);
|
||||
}, [locale, priceIds?.currency, priceIds?.monthly_amount_minor]);
|
||||
@@ -97,15 +81,12 @@ export const usePremiumData = (
|
||||
yearlyPrice,
|
||||
giftMonthlyPrice,
|
||||
giftYearlyPrice,
|
||||
localizedPriceIds,
|
||||
basePriceIds,
|
||||
hasPricingChoice,
|
||||
selectedPricingMode: pricingMode,
|
||||
localizedCurrency: localizedPriceIds?.currency ?? null,
|
||||
baseCurrency: hasPricingChoice ? (basePriceIds?.currency ?? null) : null,
|
||||
currentSubscriptionPrice,
|
||||
currentSubscriptionPriceLabel,
|
||||
currentSubscriptionListPriceLabel,
|
||||
isCurrentSubscriptionGrandfathered: currentSubscriptionPrice?.is_grandfathered ?? false,
|
||||
isCurrentSubscriptionGrandfathered:
|
||||
currentSubscriptionPrice?.is_grandfathered === true &&
|
||||
(currentSubscriptionPrice.list_amount_minor == null ||
|
||||
currentSubscriptionPrice.list_amount_minor > currentSubscriptionPrice.amount_minor),
|
||||
};
|
||||
};
|
||||
|
||||
+60
@@ -5,6 +5,8 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback, useState} from 'react';
|
||||
@@ -91,6 +93,31 @@ const CANCEL_PENDING_CHANGE_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Body of the generic fallback error modal shown when canceling a scheduled Plutonium billing cycle change fails.',
|
||||
});
|
||||
const YOUR_SUBSCRIPTION_WILL_MOVE_TO_THE_NEW_PRICE_DESCRIPTOR = msg({
|
||||
message: 'Your subscription moves to the new price on {effectiveDate}.',
|
||||
comment:
|
||||
'Toast success shown after a Plutonium subscription is scheduled to move down to the current price. {effectiveDate} is the localized date the new price starts.',
|
||||
});
|
||||
const THE_NEW_PRICE_IS_ALREADY_SCHEDULED_DESCRIPTOR = msg({
|
||||
message: 'The new price is already scheduled for {effectiveDate}.',
|
||||
comment:
|
||||
'Toast success shown when a move down to the current price was already scheduled by an earlier request. {effectiveDate} is the localized date the new price starts.',
|
||||
});
|
||||
const SWITCH_TO_LIST_PRICE_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
message: "Your subscription can't move to the new price right now.",
|
||||
comment:
|
||||
'Toast error shown when the server refuses to move a Plutonium subscription down to the current price, whatever the reason.',
|
||||
});
|
||||
const SWITCH_TO_LIST_PRICE_FAILED_TITLE_DESCRIPTOR = msg({
|
||||
message: "Couldn't change your price",
|
||||
comment:
|
||||
'Title of the generic fallback error modal shown when moving a Plutonium subscription down to the current price fails.',
|
||||
});
|
||||
const SWITCH_TO_LIST_PRICE_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Something went wrong while changing your price. Please try again in a moment.',
|
||||
comment:
|
||||
'Body of the generic fallback error modal shown when moving a Plutonium subscription down to the current price fails.',
|
||||
});
|
||||
const logger = new Logger('useSubscriptionActions');
|
||||
export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
const {i18n} = useLingui();
|
||||
@@ -100,6 +127,7 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
const [loadingEndGrace, setLoadingEndGrace] = useState(false);
|
||||
const [loadingChangeBillingCycle, setLoadingChangeBillingCycle] = useState<'monthly' | 'yearly' | null>(null);
|
||||
const [loadingCancelPendingChange, setLoadingCancelPendingChange] = useState(false);
|
||||
const [loadingSwitchToListPrice, setLoadingSwitchToListPrice] = useState(false);
|
||||
const handleOpenCustomerPortal = useCallback(async () => {
|
||||
setLoadingPortal(true);
|
||||
try {
|
||||
@@ -213,6 +241,36 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
},
|
||||
[countryCode, i18n],
|
||||
);
|
||||
const handleSwitchToListPrice = useCallback(async () => {
|
||||
setLoadingSwitchToListPrice(true);
|
||||
try {
|
||||
const result = await PremiumCommands.switchSubscriptionToListPrice();
|
||||
await PremiumCommands.refreshPremiumState(countryCode ?? undefined);
|
||||
if (result.status === 'ineligible') {
|
||||
logger.warn('List price switch refused', {reason: result.reason});
|
||||
ToastCommands.error(i18n._(SWITCH_TO_LIST_PRICE_UNAVAILABLE_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
const effectiveDate = getFormattedLongDate(result.effective_at, LocaleUtils.getCurrentLocale());
|
||||
ToastCommands.success(
|
||||
result.status === 'already_scheduled'
|
||||
? i18n._(THE_NEW_PRICE_IS_ALREADY_SCHEDULED_DESCRIPTOR, {effectiveDate})
|
||||
: i18n._(YOUR_SUBSCRIPTION_WILL_MOVE_TO_THE_NEW_PRICE_DESCRIPTOR, {effectiveDate}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch subscription to the current list price', error);
|
||||
showPremiumActionErrorModal(
|
||||
error,
|
||||
{
|
||||
fallbackTitle: SWITCH_TO_LIST_PRICE_FAILED_TITLE_DESCRIPTOR,
|
||||
fallbackMessage: SWITCH_TO_LIST_PRICE_FAILED_MESSAGE_DESCRIPTOR,
|
||||
},
|
||||
'app.plutonium.use-subscription-actions.switch-to-list-price.generic-error-modal',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSwitchToListPrice(false);
|
||||
}
|
||||
}, [countryCode, i18n]);
|
||||
const handleCancelPendingSubscriptionChange = useCallback(async () => {
|
||||
setLoadingCancelPendingChange(true);
|
||||
try {
|
||||
@@ -240,11 +298,13 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
loadingEndGrace,
|
||||
loadingChangeBillingCycle,
|
||||
loadingCancelPendingChange,
|
||||
loadingSwitchToListPrice,
|
||||
handleOpenCustomerPortal,
|
||||
handleCancelSubscription,
|
||||
handleEndPremiumGracePeriod,
|
||||
handleReactivateSubscription,
|
||||
handleChangeSubscriptionBillingCycle,
|
||||
handleCancelPendingSubscriptionChange,
|
||||
handleSwitchToListPrice,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {MediaDeviceStartupPreloadManager} from '@app/features/voice/components/MediaDeviceStartupPreloadManager';
|
||||
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
|
||||
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -35,6 +36,9 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
|
||||
{isAuthenticated && (
|
||||
<MediaDeviceStartupPreloadManager data-flx="app.app-layout.media-device-startup-preload-manager" />
|
||||
)}
|
||||
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
|
||||
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
|
||||
<div
|
||||
|
||||
@@ -12,6 +12,7 @@ import {AuthCardContainer} from '@app/features/auth/flow/AuthCardContainer';
|
||||
import {useAuthBackground} from '@app/features/auth/hooks/useAuthBackground';
|
||||
import {type AuthCardVariant, AuthLayoutContext} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {AuthRegisterDraftContext, type AuthRegisterFormDraft} from '@app/features/auth/state/AuthRegisterDraftContext';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {FluxerWordmark} from '@app/features/ui/components/icons/FluxerWordmark';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
@@ -21,7 +22,6 @@ import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import foodPatternUrl from '@app/media/images/i-like-food.svg';
|
||||
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
@@ -258,8 +258,8 @@ export const AuthLayout = observer(function AuthLayout({children}: {children?: R
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<AuthLayoutContent data-flx="app.auth-layout.auth-layout-content">{children}</AuthLayoutContent>
|
||||
</I18nProvider>
|
||||
</AppI18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1275,6 +1275,8 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.PREMIUM_GRACE_PERIOD]: {tone: SkeletonNagbarTone.PREMIUM, hasActions: true},
|
||||
[NagbarType.PREMIUM_EXPIRED]: {tone: SkeletonNagbarTone.DANGER, hasActions: true},
|
||||
[NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.PRICE_ANNOUNCEMENT]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.LEGACY_PRICE_OPT_IN]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
|
||||
@@ -18,6 +18,8 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
@@ -151,6 +153,27 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
user?.isPremium() && !user?.hasDismissedPremiumOnboarding && !nagbarState.premiumOnboardingDismissed,
|
||||
);
|
||||
})();
|
||||
const premiumState = PremiumState.loadedForUserId === user?.id ? PremiumState.state : null;
|
||||
const priceAnnouncementCampaign = resolvePriceAnnouncementCampaign(premiumState);
|
||||
const hasPurchaseReadyAccount = Boolean(user?.isClaimed() && (!RuntimeConfig.emailsEnabled || user.verified));
|
||||
const canShowPriceAnnouncement = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!hasPurchaseReadyAccount) return false;
|
||||
if (!premiumState || !priceAnnouncementCampaign) return false;
|
||||
if (premiumState.actual.has_active_paid_premium) return false;
|
||||
return !nagbarState.getPriceAnnouncementDismissed(priceAnnouncementCampaign.campaign.id);
|
||||
})();
|
||||
const canShowLegacyPriceOptIn = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!hasPurchaseReadyAccount) return false;
|
||||
if (!premiumState || !priceAnnouncementCampaign) return false;
|
||||
const listPriceSwitch = premiumState.billing.list_price_switch ?? null;
|
||||
if (!listPriceSwitch?.available || listPriceSwitch.pending) return false;
|
||||
if (listPriceSwitch.currency !== priceAnnouncementCampaign.currency) return false;
|
||||
if (listPriceSwitch.current_amount_minor == null || listPriceSwitch.list_amount_minor == null) return false;
|
||||
if (listPriceSwitch.effective_at == null || listPriceSwitch.billing_cycle == null) return false;
|
||||
return !nagbarState.getLegacyPriceOptInDismissed(priceAnnouncementCampaign.campaign.id);
|
||||
})();
|
||||
const isNativeDesktop = isDesktop();
|
||||
const hasBrokenElectronNativeContext = hasUnavailableElectronNativeContext();
|
||||
const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
@@ -281,6 +304,8 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowPremiumGracePeriod,
|
||||
canShowPremiumExpired,
|
||||
canShowPremiumOnboarding,
|
||||
canShowPriceAnnouncement,
|
||||
canShowLegacyPriceOptIn,
|
||||
canShowGiftInventory,
|
||||
canShowDesktopDownload,
|
||||
canShowGuildMembershipCta,
|
||||
@@ -356,6 +381,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowVoiceSessionRestore,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.LEGACY_PRICE_OPT_IN,
|
||||
priority: 2,
|
||||
visible: conditions.canShowLegacyPriceOptIn,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PREMIUM_ONBOARDING,
|
||||
priority: 4,
|
||||
@@ -368,6 +399,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowGiftInventory,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PRICE_ANNOUNCEMENT,
|
||||
priority: 5.5,
|
||||
visible: conditions.canShowPriceAnnouncement,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.GUILD_MEMBERSHIP_CTA,
|
||||
priority: 6,
|
||||
|
||||
@@ -13,6 +13,8 @@ export const NagbarType = {
|
||||
PREMIUM_GRACE_PERIOD: 'premium-grace-period',
|
||||
PREMIUM_EXPIRED: 'premium-expired',
|
||||
PREMIUM_ONBOARDING: 'premium-onboarding',
|
||||
PRICE_ANNOUNCEMENT: 'price-announcement',
|
||||
LEGACY_PRICE_OPT_IN: 'legacy-price-opt-in',
|
||||
GIFT_INVENTORY: 'gift-inventory',
|
||||
DESKTOP_DOWNLOAD: 'desktop-download',
|
||||
DESKTOP_UPDATE_READY: 'desktop-update-ready',
|
||||
@@ -49,6 +51,8 @@ export interface NagbarConditions {
|
||||
canShowPremiumGracePeriod: boolean;
|
||||
canShowPremiumExpired: boolean;
|
||||
canShowPremiumOnboarding: boolean;
|
||||
canShowPriceAnnouncement: boolean;
|
||||
canShowLegacyPriceOptIn: boolean;
|
||||
canShowGiftInventory: boolean;
|
||||
canShowDesktopDownload: boolean;
|
||||
canShowDesktopUpdateReady: boolean;
|
||||
|
||||
@@ -11,10 +11,12 @@ import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_
|
||||
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
||||
import {LegacyPriceOptInNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LegacyPriceOptInNagbar';
|
||||
import {LinuxInputAccessNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LinuxInputAccessNagbar';
|
||||
import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumExpiredNagbar';
|
||||
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
|
||||
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
|
||||
import {PriceAnnouncementNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PriceAnnouncementNagbar';
|
||||
import {ScheduledMaintenanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ScheduledMaintenanceNagbar';
|
||||
import {StreamerModeNagbar} from '@app/features/app/components/layout/app_layout/nagbars/StreamerModeNagbar';
|
||||
import {TermsAcceptanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/TermsAcceptanceNagbar';
|
||||
@@ -137,6 +139,24 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.premium-onboarding-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.PRICE_ANNOUNCEMENT:
|
||||
if (!showPremiumFeatures) return null;
|
||||
return (
|
||||
<PriceAnnouncementNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.price-announcement-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.LEGACY_PRICE_OPT_IN:
|
||||
if (!showPremiumFeatures) return null;
|
||||
return (
|
||||
<LegacyPriceOptInNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.legacy-price-opt-in-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.GIFT_INVENTORY:
|
||||
if (!showPremiumFeatures) return null;
|
||||
return (
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export const LegacyPriceOptInNagbar = observer(function LegacyPriceOptInNagbar({isMobile}: {isMobile: boolean}) {
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.currentUser;
|
||||
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
|
||||
const resolved = resolvePriceAnnouncementCampaign(premiumState);
|
||||
const campaignId = resolved?.campaign.id ?? null;
|
||||
const handleOpenPremiumSettings = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.handle-open-premium-settings.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, []);
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (campaignId == null) return;
|
||||
NagbarState.dismissLegacyPriceOptIn(campaignId);
|
||||
}, [campaignId]);
|
||||
if (!premiumState || !resolved) return null;
|
||||
const listPriceSwitch = premiumState.billing.list_price_switch ?? null;
|
||||
if (!listPriceSwitch?.available || listPriceSwitch.pending) return null;
|
||||
if (listPriceSwitch.currency !== resolved.currency || listPriceSwitch.effective_at == null) return null;
|
||||
if (listPriceSwitch.billing_cycle == null) return null;
|
||||
const locale = LocaleUtils.getCurrentLocale();
|
||||
const currentPrice = formatMinorUnitPrice(listPriceSwitch.current_amount_minor, listPriceSwitch.currency, locale);
|
||||
const newPrice = formatMinorUnitPrice(listPriceSwitch.list_amount_minor, listPriceSwitch.currency, locale);
|
||||
if (currentPrice == null || newPrice == null) return null;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(
|
||||
listPriceSwitch.billing_cycle === 'monthly'
|
||||
? resolved.campaign.optInMonthlyMessage
|
||||
: resolved.campaign.optInYearlyMessage,
|
||||
{
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
currentPrice,
|
||||
newPrice,
|
||||
effectiveDate: getFormattedLongDate(listPriceSwitch.effective_at, locale),
|
||||
},
|
||||
)}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleOpenPremiumSettings}
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar-button.open-premium-settings"
|
||||
>
|
||||
{i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {VIEW_PLANS_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export const PriceAnnouncementNagbar = observer(function PriceAnnouncementNagbar({isMobile}: {isMobile: boolean}) {
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.currentUser;
|
||||
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
|
||||
const resolved = resolvePriceAnnouncementCampaign(premiumState);
|
||||
const campaignId = resolved?.campaign.id ?? null;
|
||||
const handleOpenPlans = useCallback(() => {
|
||||
if (campaignId != null) {
|
||||
NagbarState.dismissPriceAnnouncement(campaignId);
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.handle-open-plans.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, [campaignId]);
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (campaignId == null) return;
|
||||
NagbarState.dismissPriceAnnouncement(campaignId);
|
||||
}, [campaignId]);
|
||||
if (!premiumState || !resolved || premiumState.actual.has_active_paid_premium) return null;
|
||||
const locale = LocaleUtils.getCurrentLocale();
|
||||
const monthlyPrice = formatMinorUnitPrice(resolved.monthlyAmountMinor, resolved.currency, locale);
|
||||
const yearlyPrice = formatMinorUnitPrice(resolved.yearlyAmountMinor, resolved.currency, locale);
|
||||
if (monthlyPrice == null || yearlyPrice == null) return null;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(resolved.campaign.announcementMessage, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
})}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleOpenPlans}
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar-button.open-plans"
|
||||
>
|
||||
{i18n._(VIEW_PLANS_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
@@ -23,7 +23,6 @@ export const MACOS_SCREEN_RECORDING_PERMISSION_NAME = 'Screen Recording';
|
||||
export const PIX_PAYMENT_METHOD = 'Pix';
|
||||
export const UPI_PAYMENT_METHOD = 'UPI';
|
||||
export const BLIK_PAYMENT_METHOD = 'BLIK';
|
||||
export const MB_WAY_PAYMENT_METHOD = 'MB WAY';
|
||||
export const SUPPORT_EMAIL = '[email protected]';
|
||||
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
|
||||
export const I18N_EMAIL = '[email protected]';
|
||||
|
||||
@@ -122,6 +122,7 @@ export const Endpoints = {
|
||||
PREMIUM_REACTIVATE_SUBSCRIPTION: '/premium/reactivate-subscription',
|
||||
PREMIUM_CHANGE_SUBSCRIPTION: '/premium/change-subscription',
|
||||
PREMIUM_CANCEL_PENDING_SUBSCRIPTION_CHANGE: '/premium/cancel-pending-subscription-change',
|
||||
PREMIUM_SWITCH_TO_LIST_PRICE: '/premium/switch-to-list-price',
|
||||
PREMIUM_GRACE_END: '/premium/grace/end',
|
||||
PREMIUM_REFUND_ELIGIBILITY: '/premium/refund-eligibility',
|
||||
PREMIUM_REFUND_LATEST: '/premium/refund-latest',
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {UpdaterEvent} from '@app/types/electron.d';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
|
||||
interface ManualUpdatePrompt {
|
||||
options: ReadonlyArray<UpdaterDownloadOption>;
|
||||
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const {
|
||||
pushManualUpdateAvailableModal,
|
||||
pushUnsupportedUpdateModal,
|
||||
pushUpdateCheckFailedModal,
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal,
|
||||
} = vi.hoisted(() => ({
|
||||
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
|
||||
pushUnsupportedUpdateModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal: vi.fn(),
|
||||
pushUpdateReadyModal: vi.fn(),
|
||||
pushUpToDateModal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
|
||||
pushDesktopUpdateDownloadFailedModal: vi.fn(),
|
||||
pushDesktopUpdateInstallFailedModal: vi.fn(),
|
||||
pushManualUpdateAvailableModal: vi.fn(),
|
||||
pushUnsupportedUpdateModal: vi.fn(),
|
||||
pushManualUpdateAvailableModal,
|
||||
pushUnsupportedUpdateModal,
|
||||
pushUpdateAvailableModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal,
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal: vi.fn(),
|
||||
pushUpToDateModal,
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
@@ -29,15 +47,30 @@ vi.mock('@app/features/platform/utils/ClientInfo', () => ({
|
||||
|
||||
installVoiceMenuTestBootstrap();
|
||||
|
||||
const REPORTED_VERSION = '2026.908.173325';
|
||||
const NEWER_VERSION = '2026.909.202036';
|
||||
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
|
||||
const REPORTED_DEB_DOWNLOAD = [
|
||||
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
|
||||
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
|
||||
];
|
||||
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
|
||||
|
||||
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
|
||||
let onUpdaterCheck: (() => void) | null = null;
|
||||
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
|
||||
let loadedUpdater: {dispose: () => void} | null = null;
|
||||
|
||||
function installElectronApi(): void {
|
||||
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
|
||||
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
|
||||
Promise.resolve({success: true}),
|
||||
);
|
||||
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
function installElectronApi(platform: string): void {
|
||||
nativeEventListener = null;
|
||||
onUpdaterCheck = null;
|
||||
(window as unknown as {electron: unknown}).electron = {
|
||||
platform: 'win32',
|
||||
platform,
|
||||
buildChannel: 'canary',
|
||||
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
|
||||
nativeEventListener = listener;
|
||||
@@ -45,12 +78,11 @@ function installElectronApi(): void {
|
||||
nativeEventListener = null;
|
||||
};
|
||||
},
|
||||
updaterCheck: () => {
|
||||
onUpdaterCheck?.();
|
||||
return Promise.resolve();
|
||||
},
|
||||
updaterCheck,
|
||||
updaterDownload: () => Promise.resolve(),
|
||||
updaterInstall: () => Promise.resolve(),
|
||||
downloadFile,
|
||||
openExternal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,16 +91,16 @@ function emit(event: UpdaterEvent): void {
|
||||
nativeEventListener(event);
|
||||
}
|
||||
|
||||
async function loadUpdater() {
|
||||
async function loadUpdater(platform = 'win32') {
|
||||
vi.resetModules();
|
||||
installElectronApi();
|
||||
installElectronApi(platform);
|
||||
const {default: Updater} = await import('@app/features/app/state/Updater');
|
||||
loadedUpdater = Updater;
|
||||
await vi.waitFor(() => {
|
||||
expect(nativeEventListener).not.toBeNull();
|
||||
expect(Updater.lastCheckedAt).not.toBeNull();
|
||||
});
|
||||
pushUpdateReadyModal.mockClear();
|
||||
vi.clearAllMocks();
|
||||
return Updater;
|
||||
}
|
||||
|
||||
@@ -77,7 +109,37 @@ function emitUserDownloadCompletion(version: string): void {
|
||||
emit({type: 'downloaded', context: 'user', version});
|
||||
}
|
||||
|
||||
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
|
||||
emit({
|
||||
type: 'available',
|
||||
context,
|
||||
version,
|
||||
downloadStarted: false,
|
||||
downloadUrl: `${CANARY_X64}/latest/appimage`,
|
||||
downloadOptions: [
|
||||
{
|
||||
format: 'deb',
|
||||
label: 'DEB package',
|
||||
url: `${CANARY_X64}/latest/deb`,
|
||||
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
|
||||
sha256: `deb-${version}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function openLinuxManualUpdatePrompt() {
|
||||
const Updater = await loadUpdater('linux');
|
||||
emitLinuxManualUpdate('background', REPORTED_VERSION);
|
||||
await Updater.applyUpdate();
|
||||
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
|
||||
const debOption = prompt?.options.find((option) => option.format === 'deb');
|
||||
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
|
||||
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
loadedUpdater?.dispose();
|
||||
loadedUpdater = null;
|
||||
});
|
||||
@@ -110,3 +172,142 @@ describe('updater update-ready surface', () => {
|
||||
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updater manual Linux package download', () => {
|
||||
test('refreshes the check as the user and saves a newer build under its own name', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => {
|
||||
emit({type: 'checking', context: 'user'});
|
||||
emitLinuxManualUpdate('user', NEWER_VERSION);
|
||||
};
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
|
||||
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
|
||||
expect(Updater.displayVersion).toBe(NEWER_VERSION);
|
||||
expect(Updater.isChecking).toBe(false);
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh fails', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh finds no update', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushUpToDateModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh does not answer in time', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => new Promise<void>(() => {});
|
||||
vi.useFakeTimers();
|
||||
const downloaded = download();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await downloaded;
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(Updater.isChecking).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
|
||||
['no update', () => emit({type: 'not-available', context: 'user'})],
|
||||
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
|
||||
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => new Promise<void>(() => {});
|
||||
vi.useFakeTimers();
|
||||
const downloaded = download();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await downloaded;
|
||||
answerLate();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
|
||||
expect(pushUpToDateModal).not.toHaveBeenCalled();
|
||||
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => new Promise<void>(() => {});
|
||||
vi.useFakeTimers();
|
||||
const firstDownload = download();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await firstDownload;
|
||||
onUpdaterCheck = () => {
|
||||
emit({type: 'error', context: 'user', message: 'fetch failed'});
|
||||
emitLinuxManualUpdate('user', NEWER_VERSION);
|
||||
};
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
|
||||
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
|
||||
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('still shows the failure of a user check made while a package is being saved', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
|
||||
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
|
||||
void download();
|
||||
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
|
||||
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
|
||||
await Updater.checkForUpdates(true, true);
|
||||
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
|
||||
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not refresh when a check is already running at the click', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
void Updater.checkForUpdates(true);
|
||||
await download();
|
||||
expect(updaterCheck).not.toHaveBeenCalled();
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
});
|
||||
|
||||
test('saves one package when the button is pressed twice during the refresh', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
let answerRefresh = () => {};
|
||||
onUpdaterCheck = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
answerRefresh = () => {
|
||||
emitLinuxManualUpdate('user', NEWER_VERSION);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const firstClick = download();
|
||||
const secondClick = download();
|
||||
answerRefresh();
|
||||
await Promise.all([firstClick, secondClick]);
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
|
||||
});
|
||||
|
||||
test('opens the pinned link and never the download page when saving fails', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
|
||||
downloadFile.mockResolvedValueOnce({success: false});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
|
||||
const logger = new Logger('Updater');
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
|
||||
const VERSION_ENDPOINT = '/version.json';
|
||||
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
|
||||
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
|
||||
@@ -132,6 +133,7 @@ class Updater {
|
||||
private backgroundCheckCleanups: Array<() => void> = [];
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
private pendingManualDownloadRefreshes = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -288,7 +290,17 @@ class Updater {
|
||||
}
|
||||
|
||||
private handleNativeEvent(event: UpdaterEvent): void {
|
||||
const isUserCheck = event.context === 'user';
|
||||
const isManualDownloadRefreshResult =
|
||||
event.context === 'user' &&
|
||||
this.pendingManualDownloadRefreshes > 0 &&
|
||||
(event.type === 'available' ||
|
||||
event.type === 'not-available' ||
|
||||
event.type === 'error' ||
|
||||
event.type === 'unsupported');
|
||||
if (isManualDownloadRefreshResult) {
|
||||
this.pendingManualDownloadRefreshes -= 1;
|
||||
}
|
||||
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
|
||||
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
|
||||
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
|
||||
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
|
||||
@@ -643,7 +655,7 @@ class Updater {
|
||||
currentVersion: this.currentVersion,
|
||||
version: this.updateInfo.native.version,
|
||||
options: this.nativeManualDownloadOptions,
|
||||
onDownload: (option) => this.downloadManualNativeUpdateOrOpen(option.url, option.suggestedName),
|
||||
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -651,6 +663,42 @@ class Updater {
|
||||
pushUpdateAvailableModal(this.updateInfo.native.version, () => this.downloadManualNativeUpdateOrOpen(url));
|
||||
}
|
||||
|
||||
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
|
||||
if (this.checkInProgress) {
|
||||
return option;
|
||||
}
|
||||
this.transition({type: 'check.started'});
|
||||
let timeoutId: number | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
this.pendingManualDownloadRefreshes += 1;
|
||||
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
|
||||
if (!checked) {
|
||||
return option;
|
||||
}
|
||||
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
this.transition({type: 'check.finished', now: Date.now()});
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
|
||||
if (this.manualNativeDownloadInFlight) {
|
||||
return;
|
||||
}
|
||||
this.transition({type: 'manualDownload.started'});
|
||||
let currentOption = option;
|
||||
try {
|
||||
currentOption = await this.refreshManualNativeDownloadOption(option);
|
||||
} finally {
|
||||
this.transition({type: 'manualDownload.finished'});
|
||||
}
|
||||
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
|
||||
}
|
||||
|
||||
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
|
||||
if (this.manualNativeDownloadInFlight) {
|
||||
return;
|
||||
|
||||
@@ -165,9 +165,11 @@ export const TermsAcceptanceModal = observer(() => {
|
||||
ref={primaryRef}
|
||||
data-flx="auth.terms-acceptance-modal.button.accept"
|
||||
>
|
||||
{kind === 'terms' && i18n._(I_AGREE_TO_THE_UPDATED_TERMS_DESCRIPTOR)}
|
||||
{kind === 'privacy' && i18n._(I_AGREE_TO_THE_UPDATED_PRIVACY_POLICY_DESCRIPTOR)}
|
||||
{kind === 'both' && i18n._(I_AGREE_TO_THE_UPDATED_POLICIES_DESCRIPTOR)}
|
||||
{kind === 'terms'
|
||||
? i18n._(I_AGREE_TO_THE_UPDATED_TERMS_DESCRIPTOR)
|
||||
: kind === 'privacy'
|
||||
? i18n._(I_AGREE_TO_THE_UPDATED_PRIVACY_POLICY_DESCRIPTOR)
|
||||
: i18n._(I_AGREE_TO_THE_UPDATED_POLICIES_DESCRIPTOR)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AuthBottomLink({variant, to}: AuthBottomLinkProps) {
|
||||
return (
|
||||
<div className={styles.bottomLink} data-flx="auth.flow.auth-bottom-link.bottom-link">
|
||||
<span className={styles.bottomLinkText} data-flx="auth.flow.auth-bottom-link.bottom-link-text">
|
||||
{variant === 'login' ? i18n._(ALREADY_HAVE_ACCOUNT_DESCRIPTOR) : i18n._(NEED_ACCOUNT_DESCRIPTOR)}{' '}
|
||||
{`${variant === 'login' ? i18n._(ALREADY_HAVE_ACCOUNT_DESCRIPTOR) : i18n._(NEED_ACCOUNT_DESCRIPTOR)} `}
|
||||
</span>
|
||||
<AuthRouterLink
|
||||
to={to}
|
||||
|
||||
@@ -142,7 +142,9 @@ const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizatio
|
||||
data-flx="auth.flow.ip-authorization-screen.button.resend"
|
||||
>
|
||||
{resendUsed ? <Trans>Resent</Trans> : <Trans>Resend email</Trans>}
|
||||
{resendIn > 0 ? ` (${resendIn}s)` : ''}
|
||||
<flx-i18n data-flx="auth.flow.ip-authorization-screen.flx-i18n">
|
||||
{resendIn > 0 ? ` (${resendIn}s)` : ''}
|
||||
</flx-i18n>
|
||||
</Button>
|
||||
)}
|
||||
{onBack ? (
|
||||
|
||||
@@ -182,7 +182,9 @@ export const ReplyBar = observer(function ReplyBar({
|
||||
data-flx="channel.reply-bar.switch.toggle-mention"
|
||||
>
|
||||
<AtIcon weight="bold" className={styles.mentionIcon} data-flx="channel.reply-bar.mention-icon" />
|
||||
{shouldMention ? i18n._(ON_DESCRIPTOR) : i18n._(OFF_DESCRIPTOR)}
|
||||
<flx-i18n data-flx="channel.channel-reply-bar.reply-bar.flx-i18n">
|
||||
{shouldMention ? i18n._(ON_DESCRIPTOR) : i18n._(OFF_DESCRIPTOR)}
|
||||
</flx-i18n>
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
|
||||
@@ -92,7 +92,11 @@ import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
|
||||
import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentPermissionUtils';
|
||||
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
|
||||
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
|
||||
import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import {
|
||||
canSubmitComposerContent,
|
||||
getComposerMessageContent,
|
||||
hasVisibleMessageContent,
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {
|
||||
resolveTypedEmojiShortcodes,
|
||||
@@ -267,6 +271,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
const referencedMessage = MessageReply.getReferencedMessage(channel.id);
|
||||
const editingMessage = editingMobileMessageId ? Messages.getMessage(channel.id, editingMobileMessageId) : null;
|
||||
const editingMessageForComposer = editingMessage === undefined ? null : editingMessage;
|
||||
const isEditingMessageOnMobile = editingMessageForComposer !== null && mobileLayout.enabled;
|
||||
const maxMessageLength = Limits.getMaxMessageLength();
|
||||
const premiumMaxLength = Limits.getStockValue('max_message_length', maxMessageLength);
|
||||
const maxAttachments = Limits.getMaxAttachmentsPerMessage();
|
||||
@@ -496,6 +501,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
isSlotMenu,
|
||||
onCursorMove,
|
||||
handleSelect,
|
||||
specialMentionsAllowed,
|
||||
} = useLexicalAutocomplete({
|
||||
channel,
|
||||
handleRef,
|
||||
@@ -526,7 +532,11 @@ export const LexicalChannelTextareaContent = observer(
|
||||
() => resolveTypedEmojiContent(wireValue.trim()),
|
||||
[resolveTypedEmojiContent, wireValue],
|
||||
);
|
||||
const hasMessageContent = useMemo(() => hasVisibleMessageContent(trimmedMessageContent), [trimmedMessageContent]);
|
||||
const composerMessageContent = useMemo(
|
||||
() => getComposerMessageContent(trimmedMessageContent, isEditingMessageOnMobile),
|
||||
[isEditingMessageOnMobile, trimmedMessageContent],
|
||||
);
|
||||
const hasMessageContent = useMemo(() => hasVisibleMessageContent(composerMessageContent), [composerMessageContent]);
|
||||
const isSubmissionBlockedBySlowmode = useMemo(() => {
|
||||
if (!isSlowmodeActive || isEditingMessageInComposer) {
|
||||
return false;
|
||||
@@ -708,12 +718,16 @@ export const LexicalChannelTextareaContent = observer(
|
||||
}, [channel.id, hasAttachments, hasPendingSticker]);
|
||||
const showAttachments = hasAttachments;
|
||||
const showStickers = hasPendingSticker;
|
||||
const isOverCharacterLimit = trimmedMessageContent.length > maxMessageLength;
|
||||
const canSubmit =
|
||||
!textareaInputDisabled &&
|
||||
!isSubmissionBlockedBySlowmode &&
|
||||
!isOverCharacterLimit &&
|
||||
(hasMessageContent || hasAttachments || hasPendingSticker);
|
||||
const isOverCharacterLimit = composerMessageContent.length > maxMessageLength;
|
||||
const canSubmit = canSubmitComposerContent({
|
||||
inputDisabled: textareaInputDisabled,
|
||||
isSubmissionBlockedBySlowmode,
|
||||
isOverCharacterLimit,
|
||||
hasMessageContent,
|
||||
hasAttachments,
|
||||
hasPendingSticker,
|
||||
isEditingMessageOnMobile,
|
||||
});
|
||||
const {onSubmit} = useTextareaSubmit({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId === undefined ? null : channel.guildId,
|
||||
@@ -1343,9 +1357,11 @@ export const LexicalChannelTextareaContent = observer(
|
||||
initialSegments={initialDraftRef.current.segments}
|
||||
slotResolvers={slotResolvers}
|
||||
emojiShortcodeResolver={composerEmojiResolver}
|
||||
specialMentionsAllowed={specialMentionsAllowed}
|
||||
channelId={channel.id}
|
||||
guildId={channel.guildId}
|
||||
submitOnEnter={!mobileLayout.enabled}
|
||||
silentMessagePrefix={!isEditingMessageOnMobile}
|
||||
focusRingTarget={containerRef}
|
||||
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
|
||||
className={lexicalStyles.composerEditable}
|
||||
@@ -1406,7 +1422,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
styles.inputSection,
|
||||
)}
|
||||
<MessageCharacterCounter
|
||||
currentLength={trimmedMessageContent.length}
|
||||
currentLength={composerMessageContent.length}
|
||||
maxLength={maxMessageLength}
|
||||
canUpgrade={maxMessageLength < premiumMaxLength}
|
||||
premiumMaxLength={premiumMaxLength}
|
||||
|
||||
@@ -223,8 +223,7 @@ export const ReplyPreview = observer(
|
||||
data-guild-id={resolvedGuildId}
|
||||
data-flx="channel.reply-preview.replied-username"
|
||||
>
|
||||
{message.mentions.some((mention) => mention.id === referencedMessage.author.id) && '@'}
|
||||
{NicknameUtils.getNickname(referencedMessage.author, resolvedGuildId)}
|
||||
{`${message.mentions.some((mention) => mention.id === referencedMessage.author.id) ? '@' : ''}${NicknameUtils.getNickname(referencedMessage.author, resolvedGuildId)}`}
|
||||
</span>
|
||||
</PreloadableUserPopout>
|
||||
<FocusRing offset={-2} data-flx="channel.reply-preview.focus-ring">
|
||||
|
||||
@@ -19,6 +19,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
|
||||
import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
@@ -29,6 +30,7 @@ import {hasStyleableMessageText} from '@app/features/messaging/utils/FailedMessa
|
||||
import {
|
||||
buildExistingAttachmentEditReferences,
|
||||
canSubmitEmptyMessageEdit,
|
||||
isAttachmentOnlyMessage,
|
||||
} from '@app/features/messaging/utils/MessageEditContentUtils';
|
||||
import {retryFailedMessage} from '@app/features/messaging/utils/MessageRetryUtils';
|
||||
import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums';
|
||||
@@ -193,13 +195,13 @@ export const UserMessage = observer(() => {
|
||||
if (message.messageSnapshots) {
|
||||
return;
|
||||
}
|
||||
const content = (actualContent ?? '').trim();
|
||||
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
|
||||
if (!content) {
|
||||
if (isAttachmentOnlyMessage(message)) {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
if (canSubmitEmptyMessageEdit(message)) {
|
||||
if (message.content.length === 0) {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
finishEditing();
|
||||
void MessageCommands.edit(
|
||||
channel.id,
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
|
||||
mockTitlebarPlatformOverride: 'auto',
|
||||
mockAttachmentStates: {},
|
||||
noOpInAppReports: false,
|
||||
disableTranslationDomGuard: false,
|
||||
} satisfies DeveloperOptionsState;
|
||||
const PREMIUM_SCENARIO_OVERRIDE_KEYS = new Set<keyof DeveloperOptionsState>([
|
||||
'premiumTypeOverride',
|
||||
|
||||
+5
-4
@@ -81,10 +81,11 @@ export function CsvAttachmentTablePanel({
|
||||
data-flx="channel.embeds.attachments.csv-attachment-table-panel.warning-circle-icon"
|
||||
/>
|
||||
<span data-flx="channel.embeds.attachments.csv-attachment-table-panel.span">
|
||||
{previewError?.type === 'size'
|
||||
? i18n._(FILE_IS_TOO_LARGE_FOR_INLINE_PREVIEW_LIMIT_DESCRIPTOR, {previewLimitKb: PREVIEW_LIMIT_KB})
|
||||
: i18n._(UNABLE_TO_LOAD_PREVIEW_DESCRIPTOR)}
|
||||
{previewError?.type === 'network' && previewError.message ? ` ${previewError.message}` : ''}
|
||||
{`${
|
||||
previewError?.type === 'size'
|
||||
? i18n._(FILE_IS_TOO_LARGE_FOR_INLINE_PREVIEW_LIMIT_DESCRIPTOR, {previewLimitKb: PREVIEW_LIMIT_KB})
|
||||
: i18n._(UNABLE_TO_LOAD_PREVIEW_DESCRIPTOR)
|
||||
}${previewError?.type === 'network' && previewError.message ? ` ${previewError.message}` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-4
@@ -64,10 +64,11 @@ export function TextualAttachmentCodePanel({
|
||||
data-flx="channel.embeds.attachments.textual-attachment-code-panel.warning-circle-icon"
|
||||
/>
|
||||
<span data-flx="channel.embeds.attachments.textual-attachment-code-panel.span">
|
||||
{previewError?.type === 'size'
|
||||
? i18n._(FILE_IS_TOO_LARGE_FOR_INLINE_PREVIEW_LIMIT_DESCRIPTOR, {previewLimitKb: PREVIEW_LIMIT_KB})
|
||||
: i18n._(UNABLE_TO_LOAD_PREVIEW_DESCRIPTOR)}
|
||||
{previewError?.type === 'network' && previewError.message ? ` ${previewError.message}` : ''}
|
||||
{`${
|
||||
previewError?.type === 'size'
|
||||
? i18n._(FILE_IS_TOO_LARGE_FOR_INLINE_PREVIEW_LIMIT_DESCRIPTOR, {previewLimitKb: PREVIEW_LIMIT_KB})
|
||||
: i18n._(UNABLE_TO_LOAD_PREVIEW_DESCRIPTOR)
|
||||
}${previewError?.type === 'network' && previewError.message ? ` ${previewError.message}` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {YOUTUBE_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstan
|
||||
import styles from '@app/features/channel/components/embeds/media/EmbedYouTube.module.css';
|
||||
import {OverlayActionButton, OverlayPlayButton} from '@app/features/channel/components/embeds/media/MediaButtons';
|
||||
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
|
||||
import ActiveIframeEmbed from '@app/features/messaging/state/ActiveIframeEmbed';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import {
|
||||
@@ -21,7 +22,7 @@ import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon, PlayIcon} from '@phosphor-icons/react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {type FC, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {type FC, useCallback, useEffect, useId, useMemo, useState} from 'react';
|
||||
|
||||
const PLAY_VIDEO_DESCRIPTOR = msg({
|
||||
message: 'Play video',
|
||||
@@ -147,7 +148,8 @@ const Thumbnail: FC<ThumbnailProps> = observer(
|
||||
);
|
||||
export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOUTUBE_CONFIG.DEFAULT_WIDTH}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
const embedId = useId();
|
||||
const hasInteracted = ActiveIframeEmbed.isActive(embedId);
|
||||
const posterSrc = embed.thumbnail?.proxy_url || '';
|
||||
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({rememberKey: posterSrc});
|
||||
const [posterCacheAtMount] = useState(() => ({src: posterSrc, cached: ImageCacheUtils.hasImage(posterSrc)}));
|
||||
@@ -171,10 +173,14 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
|
||||
);
|
||||
return cleanup;
|
||||
}, [isNearViewport, loadedPosterSrc, posterSrc]);
|
||||
const handleInitialPlay = useCallback((event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
setHasInteracted(true);
|
||||
}, []);
|
||||
const handleInitialPlay = useCallback(
|
||||
(event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
ActiveIframeEmbed.claim(embedId);
|
||||
},
|
||||
[embedId],
|
||||
);
|
||||
useEffect(() => () => ActiveIframeEmbed.release(embedId), [embedId]);
|
||||
const handleOpenInNewTab = useCallback(
|
||||
(event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -250,6 +256,7 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
|
||||
sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
|
||||
src={embedVideoUrl.toString()}
|
||||
className={styles.iframe}
|
||||
scrolling="no"
|
||||
data-embed-media="true"
|
||||
aria-label={embed.title || i18n._(VIDEO_DESCRIPTOR, {youtubeProviderName: YOUTUBE_PROVIDER_NAME})}
|
||||
data-flx="channel.embeds.media.embed-you-tube.iframe"
|
||||
|
||||
@@ -116,7 +116,9 @@ export const FiltersSection: React.FC<FiltersSectionProps> = observer(
|
||||
size={remFromPx(12)}
|
||||
data-flx="channel.message-search-bar.filter-option.filters-section.funnel-icon"
|
||||
/>
|
||||
{title || i18n._(SEARCH_FILTERS_DESCRIPTOR)}
|
||||
<flx-i18n data-flx="channel.message-search-bar.filter-option.filters-section.flx-i18n">
|
||||
{title || i18n._(SEARCH_FILTERS_DESCRIPTOR)}
|
||||
</flx-i18n>
|
||||
</span>
|
||||
</div>
|
||||
{options.map((option: SearchFilterOption, index) => (
|
||||
|
||||
@@ -91,7 +91,9 @@ export const PlaintextSection: React.FC<PlaintextSectionProps> = observer(
|
||||
data-flx="channel.message-search-bar.plaintext-section.magnifying-glass-icon"
|
||||
/>
|
||||
)}
|
||||
{group === 'filter-key' ? i18n._(SEARCH_FILTERS_DESCRIPTOR) : `${group}:`}
|
||||
<flx-i18n data-flx="channel.message-search-bar.plaintext-section.flx-i18n">
|
||||
{group === 'filter-key' ? i18n._(SEARCH_FILTERS_DESCRIPTOR) : `${group}:`}
|
||||
</flx-i18n>
|
||||
</span>
|
||||
</div>
|
||||
{entries.map(({row, index}) => {
|
||||
|
||||
+2
-1
@@ -84,7 +84,8 @@ export const UnaddableRecipientsConfirmModal = observer(
|
||||
<ul data-flx="channel.unaddable-recipients-confirm-modal.ul">
|
||||
{rows.map((row) => (
|
||||
<li key={row.userId} data-flx="channel.unaddable-recipients-confirm-modal.li">
|
||||
<strong data-flx="channel.unaddable-recipients-confirm-modal.strong">{row.name}</strong>: {row.reasonText}
|
||||
<strong data-flx="channel.unaddable-recipients-confirm-modal.strong">{row.name}</strong>
|
||||
<span data-flx="channel.unaddable-recipients-confirm-modal.span">{`: ${row.reasonText}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
|
||||
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
||||
@@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer(
|
||||
});
|
||||
}, [actualTopic, form, isTopicInitialized]);
|
||||
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||
setActualTopic(wire);
|
||||
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
|
||||
}, []);
|
||||
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
||||
const composer = composerRef.current;
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
padding-inline-start: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listItem::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--text-chat-muted);
|
||||
}
|
||||
|
||||
.listItem strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-chat-muted);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import styles from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal.module.css';
|
||||
import FavoriteGif from '@app/features/expressions/state/FavoriteGif';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {formatUserSettingsPath} from '@app/features/user/components/settings_utils/SettingsConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useRef} from 'react';
|
||||
|
||||
const HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR = msg({
|
||||
message: 'How should we save your GIF favorites?',
|
||||
comment: 'Confirmation prompt in the channel and chat favorite gif first time prompt modal.',
|
||||
});
|
||||
|
||||
interface FavoriteGifFirstTimePromptModalProps {
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const FavoriteGifFirstTimePromptModal = observer(function FavoriteGifFirstTimePromptModal({
|
||||
onConfirm,
|
||||
}: FavoriteGifFirstTimePromptModalProps) {
|
||||
const {i18n} = useLingui();
|
||||
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const mediaSettingsPath = formatUserSettingsPath(i18n, 'chat_settings', 'media');
|
||||
const handleConfirm = () => {
|
||||
FavoriteGif.setSaveGifFavoritesAsSavedMedia(false);
|
||||
FavoriteGif.markFirstTimePromptSeen();
|
||||
ModalCommands.pop();
|
||||
onConfirm();
|
||||
};
|
||||
const handleUseSavedMedia = () => {
|
||||
FavoriteGif.setSaveGifFavoritesAsSavedMedia(true);
|
||||
FavoriteGif.markFirstTimePromptSeen();
|
||||
ModalCommands.pop();
|
||||
onConfirm();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
ModalCommands.pop();
|
||||
};
|
||||
return (
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
initialFocusRef={initialFocusRef}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-root"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR)}
|
||||
onClose={handleCancel}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-header"
|
||||
/>
|
||||
<Modal.Content data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-content">
|
||||
<div className={styles.body} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.body">
|
||||
<p
|
||||
className={styles.description}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.description"
|
||||
>
|
||||
<Trans>
|
||||
You can store starred GIFs as URL-only favorites or upload them to your saved media. Pick the one that
|
||||
fits how you use them. You can change it any time in {mediaSettingsPath}.
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className={styles.list} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list">
|
||||
<li
|
||||
className={styles.listItem}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item"
|
||||
>
|
||||
<Trans>
|
||||
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong">
|
||||
URL-only favorites (default)
|
||||
</strong>
|
||||
: synced across your devices, no upload, doesn't count against saved media. The original media may
|
||||
disappear if its host removes it.
|
||||
</Trans>
|
||||
</li>
|
||||
<li
|
||||
className={styles.listItem}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item--2"
|
||||
>
|
||||
<Trans>
|
||||
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong--2">
|
||||
Saved media
|
||||
</strong>
|
||||
: uploaded, taggable, searchable, and persistent, but counts against your saved media limit.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
<p className={styles.hint} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.hint">
|
||||
<Trans>We'll only ask once.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-footer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUseSavedMedia}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.use-saved-media"
|
||||
>
|
||||
<Trans>Use saved media</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
ref={initialFocusRef}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.confirm"
|
||||
>
|
||||
<Trans>Use URL-only (recommended)</Trans>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {type FavoriteGifEntry, slimFavoriteGifEntry} from './FavoriteGifTypes';
|
||||
|
||||
function format(src: string, width: number) {
|
||||
return {src, proxy_src: `https://media.test/external/sig/${src}`, width, height: Math.round(width * 0.84)};
|
||||
}
|
||||
|
||||
const FAT: FavoriteGifEntry = {
|
||||
url: 'https://klipy.com/gifs/reaction',
|
||||
proxy_url: 'https://media.test/external/sig/tiny.webm',
|
||||
width: 220,
|
||||
height: 185,
|
||||
media: {
|
||||
nanowebm: format('nano.webm', 90),
|
||||
tinywebm: format('tiny.webm', 220),
|
||||
mediumwebm: format('medium.webm', 320),
|
||||
webm: format('full.webm', 498),
|
||||
gif: format('full.gif', 498),
|
||||
},
|
||||
content_type: 'video/webm',
|
||||
placeholder: 'placeholder',
|
||||
};
|
||||
|
||||
describe('slimFavoriteGifEntry', () => {
|
||||
it('drops the media map so a favorite is cheap to sync', () => {
|
||||
expect(slimFavoriteGifEntry(FAT).media).toEqual({});
|
||||
});
|
||||
|
||||
it('promotes a preview that covers a 200px tile at 2x into the top-level fields', () => {
|
||||
const slim = slimFavoriteGifEntry(FAT);
|
||||
expect(slim.proxy_url).toBe(FAT.media.webm.proxy_src);
|
||||
expect(slim.width).toBe(498);
|
||||
expect(slim.content_type).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('keeps url and placeholder untouched', () => {
|
||||
const slim = slimFavoriteGifEntry(FAT);
|
||||
expect(slim.url).toBe(FAT.url);
|
||||
expect(slim.placeholder).toBe(FAT.placeholder);
|
||||
});
|
||||
|
||||
it('is idempotent so the synced roundtrip stays stable', () => {
|
||||
const once = slimFavoriteGifEntry(FAT);
|
||||
expect(slimFavoriteGifEntry(once)).toEqual(once);
|
||||
});
|
||||
|
||||
it('leaves an entry that already carries no media alone', () => {
|
||||
const urlOnly: FavoriteGifEntry = {...FAT, media: {}};
|
||||
expect(slimFavoriteGifEntry(urlOnly)).toBe(urlOnly);
|
||||
});
|
||||
|
||||
it('does not invent a preview when every format is unusable', () => {
|
||||
const broken: FavoriteGifEntry = {...FAT, media: {webm: {src: '', proxy_src: '', width: 0, height: 0}}};
|
||||
const slim = slimFavoriteGifEntry(broken);
|
||||
expect(slim.media).toEqual({});
|
||||
expect(slim.proxy_url).toBe(FAT.proxy_url);
|
||||
});
|
||||
});
|
||||
@@ -105,4 +105,24 @@ export function pickCanonicalPreviewFormat(
|
||||
return null;
|
||||
}
|
||||
|
||||
export const STORED_PREVIEW_DEVICE_PIXEL_RATIO = 2;
|
||||
|
||||
export function slimFavoriteGifEntry(entry: FavoriteGifEntry): FavoriteGifEntry {
|
||||
const best = pickBestPreviewFormat(entry.media, 'any', {
|
||||
cssWidth: PREVIEW_TILE_CSS_WIDTH,
|
||||
devicePixelRatio: STORED_PREVIEW_DEVICE_PIXEL_RATIO,
|
||||
});
|
||||
if (best == null) {
|
||||
return Object.keys(entry.media).length === 0 ? entry : {...entry, media: {}};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
proxy_url: best.format.proxy_src,
|
||||
width: best.format.width,
|
||||
height: best.format.height,
|
||||
content_type: inferFormatContentType(best.key),
|
||||
media: {},
|
||||
};
|
||||
}
|
||||
|
||||
export {inferFormatContentType};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user