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
50 changes: 50 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2635,6 +2635,56 @@ model TransactionReorg {
@@map("transaction_reorgs")
}

// ─── Issue #633: Payment Method Micro-Deposit Verification ────────────────────

enum PaymentMethodVerificationStatus {
pending
verified
failed
locked
}

model PaymentMethod {
id String @id @default(uuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
type String @default("bank_account")
last4 String? @map("last4")
status PaymentMethodVerificationStatus @default(pending)
depositAmount1Cents Int? @map("deposit_amount1_cents")
depositAmount2Cents Int? @map("deposit_amount2_cents")
verificationAttempts Int @default(0) @map("verification_attempts")
maxVerificationAttempts Int @default(5) @map("max_verification_attempts")
depositsIssuedAt DateTime? @map("deposits_issued_at")
verifiedAt DateTime? @map("verified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

@@index([tenantId])
@@index([userId])
@@index([status])
@@map("payment_methods")
}

// ─── Issue #632: Role-Based Onboarding Checklist ───────────────────────────────

model OnboardingChecklist {
id String @id @default(uuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
role WorkspaceRole @default(member)
tasks Json // ordered list of { id, title, completed, completedAt }
completionPercent Int @default(0) @map("completion_percent")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

@@unique([tenantId, userId])
@@index([tenantId])
@@index([userId])
@@map("onboarding_checklists")
}

// ─── Payment reconciliation (Issue #628) ──────────────────────────────────────
// Note: distinct from the on-chain balance reconciliation feature (Issue
// #465, `services/reconciliation/`) — this is payment-matching against
Expand Down
8 changes: 8 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { queueRouter } from './routes/queue.js';
import { slaRouter } from './routes/sla.js';
import { legacyRouter } from './routes/legacy.js';
import { onboardingRouter } from './routes/onboarding.js';
import { roleOnboardingRouter } from './routes/role-onboarding.js';
import { paymentMethodsRouter } from './routes/payment-methods.js';
import { splitsRouter } from './routes/splits.js';
import { refundsRouter } from './routes/refunds.js';
import allowancesRouter from './routes/allowances.js';
Expand Down Expand Up @@ -299,6 +301,12 @@ apiV1Router.use('/queue', bullMQMonitorRouter);
apiV1Router.use('/outbox', outboxRouter);
apiV1Router.use('/sla', slaRouter);
apiV1Router.use('/onboarding', onboardingRouter);

// Role-based onboarding checklist — Issue #632
apiV1Router.use('/onboarding-checklist', roleOnboardingRouter);

// Payment method micro-deposit verification — Issue #633
apiV1Router.use('/payment-methods', paymentMethodsRouter);
apiV1Router.use('/legacy', legacyRouter);
apiV1Router.use('/flags', flagsRouter);
apiV1Router.use('/rate-limit', rateLimitAnalyticsRouter);
Expand Down
9 changes: 9 additions & 0 deletions backend/src/routes/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ apiKeysRouter.get('/:keyId/usage', asyncHandler(async (req, res) => {
res.json(summary);
}));

apiKeysRouter.get('/:keyId/usage/daily', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const key = await prisma.apiKey.findUnique({ where: { keyId: req.params.keyId } });
if (!key || key.tenantId !== tenantId) throw new AppError(404, 'API key not found', 'KEY_NOT_FOUND');
const days = Math.min(Math.max(parseInt(req.query.days as string) || 7, 1), 90);
const daily = await quotaManagerService.getDailyUsage(req.params.keyId as string, days);
res.json({ keyId: req.params.keyId, days, daily });
}));

apiKeysRouter.put('/:keyId/quota', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const key = await prisma.apiKey.findUnique({ where: { keyId: req.params.keyId } });
Expand Down
61 changes: 61 additions & 0 deletions backend/src/routes/payment-methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Issue #633: Payment method micro-deposit verification (minimal real slice).
// Deferred: verification notifications, verification analytics/reporting.

import { Router } from 'express';
import { prisma } from '../lib/prisma.js';
import { asyncHandler } from '../middleware/errorHandler.js';
import { AppError } from '../middleware/errorHandler.js';
import { microDepositVerificationService } from '../services/payments/micro-deposit-verification.js';

export const paymentMethodsRouter = Router();

function resolveTenant(req: any): string {
return (req.headers['x-tenant-id'] as string) ?? 'default';
}

function resolveUser(req: any): string {
return (req.headers['x-user-id'] as string) ?? 'unknown';
}

paymentMethodsRouter.post('/', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const userId = resolveUser(req);
const { type, last4 } = req.body as { type?: string; last4?: string };

const method = await prisma.paymentMethod.create({
data: { tenantId, userId, type: type ?? 'bank_account', last4 },
});
res.status(201).json(method);
}));

