diff --git a/apps/bot/test/commands.test.ts b/apps/bot/test/commands.test.ts index d125d17..b0c27b6 100644 --- a/apps/bot/test/commands.test.ts +++ b/apps/bot/test/commands.test.ts @@ -33,6 +33,7 @@ function account( ? null : { points, source: "manual", capturedAt: new Date("2026-01-01") }, estimatedValueCents: valueCents ?? 0, + customCentsPerPoint: null, trend: {} as LoyaltyAccountReadModel["trend"], expiresAt: null, daysUntilExpiry: daysUntilExpiry ?? null, diff --git a/apps/web/src/app/api/v1/valuations/[providerId]/route.ts b/apps/web/src/app/api/v1/valuations/[providerId]/route.ts new file mode 100644 index 0000000..1cc610d --- /dev/null +++ b/apps/web/src/app/api/v1/valuations/[providerId]/route.ts @@ -0,0 +1,36 @@ +import { + setCustomValuationRequestSchema, + toCustomValuationDto, +} from "@pointup/core/contracts"; +import { NextResponse } from "next/server"; + +import { getContainer } from "@/server/container"; +import { withAuthenticatedUser } from "@/server/http"; + +type Context = { params: Promise<{ providerId: string }> }; + +/** Set (or replace) the caller's cents-per-point override for a provider. */ +export function PUT(request: Request, context: Context) { + return withAuthenticatedUser(async (userId) => { + const { providerId } = await context.params; + const body = setCustomValuationRequestSchema.parse(await request.json()); + const valuation = await getContainer().useCases.setCustomValuation.execute({ + userId, + providerId, + centsPerPoint: body.centsPerPoint, + }); + return NextResponse.json(toCustomValuationDto(valuation)); + }); +} + +/** Clear the override, reverting the provider to its editorial valuation. */ +export function DELETE(_request: Request, context: Context) { + return withAuthenticatedUser(async (userId) => { + const { providerId } = await context.params; + await getContainer().useCases.deleteCustomValuation.execute( + userId, + providerId, + ); + return new NextResponse(null, { status: 204 }); + }); +} diff --git a/apps/web/src/app/api/v1/valuations/route.ts b/apps/web/src/app/api/v1/valuations/route.ts new file mode 100644 index 0000000..5be5299 --- /dev/null +++ b/apps/web/src/app/api/v1/valuations/route.ts @@ -0,0 +1,14 @@ +import { toCustomValuationDto } from "@pointup/core/contracts"; +import { NextResponse } from "next/server"; + +import { getContainer } from "@/server/container"; +import { withAuthenticatedUser } from "@/server/http"; + +/** List the caller's custom cents-per-point overrides. */ +export function GET() { + return withAuthenticatedUser(async (userId) => { + const valuations = + await getContainer().useCases.listCustomValuations.execute(userId); + return NextResponse.json(valuations.map(toCustomValuationDto)); + }); +} diff --git a/apps/web/src/server/container.ts b/apps/web/src/server/container.ts index 390a8b6..e3815ed 100644 --- a/apps/web/src/server/container.ts +++ b/apps/web/src/server/container.ts @@ -13,6 +13,10 @@ import { DrizzleTripGoalRepository, BedrockAssistant, BulkUpdateMembershipNumbers, + DeleteCustomValuation, + DrizzleCustomValuationRepository, + ListCustomValuations, + SetCustomValuation, ExportPortfolio, FirecrawlPageScraper, GetBalanceHistory, @@ -88,6 +92,9 @@ export interface Container { getPublicPortfolioSnapshot: GetPublicPortfolioSnapshot; chatWithAssistant: ChatWithAssistant; getValueAdvice: GetValueAdvice; + listCustomValuations: ListCustomValuations; + setCustomValuation: SetCustomValuation; + deleteCustomValuation: DeleteCustomValuation; ingestDealPage: IngestDealPage; syncLoyaltyAccount: SyncLoyaltyAccount; syncAllLoyaltyAccounts: SyncAllLoyaltyAccounts; @@ -142,6 +149,7 @@ function buildContainer(): Container { const activity = new DrizzleActivityEventRepository(db); const tripGoals = new DrizzleTripGoalRepository(db); const shares = new DrizzlePortfolioShareRepository(db); + const customValuations = new DrizzleCustomValuationRepository(db); const vault = buildVault(); const gateway = new CompositeTravelProviderGateway([ new SimulatedTravelProviderGateway(), @@ -157,6 +165,8 @@ function buildContainer(): Container { const listLoyaltyAccounts = new ListLoyaltyAccounts( loyaltyAccounts, balanceSnapshots, + undefined, + customValuations, ); const linkLoyaltyAccount = new LinkLoyaltyAccount(loyaltyAccounts, activity); const recordManualBalance = new RecordManualBalance( @@ -185,6 +195,8 @@ function buildContainer(): Container { getLoyaltyAccount: new GetLoyaltyAccount( loyaltyAccounts, balanceSnapshots, + undefined, + customValuations, ), linkLoyaltyAccount, updateLoyaltyAccount, @@ -241,6 +253,9 @@ function buildContainer(): Container { llm, ), getValueAdvice: new GetValueAdvice(listLoyaltyAccounts), + listCustomValuations: new ListCustomValuations(customValuations), + setCustomValuation: new SetCustomValuation(customValuations), + deleteCustomValuation: new DeleteCustomValuation(customValuations), ingestDealPage: new IngestDealPage(scraper), syncLoyaltyAccount, syncAllLoyaltyAccounts: new SyncAllLoyaltyAccounts( diff --git a/docs/api.md b/docs/api.md index 008e9fa..7c11d66 100644 --- a/docs/api.md +++ b/docs/api.md @@ -21,6 +21,7 @@ Every surface — web app, mobile, browser extension — talks to the same versi | `INVALID_REQUEST` | 400 | Request body/query failed schema validation | | `PROVIDER_NOT_SUPPORTED` | 422 | Provider id is not in the catalog | | `INVALID_MEMBERSHIP_NUMBER` | 422 | Membership number is blank | +| `INVALID_VALUATION` | 422 | Custom cents-per-point is ≤ 0 or > 100 | | `INVALID_BALANCE` | 422 | Points value is negative or fractional | | `INVALID_CAPTURE_TIME` | 422 | Capture timestamp is malformed or in the future | | `INVALID_GOAL_TITLE` | 422 | Goal title/notes failed validation | @@ -274,6 +275,26 @@ Response: Unlink the account. Balance history cascades at the database layer. Returns `204` with no body. +### `GET /api/v1/valuations` + +List the caller's custom cents-per-point overrides. Each account's `estimatedValueCents` (and the `customCentsPerPoint` field) reflects the override when one is set; portfolio summary, digests, and alerts all use it. + +```json +[ { "providerId": "chase-ultimate-rewards", "centsPerPoint": 2.05, "updatedAt": "2026-07-09T14:03:00.000Z" } ] +``` + +### `PUT /api/v1/valuations/{providerId}` + +Set (or replace) a provider's cents-per-point override (`0 < v ≤ 100`). Returns the saved valuation. + +```json +{ "centsPerPoint": 2.05 } +``` + +### `DELETE /api/v1/valuations/{providerId}` + +Clear the override, reverting the provider to its editorial valuation. Returns `204`. + ### `GET /api/v1/loyalty-accounts/{id}/balances` Balance history, newest first. Query parameter `limit` (1–365, default 50). diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index fffa5ac..896abee 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -2,6 +2,8 @@ import type { ActivityEventDto, BulkUpdateMembershipRequest, BulkUpdateMembershipResultDto, + CustomValuationDto, + SetCustomValuationRequest, ApiError, ChatAssistantRequest, ChatAssistantResponse, @@ -118,6 +120,28 @@ export class PointUpClient { return this.request("PATCH", "/api/v1/loyalty-accounts", body); } + listCustomValuations(): Promise { + return this.request("GET", "/api/v1/valuations"); + } + + setCustomValuation( + providerId: string, + body: SetCustomValuationRequest, + ): Promise { + return this.request( + "PUT", + `/api/v1/valuations/${encodeURIComponent(providerId)}`, + body, + ); + } + + deleteCustomValuation(providerId: string): Promise { + return this.request( + "DELETE", + `/api/v1/valuations/${encodeURIComponent(providerId)}`, + ); + } + /** Balance history, newest first (default 50, max 365 entries). */ getBalanceHistory(accountId: string, limit?: number): Promise { const query = limit !== undefined ? `?limit=${limit}` : ""; diff --git a/packages/core/drizzle/0005_nice_rage.sql b/packages/core/drizzle/0005_nice_rage.sql new file mode 100644 index 0000000..7d7179b --- /dev/null +++ b/packages/core/drizzle/0005_nice_rage.sql @@ -0,0 +1,7 @@ +CREATE TABLE "user_provider_valuation" ( + "user_id" varchar(255) NOT NULL, + "provider_id" varchar(64) NOT NULL, + "cents_per_point_milli" integer NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "user_provider_valuation_user_id_provider_id_pk" PRIMARY KEY("user_id","provider_id") +); diff --git a/packages/core/drizzle/meta/0005_snapshot.json b/packages/core/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..0b105da --- /dev/null +++ b/packages/core/drizzle/meta/0005_snapshot.json @@ -0,0 +1,549 @@ +{ + "id": "9800d5e2-6e4f-447f-972f-81f81dfc0ca5", + "prevId": "56d97cc6-e291-4434-8958-2da1c365b1f7", + "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.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 + } + }, + "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 148d7f3..b56e329 100644 --- a/packages/core/drizzle/meta/_journal.json +++ b/packages/core/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1783559967720, "tag": "0004_colossal_tyrannus", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783581823009, + "tag": "0005_nice_rage", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/application/loyalty/custom-valuations.ts b/packages/core/src/application/loyalty/custom-valuations.ts new file mode 100644 index 0000000..367ff3d --- /dev/null +++ b/packages/core/src/application/loyalty/custom-valuations.ts @@ -0,0 +1,53 @@ +import { + assertValidCentsPerPoint, + type CustomValuation, + type CustomValuationRepository, +} from "../../domain/loyalty/custom-valuation"; +import { getProviderOrThrow } from "../../domain/loyalty/provider"; +import type { Clock } from "../ports"; +import { systemClock } from "../ports"; + +export class ListCustomValuations { + constructor(private readonly valuations: CustomValuationRepository) {} + + execute(userId: string): Promise { + return this.valuations.listForUser(userId); + } +} + +/** + * Sets (or replaces) a user's cents-per-point override for a provider. Rejects + * unknown providers and out-of-range values before persisting. + */ +export class SetCustomValuation { + constructor( + private readonly valuations: CustomValuationRepository, + private readonly clock: Clock = systemClock, + ) {} + + async execute(input: { + readonly userId: string; + readonly providerId: string; + readonly centsPerPoint: number; + }): Promise { + getProviderOrThrow(input.providerId); + assertValidCentsPerPoint(input.centsPerPoint); + + const valuation: CustomValuation = { + userId: input.userId, + providerId: input.providerId, + centsPerPoint: input.centsPerPoint, + updatedAt: this.clock.now(), + }; + await this.valuations.upsert(valuation); + return valuation; + } +} + +export class DeleteCustomValuation { + constructor(private readonly valuations: CustomValuationRepository) {} + + execute(userId: string, providerId: string): Promise { + return this.valuations.delete(userId, providerId); + } +} diff --git a/packages/core/src/application/loyalty/get-loyalty-account.ts b/packages/core/src/application/loyalty/get-loyalty-account.ts index 1bb1864..c162b2a 100644 --- a/packages/core/src/application/loyalty/get-loyalty-account.ts +++ b/packages/core/src/application/loyalty/get-loyalty-account.ts @@ -1,3 +1,7 @@ +import { + toValuationOverrides, + type CustomValuationRepository, +} from "../../domain/loyalty/custom-valuation"; import type { BalanceSnapshotRepository, LoyaltyAccountRepository, @@ -13,6 +17,7 @@ export class GetLoyaltyAccount { private readonly accounts: LoyaltyAccountRepository, private readonly balances: BalanceSnapshotRepository, private readonly clock: Clock = systemClock, + private readonly valuations?: CustomValuationRepository, ) {} async execute( @@ -20,14 +25,17 @@ export class GetLoyaltyAccount { accountId: string, ): Promise { const account = await requireOwnedAccount(this.accounts, userId, accountId); - const trends = await this.balances.findTrendContextByAccountIds( - [account.id], - this.clock.now(), - ); + const [trends, overrides] = await Promise.all([ + this.balances.findTrendContextByAccountIds([account.id], this.clock.now()), + this.valuations + ? this.valuations.listForUser(userId).then(toValuationOverrides) + : Promise.resolve(new Map()), + ]); return toLoyaltyAccountReadModel( account, trends.get(account.id) ?? null, this.clock.now(), + overrides, ); } } diff --git a/packages/core/src/application/loyalty/list-loyalty-accounts.ts b/packages/core/src/application/loyalty/list-loyalty-accounts.ts index b96d1b2..00ed1f8 100644 --- a/packages/core/src/application/loyalty/list-loyalty-accounts.ts +++ b/packages/core/src/application/loyalty/list-loyalty-accounts.ts @@ -1,3 +1,7 @@ +import { + toValuationOverrides, + type CustomValuationRepository, +} from "../../domain/loyalty/custom-valuation"; import type { BalanceSnapshotRepository, LoyaltyAccountRepository, @@ -12,20 +16,28 @@ export class ListLoyaltyAccounts { private readonly accounts: LoyaltyAccountRepository, private readonly balances: BalanceSnapshotRepository, private readonly clock: Clock = systemClock, + /** Optional: applies per-user cents-per-point overrides to value. */ + private readonly valuations?: CustomValuationRepository, ) {} async execute(userId: string): Promise { const accounts = await this.accounts.findByUserId(userId); - const trends = await this.balances.findTrendContextByAccountIds( - accounts.map((account) => account.id), - this.clock.now(), - ); + const [trends, overrides] = await Promise.all([ + this.balances.findTrendContextByAccountIds( + accounts.map((account) => account.id), + this.clock.now(), + ), + this.valuations + ? this.valuations.listForUser(userId).then(toValuationOverrides) + : Promise.resolve(new Map()), + ]); return accounts.map((account) => toLoyaltyAccountReadModel( account, trends.get(account.id) ?? null, this.clock.now(), + overrides, ), ); } diff --git a/packages/core/src/application/loyalty/mappers.ts b/packages/core/src/application/loyalty/mappers.ts index 2c7f566..5587ce1 100644 --- a/packages/core/src/application/loyalty/mappers.ts +++ b/packages/core/src/application/loyalty/mappers.ts @@ -1,9 +1,6 @@ import type { BalanceSnapshot } from "../../domain/loyalty/balance-snapshot"; import type { LoyaltyAccount } from "../../domain/loyalty/loyalty-account"; -import { - estimateValueCents, - getProviderOrThrow, -} from "../../domain/loyalty/provider"; +import { getProviderOrThrow } from "../../domain/loyalty/provider"; import type { BalanceTrendContext } from "../../domain/loyalty/repositories"; import { computeBalanceTrend, @@ -36,6 +33,8 @@ export function toLoyaltyAccountReadModel( account: LoyaltyAccount, context: BalanceTrendContext | null, now: Date = new Date(), + /** providerId → user override cents-per-point; applied to value when present. */ + valuationOverrides: ReadonlyMap = new Map(), ): LoyaltyAccountReadModel { const provider = getProviderOrThrow(account.providerId); const latest = context?.latest ?? null; @@ -43,6 +42,10 @@ export function toLoyaltyAccountReadModel( ? computeBalanceTrend(context) : emptyBalanceTrend(); + const customCentsPerPoint = valuationOverrides.get(provider.id) ?? null; + const effectiveCentsPerPoint = + customCentsPerPoint ?? provider.estimatedCentsPerPoint; + return { id: account.id, provider: { @@ -56,7 +59,11 @@ export function toLoyaltyAccountReadModel( membershipNumber: account.membershipNumber, hasStoredCredential: account.credentialRef !== null, latestBalance: latest ? toBalanceReadModel(latest) : null, - estimatedValueCents: latest ? estimateValueCents(provider, latest.points) : 0, + // Value uses the user's override when set, else the editorial estimate. + estimatedValueCents: latest + ? Math.round(latest.points * effectiveCentsPerPoint) + : 0, + customCentsPerPoint, trend, expiresAt: account.expiresAt, daysUntilExpiry: daysUntil(account.expiresAt, now), diff --git a/packages/core/src/application/loyalty/read-models.ts b/packages/core/src/application/loyalty/read-models.ts index 2fb7af0..111813f 100644 --- a/packages/core/src/application/loyalty/read-models.ts +++ b/packages/core/src/application/loyalty/read-models.ts @@ -31,8 +31,13 @@ export interface LoyaltyAccountReadModel { readonly membershipNumber: string; readonly hasStoredCredential: boolean; readonly latestBalance: BalanceReadModel | null; - /** Approximate USD value of the latest balance, in whole cents. */ + /** + * Approximate USD value of the latest balance, in whole cents. Uses the + * user's custom cents-per-point override when set, else the editorial rate. + */ readonly estimatedValueCents: number; + /** User override of cents-per-point for this provider; null when unset. */ + readonly customCentsPerPoint: number | null; /** Change vs. previous / 30-day / 90-day baselines. */ readonly trend: BalanceTrend; /** Projected inactivity expiry; null when the program does not expire. */ diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts index 8c072b7..158c812 100644 --- a/packages/core/src/contracts/index.ts +++ b/packages/core/src/contracts/index.ts @@ -70,6 +70,8 @@ export const loyaltyAccountDtoSchema = z.object({ latestBalance: balanceDtoSchema.nullable(), /** Approximate USD value of the latest balance, in whole cents. */ estimatedValueCents: z.number().int().nonnegative(), + /** User override of cents-per-point for this provider; null when unset. */ + customCentsPerPoint: z.number().nullable(), trend: balanceTrendDtoSchema, expiresAt: isoDateTimeSchema.nullable(), daysUntilExpiry: z.number().int().nullable(), @@ -147,6 +149,19 @@ export const bulkUpdateMembershipResultDtoSchema = z.object({ ), }); +export const customValuationDtoSchema = z.object({ + providerId: z.string(), + centsPerPoint: z.number().positive(), + updatedAt: isoDateTimeSchema, +}); + +export const setCustomValuationRequestSchema = z + .object({ + /** Override redemption value of one point, in US cents (0 < v ≤ 100). */ + centsPerPoint: z.number().positive().max(100), + }) + .strict(); + export const recordManualBalanceRequestSchema = z .object({ points: z.number().int().nonnegative(), @@ -243,6 +258,7 @@ export const HTTP_STATUS_BY_ERROR_CODE = { INVALID_REQUEST: 400, PROVIDER_NOT_SUPPORTED: 422, INVALID_MEMBERSHIP_NUMBER: 422, + INVALID_VALUATION: 422, INVALID_BALANCE: 422, INVALID_CAPTURE_TIME: 422, INVALID_GOAL_TITLE: 422, @@ -293,6 +309,10 @@ export type BulkUpdateMembershipRequest = z.infer< export type BulkUpdateMembershipResultDto = z.infer< typeof bulkUpdateMembershipResultDtoSchema >; +export type CustomValuationDto = z.infer; +export type SetCustomValuationRequest = z.infer< + typeof setCustomValuationRequestSchema +>; export type RecordManualBalanceRequest = z.infer< typeof recordManualBalanceRequestSchema >; @@ -329,6 +349,7 @@ export function toLoyaltyAccountDto( ? toBalanceDto(account.latestBalance) : null, estimatedValueCents: account.estimatedValueCents, + customCentsPerPoint: account.customCentsPerPoint, trend: account.trend, expiresAt: account.expiresAt?.toISOString() ?? null, daysUntilExpiry: account.daysUntilExpiry, @@ -800,3 +821,15 @@ export function toIngestDealPageResultDto( // re-exported here: it imports from this module, so re-exporting would create // an import cycle (index -> openapi -> index) that fails at load with a TDZ // "Cannot access '...' before initialization" error. + +export function toCustomValuationDto(valuation: { + readonly providerId: string; + readonly centsPerPoint: number; + readonly updatedAt: Date; +}): CustomValuationDto { + return { + providerId: valuation.providerId, + centsPerPoint: valuation.centsPerPoint, + updatedAt: valuation.updatedAt.toISOString(), + }; +} diff --git a/packages/core/src/contracts/openapi.ts b/packages/core/src/contracts/openapi.ts index 3cc3c38..ce86b2e 100644 --- a/packages/core/src/contracts/openapi.ts +++ b/packages/core/src/contracts/openapi.ts @@ -10,7 +10,9 @@ import { chatAssistantResponseSchema, createPortfolioShareRequestSchema, createTripGoalRequestSchema, + customValuationDtoSchema, deletedAccountDtoSchema, + setCustomValuationRequestSchema, importPortfolioRequestSchema, importPortfolioResultDtoSchema, ingestDealPageResultDtoSchema, @@ -60,6 +62,8 @@ const COMPONENT_SCHEMAS = { ChatAssistantResponse: chatAssistantResponseSchema, ImportPortfolioResultDto: importPortfolioResultDtoSchema, BulkUpdateMembershipResultDto: bulkUpdateMembershipResultDtoSchema, + CustomValuationDto: customValuationDtoSchema, + SetCustomValuationRequest: setCustomValuationRequestSchema, ApiError: apiErrorSchema, LinkLoyaltyAccountRequest: linkLoyaltyAccountRequestSchema, UpdateLoyaltyAccountRequest: updateLoyaltyAccountRequestSchema, @@ -449,6 +453,35 @@ export function buildOpenApiDocument(options: BuildOpenApiOptions = {}): Json { }, }, }, + "/api/v1/valuations": { + get: { + summary: "List the caller's custom cents-per-point overrides", + responses: { + "200": jsonResponse("Custom valuations", arrayOf("CustomValuationDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/valuations/{providerId}": { + parameters: [ + { name: "providerId", in: "path", required: true, schema: { type: "string" } }, + ], + put: { + summary: "Set a custom cents-per-point override for a provider", + requestBody: body("SetCustomValuationRequest"), + responses: { + "200": jsonResponse("Saved override", ref("CustomValuationDto")), + ...ERROR_RESPONSES, + }, + }, + delete: { + summary: "Clear a custom override (revert to editorial)", + responses: { + "204": { description: "Cleared" }, + ...ERROR_RESPONSES, + }, + }, + }, "/api/v1/loyalty-accounts/deleted": { get: { summary: "Recently unlinked accounts (restore window)", diff --git a/packages/core/src/domain/errors.ts b/packages/core/src/domain/errors.ts index 95f9d0c..1018a6a 100644 --- a/packages/core/src/domain/errors.ts +++ b/packages/core/src/domain/errors.ts @@ -44,6 +44,14 @@ export class InvalidMembershipNumberError extends DomainError { } } +export class InvalidValuationError extends DomainError { + readonly code = "INVALID_VALUATION"; + + constructor() { + super("Cents-per-point must be a number greater than 0 and at most 100"); + } +} + export class CredentialUnavailableError extends DomainError { readonly code = "CREDENTIAL_UNAVAILABLE"; diff --git a/packages/core/src/domain/loyalty/custom-valuation.ts b/packages/core/src/domain/loyalty/custom-valuation.ts new file mode 100644 index 0000000..6a12c3b --- /dev/null +++ b/packages/core/src/domain/loyalty/custom-valuation.ts @@ -0,0 +1,39 @@ +import { InvalidValuationError } from "../errors"; + +/** + * A per-user override of a program's editorial cents-per-point valuation. + * When present, portfolio value for that provider is computed from this rate + * instead of the catalog default. + */ +export interface CustomValuation { + readonly userId: string; + readonly providerId: string; + /** Override redemption value of one point, in US cents (e.g. 1.8). */ + readonly centsPerPoint: number; + readonly updatedAt: Date; +} + +/** Upper bound guards against fat-finger inputs; 100¢/pt is already extreme. */ +export const MAX_CENTS_PER_POINT = 100; + +export function assertValidCentsPerPoint(value: number): void { + if (!Number.isFinite(value) || value <= 0 || value > MAX_CENTS_PER_POINT) { + throw new InvalidValuationError(); + } +} + +export interface CustomValuationRepository { + listForUser(userId: string): Promise; + upsert(valuation: CustomValuation): Promise; + delete(userId: string, providerId: string): Promise; +} + +/** + * Builds a providerId → centsPerPoint lookup from a user's overrides, for the + * read-model mapper to apply. + */ +export function toValuationOverrides( + valuations: readonly CustomValuation[], +): Map { + return new Map(valuations.map((v) => [v.providerId, v.centsPerPoint])); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6caad43..b39b09d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export * from "./domain/loyalty/balance-snapshot"; export * from "./domain/loyalty/repositories"; export * from "./domain/loyalty/trip-goal"; export * from "./domain/loyalty/portfolio-share"; +export * from "./domain/loyalty/custom-valuation"; // Application export * from "./application/ports"; @@ -15,6 +16,7 @@ export * from "./application/loyalty/link-loyalty-account"; export * from "./application/loyalty/list-loyalty-accounts"; export * from "./application/loyalty/get-loyalty-account"; export * from "./application/loyalty/update-loyalty-account"; +export * from "./application/loyalty/custom-valuations"; export * from "./application/loyalty/bulk-update-membership"; export * from "./application/loyalty/restore-loyalty-account"; export * from "./application/loyalty/get-balance-history"; @@ -49,6 +51,7 @@ export { refreshExpiryFromActivity } from "./domain/loyalty/loyalty-account"; export * from "./infrastructure/db/client"; 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/providers/composite-travel-provider-gateway"; export * from "./infrastructure/providers/simulated-travel-provider-gateway"; export * from "./infrastructure/vault/one-password-connect-vault"; diff --git a/packages/core/src/infrastructure/db/schema.ts b/packages/core/src/infrastructure/db/schema.ts index 2631d27..5bbc4f3 100644 --- a/packages/core/src/infrastructure/db/schema.ts +++ b/packages/core/src/infrastructure/db/schema.ts @@ -2,7 +2,9 @@ import { relations } from "drizzle-orm"; import { bigint, index, + integer, pgTable, + primaryKey, timestamp, uniqueIndex, varchar, @@ -138,6 +140,21 @@ export const tripGoals = pgTable( (goal) => [index("trip_goal_user_id_idx").on(goal.userId)], ); +// ─── Custom valuations ───────────────────────────────────────────────────── +// Per-user override of a provider's editorial cents-per-point. Stored as an +// integer number of milli-cents (centsPerPoint × 1000) to avoid float drift. + +export const userProviderValuations = pgTable( + "user_provider_valuation", + { + userId: varchar("user_id", { length: 255 }).notNull(), + providerId: varchar("provider_id", { length: 64 }).notNull(), + centsPerPointMilli: integer("cents_per_point_milli").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (row) => [primaryKey({ columns: [row.userId, row.providerId] })], +); + // ─── Public portfolio shares ─────────────────────────────────────────────── export const portfolioShares = pgTable( diff --git a/packages/core/src/infrastructure/repositories/drizzle-custom-valuation-repository.ts b/packages/core/src/infrastructure/repositories/drizzle-custom-valuation-repository.ts new file mode 100644 index 0000000..af81ff2 --- /dev/null +++ b/packages/core/src/infrastructure/repositories/drizzle-custom-valuation-repository.ts @@ -0,0 +1,64 @@ +import { and, eq } from "drizzle-orm"; + +import type { + CustomValuation, + CustomValuationRepository, +} from "../../domain/loyalty/custom-valuation"; +import type { Database } from "../db/client"; +import { userProviderValuations } from "../db/schema"; + +/** Cents-per-point is persisted as an integer number of milli-cents. */ +const MILLI = 1000; + +export class DrizzleCustomValuationRepository + implements CustomValuationRepository +{ + constructor(private readonly db: Database) {} + + async listForUser(userId: string): Promise { + const rows = await this.db + .select() + .from(userProviderValuations) + .where(eq(userProviderValuations.userId, userId)); + + return rows.map((row) => ({ + userId: row.userId, + providerId: row.providerId, + centsPerPoint: row.centsPerPointMilli / MILLI, + updatedAt: row.updatedAt, + })); + } + + async upsert(valuation: CustomValuation): Promise { + const row = { + userId: valuation.userId, + providerId: valuation.providerId, + centsPerPointMilli: Math.round(valuation.centsPerPoint * MILLI), + updatedAt: valuation.updatedAt, + }; + await this.db + .insert(userProviderValuations) + .values(row) + .onConflictDoUpdate({ + target: [ + userProviderValuations.userId, + userProviderValuations.providerId, + ], + set: { + centsPerPointMilli: row.centsPerPointMilli, + updatedAt: row.updatedAt, + }, + }); + } + + async delete(userId: string, providerId: string): Promise { + await this.db + .delete(userProviderValuations) + .where( + and( + eq(userProviderValuations.userId, userId), + eq(userProviderValuations.providerId, providerId), + ), + ); + } +} diff --git a/packages/core/test/custom-valuations.test.ts b/packages/core/test/custom-valuations.test.ts new file mode 100644 index 0000000..585519a --- /dev/null +++ b/packages/core/test/custom-valuations.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { + DeleteCustomValuation, + ListCustomValuations, + SetCustomValuation, +} from "../src/application/loyalty/custom-valuations"; +import { ListLoyaltyAccounts } from "../src/application/loyalty/list-loyalty-accounts"; +import { + InvalidValuationError, + ProviderNotSupportedError, +} from "../src/domain/errors"; +import { createBalanceSnapshot } from "../src/domain/loyalty/balance-snapshot"; +import { createLoyaltyAccount } from "../src/domain/loyalty/loyalty-account"; +import { + InMemoryBalanceSnapshotRepository, + InMemoryCustomValuationRepository, + InMemoryLoyaltyAccountRepository, +} from "./fakes"; + +function fixedClock(iso: string) { + return { now: () => new Date(iso) }; +} + +describe("SetCustomValuation", () => { + it("rejects unknown providers", async () => { + const repo = new InMemoryCustomValuationRepository(); + await expect( + new SetCustomValuation(repo).execute({ + userId: "u", + providerId: "not-a-real-provider", + centsPerPoint: 2, + }), + ).rejects.toBeInstanceOf(ProviderNotSupportedError); + }); + + it("rejects out-of-range cents-per-point", async () => { + const repo = new InMemoryCustomValuationRepository(); + for (const bad of [0, -1, 101, Number.NaN]) { + await expect( + new SetCustomValuation(repo).execute({ + userId: "u", + providerId: "united", + centsPerPoint: bad, + }), + ).rejects.toBeInstanceOf(InvalidValuationError); + } + }); + + it("upserts a valuation with the clock's timestamp", async () => { + const repo = new InMemoryCustomValuationRepository(); + const set = new SetCustomValuation(repo, fixedClock("2026-02-01T00:00:00Z")); + await set.execute({ userId: "u", providerId: "united", centsPerPoint: 2.1 }); + await set.execute({ userId: "u", providerId: "united", centsPerPoint: 2.5 }); + + const all = await new ListCustomValuations(repo).execute("u"); + expect(all).toHaveLength(1); // upsert, not duplicate + expect(all[0]).toMatchObject({ providerId: "united", centsPerPoint: 2.5 }); + }); +}); + +describe("custom valuations affect portfolio value", () => { + async function setup() { + const accounts = new InMemoryLoyaltyAccountRepository(); + const balances = new InMemoryBalanceSnapshotRepository(); + const valuations = new InMemoryCustomValuationRepository(); + + const account = createLoyaltyAccount({ + userId: "u", + providerId: "united", // editorial 1.2¢/pt + membershipNumber: "MP1", + }); + accounts.rows.set(account.id, account); + await balances.insert( + createBalanceSnapshot({ + loyaltyAccountId: account.id, + points: 100_000, + source: "manual", + capturedAt: new Date("2026-01-01T00:00:00Z"), + }), + ); + return { accounts, balances, valuations }; + } + + it("uses the editorial rate when no override is set", async () => { + const { accounts, balances, valuations } = await setup(); + const [read] = await new ListLoyaltyAccounts( + accounts, + balances, + undefined, + valuations, + ).execute("u"); + expect(read!.estimatedValueCents).toBe(120_000); // 100k * 1.2 + expect(read!.customCentsPerPoint).toBeNull(); + }); + + it("uses the override when set, and reverts after delete", async () => { + const { accounts, balances, valuations } = await setup(); + await new SetCustomValuation(valuations).execute({ + userId: "u", + providerId: "united", + centsPerPoint: 2, + }); + + const list = new ListLoyaltyAccounts(accounts, balances, undefined, valuations); + let [read] = await list.execute("u"); + expect(read!.estimatedValueCents).toBe(200_000); // 100k * 2.0 override + expect(read!.customCentsPerPoint).toBe(2); + + await new DeleteCustomValuation(valuations).execute("u", "united"); + [read] = await list.execute("u"); + expect(read!.estimatedValueCents).toBe(120_000); // back to editorial + expect(read!.customCentsPerPoint).toBeNull(); + }); +}); diff --git a/packages/core/test/derive-alerts.test.ts b/packages/core/test/derive-alerts.test.ts index 4e02f2a..4b9c06e 100644 --- a/packages/core/test/derive-alerts.test.ts +++ b/packages/core/test/derive-alerts.test.ts @@ -27,6 +27,7 @@ function account( hasStoredCredential: false, latestBalance: { points: 10_000, source: "manual", capturedAt: new Date("2026-01-01") }, estimatedValueCents: 13_000, + customCentsPerPoint: null, trend: { sincePrevious: over.sincePrevious ?? null, since30Days: null, diff --git a/packages/core/test/fakes.ts b/packages/core/test/fakes.ts index 442e210..0293509 100644 --- a/packages/core/test/fakes.ts +++ b/packages/core/test/fakes.ts @@ -2,6 +2,10 @@ import type { ActivityEvent } from "../src/domain/loyalty/activity"; import type { BalanceSnapshot } from "../src/domain/loyalty/balance-snapshot"; import type { LoyaltyAccount } from "../src/domain/loyalty/loyalty-account"; import type { PortfolioShare } from "../src/domain/loyalty/portfolio-share"; +import type { + CustomValuation, + CustomValuationRepository, +} from "../src/domain/loyalty/custom-valuation"; import type { TripGoal } from "../src/domain/loyalty/trip-goal"; import type { ActivityEventRepository, @@ -218,3 +222,25 @@ export class InMemoryPortfolioShareRepository this.rows.delete(id); } } + +export class InMemoryCustomValuationRepository + implements CustomValuationRepository +{ + readonly rows = new Map(); + + private key(userId: string, providerId: string): string { + return `${userId}::${providerId}`; + } + + async listForUser(userId: string) { + return [...this.rows.values()].filter((v) => v.userId === userId); + } + + async upsert(valuation: CustomValuation) { + this.rows.set(this.key(valuation.userId, valuation.providerId), valuation); + } + + async delete(userId: string, providerId: string) { + this.rows.delete(this.key(userId, providerId)); + } +} diff --git a/packages/core/test/goals-import-calendar.test.ts b/packages/core/test/goals-import-calendar.test.ts index 026600c..27254ba 100644 --- a/packages/core/test/goals-import-calendar.test.ts +++ b/packages/core/test/goals-import-calendar.test.ts @@ -234,6 +234,7 @@ describe("ImportPortfolio", () => { capturedAt: new Date("2026-05-01T00:00:00.000Z"), }, estimatedValueCents: 14400, + customCentsPerPoint: null, trend: { sincePrevious: null, since30Days: null, @@ -292,6 +293,7 @@ describe("buildExpirationCalendar", () => { capturedAt: new Date("2026-07-01T00:00:00.000Z"), }, estimatedValueCents: 12000, + customCentsPerPoint: null, trend: { sincePrevious: null, since30Days: null,