Skip to content
Closed
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
7 changes: 7 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ enum AccountLevel
ZERO @join__enumValue(graph: PUBLIC)
}

input AccountLevelUpgradeRequestInput
@join__type(graph: PUBLIC)
{
level: AccountLevel!
}

interface AccountLimit
@join__type(graph: PUBLIC)
{
Expand Down Expand Up @@ -955,6 +961,7 @@ type Mutation
accountDisableNotificationChannel(input: AccountDisableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
accountEnableNotificationCategory(input: AccountEnableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload!
accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
accountLevelUpgradeRequest(input: AccountLevelUpgradeRequestInput!): SuccessPayload!
accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload!
accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload!
callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload!
Expand Down
1 change: 1 addition & 0 deletions src/app/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from "./send-default-wallet-balance-to-users"
export * from "./set-username"
export * from "./update-account-ip"
export * from "./update-account-level"
export * from "./request-account-level-upgrade"
export * from "./update-account-status"
export * from "./update-business-map-info"
export * from "./update-contact-alias"
Expand Down
58 changes: 58 additions & 0 deletions src/app/accounts/request-account-level-upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { InvalidAccountStatusError } from "@domain/errors"
import { checkedToAccountLevel } from "@domain/accounts"
import { AccountsRepository, UsersRepository } from "@services/mongoose"

/**
* Request an account level upgrade.
*
* This function allows users to request an upgrade to their account level (KYC level).
* The request is stored as `requestedLevel` on the user record and must be approved
* by an admin using the admin `accountUpdateLevel` mutation.
*
* Key behaviors:
* - Users can only request upgrades, not downgrades
* - Users can skip levels (e.g., request from 0 to 2 directly)
* - Previous pending requests are replaced by new requests
*
* @param accountId - The account requesting the upgrade
* @param level - The target account level (0, 1, 2, or 3)
* @returns Success or an error
*/
export const requestAccountLevelUpgrade = async ({
accountId,
level,
}: {
accountId: AccountId
level: number
}): Promise<true | ApplicationError> => {
const accountsRepo = AccountsRepository()
const usersRepo = UsersRepository()

const account = await accountsRepo.findById(accountId)
if (account instanceof Error) return account

const checkedLevel = checkedToAccountLevel(level)
if (checkedLevel instanceof Error) return checkedLevel

// Prevent downgrade requests for security reasons
if (checkedLevel < account.level) {
return new InvalidAccountStatusError("Cannot request account level downgrade")
}

// Short-circuit if no change needed
if (checkedLevel === account.level) {
return new InvalidAccountStatusError("Account is already at requested level")
}

// Get user and set requested level
const user = await usersRepo.findById(account.kratosUserId)
if (user instanceof Error) return user

const updatedUser = await usersRepo.update({
...user,
requestedLevel: checkedLevel,
})
if (updatedUser instanceof Error) return updatedUser

return true
}
19 changes: 17 additions & 2 deletions src/app/accounts/update-account-level.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AccountsRepository } from "@services/mongoose"
import { AccountsRepository, UsersRepository } from "@services/mongoose"

export const updateAccountLevel = async ({
id,
Expand All @@ -8,10 +8,25 @@ export const updateAccountLevel = async ({
level: AccountLevel
}): Promise<Account | ApplicationError> => {
const accountsRepo = AccountsRepository()
const usersRepo = UsersRepository()

const account = await accountsRepo.findById(id as AccountId)
if (account instanceof Error) return account

account.level = level
return accountsRepo.update(account)
const updatedAccount = await accountsRepo.update(account)
if (updatedAccount instanceof Error) return updatedAccount

// Clear any pending upgrade request when admin changes level
const user = await usersRepo.findById(account.kratosUserId)
if (user instanceof Error) return updatedAccount // Don't fail the whole operation

if (user.requestedLevel !== null) {
await usersRepo.update({
...user,
requestedLevel: null,
})
}

return updatedAccount
}
1 change: 1 addition & 0 deletions src/domain/users/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type User = {
createdAt: Date
deviceId?: DeviceId | undefined
deletedEmails?: EmailAddress[] | undefined
requestedLevel?: AccountLevel | null
}

type UserUpdateInput = Omit<Partial<User>, "language" | "createdAt"> & {
Expand Down
50 changes: 0 additions & 50 deletions src/graphql/admin/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,6 @@ type AuditedUser {
phone: Phone
}

"""An Opaque Bearer token"""
scalar AuthToken

type AuthTokenPayload {
authToken: AuthToken
errors: [Error!]!
totpRequired: Boolean
}

"""
A wallet belonging to an account which contains a BTC balance and a list of transactions.
"""
Expand Down Expand Up @@ -140,26 +131,6 @@ input BusinessUpdateMapInfoInput {
username: Username!
}

type CaptchaCreateChallengePayload {
errors: [Error!]!
result: CaptchaCreateChallengeResult
}

type CaptchaCreateChallengeResult {
challengeCode: String!
failbackMode: Boolean!
id: String!
newCaptcha: Boolean!
}

input CaptchaRequestAuthCodeInput {
challengeCode: String!
channel: PhoneCodeChannelType
phone: Phone!
secCode: String!
validationCode: String!
}

type Coordinates {
latitude: Float!
longitude: Float!
Expand Down Expand Up @@ -285,11 +256,8 @@ type Mutation {
adminPushNotificationSend(input: AdminPushNotificationSendInput!): AdminPushNotificationSendPayload!
businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload!
businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload!
captchaCreateChallenge: CaptchaCreateChallengePayload!
captchaRequestAuthCode(input: CaptchaRequestAuthCodeInput!): SuccessPayload!
merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload!
merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload!
userLogin(input: UserLoginInput!): AuthTokenPayload!
userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload!
}

Expand All @@ -302,9 +270,6 @@ scalar OnChainAddress

scalar OnChainTxHash

"""An authentication code valid for a single use"""
scalar OneTimeAuthCode

"""Information about pagination in a connection."""
type PageInfo {
"""When paginating forwards, the cursor to continue."""
Expand All @@ -325,11 +290,6 @@ scalar PaymentHash
"""Phone number which includes country code"""
scalar Phone

enum PhoneCodeChannelType {
SMS
WHATSAPP
}

interface PriceInterface {
base: SafeInt!
currencyUnit: String! @deprecated(reason: "Deprecated due to type renaming")
Expand Down Expand Up @@ -397,11 +357,6 @@ A string amount (of a currency) that can be negative (e.g. in a transaction)
"""
scalar SignedDisplayMajorAmount

type SuccessPayload {
errors: [Error!]!
success: Boolean
}

"""
Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch)
"""
Expand Down Expand Up @@ -513,11 +468,6 @@ type UsdWallet implements Wallet {
walletCurrency: WalletCurrency!
}

input UserLoginInput {
code: OneTimeAuthCode!
phone: Phone!
}

input UserUpdatePhoneInput {
accountUuid: ID!
phone: Phone!
Expand Down
2 changes: 2 additions & 0 deletions src/graphql/public/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import UserUpdateLanguageMutation from "@graphql/public/root/mutation/user-updat
import UserUpdateUsernameMutation from "@graphql/public/root/mutation/user-update-username"
import AccountUpdateDefaultWalletIdMutation from "@graphql/public/root/mutation/account-update-default-wallet-id"
import AccountUpdateDisplayCurrencyMutation from "@graphql/public/root/mutation/account-update-display-currency"
import AccountLevelUpgradeRequestMutation from "@graphql/public/root/mutation/account-level-upgrade-request"
import UserContactUpdateAliasMutation from "@graphql/public/root/mutation/user-contact-update-alias"
import UserQuizQuestionUpdateCompletedMutation from "@graphql/public/root/mutation/user-quiz-question-update-completed"
import OnChainPaymentSendMutation from "@graphql/public/root/mutation/onchain-payment-send"
Expand Down Expand Up @@ -98,6 +99,7 @@ export const mutationFields = {
userContactUpdateAlias: UserContactUpdateAliasMutation,
accountUpdateDefaultWalletId: AccountUpdateDefaultWalletIdMutation,
accountUpdateDisplayCurrency: AccountUpdateDisplayCurrencyMutation,
accountLevelUpgradeRequest: AccountLevelUpgradeRequestMutation,
accountEnableNotificationCategory: AccountEnableNotificationCategoryMutation,
accountDisableNotificationCategory: AccountDisableNotificationCategoryMutation,
accountEnableNotificationChannel: AccountEnableNotificationChannelMutation,
Expand Down
55 changes: 55 additions & 0 deletions src/graphql/public/root/mutation/account-level-upgrade-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Accounts } from "@app"

import { GT } from "@graphql/index"
import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"
import AccountLevel from "@graphql/shared/types/scalar/account-level"
import SuccessPayload from "@graphql/shared/types/payload/success-payload"

const AccountLevelUpgradeRequestInput = GT.Input({
name: "AccountLevelUpgradeRequestInput",
fields: () => ({
level: { type: GT.NonNull(AccountLevel) },
}),
})

/**
* Public GraphQL mutation for requesting account level upgrades.
*
* Allows authenticated users to request an upgrade to their account level (KYC level).
* The request is stored and must be approved by an admin.
*
* This differs from the admin mutation which directly sets the account level.
* This mutation only creates a request that requires admin approval.
*/
const AccountLevelUpgradeRequestMutation = GT.Field({
extensions: {
complexity: 120,
},
type: GT.NonNull(SuccessPayload),
args: {
input: { type: GT.NonNull(AccountLevelUpgradeRequestInput) },
},
resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => {
const { level } = args.input

if (level instanceof Error) {
return { errors: [{ message: level.message }], success: false }
}

const result = await Accounts.requestAccountLevelUpgrade({
accountId: domainAccount.id,
level,
})

if (result instanceof Error) {
return { errors: [mapAndParseErrorForGqlResponse(result)], success: false }
}

return {
errors: [],
success: true,
}
},
})

export default AccountLevelUpgradeRequestMutation
5 changes: 5 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ enum AccountLevel {
ZERO
}

input AccountLevelUpgradeRequestInput {
level: AccountLevel!
}

interface AccountLimit {
"""The rolling time interval in seconds that the limits would apply for."""
interval: Seconds
Expand Down Expand Up @@ -732,6 +736,7 @@ type Mutation {
accountDisableNotificationChannel(input: AccountDisableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
accountEnableNotificationCategory(input: AccountEnableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload!
accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
accountLevelUpgradeRequest(input: AccountLevelUpgradeRequestInput!): SuccessPayload!
accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload!
accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload!
callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload!
Expand Down
7 changes: 7 additions & 0 deletions src/services/mongoose/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,13 @@ const UserSchema = new Schema(
deletedEmail: {
type: [String],
},
// Pending account level upgrade request
// User sets this when requesting an upgrade, admin clears it when approving
requestedLevel: {
type: Number,
enum: [null, ...Levels],
default: null,
},
},
{ id: false },
)
Expand Down
1 change: 1 addition & 0 deletions src/services/mongoose/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ interface UserRecord {
createdAt: Date
deviceId?: DeviceId
deletedEmails?: string[] | undefined
requestedLevel?: number | null
}

type PaymentFlowStateRecord = {
Expand Down
4 changes: 4 additions & 0 deletions src/services/mongoose/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const translateToUser = (user: UserRecord): User => {
const createdAt = user.createdAt
const deviceId = user.deviceId as DeviceId | undefined
const deletedEmails = user.deletedEmails as EmailAddress[] | undefined
const requestedLevel = (user.requestedLevel ?? null) as AccountLevel | null

return {
id: user.userId as UserId,
Expand All @@ -24,6 +25,7 @@ export const translateToUser = (user: UserRecord): User => {
createdAt,
deviceId,
deletedEmails,
requestedLevel,
}
}

Expand Down Expand Up @@ -66,6 +68,7 @@ export const UsersRepository = (): IUsersRepository => {
deletedPhones,
deviceId,
deletedEmails,
requestedLevel,
}: UserUpdateInput): Promise<User | RepositoryError> => {
const updateObject: Partial<UserUpdateInput> & {
$unset?: { phone?: number; email?: number }
Expand All @@ -76,6 +79,7 @@ export const UsersRepository = (): IUsersRepository => {
deletedPhones,
deletedEmails,
deviceId,
requestedLevel,
}

// If the new phone is undefined, unset it from the document
Expand Down
Loading