Skip to content

Commit e5de51d

Browse files
committed
feat(api): inherit manager pricing for managed profiles
1 parent 17923e0 commit e5de51d

12 files changed

Lines changed: 160 additions & 20 deletions

apps/api/src/api/controllers/quote.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const createQuote = async (
5050
const quote = await quoteService.createQuote({
5151
apiCredentialId: req.credential?.credentialId,
5252
apiKey: publicApiKey,
53+
controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId,
5354
from,
5455
inputAmount,
5556
inputCurrency,
@@ -112,6 +113,7 @@ export const createBestQuote = async (
112113
const quote = await quoteService.createBestQuote({
113114
apiCredentialId: req.credential?.credentialId,
114115
apiKey: publicApiKey,
116+
controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId,
115117
countryCode,
116118
from,
117119
inputAmount,

apps/api/src/api/services/quote/core/partner-resolution.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,79 @@ describe("resolveQuotePartner", () => {
107107
expect(result.ownerPartnerId).toBeNull();
108108
});
109109

110+
it("uses the child assignment before the controlling manager assignment", async () => {
111+
const assignmentLookups: string[] = [];
112+
ProfilePartnerAssignment.findOne = mock(async ({ where }: { where: { userId: string } }) => {
113+
assignmentLookups.push(where.userId);
114+
return where.userId === "child-1" ? { partnerId: "child-partner-id" } : { partnerId: "manager-partner-id" };
115+
}) as unknown as typeof ProfilePartnerAssignment.findOne;
116+
Partner.findOne = mock(async ({ where }: { where: { id?: string } }) =>
117+
where.id === "child-partner-id" ? stubPartner("child-partner-id", "ChildPartner") : null
118+
) as typeof Partner.findOne;
119+
PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) =>
120+
where.partnerId === "child-partner-id" && where.rampType === RampDirection.BUY
121+
? stubConfig("child-partner-id", RampDirection.BUY)
122+
: null
123+
) as typeof PartnerPricingConfig.findOne;
124+
125+
const result = await resolveQuotePartner({
126+
...baseRequest,
127+
controllingManagerProfileId: "manager-1",
128+
userId: "child-1"
129+
});
130+
131+
expect(result.source).toBe("profileAssignment");
132+
expect(result.pricingPartnerId).toBe("child-partner-id");
133+
expect(result.ownerPartnerId).toBeNull();
134+
expect(assignmentLookups).toEqual(["child-1"]);
135+
});
136+
137+
it("uses the controlling manager assignment when the child has no assignment", async () => {
138+
const assignmentLookups: string[] = [];
139+
ProfilePartnerAssignment.findOne = mock(async ({ where }: { where: { userId: string } }) => {
140+
assignmentLookups.push(where.userId);
141+
return where.userId === "manager-1" ? { partnerId: "manager-partner-id" } : null;
142+
}) as unknown as typeof ProfilePartnerAssignment.findOne;
143+
Partner.findOne = mock(async ({ where }: { where: { id?: string } }) =>
144+
where.id === "manager-partner-id" ? stubPartner("manager-partner-id", "ManagerPartner") : null
145+
) as typeof Partner.findOne;
146+
PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) =>
147+
where.partnerId === "manager-partner-id" && where.rampType === RampDirection.BUY
148+
? stubConfig("manager-partner-id", RampDirection.BUY)
149+
: null
150+
) as typeof PartnerPricingConfig.findOne;
151+
152+
const result = await resolveQuotePartner({
153+
...baseRequest,
154+
controllingManagerProfileId: "manager-1",
155+
userId: "child-1"
156+
});
157+
158+
expect(result.source).toBe("managerProfileAssignment");
159+
expect(result.pricingPartnerId).toBe("manager-partner-id");
160+
expect(result.ownerPartnerId).toBeNull();
161+
expect(assignmentLookups).toEqual(["child-1", "manager-1"]);
162+
});
163+
164+
it("does not expose manager pricing when an active child assignment is invalid", async () => {
165+
const assignmentLookups: string[] = [];
166+
ProfilePartnerAssignment.findOne = mock(async ({ where }: { where: { userId: string } }) => {
167+
assignmentLookups.push(where.userId);
168+
return where.userId === "child-1" ? { partnerId: null } : { partnerId: "manager-partner-id" };
169+
}) as unknown as typeof ProfilePartnerAssignment.findOne;
170+
171+
const result = await resolveQuotePartner({
172+
...baseRequest,
173+
controllingManagerProfileId: "manager-1",
174+
userId: "child-1"
175+
});
176+
177+
expect(result.source).toBe("none");
178+
expect(result.pricingPartnerId).toBeNull();
179+
expect(result.ownerPartnerId).toBeNull();
180+
expect(assignmentLookups).toEqual(["child-1"]);
181+
});
182+
110183
it("resolves the sell-direction pricing config for sell users", async () => {
111184
ProfilePartnerAssignment.findOne = mock(async () => ({
112185
partnerId: "assigned-id"

apps/api/src/api/services/quote/core/partner-resolution.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getTargetFiatCurrency } from "../../phases/blocks/core/helpers";
77
import type { PartnerPricingSource } from "./types";
88

99
type QuotePartnerResolutionRequest = CreateQuoteRequest & {
10+
controllingManagerProfileId?: string;
1011
userId?: string;
1112
};
1213

@@ -52,7 +53,7 @@ async function findPartnerByIdForRamp(
5253
return partner;
5354
}
5455

55-
async function findAssignedPartnerId(userId: string, now: Date): Promise<string | null> {
56+
async function findAssignment(userId: string, now: Date): Promise<{ partnerId: string | null } | null> {
5657
const assignment = await ProfilePartnerAssignment.findOne({
5758
order: [["createdAt", "DESC"]],
5859
where: {
@@ -62,7 +63,7 @@ async function findAssignedPartnerId(userId: string, now: Date): Promise<string
6263
}
6364
});
6465

65-
return assignment?.partnerId ?? null;
66+
return assignment ? { partnerId: assignment.partnerId } : null;
6667
}
6768

6869
export async function resolveQuotePartner(
@@ -82,14 +83,31 @@ export async function resolveQuotePartner(
8283
}
8384

8485
if (request.userId) {
85-
const assignedPartnerId = await findAssignedPartnerId(request.userId, now);
86-
if (assignedPartnerId) {
87-
const partner = await findPartnerByIdForRamp(assignedPartnerId, request.rampType, fiatCurrency);
86+
const assignment = await findAssignment(request.userId, now);
87+
if (assignment) {
88+
const partner = assignment.partnerId
89+
? await findPartnerByIdForRamp(assignment.partnerId, request.rampType, fiatCurrency)
90+
: null;
8891
return {
8992
ownerPartnerId: null,
9093
partner,
9194
pricingPartnerId: partner?.id ?? null,
92-
source: "profileAssignment"
95+
source: partner ? "profileAssignment" : "none"
96+
};
97+
}
98+
}
99+
100+
if (request.controllingManagerProfileId) {
101+
const assignment = await findAssignment(request.controllingManagerProfileId, now);
102+
if (assignment) {
103+
const partner = assignment.partnerId
104+
? await findPartnerByIdForRamp(assignment.partnerId, request.rampType, fiatCurrency)
105+
: null;
106+
return {
107+
ownerPartnerId: null,
108+
partner,
109+
pricingPartnerId: partner?.id ?? null,
110+
source: partner ? "managerProfileAssignment" : "none"
93111
};
94112
}
95113
}

apps/api/src/api/services/quote/core/quote-context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { CreateQuoteRequest, RampCurrency, RampDirection } from "@vortexfi/share
1111
import type { QuoteContext as IQuoteContext, PartnerInfo, PartnerPricingSource } from "./types";
1212

1313
export function createQuoteContext(args: {
14-
request: CreateQuoteRequest & { apiCredentialId?: string; userId?: string };
14+
request: CreateQuoteRequest & {
15+
apiCredentialId?: string;
16+
controllingManagerProfileId?: string;
17+
userId?: string;
18+
};
1519
targetFeeFiatCurrency: RampCurrency;
1620
partner: PartnerInfo | null;
1721
partnerOwnerId?: string | null;

apps/api/src/api/services/quote/core/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ export interface PartnerInfo {
4646
payoutAddressEvm?: string | null;
4747
}
4848

49-
export type PartnerPricingSource = "request" | "profileAssignment" | "none";
49+
export type PartnerPricingSource = "request" | "profileAssignment" | "managerProfileAssignment" | "none";
5050

5151
// Quote context flows through all stages. Defined in quote-context.ts.
5252
// Re-export here for convenience to avoid deep imports.
5353
export interface QuoteContext {
5454
// immutable request details
55-
readonly request: CreateQuoteRequest & { apiCredentialId?: string; userId?: string };
55+
readonly request: CreateQuoteRequest & {
56+
apiCredentialId?: string;
57+
controllingManagerProfileId?: string;
58+
userId?: string;
59+
};
5660
readonly now: Date;
5761

5862
// Partner info (if any)

apps/api/src/api/services/quote/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class QuoteService extends BaseRampService {
4545
request: CreateQuoteRequest & {
4646
apiCredentialId?: string;
4747
apiKey?: string | null;
48+
controllingManagerProfileId?: string;
4849
userId?: string;
4950
}
5051
): Promise<QuoteResponse> {
@@ -74,6 +75,7 @@ export class QuoteService extends BaseRampService {
7475
request: CreateBestQuoteRequest & {
7576
apiCredentialId?: string;
7677
apiKey?: string | null;
78+
controllingManagerProfileId?: string;
7779
userId?: string;
7880
}
7981
): Promise<QuoteResponse> {
@@ -179,6 +181,7 @@ export class QuoteService extends BaseRampService {
179181
request: CreateQuoteRequest & {
180182
apiCredentialId?: string;
181183
apiKey?: string | null;
184+
controllingManagerProfileId?: string;
182185
userId?: string;
183186
},
184187
skipPersistence = false

apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@ describe("managed-profile quote and registered-ramp lifecycle", () => {
8686
payoutAddressEvm: DESTINATION,
8787
rampType: RampDirection.BUY
8888
});
89+
const managerPricingPartner = await createTestPartner({
90+
fiatCurrency: FiatToken.BRL,
91+
markupCurrency: FiatToken.BRL,
92+
markupType: "absolute",
93+
markupValue: 2,
94+
payoutAddressEvm: DESTINATION,
95+
rampType: RampDirection.BUY
96+
});
97+
await ProfilePartnerAssignment.create({
98+
isActive: true,
99+
partnerId: managerPricingPartner.id,
100+
partnerName: managerPricingPartner.name,
101+
userId: manager.id
102+
});
89103
await ProfilePartnerAssignment.create({
90104
isActive: true,
91105
partnerId: pricingPartner.id,
@@ -180,6 +194,17 @@ describe("managed-profile quote and registered-ramp lifecycle", () => {
180194
expect(Number(directQuoteResponse.partnerFeeFiat)).toBe(5);
181195
expect(Number(directQuoteResponse.outputAmount)).toBe(99.9);
182196

197+
const delegatedSiblingQuoteResponse = await createQuote({
198+
...managerHeaders,
199+
"X-Managed-Profile-Id": siblingId
200+
});
201+
const delegatedSiblingQuote = await QuoteTicket.findByPk(delegatedSiblingQuoteResponse.id as string);
202+
expect(delegatedSiblingQuote?.userId).toBe(siblingId);
203+
expect(delegatedSiblingQuote?.partnerId).toBeNull();
204+
expect(delegatedSiblingQuote?.pricingPartnerId).toBe(managerPricingPartner.id);
205+
expect(delegatedSiblingQuote?.apiCredentialId).toBe(managerCredential.record.id);
206+
expect(Number(delegatedSiblingQuoteResponse.partnerFeeFiat)).toBe(2);
207+
183208
const pendingQuoteResponse = await createQuote({
184209
"Content-Type": "application/json",
185210
"X-API-Key": childCredential.secretKey
@@ -214,6 +239,12 @@ describe("managed-profile quote and registered-ramp lifecycle", () => {
214239
"X-API-Key": siblingCredential.secretKey
215240
});
216241
const siblingQuoteId = siblingQuoteResponse.id as string;
242+
const siblingQuote = await QuoteTicket.findByPk(siblingQuoteId);
243+
expect(siblingQuote?.userId).toBe(siblingId);
244+
expect(siblingQuote?.partnerId).toBeNull();
245+
expect(siblingQuote?.pricingPartnerId).toBe(managerPricingPartner.id);
246+
expect(siblingQuote?.apiCredentialId).toBe(siblingCredential.id);
247+
expect(Number(siblingQuoteResponse.partnerFeeFiat)).toBe(2);
217248
const siblingRegistration = await jsonRequest("/v1/ramp/register", {
218249
body: JSON.stringify({
219250
additionalData: { destinationAddress: DESTINATION, taxId: "12345678902" },

docs/adr-0003-managed-headless-profiles.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ than introduce a parallel tenant or impersonation model.
2727
non-empty subset. It never expands the canonical matrix.
2828
- Provisioning creates an `individual` or `business` child, active entity, immutable
2929
external subject ID, normalized provider contact email, and relationship atomically.
30-
Pricing is resolved from the child and remains independent of management.
30+
A managed child defaults to its controlling manager profile's pricing assignment. The
31+
child may have its own profile pricing assignment, administered like any regular
32+
profile assignment, which takes precedence over the manager assignment.
3133
- Deletion is logical and idempotent. It revokes child credentials and blocks new child
3234
activity while retaining provider, compliance, quote, ramp, callback, and attribution
3335
records needed for in-flight processing and reconciliation.

docs/api/pages/03-authentication-and-partner-keys.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ X-API-Key: sk_live_...
3737
X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002
3838
```
3939

40-
A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, quote pricing, and ramp history resolve from the child subject.
40+
A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, and ramp history resolve from the child subject. Quote pricing uses the child's active profile assignment when present, otherwise the controlling manager profile's active assignment, then default Vortex pricing. This precedence is identical for manager-delegated requests and direct child credentials.
4141

4242
The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; and customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. The EUR corridor's flows are bound to a verified login email, so they and all recipient-invitation routes do not support managed children.
4343

docs/api/pages/06-quotes-and-pricing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,6 @@ Quotes are immutable and short-lived. If the user takes too long to confirm, or
112112

113113
Pass the credential's public value through `X-Public-Key` to apply partner pricing and attribution. The SDK also retains it in the quote body for compatibility. When `X-Public-Key` and `X-API-Key` are both present, they must belong to the same credential or Vortex returns `403 CREDENTIAL_MISMATCH`. See [Authentication And API Credentials](https://api-docs.vortexfinance.co/authentication-and-partner-keys).
114114

115+
Managed profiles default to the controlling manager profile's pricing assignment. Assigning pricing directly to a managed child overrides the manager's pricing just as a profile assignment does for any regular profile. The same precedence applies whether the manager delegates with `X-Managed-Profile-Id` or the child authenticates with its own credential: child assignment, manager assignment, then default Vortex pricing.
116+
115117
---

0 commit comments

Comments
 (0)