paymentMethodsRouter.get('/:id', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const method = await prisma.paymentMethod.findUnique({ where: { id: req.params.id } });
if (!method || method.tenantId !== tenantId) throw new AppError(404, 'Payment method not found', 'PAYMENT_METHOD_NOT_FOUND');
res.json(method);
}));

paymentMethodsRouter.post('/:id/micro-deposits', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const method = await prisma.paymentMethod.findUnique({ where: { id: req.params.id } });
if (!method || method.tenantId !== tenantId) throw new AppError(404, 'Payment method not found', 'PAYMENT_METHOD_NOT_FOUND');

// Amounts are only ever handed back here so the caller can push them to the
// outbound deposit rail; they are never persisted in plaintext response form.
const amounts = await microDepositVerificationService.issueMicroDeposits(req.params.id as string);
res.status(201).json({ issued: true, ...amounts });
}));

paymentMethodsRouter.post('/:id/verify', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const method = await prisma.paymentMethod.findUnique({ where: { id: req.params.id } });
if (!method || method.tenantId !== tenantId) throw new AppError(404, 'Payment method not found', 'PAYMENT_METHOD_NOT_FOUND');

const { amount1Cents, amount2Cents } = req.body as { amount1Cents: number; amount2Cents: number };
if (typeof amount1Cents !== 'number' || typeof amount2Cents !== 'number') {
throw new AppError(400, 'amount1Cents and amount2Cents are required numbers', 'INVALID_BODY');
}

const result = await microDepositVerificationService.verifyMicroDeposits(req.params.id as string, amount1Cents, amount2Cents);
res.json(result);
}));
45 changes: 45 additions & 0 deletions backend/src/routes/role-onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Issue #632: Role-based onboarding checklist (minimal real slice).
//
// Distinct from the existing document-review merchant `onboarding.ts`
// router: this covers a lightweight, role-based task checklist (owner /
// admin / member / viewer), not merchant KYC document review.
//
// Deferred: automated cross-team task assignment, onboarding analytics,
// notifications, completion incentives.

import { Router } from 'express';
import { asyncHandler } from '../middleware/errorHandler.js';
import { AppError } from '../middleware/errorHandler.js';
import { onboardingChecklistService, type OnboardingRole } from '../services/onboarding/onboarding-checklist.js';

export const roleOnboardingRouter = Router();

const VALID_ROLES: OnboardingRole[] = ['owner', 'admin', 'member', 'viewer'];

function resolveTenant(req: any): string {
return (req.headers['x-tenant-id'] as string) ?? 'default';
}

function resolveUser(req: any): string {
return (req.headers['x-user-id'] as string) ?? 'unknown';
}

roleOnboardingRouter.get('/checklist', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const userId = resolveUser(req);
const role = (req.query.role as string) ?? 'member';

if (!VALID_ROLES.includes(role as OnboardingRole)) {
throw new AppError(400, `Invalid role: ${role}`, 'INVALID_ROLE');
}

const checklist = await onboardingChecklistService.getOrCreateChecklist(tenantId, userId, role as OnboardingRole);
res.json(checklist);
}));

roleOnboardingRouter.post('/checklist/tasks/:taskId/complete', asyncHandler(async (req, res) => {
const tenantId = resolveTenant(req);
const userId = resolveUser(req);
const checklist = await onboardingChecklistService.completeTask(tenantId, userId, req.params.taskId as string);
res.json(checklist);
}));
31 changes: 31 additions & 0 deletions backend/src/services/keys/quota-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@ export class QuotaManagerService {
};
}

/**
* Issue #631 (minimal slice): request counts for a given key, bucketed by
* calendar day, for the last `days` days. Deferred: dashboard UI,
* forecasting/trending, alerts, and cross-key comparison tools — those
* remain follow-up work on top of this counting primitive.
*/
async getDailyUsage(keyId: string, days = 7) {
const since = new Date();
since.setUTCHours(0, 0, 0, 0);
since.setUTCDate(since.getUTCDate() - (days - 1));

const usage = await prisma.apiKeyUsage.findMany({
where: { keyId, recordedAt: { gte: since } },
select: { recordedAt: true },
});

const counts = new Map<string, number>();
for (let i = 0; i < days; i++) {
const d = new Date(since);
d.setUTCDate(d.getUTCDate() + i);
counts.set(d.toISOString().slice(0, 10), 0);
}

for (const row of usage) {
const day = row.recordedAt.toISOString().slice(0, 10);
counts.set(day, (counts.get(day) ?? 0) + 1);
}

return Array.from(counts.entries()).map(([date, count]) => ({ date, count }));
}

