diff --git a/.env-example b/.env-example index f539f08..250e8e1 100644 --- a/.env-example +++ b/.env-example @@ -43,6 +43,10 @@ CLERK_SECRET_KEY="" # AGGREGATOR_API_URL="https://api.vendor.example" # AGGREGATOR_API_KEY="" +# Optional: FX API for display-currency conversion (frankfurter-style; falls +# back to pinned static rates in dev). +# FX_API_URL="https://api.frankfurter.dev/v1""" + # Background worker (apps/worker) - scheduled syncs and email digests. # MAILER: ses | smtp | console (default console = log instead of sending) # MAILER="smtp" diff --git a/apps/web/src/app/api/v1/settings/route.ts b/apps/web/src/app/api/v1/settings/route.ts new file mode 100644 index 0000000..26caae4 --- /dev/null +++ b/apps/web/src/app/api/v1/settings/route.ts @@ -0,0 +1,28 @@ +import { + toUserSettingsDto, + updateUserSettingsRequestSchema, +} from "@pointup/core/contracts"; +import { NextResponse } from "next/server"; + +import { getContainer } from "@/server/container"; +import { withAuthenticatedUser } from "@/server/http"; + +/** Display settings (currency); defaults to USD when never saved. */ +export function GET() { + return withAuthenticatedUser(async (userId) => { + const settings = await getContainer().useCases.getUserSettings.execute(userId); + return NextResponse.json(toUserSettingsDto(settings)); + }); +} + +/** Set the display currency. Values remain USD-denominated internally. */ +export function PUT(request: Request) { + return withAuthenticatedUser(async (userId) => { + const body = updateUserSettingsRequestSchema.parse(await request.json()); + const settings = await getContainer().useCases.setDisplayCurrency.execute( + userId, + body.displayCurrency, + ); + return NextResponse.json(toUserSettingsDto(settings)); + }); +} diff --git a/apps/web/src/app/api/v1/summary/route.ts b/apps/web/src/app/api/v1/summary/route.ts index 06e4e23..b896823 100644 --- a/apps/web/src/app/api/v1/summary/route.ts +++ b/apps/web/src/app/api/v1/summary/route.ts @@ -4,11 +4,19 @@ import { NextResponse } from "next/server"; import { getContainer } from "@/server/container"; import { withAuthenticatedUser } from "@/server/http"; -/** Aggregated portfolio view: totals, per-kind breakdown, last sync. */ +/** + * Aggregated portfolio view: totals, per-kind breakdown, last sync. When the + * user prefers a non-USD display currency, `display` carries the converted + * total (best-effort — an FX outage never breaks the summary). + */ export function GET() { return withAuthenticatedUser(async (userId) => { - const summary = - await getContainer().useCases.getPortfolioSummary.execute(userId); - return NextResponse.json(toPortfolioSummaryDto(summary)); + const { getPortfolioSummary, buildDisplayValue } = getContainer().useCases; + const summary = await getPortfolioSummary.execute(userId); + const display = await buildDisplayValue.execute( + userId, + summary.totalValueCents, + ); + return NextResponse.json(toPortfolioSummaryDto(summary, display)); }); } diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index a38a85b..ca90d04 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -53,6 +53,9 @@ export const env = createEnv({ // Optional loyalty-data aggregator for real balance syncs. AGGREGATOR_API_URL: z.url().optional(), AGGREGATOR_API_KEY: z.string().min(1).optional(), + // Optional FX API for display-currency conversion (frankfurter-style; + // falls back to pinned static rates). + FX_API_URL: z.url().optional(), }, client: { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1), @@ -75,6 +78,7 @@ export const env = createEnv({ FIRECRAWL_BASE_URL: process.env.FIRECRAWL_BASE_URL, AGGREGATOR_API_URL: process.env.AGGREGATOR_API_URL, AGGREGATOR_API_KEY: process.env.AGGREGATOR_API_KEY, + FX_API_URL: process.env.FX_API_URL, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, emptyStringAsUndefined: true, diff --git a/apps/web/src/server/container.ts b/apps/web/src/server/container.ts index 50a381e..f2caa74 100644 --- a/apps/web/src/server/container.ts +++ b/apps/web/src/server/container.ts @@ -13,11 +13,17 @@ import { DrizzleTripGoalRepository, BedrockAssistant, BulkUpdateMembershipNumbers, + BuildDisplayValue, CreateAwardWatch, DeleteAwardWatch, DrizzleAwardWatchRepository, ListAwardWatches, DeleteCustomValuation, + DrizzleUserSettingsRepository, + GetUserSettings, + HttpFxRateSource, + SetDisplayCurrency, + StaticFxRateSource, DrizzleCustomValuationRepository, ListCustomValuations, SetCustomValuation, @@ -99,6 +105,9 @@ export interface Container { createAwardWatch: CreateAwardWatch; listAwardWatches: ListAwardWatches; deleteAwardWatch: DeleteAwardWatch; + getUserSettings: GetUserSettings; + setDisplayCurrency: SetDisplayCurrency; + buildDisplayValue: BuildDisplayValue; setCustomValuation: SetCustomValuation; deleteCustomValuation: DeleteCustomValuation; ingestDealPage: IngestDealPage; @@ -157,6 +166,10 @@ function buildContainer(): Container { const shares = new DrizzlePortfolioShareRepository(db); const customValuations = new DrizzleCustomValuationRepository(db); const awardWatches = new DrizzleAwardWatchRepository(db); + const settings = new DrizzleUserSettingsRepository(db); + const fx = env.FX_API_URL + ? new HttpFxRateSource({ baseUrl: env.FX_API_URL }) + : new StaticFxRateSource(); const vault = buildVault(); const gateway = buildTravelProviderGateway({ aggregator: @@ -267,6 +280,9 @@ function buildContainer(): Container { createAwardWatch: new CreateAwardWatch(awardWatches), listAwardWatches: new ListAwardWatches(awardWatches), deleteAwardWatch: new DeleteAwardWatch(awardWatches), + getUserSettings: new GetUserSettings(settings), + setDisplayCurrency: new SetDisplayCurrency(settings), + buildDisplayValue: new BuildDisplayValue(settings, fx), setCustomValuation: new SetCustomValuation(customValuations), deleteCustomValuation: new DeleteCustomValuation(customValuations), ingestDealPage: new IngestDealPage(scraper), diff --git a/docs/api.md b/docs/api.md index 72b6dcb..ecab42d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,6 +23,7 @@ Every surface — web app, mobile, browser extension — talks to the same versi | `INVALID_MEMBERSHIP_NUMBER` | 422 | Membership number is blank | | `INVALID_VALUATION` | 422 | Custom cents-per-point is ≤ 0 or > 100 | | `INVALID_AWARD_WATCH` | 422 | Watch label/threshold failed validation | +| `INVALID_DISPLAY_CURRENCY` | 422 | Display currency not in the supported set | | `AWARD_WATCH_NOT_FOUND` | 404 | Watch does not exist **or is not yours** | | `INVALID_BALANCE` | 422 | Points value is negative or fractional | | `INVALID_CAPTURE_TIME` | 422 | Capture timestamp is malformed or in the future | @@ -71,6 +72,12 @@ The catalog of supported loyalty programs. Public — surfaces use it to render Aggregated portfolio view for the signed-in user. +When the user's display currency (see `/api/v1/settings`) is not USD, the response also carries a best-effort converted total — omitted/null if FX is unavailable: + +```json +{ "display": { "currency": "EUR", "amount": 1877.2, "ratePerUsd": 0.92 } } +``` + ```json { "totalPoints": 154120, @@ -91,6 +98,14 @@ Monetary fields are whole US cents at each provider's `estimatedCentsPerPoint`. `byKind` always contains every provider kind, with zeroed entries for kinds the user has no accounts in. +### `GET /api/v1/settings` / `PUT /api/v1/settings` + +Per-user display settings. Currently one preference: `displayCurrency` (`USD`, `EUR`, `GBP`, `CAD`, `AUD`, `JPY`; defaults to USD). Valuations stay USD-denominated internally — conversion happens only at the display edge using an FX source (`FX_API_URL`, frankfurter-style; pinned static rates in dev). + +```json +{ "displayCurrency": "EUR" } +``` + ### `GET /api/v1/export` Portable dump of the signed-in user's accounts and balance history. Query: `?format=json` (default) or `?format=csv`. diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 9191182..4872a65 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -5,6 +5,8 @@ import type { BulkUpdateMembershipResultDto, CreateAwardWatchRequest, CustomValuationDto, + UpdateUserSettingsRequest, + UserSettingsDto, SetCustomValuationRequest, ApiError, ChatAssistantRequest, @@ -141,6 +143,16 @@ export class PointUpClient { ); } + getUserSettings(): Promise { + return this.request("GET", "/api/v1/settings"); + } + + updateUserSettings( + body: UpdateUserSettingsRequest, + ): Promise { + return this.request("PUT", "/api/v1/settings", body); + } + setCustomValuation( providerId: string, body: SetCustomValuationRequest, diff --git a/packages/core/drizzle/0007_spotty_kabuki.sql b/packages/core/drizzle/0007_spotty_kabuki.sql new file mode 100644 index 0000000..5a5fc98 --- /dev/null +++ b/packages/core/drizzle/0007_spotty_kabuki.sql @@ -0,0 +1,5 @@ +CREATE TABLE "user_setting" ( + "user_id" varchar(255) PRIMARY KEY NOT NULL, + "display_currency" varchar(3) NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); diff --git a/packages/core/drizzle/meta/0007_snapshot.json b/packages/core/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..4b9713e --- /dev/null +++ b/packages/core/drizzle/meta/0007_snapshot.json @@ -0,0 +1,669 @@ +{ + "id": "f1059649-aa83-4217-9c4f-016925b5d65a", + "prevId": "b8e8f160-5f4e-49c3-a8f6-8810bdd439ba", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_event": { + "name": "activity_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "activity_event_user_occurred_idx": { + "name": "activity_event_user_occurred_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.award_watch": { + "name": "award_watch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "min_cents_per_point_milli": { + "name": "min_cents_per_point_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "best_seen_cents_per_point_milli": { + "name": "best_seen_cents_per_point_milli", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "award_watch_user_id_idx": { + "name": "award_watch_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.balance_snapshot": { + "name": "balance_snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "loyalty_account_id": { + "name": "loyalty_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "balance_snapshot_account_captured_idx": { + "name": "balance_snapshot_account_captured_idx", + "columns": [ + { + "expression": "loyalty_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "balance_snapshot_loyalty_account_id_loyalty_account_id_fk": { + "name": "balance_snapshot_loyalty_account_id_loyalty_account_id_fk", + "tableFrom": "balance_snapshot", + "tableTo": "loyalty_account", + "columnsFrom": [ + "loyalty_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loyalty_account": { + "name": "loyalty_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "membership_number": { + "name": "membership_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_ref": { + "name": "credential_ref", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "loyalty_account_user_id_idx": { + "name": "loyalty_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loyalty_account_expires_at_idx": { + "name": "loyalty_account_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loyalty_account_deleted_at_idx": { + "name": "loyalty_account_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loyalty_account_user_provider_unique": { + "name": "loyalty_account_user_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_share": { + "name": "portfolio_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "portfolio_share_user_id_idx": { + "name": "portfolio_share_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolio_share_token_unique": { + "name": "portfolio_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trip_goal": { + "name": "trip_goal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "target_points": { + "name": "target_points", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "target_date": { + "name": "target_date", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "account_ids": { + "name": "account_ids", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trip_goal_user_id_idx": { + "name": "trip_goal_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_valuation": { + "name": "user_provider_valuation", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "cents_per_point_milli": { + "name": "cents_per_point_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_provider_valuation_user_id_provider_id_pk": { + "name": "user_provider_valuation_user_id_provider_id_pk", + "columns": [ + "user_id", + "provider_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_setting": { + "name": "user_setting", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "display_currency": { + "name": "display_currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/drizzle/meta/_journal.json b/packages/core/drizzle/meta/_journal.json index 2225553..eadc054 100644 --- a/packages/core/drizzle/meta/_journal.json +++ b/packages/core/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1783638398794, "tag": "0006_productive_thor", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1783638907053, + "tag": "0007_spotty_kabuki", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/application/loyalty/display-settings.ts b/packages/core/src/application/loyalty/display-settings.ts new file mode 100644 index 0000000..7930f43 --- /dev/null +++ b/packages/core/src/application/loyalty/display-settings.ts @@ -0,0 +1,85 @@ +import { + assertSupportedDisplayCurrency, + convertUsdCents, + type DisplayCurrency, + type FxRateSource, +} from "../../domain/fx"; +import { + DEFAULT_DISPLAY_CURRENCY, + type UserSettings, + type UserSettingsRepository, +} from "../../domain/loyalty/user-settings"; +import type { Clock } from "../ports"; +import { systemClock } from "../ports"; + +export class GetUserSettings { + constructor(private readonly settings: UserSettingsRepository) {} + + async execute(userId: string): Promise { + return ( + (await this.settings.get(userId)) ?? { + userId, + displayCurrency: DEFAULT_DISPLAY_CURRENCY, + updatedAt: new Date(0), + } + ); + } +} + +export class SetDisplayCurrency { + constructor( + private readonly settings: UserSettingsRepository, + private readonly clock: Clock = systemClock, + ) {} + + async execute(userId: string, currency: string): Promise { + const updated: UserSettings = { + userId, + displayCurrency: assertSupportedDisplayCurrency(currency), + updatedAt: this.clock.now(), + }; + await this.settings.upsert(updated); + return updated; + } +} + +export interface DisplayValue { + readonly currency: DisplayCurrency; + /** Decimal amount in the display currency (2dp; 0dp for JPY). */ + readonly amount: number; + /** Units of the currency per 1 USD used for the conversion. */ + readonly ratePerUsd: number; +} + +/** + * Converts a USD-cents value into the user's display currency. Returns null + * for USD (nothing to convert) or when the rate source fails — display + * conversion is a nice-to-have that must never break the underlying response. + */ +export class BuildDisplayValue { + constructor( + private readonly settings: UserSettingsRepository, + private readonly fx: FxRateSource, + ) {} + + async execute( + userId: string, + usdCents: number, + ): Promise { + const settings = await this.settings.get(userId); + const currency = settings?.displayCurrency ?? DEFAULT_DISPLAY_CURRENCY; + if (currency === "USD") return null; + + try { + const ratePerUsd = await this.fx.getUsdRate(currency); + if (!Number.isFinite(ratePerUsd) || ratePerUsd <= 0) return null; + return { + currency, + amount: convertUsdCents(usdCents, currency, ratePerUsd), + ratePerUsd, + }; + } catch { + return null; + } + } +} diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts index 197ce70..3ae8061 100644 --- a/packages/core/src/contracts/index.ts +++ b/packages/core/src/contracts/index.ts @@ -232,6 +232,34 @@ export const portfolioExportDtoSchema = z.object({ ), }); +export const displayCurrencySchema = z.enum([ + "USD", + "EUR", + "GBP", + "CAD", + "AUD", + "JPY", +]); + +export const displayValueDtoSchema = z.object({ + currency: displayCurrencySchema, + /** Decimal amount in the display currency (2dp; 0dp for JPY). */ + amount: z.number(), + /** Units of the currency per 1 USD used for the conversion. */ + ratePerUsd: z.number().positive(), +}); + +export const userSettingsDtoSchema = z.object({ + displayCurrency: displayCurrencySchema, + updatedAt: isoDateTimeSchema, +}); + +export const updateUserSettingsRequestSchema = z + .object({ + displayCurrency: displayCurrencySchema, + }) + .strict(); + export const portfolioSummaryDtoSchema = z.object({ totalPoints: z.number().int().nonnegative(), /** Approximate USD value of the whole portfolio, in whole cents. */ @@ -246,6 +274,8 @@ export const portfolioSummaryDtoSchema = z.object({ }), ), lastSyncedAt: isoDateTimeSchema.nullable(), + /** Total value converted to the user's display currency; null for USD. */ + display: displayValueDtoSchema.nullable().optional(), }); export const syncOutcomeDtoSchema = z.discriminatedUnion("ok", [ @@ -279,6 +309,7 @@ export const HTTP_STATUS_BY_ERROR_CODE = { PROVIDER_NOT_SUPPORTED: 422, INVALID_MEMBERSHIP_NUMBER: 422, INVALID_VALUATION: 422, + INVALID_DISPLAY_CURRENCY: 422, INVALID_AWARD_WATCH: 422, AWARD_WATCH_NOT_FOUND: 404, INVALID_BALANCE: 422, @@ -332,6 +363,11 @@ export type BulkUpdateMembershipResultDto = z.infer< typeof bulkUpdateMembershipResultDtoSchema >; export type CustomValuationDto = z.infer; +export type DisplayValueDto = z.infer; +export type UserSettingsDto = z.infer; +export type UpdateUserSettingsRequest = z.infer< + typeof updateUserSettingsRequestSchema +>; export type AwardWatchDto = z.infer; export type CreateAwardWatchRequest = z.infer< typeof createAwardWatchRequestSchema @@ -475,6 +511,7 @@ function csvEscape(value: string): string { export function toPortfolioSummaryDto( summary: PortfolioSummaryReadModel, + display: DisplayValueDto | null = null, ): PortfolioSummaryDto { return { totalPoints: summary.totalPoints, @@ -482,6 +519,17 @@ export function toPortfolioSummaryDto( accountCount: summary.accountCount, byKind: summary.byKind, lastSyncedAt: summary.lastSyncedAt?.toISOString() ?? null, + display, + }; +} + +export function toUserSettingsDto(settings: { + readonly displayCurrency: DisplayValueDto["currency"]; + readonly updatedAt: Date; +}): UserSettingsDto { + return { + displayCurrency: settings.displayCurrency, + updatedAt: settings.updatedAt.toISOString(), }; } diff --git a/packages/core/src/contracts/openapi.ts b/packages/core/src/contracts/openapi.ts index 67ee1bc..6aa99b7 100644 --- a/packages/core/src/contracts/openapi.ts +++ b/packages/core/src/contracts/openapi.ts @@ -15,6 +15,8 @@ import { customValuationDtoSchema, deletedAccountDtoSchema, setCustomValuationRequestSchema, + updateUserSettingsRequestSchema, + userSettingsDtoSchema, importPortfolioRequestSchema, importPortfolioResultDtoSchema, ingestDealPageResultDtoSchema, @@ -67,6 +69,8 @@ const COMPONENT_SCHEMAS = { CustomValuationDto: customValuationDtoSchema, AwardWatchDto: awardWatchDtoSchema, CreateAwardWatchRequest: createAwardWatchRequestSchema, + UserSettingsDto: userSettingsDtoSchema, + UpdateUserSettingsRequest: updateUserSettingsRequestSchema, SetCustomValuationRequest: setCustomValuationRequestSchema, ApiError: apiErrorSchema, LinkLoyaltyAccountRequest: linkLoyaltyAccountRequestSchema, @@ -486,6 +490,23 @@ export function buildOpenApiDocument(options: BuildOpenApiOptions = {}): Json { }, }, }, + "/api/v1/settings": { + get: { + summary: "Get display settings (currency)", + responses: { + "200": jsonResponse("User settings", ref("UserSettingsDto")), + ...ERROR_RESPONSES, + }, + }, + put: { + summary: "Set the display currency", + requestBody: body("UpdateUserSettingsRequest"), + responses: { + "200": jsonResponse("Saved settings", ref("UserSettingsDto")), + ...ERROR_RESPONSES, + }, + }, + }, "/api/v1/watches": { get: { summary: "List award watches", diff --git a/packages/core/src/domain/errors.ts b/packages/core/src/domain/errors.ts index c5b533a..de7fdca 100644 --- a/packages/core/src/domain/errors.ts +++ b/packages/core/src/domain/errors.ts @@ -52,6 +52,14 @@ export class InvalidValuationError extends DomainError { } } +export class InvalidDisplayCurrencyError extends DomainError { + readonly code = "INVALID_DISPLAY_CURRENCY"; + + constructor(currency: string) { + super(`Display currency "${currency}" is not supported`); + } +} + export class CredentialUnavailableError extends DomainError { readonly code = "CREDENTIAL_UNAVAILABLE"; diff --git a/packages/core/src/domain/fx.ts b/packages/core/src/domain/fx.ts new file mode 100644 index 0000000..35f0593 --- /dev/null +++ b/packages/core/src/domain/fx.ts @@ -0,0 +1,55 @@ +import { InvalidDisplayCurrencyError } from "./errors"; + +/** + * Foreign-exchange support for displaying portfolio value in the user's + * currency. Valuations stay **USD-denominated internally** (editorial and + * custom cents-per-point are US cents); conversion happens only at the + * display edge, so no stored data changes with the exchange rate. + */ + +export const SUPPORTED_DISPLAY_CURRENCIES = [ + "USD", + "EUR", + "GBP", + "CAD", + "AUD", + "JPY", +] as const; + +export type DisplayCurrency = (typeof SUPPORTED_DISPLAY_CURRENCIES)[number]; + +export function isSupportedDisplayCurrency( + value: string, +): value is DisplayCurrency { + return (SUPPORTED_DISPLAY_CURRENCIES as readonly string[]).includes(value); +} + +export function assertSupportedDisplayCurrency( + value: string, +): DisplayCurrency { + if (!isSupportedDisplayCurrency(value)) { + throw new InvalidDisplayCurrencyError(value); + } + return value; +} + +/** + * Rate source port: units of `currency` per 1 USD (e.g. EUR ≈ 0.92). + * Implementations: an HTTP FX API, or the static dev fallback. + */ +export interface FxRateSource { + getUsdRate(currency: DisplayCurrency): Promise; +} + +/** + * Convert whole US cents to a decimal amount in the target currency. + * Two decimal places for all supported currencies except JPY (zero-decimal). + */ +export function convertUsdCents( + usdCents: number, + currency: DisplayCurrency, + ratePerUsd: number, +): number { + const amount = (usdCents / 100) * ratePerUsd; + return currency === "JPY" ? Math.round(amount) : Math.round(amount * 100) / 100; +} diff --git a/packages/core/src/domain/loyalty/user-settings.ts b/packages/core/src/domain/loyalty/user-settings.ts new file mode 100644 index 0000000..038624b --- /dev/null +++ b/packages/core/src/domain/loyalty/user-settings.ts @@ -0,0 +1,20 @@ +import type { DisplayCurrency } from "../fx"; + +/** + * Per-user display preferences. Deliberately tiny: one row per user, extended + * column-by-column as preferences accrue (notification prefs are a natural + * next tenant). + */ +export interface UserSettings { + readonly userId: string; + readonly displayCurrency: DisplayCurrency; + readonly updatedAt: Date; +} + +export const DEFAULT_DISPLAY_CURRENCY: DisplayCurrency = "USD"; + +export interface UserSettingsRepository { + /** Null when the user has never saved settings (callers apply defaults). */ + get(userId: string): Promise; + upsert(settings: UserSettings): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b3edf7d..81ea93b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,8 @@ export * from "./domain/loyalty/trip-goal"; export * from "./domain/loyalty/portfolio-share"; export * from "./domain/loyalty/custom-valuation"; export * from "./domain/loyalty/award-watch"; +export * from "./domain/fx"; +export * from "./domain/loyalty/user-settings"; // Application export * from "./application/ports"; @@ -19,6 +21,7 @@ export * from "./application/loyalty/get-loyalty-account"; export * from "./application/loyalty/update-loyalty-account"; export * from "./application/loyalty/custom-valuations"; export * from "./application/loyalty/award-watches"; +export * from "./application/loyalty/display-settings"; export * from "./application/loyalty/bulk-update-membership"; export * from "./application/loyalty/restore-loyalty-account"; export * from "./application/loyalty/get-balance-history"; @@ -55,6 +58,8 @@ export * as dbSchema from "./infrastructure/db/schema"; export * from "./infrastructure/repositories/drizzle-loyalty-account-repository"; export * from "./infrastructure/repositories/drizzle-custom-valuation-repository"; export * from "./infrastructure/repositories/drizzle-award-watch-repository"; +export * from "./infrastructure/repositories/drizzle-user-settings-repository"; +export * from "./infrastructure/fx/fx-rate-sources"; export * from "./infrastructure/providers/composite-travel-provider-gateway"; export * from "./infrastructure/providers/simulated-travel-provider-gateway"; export * from "./infrastructure/providers/http-aggregator-travel-provider-gateway"; diff --git a/packages/core/src/infrastructure/db/schema.ts b/packages/core/src/infrastructure/db/schema.ts index d1e2a61..c852e7d 100644 --- a/packages/core/src/infrastructure/db/schema.ts +++ b/packages/core/src/infrastructure/db/schema.ts @@ -155,6 +155,16 @@ export const userProviderValuations = pgTable( (row) => [primaryKey({ columns: [row.userId, row.providerId] })], ); +// ─── User settings ───────────────────────────────────────────────────────── +// One row per user; extended column-by-column as preferences accrue. + +export const userSettings = pgTable("user_setting", { + userId: varchar("user_id", { length: 255 }).notNull().primaryKey(), + /** ISO 4217 display currency, e.g. "EUR". Values stay USD internally. */ + displayCurrency: varchar("display_currency", { length: 3 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), +}); + // ─── Award watchlist ─────────────────────────────────────────────────────── // Watched award/deal pages, re-scraped on a schedule. Cents-per-point values // are stored as integer milli-cents (× 1000) like custom valuations. diff --git a/packages/core/src/infrastructure/fx/fx-rate-sources.ts b/packages/core/src/infrastructure/fx/fx-rate-sources.ts new file mode 100644 index 0000000..5a8c343 --- /dev/null +++ b/packages/core/src/infrastructure/fx/fx-rate-sources.ts @@ -0,0 +1,82 @@ +import type { DisplayCurrency, FxRateSource } from "../../domain/fx"; + +/** Minimal fetch shape so the adapter is unit-testable without a network. */ +export type FxFetch = ( + url: string, + init: { method: string; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; text(): Promise }>; + +const defaultFetch: FxFetch = (url, init) => fetch(url, init as RequestInit); + +export interface HttpFxRateSourceConfig { + /** e.g. https://api.frankfurter.dev/v1 — no API key required. */ + readonly baseUrl: string; + readonly fetchImpl?: FxFetch; + /** Rates barely move intraday; cache to avoid hammering the API. */ + readonly cacheTtlMs?: number; +} + +/** + * Frankfurter-style FX API adapter: + * GET {baseUrl}/latest?base=USD&symbols=EUR → { "rates": { "EUR": 0.92 } } + * Responses are cached in-process (default 1h) since display conversion does + * not need tick-level precision. + */ +export class HttpFxRateSource implements FxRateSource { + private readonly baseUrl: string; + private readonly fetchImpl: FxFetch; + private readonly cacheTtlMs: number; + private readonly cache = new Map< + DisplayCurrency, + { rate: number; fetchedAt: number } + >(); + + constructor(config: HttpFxRateSourceConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ""); + this.fetchImpl = config.fetchImpl ?? defaultFetch; + this.cacheTtlMs = config.cacheTtlMs ?? 60 * 60 * 1000; + } + + async getUsdRate(currency: DisplayCurrency): Promise { + const cached = this.cache.get(currency); + if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) { + return cached.rate; + } + + const response = await this.fetchImpl( + `${this.baseUrl}/latest?base=USD&symbols=${currency}`, + { method: "GET", signal: AbortSignal.timeout(10_000) }, + ); + if (!response.ok) { + throw new Error(`FX rate fetch failed (${response.status})`); + } + const payload = JSON.parse(await response.text()) as { + rates?: Record; + }; + const rate = payload.rates?.[currency]; + if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) { + throw new Error(`FX API returned an invalid rate for ${currency}`); + } + this.cache.set(currency, { rate, fetchedAt: Date.now() }); + return rate; + } +} + +/** + * Pinned rates for local development and tests — order-of-magnitude correct, + * not live. Production should configure `HttpFxRateSource`. + */ +export class StaticFxRateSource implements FxRateSource { + private static readonly RATES: Record = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + CAD: 1.36, + AUD: 1.5, + JPY: 155, + }; + + async getUsdRate(currency: DisplayCurrency): Promise { + return StaticFxRateSource.RATES[currency]; + } +} diff --git a/packages/core/src/infrastructure/repositories/drizzle-user-settings-repository.ts b/packages/core/src/infrastructure/repositories/drizzle-user-settings-repository.ts new file mode 100644 index 0000000..777719a --- /dev/null +++ b/packages/core/src/infrastructure/repositories/drizzle-user-settings-repository.ts @@ -0,0 +1,46 @@ +import { eq } from "drizzle-orm"; + +import { assertSupportedDisplayCurrency } from "../../domain/fx"; +import type { + UserSettings, + UserSettingsRepository, +} from "../../domain/loyalty/user-settings"; +import type { Database } from "../db/client"; +import { userSettings } from "../db/schema"; + +export class DrizzleUserSettingsRepository implements UserSettingsRepository { + constructor(private readonly db: Database) {} + + async get(userId: string): Promise { + const rows = await this.db + .select() + .from(userSettings) + .where(eq(userSettings.userId, userId)) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + userId: row.userId, + // Validate on read so a bad row degrades loudly, not silently. + displayCurrency: assertSupportedDisplayCurrency(row.displayCurrency), + updatedAt: row.updatedAt, + }; + } + + async upsert(settings: UserSettings): Promise { + await this.db + .insert(userSettings) + .values({ + userId: settings.userId, + displayCurrency: settings.displayCurrency, + updatedAt: settings.updatedAt, + }) + .onConflictDoUpdate({ + target: userSettings.userId, + set: { + displayCurrency: settings.displayCurrency, + updatedAt: settings.updatedAt, + }, + }); + } +} diff --git a/packages/core/test/display-settings.test.ts b/packages/core/test/display-settings.test.ts new file mode 100644 index 0000000..6b7c725 --- /dev/null +++ b/packages/core/test/display-settings.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { + BuildDisplayValue, + GetUserSettings, + SetDisplayCurrency, +} from "../src/application/loyalty/display-settings"; +import { convertUsdCents } from "../src/domain/fx"; +import { InvalidDisplayCurrencyError } from "../src/domain/errors"; +import { + HttpFxRateSource, + StaticFxRateSource, + type FxFetch, +} from "../src/infrastructure/fx/fx-rate-sources"; +import { InMemoryUserSettingsRepository } from "./fakes"; + +const clock = { now: () => new Date("2026-07-09T12:00:00Z") }; + +describe("convertUsdCents", () => { + it("converts to two decimals, zero decimals for JPY", () => { + expect(convertUsdCents(204_044, "EUR", 0.92)).toBe(1877.2); + expect(convertUsdCents(204_044, "JPY", 155)).toBe(316_268); + }); +}); + +describe("settings use cases", () => { + it("defaults to USD when never saved, and round-trips a change", async () => { + const repo = new InMemoryUserSettingsRepository(); + expect((await new GetUserSettings(repo).execute("u")).displayCurrency).toBe( + "USD", + ); + + await new SetDisplayCurrency(repo, clock).execute("u", "EUR"); + expect((await new GetUserSettings(repo).execute("u")).displayCurrency).toBe( + "EUR", + ); + }); + + it("rejects unsupported currencies", async () => { + const repo = new InMemoryUserSettingsRepository(); + await expect( + new SetDisplayCurrency(repo, clock).execute("u", "XYZ"), + ).rejects.toBeInstanceOf(InvalidDisplayCurrencyError); + }); +}); + +describe("BuildDisplayValue", () => { + it("returns null for USD users and converts for others", async () => { + const repo = new InMemoryUserSettingsRepository(); + const build = new BuildDisplayValue(repo, new StaticFxRateSource()); + + expect(await build.execute("u", 100_000)).toBeNull(); // default USD + + await new SetDisplayCurrency(repo, clock).execute("u", "GBP"); + const display = await build.execute("u", 100_000); + expect(display).toEqual({ currency: "GBP", amount: 790, ratePerUsd: 0.79 }); + }); + + it("degrades to null when the rate source fails", async () => { + const repo = new InMemoryUserSettingsRepository(); + await new SetDisplayCurrency(repo, clock).execute("u", "EUR"); + const build = new BuildDisplayValue(repo, { + getUsdRate: async () => { + throw new Error("fx down"); + }, + }); + expect(await build.execute("u", 100_000)).toBeNull(); + }); +}); + +describe("HttpFxRateSource", () => { + function fxFetch( + respond: () => { ok: boolean; status?: number; text: string }, + ): { fetchImpl: FxFetch; calls: string[] } { + const calls: string[] = []; + const fetchImpl: FxFetch = async (url) => { + calls.push(url); + const r = respond(); + return { + ok: r.ok, + status: r.status ?? 200, + text: async () => r.text, + }; + }; + return { fetchImpl, calls }; + } + + it("fetches and caches the rate", async () => { + const { fetchImpl, calls } = fxFetch(() => ({ + ok: true, + text: JSON.stringify({ rates: { EUR: 0.93 } }), + })); + const fx = new HttpFxRateSource({ baseUrl: "https://fx.test/v1/", fetchImpl }); + + expect(await fx.getUsdRate("EUR")).toBe(0.93); + expect(await fx.getUsdRate("EUR")).toBe(0.93); // cached + expect(calls).toHaveLength(1); + expect(calls[0]).toBe("https://fx.test/v1/latest?base=USD&symbols=EUR"); + }); + + it("throws on HTTP errors and invalid rates", async () => { + const down = fxFetch(() => ({ ok: false, status: 503, text: "" })); + await expect( + new HttpFxRateSource({ baseUrl: "https://f", fetchImpl: down.fetchImpl }).getUsdRate("EUR"), + ).rejects.toThrow(/503/); + + const bad = fxFetch(() => ({ ok: true, text: JSON.stringify({ rates: { EUR: -1 } }) })); + await expect( + new HttpFxRateSource({ baseUrl: "https://f", fetchImpl: bad.fetchImpl }).getUsdRate("EUR"), + ).rejects.toThrow(/invalid rate/); + }); +}); diff --git a/packages/core/test/fakes.ts b/packages/core/test/fakes.ts index 96f5dde..73b5714 100644 --- a/packages/core/test/fakes.ts +++ b/packages/core/test/fakes.ts @@ -10,6 +10,10 @@ import type { AwardWatch, AwardWatchRepository, } from "../src/domain/loyalty/award-watch"; +import type { + UserSettings, + UserSettingsRepository, +} from "../src/domain/loyalty/user-settings"; import type { TripGoal } from "../src/domain/loyalty/trip-goal"; import type { ActivityEventRepository, @@ -276,3 +280,15 @@ export class InMemoryAwardWatchRepository implements AwardWatchRepository { this.rows.delete(id); } } + +export class InMemoryUserSettingsRepository implements UserSettingsRepository { + readonly rows = new Map(); + + async get(userId: string): Promise { + return this.rows.get(userId) ?? null; + } + + async upsert(settings: UserSettings): Promise { + this.rows.set(settings.userId, settings); + } +}