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
36 changes: 36 additions & 0 deletions apps/api/src/__tests__/IdentityVerificationSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,42 @@ describe('Identity volume threshold gating', () => {
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('refresh clears document currently_due when volume is now under threshold', async () => {
mockDb.Aggregate.mockResolvedValue([{ gross: 250_000 }]);
await module.EvaluateAndApply(connectedId);
expect(
storedAccounts.get(connectedId)?.requirements?.currently_due
).toContain(IDENTITY_REQUIREMENT_FIELDS.verificationDocument);

mockDb.Aggregate.mockResolvedValue([{ gross: 50_000 }]);
const refreshed = await module.RefreshIdentityRequirements(connectedId);

expect(refreshed.requirements?.currently_due).not.toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
expect(refreshed.requirements?.eventually_due).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);

mockDb.Aggregate.mockResolvedValue([{ gross: 250_000 }]);
const evaluation = await module.EvaluateAndApply(connectedId);
expect(evaluation.blocking).toBe(true);
expect(evaluation.currentlyDue).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('refresh keeps document currently_due when still over threshold', async () => {
mockDb.Aggregate.mockResolvedValue([{ gross: 250_000 }]);
await module.EvaluateAndApply(connectedId);

const refreshed = await module.RefreshIdentityRequirements(connectedId);

expect(refreshed.requirements?.currently_due).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});
});

describe('AccountModule identity settings merge', () => {
Expand Down
43 changes: 43 additions & 0 deletions apps/api/src/modules/identity/IdentityLite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,49 @@ export class IdentityLiteModule {
return updated;
}

/**
* Platform operator rebuilds currently_due from live person + threshold
* rules. Does not persist an exemption — a later volume crossing can put
* document IDV back on currently_due.
*/
async RefreshIdentityRequirements(accountId: string): Promise<AccountType> {
const account = await this.accountModule.GetAccount(accountId);
if (!account) {
throw new AppError(
ERRORS.ACCOUNT_NOT_FOUND.message,
ERRORS.ACCOUNT_NOT_FOUND.status,
ERRORS.ACCOUNT_NOT_FOUND.type
);
}

if (IsRejectedAccountReason(account.requirements?.disabled_reason)) {
throw new AppError(
'Cannot refresh identity requirements for a rejected account',
400,
'invalid_request_error'
);
}

const evaluation = await this.EvaluateAndApply(accountId);
if (!evaluation.blocking) {
const restored = await this.RestorePayoutsIfEligible(accountId);
if (restored) {
return restored;
}
}

const updated = await this.accountModule.GetAccount(accountId);
if (!updated) {
throw new AppError(
ERRORS.ACCOUNT_NOT_FOUND.message,
ERRORS.ACCOUNT_NOT_FOUND.status,
ERRORS.ACCOUNT_NOT_FOUND.type
);
}

return updated;
}

/**
* Assert the account may attach a wallet / enable payouts.
* Hosted document IDV does not block wallet attach — payouts are
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/routes/accounts.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,9 @@ router.post(
accountId,
});

if (enabled) {
await identityLiteModule.EvaluateAndApply(accountId);
}
const account = enabled
? await accountModule.PayoutsEnabled(accountId)
: await accountModule.PayoutsDisabled(accountId);
Expand Down Expand Up @@ -512,6 +515,30 @@ router.post(
})
);

// ─────────────────────────────────────────────────────────────────────────────
// POST /v1/accounts/:id/refresh_identity - Rebuild currently_due from live rules
// Zoneless extension: does not persist an IDV exemption
// ─────────────────────────────────────────────────────────────────────────────
router.post(
'/:id/refresh_identity',
RequirePlatform(),
AsyncHandler(async (req: express.Request, res: express.Response) => {
const accountId = req.params.id;
await RequirePlatformOwnedAccount(accountId, req.user.account);

Logger.info('Refreshing identity requirements', { accountId });

const account = await identityLiteModule.RefreshIdentityRequirements(
accountId
);
const populatedAccount = await PopulateAccountResources(account, true);

Logger.info('Identity requirements refreshed', { accountId });

res.json(populatedAccount);
})
);

// ─────────────────────────────────────────────────────────────────────────────
// POST /v1/accounts/:id/agree_terms - Agree to terms of service
// This is a Zoneless extension that handles TOS acceptance from the frontend
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/app/data/services/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export class AccountService {
);
}

/**
* Platform-only: rebuild currently_due from live identity rules.
*/
async RefreshIdentityRequirements(accountId: string): Promise<Account> {
return this.api.Call<Account>(
'POST',
`accounts/${accountId}/refresh_identity`,
{}
);
}

/**
* Platform-only: reject a connected account.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ <h2 class="section-title">Capabilities</h2>
</div>

<div class="view-section">
<div class="flex align-vert-start space-between gap-small">
<div class="flex align-vert-start space-between">
<h2 class="section-title">Identity requirements</h2>
@if (NeedsIdentityReview()) {
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class ConnectedAccountDetailViewComponent implements OnInit, OnDestroy {
account: WritableSignal<Account | null> = signal(null);
loading: WritableSignal<boolean> = signal(false);
approvingIdentity: WritableSignal<boolean> = signal(false);
refreshingIdentity: WritableSignal<boolean> = signal(false);
activeTab: WritableSignal<DetailTab> = signal('overview');
detailPanel: WritableSignal<DetailPanel> = signal('main');
moneyMovementTab: WritableSignal<MoneyMovementTab> = signal('payouts');
Expand Down Expand Up @@ -305,6 +306,15 @@ export class ConnectedAccountDetailViewComponent implements OnInit, OnDestroy {
external: true,
action: () => this.OnViewDashboard(),
},
{
title: 'Refresh identity checks',
section: 'Actions',
action: () => void this.OnRefreshIdentityChecks(),
hidden: (account: Account) =>
(account.requirements?.currently_due?.length ?? 0) === 0 ||
this.actions.IsAccountRejected(account),
disabled: () => this.refreshingIdentity(),
},
{
title: 'Pause payouts',
section: 'Actions',
Expand Down Expand Up @@ -599,6 +609,25 @@ export class ConnectedAccountDetailViewComponent implements OnInit, OnDestroy {
}
}

async OnRefreshIdentityChecks(): Promise<void> {
const account = this.account();
if (!account || this.refreshingIdentity()) return;

this.refreshingIdentity.set(true);
try {
const updated = await this.accountService.RefreshIdentityRequirements(
account.id
);
this.account.set(updated);
this.actions.SetActiveAccount(updated);
this.actions.events$.next({ type: 'updated', account: updated });
} catch (error) {
console.error('Failed to refresh identity requirements:', error);
} finally {
this.refreshingIdentity.set(false);
}
}

OpenIdentityDocumentDetail(): void {
this.detailPanel.set('identity_document');
}
Expand Down
Loading