async getTenantUsageSummary(tenantId: string) {
const now = new Date();
const hourAgo = new Date(now.getTime() - 3600_000);
Expand Down
113 changes: 113 additions & 0 deletions backend/src/services/onboarding/__tests__/onboarding-checklist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const onboardingChecklistMock = {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};

vi.mock('../../../lib/prisma.js', () => ({
prisma: {
onboardingChecklist: onboardingChecklistMock,
},
}));

const { onboardingChecklistService } = await import('../onboarding-checklist.js');

describe('OnboardingChecklistService', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('getOrCreateChecklist', () => {
it('creates a checklist from the role template on first access', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue(null);
onboardingChecklistMock.create.mockImplementation(async ({ data }: any) => ({ id: 'chk-1', ...data }));

const checklist = await onboardingChecklistService.getOrCreateChecklist('tenant-1', 'user-1', 'admin');

expect(onboardingChecklistMock.create).toHaveBeenCalled();
expect((checklist as any).tasks.length).toBeGreaterThan(0);
expect((checklist as any).tasks.every((t: any) => !t.completed)).toBe(true);
expect((checklist as any).completionPercent).toBe(0);
});

it('returns the existing checklist without recreating it', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue({ id: 'chk-1', tasks: [] });

const checklist = await onboardingChecklistService.getOrCreateChecklist('tenant-1', 'user-1', 'member');

expect(onboardingChecklistMock.create).not.toHaveBeenCalled();
expect(checklist).toEqual({ id: 'chk-1', tasks: [] });
});

it('rejects an unknown role', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue(null);
await expect(
onboardingChecklistService.getOrCreateChecklist('tenant-1', 'user-1', 'bogus' as any)
).rejects.toMatchObject({ code: 'UNKNOWN_ROLE' });
});

it('gives different task sets per role', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue(null);
onboardingChecklistMock.create.mockImplementation(async ({ data }: any) => ({ id: 'chk', ...data }));

const owner = await onboardingChecklistService.getOrCreateChecklist('t', 'u', 'owner');
const viewer = await onboardingChecklistService.getOrCreateChecklist('t', 'u', 'viewer');

expect((owner as any).tasks).not.toEqual((viewer as any).tasks);
});
});

describe('completeTask', () => {
it('marks a task complete and recomputes completion percentage', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue({
tenantId: 't',
userId: 'u',
tasks: [
{ id: 'task-a', title: 'A', completed: false, completedAt: null },
{ id: 'task-b', title: 'B', completed: false, completedAt: null },
],
});
onboardingChecklistMock.update.mockImplementation(async ({ data }: any) => data);

const result = await onboardingChecklistService.completeTask('t', 'u', 'task-a');

expect(result.completionPercent).toBe(50);
expect(result.tasks.find((t: any) => t.id === 'task-a').completed).toBe(true);
expect(result.completedAt).toBeNull();
});

it('sets completedAt once all tasks are done', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue({
tenantId: 't',
userId: 'u',
tasks: [{ id: 'task-a', title: 'A', completed: false, completedAt: null }],
});
onboardingChecklistMock.update.mockImplementation(async ({ data }: any) => data);

const result = await onboardingChecklistService.completeTask('t', 'u', 'task-a');

expect(result.completionPercent).toBe(100);
expect(result.completedAt).not.toBeNull();
});

it('throws when the checklist does not exist', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue(null);
await expect(onboardingChecklistService.completeTask('t', 'u', 'task-a')).rejects.toMatchObject({
code: 'CHECKLIST_NOT_FOUND',
});
});

it('throws when the task id is unknown', async () => {
onboardingChecklistMock.findUnique.mockResolvedValue({
tenantId: 't',
userId: 'u',
tasks: [{ id: 'task-a', title: 'A', completed: false, completedAt: null }],
});
await expect(onboardingChecklistService.completeTask('t', 'u', 'does-not-exist')).rejects.toMatchObject({
code: 'TASK_NOT_FOUND',
});
});
});
});
Loading
Loading