Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/bot/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/app/api/v1/valuations/[providerId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
}
14 changes: 14 additions & 0 deletions apps/web/src/app/api/v1/valuations/route.ts
Original file line number Diff line number Diff line change
@@ -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));
});
}
15 changes: 15 additions & 0 deletions apps/web/src/server/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
DrizzleTripGoalRepository,
BedrockAssistant,
BulkUpdateMembershipNumbers,
DeleteCustomValuation,
DrizzleCustomValuationRepository,
ListCustomValuations,
SetCustomValuation,
ExportPortfolio,
FirecrawlPageScraper,
GetBalanceHistory,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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(
Expand Down Expand Up @@ -185,6 +195,8 @@ function buildContainer(): Container {
getLoyaltyAccount: new GetLoyaltyAccount(
loyaltyAccounts,
balanceSnapshots,
undefined,
customValuations,
),
linkLoyaltyAccount,
updateLoyaltyAccount,
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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).
Expand Down
24 changes: 24 additions & 0 deletions packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
ActivityEventDto,
BulkUpdateMembershipRequest,
BulkUpdateMembershipResultDto,
CustomValuationDto,
SetCustomValuationRequest,
ApiError,
ChatAssistantRequest,
ChatAssistantResponse,
Expand Down Expand Up @@ -118,6 +120,28 @@ export class PointUpClient {
return this.request("PATCH", "/api/v1/loyalty-accounts", body);
}

listCustomValuations(): Promise<CustomValuationDto[]> {
return this.request("GET", "/api/v1/valuations");
}

setCustomValuation(
providerId: string,
body: SetCustomValuationRequest,
): Promise<CustomValuationDto> {
return this.request(
"PUT",
`/api/v1/valuations/${encodeURIComponent(providerId)}`,
body,
);
}

deleteCustomValuation(providerId: string): Promise<void> {
return this.request(
"DELETE",
`/api/v1/valuations/${encodeURIComponent(providerId)}`,
);
}

/** Balance history, newest first (default 50, max 365 entries). */
getBalanceHistory(accountId: string, limit?: number): Promise<BalanceDto[]> {
const query = limit !== undefined ? `?limit=${limit}` : "";
Expand Down
7 changes: 7 additions & 0 deletions packages/core/drizzle/0005_nice_rage.sql
Original file line number Diff line number Diff line change
@@ -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")
);
Loading
Loading