diff --git a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts index 968775a..487aa18 100644 --- a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts +++ b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts @@ -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', () => { diff --git a/apps/api/src/modules/identity/IdentityLite.ts b/apps/api/src/modules/identity/IdentityLite.ts index f078974..3f2a516 100644 --- a/apps/api/src/modules/identity/IdentityLite.ts +++ b/apps/api/src/modules/identity/IdentityLite.ts @@ -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 { + 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 diff --git a/apps/api/src/routes/accounts.routes.ts b/apps/api/src/routes/accounts.routes.ts index 8dfd89a..8de4a73 100644 --- a/apps/api/src/routes/accounts.routes.ts +++ b/apps/api/src/routes/accounts.routes.ts @@ -481,6 +481,9 @@ router.post( accountId, }); + if (enabled) { + await identityLiteModule.EvaluateAndApply(accountId); + } const account = enabled ? await accountModule.PayoutsEnabled(accountId) : await accountModule.PayoutsDisabled(accountId); @@ -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 diff --git a/apps/web/src/app/data/services/account.service.ts b/apps/web/src/app/data/services/account.service.ts index 0376503..894bad7 100644 --- a/apps/web/src/app/data/services/account.service.ts +++ b/apps/web/src/app/data/services/account.service.ts @@ -84,6 +84,17 @@ export class AccountService { ); } + /** + * Platform-only: rebuild currently_due from live identity rules. + */ + async RefreshIdentityRequirements(accountId: string): Promise { + return this.api.Call( + 'POST', + `accounts/${accountId}/refresh_identity`, + {} + ); + } + /** * Platform-only: reject a connected account. */ diff --git a/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.html b/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.html index 7a5c672..576211d 100644 --- a/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.html +++ b/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.html @@ -362,7 +362,7 @@

Capabilities

-
+

Identity requirements

@if (NeedsIdentityReview()) {