mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d17f320bd7 | ||
|
|
f708586c59 | ||
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 |
@@ -365,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
|
||||
|
||||
@@ -565,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}
|
||||
@@ -583,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}
|
||||
@@ -633,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}
|
||||
@@ -651,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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +13620,10 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32622,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",
|
||||
@@ -32687,7 +32683,6 @@
|
||||
},
|
||||
"required": ["disabled"]
|
||||
},
|
||||
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
|
||||
"SelfServeRefundResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -34016,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"
|
||||
@@ -34025,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",
|
||||
@@ -36836,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);
|
||||
|
||||
@@ -92,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');
|
||||
@@ -105,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,
|
||||
});
|
||||
@@ -129,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,
|
||||
@@ -145,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});
|
||||
@@ -187,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,
|
||||
@@ -204,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});
|
||||
@@ -301,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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/Pr
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
PremiumStateResponse,
|
||||
PricingMode,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
SwitchToListPriceResponse,
|
||||
@@ -144,7 +143,6 @@ export class StripeService {
|
||||
| 'euWithdrawalWaiverAccepted'
|
||||
| 'isBusiness'
|
||||
| 'priceId'
|
||||
| 'pricingMode'
|
||||
| 'purchaseGeoipCountryCode'
|
||||
| 'userId'
|
||||
>,
|
||||
@@ -160,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;
|
||||
@@ -175,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> {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
PremiumPricingState,
|
||||
PremiumStateResponse,
|
||||
PriceIdsResponse,
|
||||
PricingMode,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundIneligibilityReason,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
@@ -33,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';
|
||||
@@ -743,22 +736,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,
|
||||
@@ -775,11 +761,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 {
|
||||
|
||||
@@ -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,147 @@
|
||||
// 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('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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -31,21 +31,6 @@ 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'];
|
||||
}
|
||||
|
||||
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getCurrencyPreferences(countryCode);
|
||||
}
|
||||
|
||||
export function getBaseGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getBaseCurrencyPreferences(countryCode);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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',
|
||||
];
|
||||
|
||||
const INSTANCE_POLICY_TEST_FILES = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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,
|
||||
],
|
||||
);
|
||||
|
||||
+7
-29
@@ -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,12 +81,6 @@ export const usePremiumData = (
|
||||
yearlyPrice,
|
||||
giftMonthlyPrice,
|
||||
giftYearlyPrice,
|
||||
localizedPriceIds,
|
||||
basePriceIds,
|
||||
hasPricingChoice,
|
||||
selectedPricingMode: pricingMode,
|
||||
localizedCurrency: localizedPriceIds?.currency ?? null,
|
||||
baseCurrency: hasPricingChoice ? (basePriceIds?.currency ?? null) : null,
|
||||
currentSubscriptionPrice,
|
||||
currentSubscriptionPriceLabel,
|
||||
currentSubscriptionListPriceLabel,
|
||||
|
||||
@@ -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]';
|
||||
|
||||
@@ -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"
|
||||
|
||||
-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};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/channel/components/GifPicker.module.css';
|
||||
import {safePause, safePlay, useGifVideoPool} from '@app/features/channel/components/GifVideoPool';
|
||||
import {FavoriteGifFirstTimePromptModal} from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
import {PickerThumbnail} from '@app/features/channel/components/pickers/shared/PickerThumbnail';
|
||||
import {usePooledVideo} from '@app/features/channel/components/pickers/shared/usePooledVideo';
|
||||
@@ -24,7 +23,6 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {modal, push} from '@app/features/ui/commands/ModalCommands';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -400,17 +398,6 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isFavoritePending) return;
|
||||
if (!FavoriteGif.hasSeenFavoriteGifFirstTimePrompt && !isFavorited) {
|
||||
push(
|
||||
modal(() => (
|
||||
<FavoriteGifFirstTimePromptModal
|
||||
onConfirm={() => void performFavoriteToggle()}
|
||||
data-flx="channel.pickers.gif.gif-picker-grid-item.handle-favorite-click.favorite-gif-first-time-prompt-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void performFavoriteToggle();
|
||||
};
|
||||
const favoriteTooltipText = (() => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
FavoriteGifEntry,
|
||||
FavoriteGifMediaFormat,
|
||||
import {
|
||||
type FavoriteGifEntry,
|
||||
type FavoriteGifMediaFormat,
|
||||
slimFavoriteGifEntry,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
import {FAVORITE_GIF_MAX_ENCODED_BYTES} from '@app/features/user/state/SyncedFieldBudget';
|
||||
import type {FavoriteGifMediaFormat as FavoriteGifMediaFormatProto} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
import {FavoriteGifSettingsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
@@ -52,8 +54,9 @@ class FavoriteGif {
|
||||
field: 'favoriteGifs',
|
||||
schema: FavoriteGifSettingsSchema,
|
||||
persist: ['favoriteGifs', 'saveGifFavoritesAsSavedMedia', 'hasSeenFavoriteGifFirstTimePrompt'],
|
||||
maxEncodedBytes: FAVORITE_GIF_MAX_ENCODED_BYTES,
|
||||
toMessage: (s) => ({
|
||||
entries: s.favoriteGifs.map((entry) => ({
|
||||
entries: s.favoriteGifs.map(slimFavoriteGifEntry).map((entry) => ({
|
||||
url: entry.url,
|
||||
proxyUrl: entry.proxy_url,
|
||||
width: entry.width,
|
||||
@@ -66,15 +69,17 @@ class FavoriteGif {
|
||||
seenFirstTimePrompt: s.hasSeenFavoriteGifFirstTimePrompt,
|
||||
}),
|
||||
applyMessage: (s, m) => {
|
||||
s.favoriteGifs = m.entries.map((entry) => ({
|
||||
url: entry.url,
|
||||
proxy_url: entry.proxyUrl,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
media: mediaFromProto(entry.media),
|
||||
content_type: entry.contentType,
|
||||
placeholder: entry.placeholder ? entry.placeholder : null,
|
||||
}));
|
||||
s.favoriteGifs = m.entries.map((entry) =>
|
||||
slimFavoriteGifEntry({
|
||||
url: entry.url,
|
||||
proxy_url: entry.proxyUrl,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
media: mediaFromProto(entry.media),
|
||||
content_type: entry.contentType,
|
||||
placeholder: entry.placeholder ? entry.placeholder : null,
|
||||
}),
|
||||
);
|
||||
s.saveGifFavoritesAsSavedMedia = m.saveAsSavedMedia;
|
||||
s.hasSeenFavoriteGifFirstTimePrompt = m.seenFirstTimePrompt;
|
||||
},
|
||||
@@ -110,10 +115,6 @@ class FavoriteGif {
|
||||
setSaveGifFavoritesAsSavedMedia(value: boolean): void {
|
||||
this.saveGifFavoritesAsSavedMedia = value;
|
||||
}
|
||||
|
||||
markFirstTimePromptSeen(): void {
|
||||
this.hasSeenFavoriteGifFirstTimePrompt = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new FavoriteGif();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {FavoriteGifFirstTimePromptModal} from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal';
|
||||
import * as FavoriteGifCommands from '@app/features/expressions/commands/FavoriteGifCommands';
|
||||
import * as FavoriteMemeCommands from '@app/features/expressions/commands/FavoriteMemeCommands';
|
||||
import {AddFavoriteMemeModal} from '@app/features/expressions/components/modals/AddFavoriteMemeModal';
|
||||
@@ -106,17 +105,6 @@ export function useMediaFavorite({
|
||||
});
|
||||
}
|
||||
};
|
||||
if (!FavoriteGif.hasSeenFavoriteGifFirstTimePrompt && !isFavorited) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<FavoriteGifFirstTimePromptModal
|
||||
onConfirm={performToggle}
|
||||
data-flx="messaging.use-media-favorite.toggle-favorite.favorite-gif-first-time-prompt-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
performToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
|
||||
class ActiveIframeEmbed {
|
||||
activeId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
isActive(id: string): boolean {
|
||||
return this.activeId === id;
|
||||
}
|
||||
|
||||
claim(id: string): void {
|
||||
this.activeId = id;
|
||||
}
|
||||
|
||||
release(id: string): void {
|
||||
if (this.activeId === id) {
|
||||
this.activeId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ActiveIframeEmbed();
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
LocalizedCardPreapprovalContinueResponse,
|
||||
PremiumStateResponse,
|
||||
PriceIdsResponse,
|
||||
PricingMode,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
SwitchToListPriceResponse,
|
||||
@@ -50,15 +49,12 @@ async function resolvePremiumStateCountryCode(countryCode?: string): Promise<str
|
||||
return normalizedCountryCode(GeoIP.countryCode ?? undefined);
|
||||
}
|
||||
|
||||
function priceIdsCacheKey(countryCode: string | undefined, pricingMode: PricingMode): string {
|
||||
return `${countryCode ?? 'default'}:${pricingMode}`;
|
||||
function priceIdsCacheKey(countryCode: string | undefined): string {
|
||||
return countryCode ?? 'default';
|
||||
}
|
||||
|
||||
function priceIdsQuery(countryCode: string | undefined, pricingMode: PricingMode): Record<string, string> {
|
||||
return {
|
||||
...(countryCode ? {country_code: countryCode} : {}),
|
||||
pricing_mode: pricingMode,
|
||||
};
|
||||
function priceIdsQuery(countryCode: string | undefined): Record<string, string> {
|
||||
return countryCode ? {country_code: countryCode} : {};
|
||||
}
|
||||
|
||||
function checkoutEndpoint(isGift: boolean): string {
|
||||
@@ -69,29 +65,22 @@ function checkoutSessionBody(
|
||||
priceId: string,
|
||||
countryCode: string | undefined,
|
||||
isGift: boolean,
|
||||
pricingMode: PricingMode,
|
||||
paymentMethod?: CheckoutPaymentMethod,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
price_id: priceId,
|
||||
...(countryCode ? {country_code: countryCode} : {}),
|
||||
...(countryCode ? {client_geoip_country_code: countryCode} : {}),
|
||||
pricing_mode: pricingMode,
|
||||
...(paymentMethod && !isGift ? {payment_method: paymentMethod} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function preapprovalSessionBody(
|
||||
priceId: string,
|
||||
countryCode: string,
|
||||
pricingMode: PricingMode,
|
||||
): Record<string, string> {
|
||||
function preapprovalSessionBody(priceId: string, countryCode: string): Record<string, string> {
|
||||
const normalized = normalizedCountryCode(countryCode) ?? countryCode;
|
||||
return {
|
||||
price_id: priceId,
|
||||
country_code: normalized,
|
||||
client_geoip_country_code: normalized,
|
||||
pricing_mode: pricingMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,9 +93,9 @@ async function postAndInvalidate(endpoint: string, body?: Record<string, string>
|
||||
invalidateCurrentSubscriptionPriceCache();
|
||||
}
|
||||
|
||||
export async function fetchPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): Promise<PriceIds> {
|
||||
export async function fetchPriceIds(countryCode?: string): Promise<PriceIds> {
|
||||
const country = normalizedCountryCode(countryCode);
|
||||
const cacheKey = priceIdsCacheKey(country, pricingMode);
|
||||
const cacheKey = priceIdsCacheKey(country);
|
||||
const cachedEntry = priceIdsCache.get(cacheKey);
|
||||
if (cachedEntry?.value && cachedEntry.fetchedAt && Date.now() - cachedEntry.fetchedAt < PRICE_IDS_CACHE_TTL_MS) {
|
||||
return cachedEntry.value;
|
||||
@@ -117,7 +106,7 @@ export async function fetchPriceIds(countryCode?: string, pricingMode: PricingMo
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await http.get<PriceIds>(Endpoints.PREMIUM_PRICE_IDS, {
|
||||
query: priceIdsQuery(country, pricingMode),
|
||||
query: priceIdsQuery(country),
|
||||
});
|
||||
logger.debug('Price IDs fetched', response.body);
|
||||
priceIdsCache.set(cacheKey, {
|
||||
@@ -215,6 +204,9 @@ export async function refreshPremiumState(countryCode?: string): Promise<Premium
|
||||
}
|
||||
try {
|
||||
const state = await fetchPremiumState(countryCode);
|
||||
if (Users.currentUser?.id !== currentUserId) {
|
||||
return state;
|
||||
}
|
||||
if (currentUserId) {
|
||||
PremiumState.setState(currentUserId, state);
|
||||
}
|
||||
@@ -228,10 +220,13 @@ export async function refreshPremiumState(countryCode?: string): Promise<Premium
|
||||
}
|
||||
|
||||
export async function setPremiumPerksDisabled(disabled: boolean): Promise<PremiumStateResponse> {
|
||||
const currentUserId = Users.currentUser?.id;
|
||||
const response = await http.patch<PremiumStateResponse>(Endpoints.PREMIUM_PERKS_DISABLED, {
|
||||
body: {disabled} satisfies PremiumPerksDisabledRequest,
|
||||
});
|
||||
const currentUserId = Users.currentUser?.id;
|
||||
if (Users.currentUser?.id !== currentUserId) {
|
||||
return response.body;
|
||||
}
|
||||
if (currentUserId) {
|
||||
PremiumState.setState(currentUserId, response.body);
|
||||
}
|
||||
@@ -256,15 +251,14 @@ export async function createCheckoutSession(
|
||||
priceId: string,
|
||||
countryCode?: string,
|
||||
isGift: boolean = false,
|
||||
pricingMode: PricingMode = 'localized',
|
||||
paymentMethod?: CheckoutPaymentMethod,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const country = normalizedCountryCode(countryCode);
|
||||
const response = await http.post<UrlResponse>(checkoutEndpoint(isGift), {
|
||||
body: checkoutSessionBody(priceId, country, isGift, pricingMode, paymentMethod),
|
||||
body: checkoutSessionBody(priceId, country, isGift, paymentMethod),
|
||||
});
|
||||
logger.info('Checkout session created', {priceId, countryCode, isGift, pricingMode, paymentMethod});
|
||||
logger.info('Checkout session created', {priceId, countryCode, isGift, paymentMethod});
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Checkout session creation failed', error);
|
||||
@@ -272,16 +266,12 @@ export async function createCheckoutSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLocalizedCardPreapprovalSession(
|
||||
priceId: string,
|
||||
countryCode: string,
|
||||
pricingMode: PricingMode = 'localized',
|
||||
): Promise<string> {
|
||||
export async function createLocalizedCardPreapprovalSession(priceId: string, countryCode: string): Promise<string> {
|
||||
try {
|
||||
const response = await http.post<UrlResponse>(Endpoints.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL, {
|
||||
body: preapprovalSessionBody(priceId, countryCode, pricingMode),
|
||||
body: preapprovalSessionBody(priceId, countryCode),
|
||||
});
|
||||
logger.info('Localized card preapproval session created', {priceId, countryCode, pricingMode});
|
||||
logger.info('Localized card preapproval session created', {priceId, countryCode});
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Localized card preapproval session creation failed', error);
|
||||
|
||||
@@ -21,19 +21,19 @@ type PreapprovalState =
|
||||
|
||||
const CARD_COUNTRY_MISMATCH_WITH_COUNTRY_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Stripe reported a card issued in {countryName}. To pay in this localized currency, use a card issued in your billing country, choose another payment method, or return to {productName} and switch to the standard price.',
|
||||
'Stripe reported a card issued in {countryName}. To pay in this localized currency, use a card issued in your billing country, or return to {productName} and choose another payment method.',
|
||||
comment:
|
||||
'Premium checkout error shown when card country does not match billing country. productName is the app name.',
|
||||
});
|
||||
const CARD_COUNTRY_MISMATCH_DESCRIPTOR = msg({
|
||||
message:
|
||||
'This card does not match your billing country, so it cannot be used for this localized currency. Use a local card, choose another payment method, or return to {productName} and switch to the standard price.',
|
||||
'This card does not match your billing country, so it cannot be used for this localized currency. Use a local card, or return to {productName} and choose another payment method.',
|
||||
comment:
|
||||
'Premium checkout error shown when card country does not match billing country and Stripe did not provide the issuing country. productName is the app name.',
|
||||
});
|
||||
const CARD_PREAPPROVAL_FAILED_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Stripe could not verify this card for localized pricing. Use another card or return to {productName} and choose another payment method or the standard price.',
|
||||
'Stripe could not verify this card for localized pricing. Use another card, or return to {productName} and choose another payment method.',
|
||||
comment:
|
||||
'Premium checkout error shown when Stripe cannot verify a card for localized pricing. productName is the app name.',
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ class PremiumState {
|
||||
}
|
||||
|
||||
beginLoad(userId: string): void {
|
||||
if (this.loadedForUserId !== userId) {
|
||||
this.state = null;
|
||||
}
|
||||
this.loading = true;
|
||||
this.loadedForUserId = userId;
|
||||
}
|
||||
|
||||
@@ -2,25 +2,6 @@
|
||||
|
||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
|
||||
export type PricingMode = 'localized' | 'base';
|
||||
|
||||
export function getCurrencyCodeLabel(currency: string | null | undefined): string {
|
||||
switch (currency) {
|
||||
case 'BRL':
|
||||
return 'BRL';
|
||||
case 'INR':
|
||||
return 'INR';
|
||||
case 'PLN':
|
||||
return 'PLN';
|
||||
case 'TRY':
|
||||
return 'TRY';
|
||||
case 'EUR':
|
||||
return 'EUR';
|
||||
default:
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMinorUnitPrice(
|
||||
amountMinor: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES, decideOversizePush} from '@app/features/user/state/SyncedFieldBudget';
|
||||
import {verifyRoundtripStability} from '@app/features/user/state/SyncedFieldRoundtrip';
|
||||
import {
|
||||
createSyncedFieldMachineSnapshot,
|
||||
@@ -59,6 +60,7 @@ export async function makeSyncedField<
|
||||
}
|
||||
const isEnabled = (): boolean => config.enabled?.() ?? true;
|
||||
let machine = createSyncedFieldMachineSnapshot();
|
||||
let lastPreparedBytes: number | null = null;
|
||||
let ownerUserId: string | null = null;
|
||||
let observedUserId: string | null = null;
|
||||
const suspend = (reason: SyncedFieldFailureReason, message: string, error?: unknown): void => {
|
||||
@@ -130,13 +132,20 @@ export async function makeSyncedField<
|
||||
return;
|
||||
}
|
||||
const encodedBytes = toBinary(schema, candidate).length;
|
||||
if (encodedBytes > maxEncodedBytes) {
|
||||
const decision = decideOversizePush({encodedBytes, maxEncodedBytes, lastPreparedBytes});
|
||||
lastPreparedBytes = encodedBytes;
|
||||
if (decision === 'drop') {
|
||||
logger.error(
|
||||
`${tag}: payload of ${encodedBytes} bytes exceeds per-field budget of ${maxEncodedBytes}; push dropped.`,
|
||||
);
|
||||
transitionOnly({type: 'sync.localAlreadySynced'});
|
||||
return;
|
||||
}
|
||||
if (decision === 'push-shrinks') {
|
||||
logger.warn(
|
||||
`${tag}: payload of ${encodedBytes} bytes is over the per-field budget of ${maxEncodedBytes} but smaller than the previous candidate. Pushing so removals persist.`,
|
||||
);
|
||||
}
|
||||
const stability = verifyRoundtripStability<T, M>({
|
||||
schema,
|
||||
store,
|
||||
@@ -204,7 +213,7 @@ export async function makeSyncedField<
|
||||
processCommands();
|
||||
}
|
||||
await Promise.resolve();
|
||||
const maxEncodedBytes = config.maxEncodedBytes ?? 65_536;
|
||||
const maxEncodedBytes = config.maxEncodedBytes ?? DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES;
|
||||
const UserSettings = (await import('@app/features/user/state/UserSettings')).default;
|
||||
const SessionManager = (await import('@app/features/platform/state/AuthSession')).default;
|
||||
const applyDefaultsForUserChange = (userId: string): void => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {decideOversizePush} from './SyncedFieldBudget';
|
||||
|
||||
const MAX = 1000;
|
||||
|
||||
describe('decideOversizePush', () => {
|
||||
it('accepts a payload inside the budget', () => {
|
||||
expect(decideOversizePush({encodedBytes: 999, maxEncodedBytes: MAX, lastPreparedBytes: null})).toBe(
|
||||
'within-budget',
|
||||
);
|
||||
expect(decideOversizePush({encodedBytes: MAX, maxEncodedBytes: MAX, lastPreparedBytes: null})).toBe(
|
||||
'within-budget',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the first over-budget payload', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1001, maxEncodedBytes: MAX, lastPreparedBytes: null})).toBe('drop');
|
||||
});
|
||||
|
||||
it('drops an over-budget payload that grows', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1200, maxEncodedBytes: MAX, lastPreparedBytes: 1100})).toBe('drop');
|
||||
});
|
||||
|
||||
it('drops an over-budget payload that stays the same size', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1100, maxEncodedBytes: MAX, lastPreparedBytes: 1100})).toBe('drop');
|
||||
});
|
||||
|
||||
it('pushes an over-budget payload that shrinks so removals persist', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1050, maxEncodedBytes: MAX, lastPreparedBytes: 1100})).toBe(
|
||||
'push-shrinks',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a run of removals drain back under the budget', () => {
|
||||
let last: number | null = null;
|
||||
const decisions: Array<string> = [];
|
||||
for (const bytes of [1300, 1200, 1100, 900]) {
|
||||
decisions.push(decideOversizePush({encodedBytes: bytes, maxEncodedBytes: MAX, lastPreparedBytes: last}));
|
||||
last = bytes;
|
||||
}
|
||||
expect(decisions).toEqual(['drop', 'push-shrinks', 'push-shrinks', 'within-budget']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES = 65_536;
|
||||
|
||||
export const FAVORITE_GIF_MAX_ENCODED_BYTES = 384 * 1024;
|
||||
|
||||
export type OversizePushDecision = 'within-budget' | 'push-shrinks' | 'drop';
|
||||
|
||||
export function decideOversizePush(args: {
|
||||
encodedBytes: number;
|
||||
maxEncodedBytes: number;
|
||||
lastPreparedBytes: number | null;
|
||||
}): OversizePushDecision {
|
||||
if (args.encodedBytes <= args.maxEncodedBytes) return 'within-budget';
|
||||
if (args.lastPreparedBytes != null && args.encodedBytes < args.lastPreparedBytes) return 'push-shrinks';
|
||||
return 'drop';
|
||||
}
|
||||
@@ -736,7 +736,7 @@ if (optionalityAdvisories.length > 0) {
|
||||
console.log(' Both current lines were adjudicated by hand. ZERO documentation bugs: in each');
|
||||
console.log(' case the page is MORE precise than a bare ? marker can be.');
|
||||
console.log(' POST /stripe/checkout/subscription/preapproval requires country_code in the');
|
||||
console.log(' handler (StripeCheckoutService.ts:322 throws when it is absent) while the');
|
||||
console.log(' handler (StripeCheckoutService.ts:310 throws when no country resolves) while the');
|
||||
console.log(' shared CreateCheckoutSessionRequest requires only price_id.');
|
||||
console.log(' PATCH /admin/discovery/applications/{guild_id} takes a discriminated union.');
|
||||
console.log(' reason is required when status is rejected and optional when it is approved,');
|
||||
|
||||
@@ -236,11 +236,10 @@ A blocked purchase always fails on [create localised card preapproval](#create-l
|
||||
| country_code?<sup>1</sup> | string | Two-letter country used to select the regional price catalogue (2 characters) |
|
||||
| client_geoip_country_code?<sup>2</sup> | string | Two-letter country the client previously observed for itself (2 characters) |
|
||||
| eu_withdrawal_waiver_accepted?<sup>3</sup> | boolean | Whether the digital content withdrawal waiver was explicitly accepted |
|
||||
| pricing_mode? | string | Either `localized` or `base` (default `localized`) |
|
||||
| payment_method?<sup>4</sup> | string | Either `card`, `pix` or `upi` (default `card`) |
|
||||
| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
|
||||
|
||||
<sup>1</sup> Required whenever the resolved price is not in USD or EUR, and validated against the price catalogue for that country
|
||||
<sup>1</sup> Read only when Fluxer cannot geolocate the request, because the request-time geolocation country wins. The resolved country is required whenever the price is not in USD or EUR, and the price is validated against the catalogue for that country, so a client cannot buy outside the catalogue of the country it is browsing from
|
||||
|
||||
<sup>2</sup> Read to resolve the effective country for the withdrawal waiver, and recorded with the payment
|
||||
|
||||
@@ -282,7 +281,7 @@ Creates a setup mode session that captures and verifies a card before a localise
|
||||
### Limitations
|
||||
|
||||
- The claimed account, verified email and purchase flag requirements of [create subscription checkout](#create-subscription-checkout) apply unchanged, with the same codes.
|
||||
- An absent `country_code`, a `pricing_mode` of `base`, and a resolved price that is not a recurring price in a currency other than USD and EUR each return 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
|
||||
- An unresolvable country, meaning a request Fluxer cannot geolocate that also omits `country_code`, and a resolved price that is not a recurring price in a currency other than USD and EUR each return 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
|
||||
- The submitted price is always recurring, so the lifetime block and the existing subscription block both apply and return 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime` or `existing_subscription`.
|
||||
|
||||
### JSON body
|
||||
@@ -293,12 +292,9 @@ Creates a setup mode session that captures and verifies a card before a localise
|
||||
| country_code<sup>1</sup> | string | Two-letter country used to select the regional price catalogue (2 characters) |
|
||||
| client_geoip_country_code? | string | Two-letter country the client previously observed for itself (2 characters) |
|
||||
| eu_withdrawal_waiver_accepted? | boolean | Whether the digital content withdrawal waiver was explicitly accepted |
|
||||
| pricing_mode?<sup>2</sup> | string | Either `localized` or `base` (default `localized`) |
|
||||
| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
|
||||
|
||||
<sup>1</sup> Declared optional by the shared request schema, and this operation refuses a request that omits it
|
||||
|
||||
<sup>2</sup> A submitted `base` returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`
|
||||
<sup>1</sup> Declared optional by the shared request schema, and this operation refuses a request that resolves no country. The request-time geolocation country wins, so this value is read only when Fluxer cannot geolocate the request
|
||||
|
||||
A submitted `payment_method` is validated against the enum and then discarded. The setup session is always restricted to cards.
|
||||
|
||||
@@ -307,11 +303,11 @@ A submitted `payment_method` is validated against the enum and then discarded. T
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 200 | [redirect URL](#redirect-url-object) object | The setup session was created |
|
||||
| 400<sup>3</sup> | [error response](/http-api/#error-response) | The country, the price, the account, or the payment provider blocks the setup session |
|
||||
| 400<sup>2</sup> | [error response](/http-api/#error-response) | The country, the price, the account, or the payment provider blocks the setup session |
|
||||
| 403 | [error response](/http-api/#error-response) | The email address is unverified, or the purchase is blocked |
|
||||
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
|
||||
|
||||
<sup>3</sup> The country or pricing mode is not localised, the price is unknown or not valid for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
|
||||
<sup>2</sup> The country is absent, the price is unknown or not valid for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
|
||||
|
||||
### Side effects
|
||||
|
||||
@@ -395,10 +391,9 @@ Neither the lifetime block nor the existing subscription block applies here, so
|
||||
| country_code?<sup>1</sup> | string | Two-letter country used to select the regional gift price catalogue (2 characters) |
|
||||
| client_geoip_country_code? | string | Two-letter country the client previously observed for itself (2 characters) |
|
||||
| eu_withdrawal_waiver_accepted? | boolean | Whether the digital content withdrawal waiver was explicitly accepted |
|
||||
| pricing_mode? | string | Either `localized` or `base` (default `localized`) |
|
||||
| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
|
||||
|
||||
<sup>1</sup> Required whenever the resolved gift price is not in USD or EUR
|
||||
<sup>1</sup> Read only when Fluxer cannot geolocate the request, because the request-time geolocation country wins. The resolved country is required whenever the gift price is not in USD or EUR
|
||||
|
||||
A submitted `pix` or `upi` is validated against the enum and then discarded. The session restricts no payment method type, so the provider offers whatever the deployment's provider account accepts for the resolved currency.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Every route except [get price IDs](#get-price-ids) is user-only. A [self-hosted
|
||||
| [Billing subscription](#billing-subscription-object) | One mirrored subscription |
|
||||
| [Billing invoice](#billing-invoice-object) | One mirrored invoice |
|
||||
| [Billing payment method](#billing-payment-method-object) | One mirrored payment method |
|
||||
| [Premium pricing state](#premium-pricing-state-object) | Both resolved checkout catalogues |
|
||||
| [Premium pricing state](#premium-pricing-state-object) | The resolved checkout catalogue |
|
||||
|
||||
:::note[Billing history is mirrored from the payment provider]
|
||||
[Get premium state](#get-premium-state) returns the mirrored invoices, payment methods and subscription fields. [Create customer portal](#create-customer-portal) is the externally hosted interface for changing any of them.
|
||||
@@ -430,7 +430,7 @@ One mirrored stored payment method. A non-card method reports every card member
|
||||
|
||||
## Premium pricing state object
|
||||
|
||||
The checkout catalogue resolved for the request. A client can render prices without a second call to [get price IDs](#get-price-ids). Fluxer resolves both catalogues on every request, so one response has both a localised price and its USD or EUR equivalent.
|
||||
The checkout catalogue resolved for the request. A client can render prices without a second call to [get price IDs](#get-price-ids). Fluxer resolves one catalogue on every request, and a country with its own localised currency always resolves to that currency.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -438,9 +438,8 @@ The checkout catalogue resolved for the request. A client can render prices with
|
||||
| --- | --- | --- |
|
||||
| country_code<sup>1</sup> | ?string | The country used to resolve the prices |
|
||||
| localized<sup>2</sup> | ?[price IDs](#price-ids-object) object | The localised catalogue, or null when it cannot be resolved |
|
||||
| base<sup>2</sup> | ?[price IDs](#price-ids-object) object | The base USD or EUR catalogue, or null when it cannot be resolved |
|
||||
|
||||
<sup>1</sup> The `country_code` query value uppercased, or null when it was omitted
|
||||
<sup>1</sup> The request-time geolocation country, falling back to the `country_code` query value uppercased, or null when neither resolves
|
||||
|
||||
<sup>2</sup> Null unless the deployment configures a complete recurring pair and a complete gift pair for the currency preferences that apply, so one missing gift price nulls the whole catalogue. The amounts come from the mirrored price rows
|
||||
|
||||
@@ -450,7 +449,7 @@ Fluxer reports an unresolvable catalogue as null here, and the request still suc
|
||||
|
||||
<RouteHeader method="GET" path="/v1/premium/price-ids" unauthenticated />
|
||||
|
||||
Returns the [price IDs](#price-ids-object) object for a country and pricing mode. A supplied credential only keys the rate limit, so an unrecognised credential falls back to the client IP address.
|
||||
Returns the [price IDs](#price-ids-object) object for a country. A supplied credential only keys the rate limit, so an unrecognised credential falls back to the client IP address.
|
||||
|
||||
The deployment must configure a complete recurring pair and a complete gift pair for at least one currency that the requested country prefers. Fluxer answers 400 `STRIPE_ERROR` when it does not.
|
||||
|
||||
@@ -459,11 +458,8 @@ The deployment must configure a complete recurring pair and a complete gift pair
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| country_code?<sup>1</sup> | string | The two-letter country used to select the regional price catalogue |
|
||||
| pricing_mode?<sup>2</sup> | string | Either `localized` or `base` (default `localized`) |
|
||||
|
||||
<sup>1</sup> Fluxer trims surrounding whitespace and requires exactly two characters, and any other length fails validation with `STRING_LENGTH_EXACT`. Omitting it resolves the default currency preference order
|
||||
|
||||
<sup>2</sup> `base` restricts resolution to the deployment's base currency preferences, which is how a client reads USD or EUR pricing for a country that has its own localised catalogue
|
||||
<sup>1</sup> Fluxer trims surrounding whitespace and requires exactly two characters, and any other length fails validation with `STRING_LENGTH_EXACT`. The request-time geolocation country wins, so this value is read only when Fluxer cannot geolocate the request, and the default currency preference order applies when neither resolves
|
||||
|
||||
### Response
|
||||
|
||||
@@ -498,7 +494,7 @@ This route derives `current_subscription_price` from the mirrored subscription a
|
||||
| --- | --- | --- |
|
||||
| country_code?<sup>1</sup> | string | The two-letter country used to resolve `pricing` |
|
||||
|
||||
<sup>1</sup> Fluxer trims surrounding whitespace and requires exactly two characters. Omitting it resolves `pricing` against the default currency preference order and reports `pricing.country_code` as null
|
||||
<sup>1</sup> Fluxer trims surrounding whitespace and requires exactly two characters. The request-time geolocation country wins, so this value is read only when Fluxer cannot geolocate the request. When neither resolves, `pricing` falls back to the default currency preference order and `pricing.country_code` is null
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PricingModeEnum} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {z} from 'zod';
|
||||
@@ -11,7 +10,11 @@ export type CheckoutPaymentMethod = z.infer<typeof CheckoutPaymentMethodEnum>;
|
||||
|
||||
export const CreateCheckoutSessionRequest = z.object({
|
||||
price_id: createStringType().describe('The Stripe price ID for the subscription plan'),
|
||||
country_code: createStringType(2, 2).optional().describe('Two-letter country code used for regional pricing'),
|
||||
country_code: createStringType(2, 2)
|
||||
.optional()
|
||||
.describe(
|
||||
'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: createStringType(2, 2)
|
||||
.optional()
|
||||
.describe('Two-letter country code observed by the client GeoIP store before checkout'),
|
||||
@@ -19,7 +22,6 @@ export const CreateCheckoutSessionRequest = z.object({
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout'),
|
||||
pricing_mode: PricingModeEnum.optional().describe('Whether to use localized or standard USD/EUR pricing rules'),
|
||||
payment_method: CheckoutPaymentMethodEnum.optional().describe(
|
||||
'Preferred payment method. card (default) uses the account Payment Method Configuration. pix requires a BRL recurring price and enables Pix Automático. upi requires an INR recurring price and enables RBI-compliant UPI mandates.',
|
||||
),
|
||||
|
||||
@@ -4,10 +4,6 @@ import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {UserPremiumTypesSchema} from '@fluxer/schema/src/primitives/UserSettingsValidators';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const PricingModeEnum = z.enum(['localized', 'base']);
|
||||
|
||||
export type PricingMode = z.infer<typeof PricingModeEnum>;
|
||||
|
||||
export const WebhookReceivedResponse = z.object({
|
||||
received: z.boolean().describe('Whether the webhook was successfully received'),
|
||||
});
|
||||
@@ -40,8 +36,11 @@ export const PriceIdsResponse = z.object({
|
||||
export type PriceIdsResponse = z.infer<typeof PriceIdsResponse>;
|
||||
|
||||
export const PriceIdsQueryRequest = z.object({
|
||||
country_code: createStringType(2, 2).optional().describe('Two-letter country code for regional pricing'),
|
||||
pricing_mode: PricingModeEnum.optional().describe('Whether to resolve localized or standard USD/EUR pricing'),
|
||||
country_code: createStringType(2, 2)
|
||||
.optional()
|
||||
.describe(
|
||||
'Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins.',
|
||||
),
|
||||
});
|
||||
|
||||
export type PriceIdsQueryRequest = z.infer<typeof PriceIdsQueryRequest>;
|
||||
@@ -358,7 +357,6 @@ const PremiumBillingState = z.object({
|
||||
export const PremiumPricingState = z.object({
|
||||
country_code: createStringType(2, 2).nullable().describe('Country code used to resolve localized prices'),
|
||||
localized: PriceIdsResponse.nullable().describe('Localized checkout prices resolved from mirrored billing data'),
|
||||
base: PriceIdsResponse.nullable().describe('Standard USD/EUR checkout prices resolved from mirrored billing data'),
|
||||
});
|
||||
|
||||
export type PremiumPricingState = z.infer<typeof PremiumPricingState>;
|
||||
@@ -373,7 +371,11 @@ export const PremiumStateResponse = z.object({
|
||||
export type PremiumStateResponse = z.infer<typeof PremiumStateResponse>;
|
||||
|
||||
export const PremiumStateQueryRequest = z.object({
|
||||
country_code: createStringType(2, 2).optional().describe('Two-letter country code for regional pricing'),
|
||||
country_code: createStringType(2, 2)
|
||||
.optional()
|
||||
.describe(
|
||||
'Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins.',
|
||||
),
|
||||
});
|
||||
|
||||
export type PremiumStateQueryRequest = z.infer<typeof PremiumStateQueryRequest>;
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import {create} from '@bufbuild/protobuf';
|
||||
import {MAX_GROUP_DM_OTHER_RECIPIENTS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {encodeSyncedPreferences, SyncedPreferencesSchema} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
|
||||
import {
|
||||
encodeSyncedPreferences,
|
||||
SYNCED_PREFERENCES_MAX_ENCODED_LENGTH,
|
||||
SyncedPreferencesSchema,
|
||||
} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
|
||||
import {
|
||||
CreatePrivateChannelRequest,
|
||||
CustomStatusPayload,
|
||||
@@ -60,8 +64,12 @@ describe('UserSettingsUpdateRequest synced_preferences', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
it('accepts a string at exactly the size cap', () => {
|
||||
const atCap = 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH);
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: atCap}).success).toBe(true);
|
||||
});
|
||||
it('rejects strings exceeding the size cap', () => {
|
||||
const oversized = 'A'.repeat(400000);
|
||||
const oversized = 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH + 1);
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: oversized}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export type {SyncedPreferences} from '@fluxer/schema/src/gen/fluxer/user/prefere
|
||||
export {SyncedPreferencesSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb';
|
||||
|
||||
export const EMPTY_SYNCED_PREFERENCES_ENCODED = '';
|
||||
export const SYNCED_PREFERENCES_MAX_BYTES = 256 * 1024;
|
||||
export const SYNCED_PREFERENCES_MAX_BYTES = 512 * 1024;
|
||||
export const SYNCED_PREFERENCES_MAX_ENCODED_LENGTH = Math.ceil(SYNCED_PREFERENCES_MAX_BYTES / 3) * 4;
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/_-]*={0,2}$/;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user