Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/api/src/api/controllers/managedProfiles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export async function postManagedProfile(req: Request, res: Response): Promise<v
}

const result = await createManagedProfile({
// Server-resolved from the acting secret credential; Bearer-authenticated managers
// carry no partner attribution and provision without a pricing assignment.
attributingPartnerId: req.credential?.partnerId ?? null,
contactEmail,
creationSource: "manager",
customerType,
Expand Down
104 changes: 104 additions & 0 deletions apps/api/src/api/controllers/partnerAttribution.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import express from "express";
import ProfilePartnerAssignment from "../../models/profilePartnerAssignment.model";
import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db";
import { createTestApiKey, createTestPartner, createTestUser } from "../../test-utils/factories";
import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth";
import partnerAttributionRoutes from "../routes/v1/partner-attribution.route";

const BASE_PATH = "/v1/partner-attribution";

describe("partner attribution claim route", () => {
let server: ReturnType<typeof express.application.listen>;
let baseUrl: string;
let auth: FakeSupabaseAuth;

beforeAll(async () => {
await setupTestDatabase();
auth = installFakeSupabaseAuth();
const app = express();
app.use(express.json());
app.use(BASE_PATH, partnerAttributionRoutes);
server = app.listen(0);
const address = server.address();
if (!address || typeof address === "string") throw new Error("Could not bind test server");
baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}/claim`;
});

afterAll(() => {
auth.restore();
server?.close();
});
beforeEach(resetTestDatabase);

function claim(token: string | null, publicKey?: string) {
return fetch(baseUrl, {
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(publicKey ? { "x-public-key": publicKey } : {})
},
method: "POST"
});
}

it("requires a Supabase user token", async () => {
const partner = await createTestPartner();
const { publicKey } = await createTestApiKey({ partnerName: partner.name });
expect((await claim(null, publicKey)).status).toBe(401);
expect(await ProfilePartnerAssignment.count()).toBe(0);
});

it("requires a valid public key", async () => {
const user = await createTestUser();
expect((await claim(testUserToken(user.id))).status).toBe(400);
expect((await claim(testUserToken(user.id), `pk_test_${"a".repeat(32)}`)).status).toBe(401);
const { plaintextKey } = await createTestApiKey();
expect((await claim(testUserToken(user.id), plaintextKey)).status).toBe(400);
expect(await ProfilePartnerAssignment.count()).toBe(0);
});

it("assigns the credential's partner to the authenticated profile exactly once", async () => {
const user = await createTestUser();
const partner = await createTestPartner();
const { publicKey } = await createTestApiKey({ partnerName: partner.name });

const first = await claim(testUserToken(user.id), publicKey);
expect(first.status).toBe(200);
expect(await first.json()).toEqual({ outcome: "created" });
expect(await ProfilePartnerAssignment.findAll({ where: { userId: user.id } })).toMatchObject([
{ isActive: true, partnerId: partner.id, partnerName: partner.name }
]);

const second = await claim(testUserToken(user.id), publicKey);
expect(second.status).toBe(200);
expect(await second.json()).toEqual({ outcome: "skipped_existing_assignment" });
expect(await ProfilePartnerAssignment.count({ where: { userId: user.id } })).toBe(1);
});

it("keeps an existing assignment when a different partner's key is presented", async () => {
const user = await createTestUser();
const firstPartner = await createTestPartner();
const secondPartner = await createTestPartner();
const { publicKey: firstKey } = await createTestApiKey({ partnerName: firstPartner.name });
const { publicKey: secondKey } = await createTestApiKey({ partnerName: secondPartner.name });

expect(await (await claim(testUserToken(user.id), firstKey)).json()).toEqual({ outcome: "created" });
expect(await (await claim(testUserToken(user.id), secondKey)).json()).toEqual({ outcome: "skipped_existing_assignment" });

const active = await ProfilePartnerAssignment.findAll({ where: { isActive: true, userId: user.id } });
expect(active).toHaveLength(1);
expect(active[0].partnerId).toBe(firstPartner.id);
});

it("no-ops for keys without partner attribution", async () => {
const user = await createTestUser();
const { publicKey } = await createTestApiKey();

const response = await claim(testUserToken(user.id), publicKey);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ outcome: "no_partner_attribution" });
expect(await ProfilePartnerAssignment.count()).toBe(0);
});
});
52 changes: 52 additions & 0 deletions apps/api/src/api/controllers/partnerAttribution.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Request, Response } from "express";
import httpStatus from "http-status";
import logger from "../../config/logger";
import { claimPartnerAttribution } from "../services/partners/partner-attribution.service";

/**
* Claims partner pricing attribution for the authenticated profile from a validated public
* API key. The partner is resolved server-side from the credential's `partner_id`; the
* client only presents the key, so it cannot choose an arbitrary partner. Idempotent:
* a profile with an active assignment keeps it.
*/
export async function postPartnerAttributionClaim(req: Request, res: Response): Promise<void> {
const userId = req.userId;
if (!userId) {
res.status(httpStatus.UNAUTHORIZED).json({
error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication is required", status: httpStatus.UNAUTHORIZED }
});
return;
}

// validatePublicKey() is a no-op without a key; the claim is meaningless without one.
const credential = req.credential;
if (!credential) {
res.status(httpStatus.BAD_REQUEST).json({
error: {
code: "MISSING_PUBLIC_KEY",
message: "A public API key (x-public-key header or apiKey body field) is required",
status: httpStatus.BAD_REQUEST
}
});
return;
}

if (!credential.partnerId) {
res.status(httpStatus.OK).json({ outcome: "no_partner_attribution" });
return;
}

try {
const outcome = await claimPartnerAttribution(userId, credential.partnerId);
res.status(httpStatus.OK).json({ outcome });
} catch (error) {
logger.error("Error claiming partner attribution", error);
res.status(httpStatus.INTERNAL_SERVER_ERROR).json({
error: {
code: "INTERNAL_SERVER_ERROR",
message: "Failed to claim partner attribution",
status: httpStatus.INTERNAL_SERVER_ERROR
}
});
}
}
8 changes: 8 additions & 0 deletions apps/api/src/api/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import moneriumRoutes from "./monerium.route";
import mykoboRoutes from "./mykobo.route";
import notificationsRoutes from "./notifications.route";
import onboardingRoutes from "./onboarding.route";
import partnerAttributionRoutes from "./partner-attribution.route";
import paymentMethodsRoutes from "./payment-methods.route";
import priceRoutes from "./price.route";
import publicKeyRoutes from "./public-key.route";
Expand Down Expand Up @@ -227,6 +228,13 @@ router.use("/onboarding", onboardingRoutes);
router.use("/api-credentials", apiCredentialsRoutes);
router.use("/managed-profiles", managedProfilesRoutes);

/**
* Partner pricing attribution claim for widget-onboarded users. The partner is
* resolved server-side from the presented public API key's credential.
* POST /v1/partner-attribution/claim
*/
router.use("/partner-attribution", partnerAttributionRoutes);

/**
* Admin routes for partner-managed API credentials. The partner is addressed by
* its unique name; each credential is bound to one explicit profile subject.
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/api/routes/v1/partner-attribution.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Router } from "express";
import { postPartnerAttributionClaim } from "../../controllers/partnerAttribution.controller";
import { rejectImpersonation } from "../../middlewares/bearerPrincipal";
import { validatePublicKey } from "../../middlewares/publicKeyAuth";
import { requireAuth } from "../../middlewares/supabaseAuth";

const router = Router();

router.post("/claim", requireAuth, rejectImpersonation, validatePublicKey(), postPartnerAttributionClaim);

export default router;
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function toResultWithCustomerType(
}

export async function createManagedProfile(input: {
attributingPartnerId?: string | null;
contactEmail: string;
creationSource: ManagedProfileCreationSource;
customerType: CustomerEntityType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { beforeAll, beforeEach, describe, expect, it } from "bun:test";
import CustomerEntity from "../../models/customerEntity.model";
import ManagedProfile from "../../models/managedProfile.model";
import ManagedProfileManager from "../../models/managedProfileManager.model";
import ProfilePartnerAssignment from "../../models/profilePartnerAssignment.model";
import User from "../../models/user.model";
import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db";
import { createTestUser } from "../../test-utils/factories";
import { createTestPartner, createTestUser } from "../../test-utils/factories";
import {
ManagedProfileProvisioningError,
provisionManagedProfile
Expand Down Expand Up @@ -65,6 +66,44 @@ describe("managed profile provisioning", () => {
});
});

it("fixes partner pricing attribution to a newly provisioned profile", async () => {
const manager = await createManager();
const partner = await createTestPartner();

const result = await provisionManagedProfile({
attributingPartnerId: partner.id,
contactEmail: "attributed@example.com",
creationSource: "manager",
customerType: "individual",
externalSubjectId: "customer-attributed",
managerProfileId: manager.id
});

expect(await ProfilePartnerAssignment.findAll({ where: { userId: result.profileId } })).toMatchObject([
{ isActive: true, partnerId: partner.id, partnerName: partner.name }
]);
});

it("does not create or change attribution for unattributed or already-existing profiles", async () => {
const manager = await createManager();
const partner = await createTestPartner();
const input = {
contactEmail: "unattributed@example.com",
creationSource: "manager" as const,
customerType: "individual" as const,
externalSubjectId: "customer-unattributed",
managerProfileId: manager.id
};

const created = await provisionManagedProfile(input);
expect(await ProfilePartnerAssignment.count({ where: { userId: created.profileId } })).toBe(0);

// A later retry with attribution must not retroactively assign the existing profile.
const retried = await provisionManagedProfile({ ...input, attributingPartnerId: partner.id });
expect(retried.created).toBe(false);
expect(await ProfilePartnerAssignment.count({ where: { userId: created.profileId } })).toBe(0);
});

it("returns the existing profile for an idempotent retry", async () => {
const manager = await createManager();
const input = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CustomerEntity from "../../models/customerEntity.model";
import ManagedProfile, { type ManagedProfileCreationSource } from "../../models/managedProfile.model";
import ManagedProfileManager from "../../models/managedProfileManager.model";
import User from "../../models/user.model";
import { assignPartnerAttribution } from "./partners/partner-attribution.service";

export class ManagedProfileProvisioningError extends Error {
constructor(
Expand All @@ -22,6 +23,8 @@ export class ManagedProfileProvisioningError extends Error {
}

export interface ProvisionManagedProfileInput {
/** Partner attribution of the acting API credential; fixes partner pricing to the new profile. */
attributingPartnerId?: string | null;
contactEmail: string;
creationSource: ManagedProfileCreationSource;
customerType: CustomerEntityType;
Expand Down Expand Up @@ -144,6 +147,12 @@ export async function provisionManagedProfile(input: ProvisionManagedProfileInpu
{ transaction }
);

// Attribution applies only at creation — an existing relationship returned above keeps
// whatever pricing assignment it was (or was not) provisioned with.
if (input.attributingPartnerId) {
await assignPartnerAttribution(profile.id, input.attributingPartnerId, transaction);
}

return {
contactEmail,
created: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { beforeAll, beforeEach, describe, expect, it } from "bun:test";
import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model";
import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db";
import { createTestPartner, createTestUser } from "../../../test-utils/factories";
import { claimPartnerAttribution } from "./partner-attribution.service";

describe("partner attribution", () => {
beforeAll(setupTestDatabase);
beforeEach(resetTestDatabase);

it("creates an active assignment carrying the partner's id and name", async () => {
const user = await createTestUser();
const partner = await createTestPartner();

expect(await claimPartnerAttribution(user.id, partner.id)).toBe("created");

const assignments = await ProfilePartnerAssignment.findAll({ where: { userId: user.id } });
expect(assignments).toHaveLength(1);
expect(assignments[0]).toMatchObject({
expiresAt: null,
isActive: true,
partnerId: partner.id,
partnerName: partner.name
});
});

it("never replaces an existing active assignment (first partner wins)", async () => {
const user = await createTestUser();
const firstPartner = await createTestPartner();
const secondPartner = await createTestPartner();
await ProfilePartnerAssignment.create({
isActive: true,
partnerId: firstPartner.id,
partnerName: firstPartner.name,
userId: user.id
});

expect(await claimPartnerAttribution(user.id, secondPartner.id)).toBe("skipped_existing_assignment");

const assignments = await ProfilePartnerAssignment.findAll({ where: { isActive: true, userId: user.id } });
expect(assignments).toHaveLength(1);
expect(assignments[0].partnerId).toBe(firstPartner.id);
});

it("is idempotent for repeated claims of the same partner", async () => {
const user = await createTestUser();
const partner = await createTestPartner();

expect(await claimPartnerAttribution(user.id, partner.id)).toBe("created");
expect(await claimPartnerAttribution(user.id, partner.id)).toBe("skipped_existing_assignment");
expect(await ProfilePartnerAssignment.count({ where: { userId: user.id } })).toBe(1);
});

it("replaces an expired active row without tripping the active-assignment unique index", async () => {
const user = await createTestUser();
const expiredPartner = await createTestPartner();
const partner = await createTestPartner();
await ProfilePartnerAssignment.create({
expiresAt: new Date(Date.now() - 60_000),
isActive: true,
partnerId: expiredPartner.id,
partnerName: expiredPartner.name,
userId: user.id
});

expect(await claimPartnerAttribution(user.id, partner.id)).toBe("created");

const active = await ProfilePartnerAssignment.findAll({ where: { isActive: true, userId: user.id } });
expect(active).toHaveLength(1);
expect(active[0].partnerId).toBe(partner.id);
});

it("skips inactive partners and missing profiles without creating rows", async () => {
const user = await createTestUser();
const inactivePartner = await createTestPartner({ isActive: false });
const partner = await createTestPartner();

expect(await claimPartnerAttribution(user.id, inactivePartner.id)).toBe("skipped_partner_inactive");
expect(await claimPartnerAttribution(crypto.randomUUID(), partner.id)).toBe("skipped_profile_missing");
expect(await ProfilePartnerAssignment.count()).toBe(0);
});
});
Loading
Loading