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
19 changes: 19 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,24 @@ type BuildInformation
helmRevision: Int
}

input BusinessAccountUpgradeRequestInput
@join__type(graph: PUBLIC)
{
accountNumber: Int
accountType: String
bankBranch: String
bankName: String
businessAddress: String
businessName: String
currency: String
email: String
fullName: String!
idDocument: String
level: AccountLevel!
phoneNumber: String
terminalRequested: Boolean
}

type CallbackEndpoint
@join__type(graph: PUBLIC)
{
Expand Down Expand Up @@ -966,6 +984,7 @@ type Mutation
accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload!
accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload!
businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): SuccessPayload!
callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload!
callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload!
captchaCreateChallenge: CaptchaCreateChallengePayload!
Expand Down
15 changes: 1 addition & 14 deletions dev/config/base-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# future admins should be added via admin-api.
# future admins should be added via admin-api.
admin_accounts:
- role: "bankowner"
phone: "+16505554334"
Expand Down Expand Up @@ -37,16 +37,3 @@ cashout:

sendgrid:
apiKey: "<replace>"

frappe:
url: "http://localhost:8080"
credentials:
# get from ErpNext user -> settings -> API Access
apiKey: ""
apiSecret: ""
erpnext:
accounts:
ibex:
operating: "<ibex id> - Ibex Operating - FL"
cashout: "Cashout Payables (JMD) - FL"
serviceFees: "Service Fees - FL"
125 changes: 125 additions & 0 deletions src/app/accounts/business-account-upgrade-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { InvalidAccountStatusError } from "@domain/errors"
import { AccountLevel, checkedToAccountLevel } from "@domain/accounts"

import { AccountsRepository, UsersRepository } from "@services/mongoose"
import { IdentityRepository } from "@services/kratos"
import ErpNext from "@services/frappe/ErpNext"

import { updateAccountLevel } from "./update-account-level"

type BusinessUpgradeRequestInput = {
accountId: AccountId
level: number
fullName: string
phoneNumber?: string
email?: string
businessName?: string
businessAddress?: string
terminalRequested?: boolean
bankName?: string
bankBranch?: string
accountType?: string
currency?: string
accountNumber?: number
idDocument?: string
}

// Composable validation helpers
type Validator<T> = (value: T) => true | ApplicationError
type CheckedValidator<T, R> = (value: T) => R | ApplicationError

const validate = <T>(value: T, validators: Validator<T>[]): T | ApplicationError => {
for (const validator of validators) {
const result = validator(value)
if (result instanceof Error) return result
}
return value
}

const validateAndTransform = <T, R>(
value: T,
transform: CheckedValidator<T, R>,
validators: Validator<R>[],
): R | ApplicationError => {
const transformed = transform(value)
if (transformed instanceof Error) return transformed
return validate(transformed, validators)
}

const isGreaterThan =
(threshold: number, errorMsg: string): Validator<number> =>
(value) =>
value > threshold ? true : new InvalidAccountStatusError(errorMsg)

const isNotEqual =
(compareTo: number, errorMsg: string): Validator<number> =>
(value) =>
value !== compareTo ? true : new InvalidAccountStatusError(errorMsg)

export const businessAccountUpgradeRequest = async (
input: BusinessUpgradeRequestInput,
): Promise<true | ApplicationError> => {
const { accountId, level, fullName } = input

const accountsRepo = AccountsRepository()
const usersRepo = UsersRepository()

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

const checkedLevel = validateAndTransform(level, checkedToAccountLevel, [
isGreaterThan(account.level - 1, "Cannot request account level downgrade"),
isNotEqual(account.level, "Account is already at requested level"),
])
if (checkedLevel instanceof Error) return checkedLevel

const user = await usersRepo.findById(account.kratosUserId)
if (user instanceof Error) return user

const identity = await IdentityRepository().getIdentity(account.kratosUserId)
if (identity instanceof Error) return identity

const storedPhone = (user.phone as string) || ""
const storedEmail = (identity.email as string) || ""

// Validate phone number if provided and account has existing phone
if (input.phoneNumber && storedPhone && input.phoneNumber !== storedPhone) {
return new InvalidAccountStatusError("Phone number does not match account records")
}

// Validate email if provided and account has existing email
if (input.email && storedEmail && input.email !== storedEmail) {
return new InvalidAccountStatusError("Email does not match account records")
}

const requestResult = await ErpNext.createUpgradeRequest({
username: (account.username as string) || account.id,
currentLevel: account.level,
requestedLevel: checkedLevel,
fullName,
phoneNumber: storedPhone,
email: storedEmail || undefined,
businessName: input.businessName,
businessAddress: input.businessAddress,
terminalRequested: input.terminalRequested,
bankName: input.bankName,
bankBranch: input.bankBranch,
accountType: input.accountType,
currency: input.currency,
accountNumber: input.accountNumber,
idDocument: input.idDocument,
})

if (requestResult instanceof Error) return requestResult

// Pro accounts auto-upgrade immediately (no manual approval needed)
if (checkedLevel === AccountLevel.Pro) {
const upgradeResult = await updateAccountLevel({
id: accountId,
level: checkedLevel,
})
if (upgradeResult instanceof Error) return upgradeResult
}

return true
}
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 "./business-account-upgrade-request"
export * from "./update-account-status"
export * from "./update-business-map-info"
export * from "./update-contact-alias"
Expand Down
2 changes: 2 additions & 0 deletions src/app/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as BriaEventErrors from "@services/bria/errors"
import * as SvixErrors from "@services/svix/errors"

import * as IbexErrors from "@services/ibex/errors"
import * as ErpNextErrors from "@services/frappe/errors"

export const ApplicationErrors = {
...SharedErrors,
Expand Down Expand Up @@ -57,4 +58,5 @@ export const ApplicationErrors = {

// Flash Errors
...IbexErrors,
...ErpNextErrors,
} as const
3 changes: 3 additions & 0 deletions src/domain/accounts/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export const AccountLevel = {
One: 1,
Two: 2,
Three: 3,
// Semantic aliases
Pro: 2,
Merchant: 3,
} as const

export const AccountStatus = {
Expand Down
1 change: 1 addition & 0 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ export const mapError = (error: ApplicationError): CustomApolloError => {
case "UnknownDomainError":
case "UnknownBriaEventError":
case "CouldNotFindAccountError":
case "UpgradeRequestCreateError":
message = `Unknown error occurred (code: ${error.name})`
return new UnknownClientError({ message, logger: baseLogger })

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 BusinessAccountUpgradeRequestMutation from "@graphql/public/root/mutation/business-account-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,
businessAccountUpgradeRequest: BusinessAccountUpgradeRequestMutation,
accountEnableNotificationCategory: AccountEnableNotificationCategoryMutation,
accountDisableNotificationCategory: AccountDisableNotificationCategoryMutation,
accountEnableNotificationChannel: AccountEnableNotificationChannelMutation,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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 BusinessAccountUpgradeRequestInput = GT.Input({
name: "BusinessAccountUpgradeRequestInput",
fields: () => ({
level: { type: GT.NonNull(AccountLevel) },
fullName: { type: GT.NonNull(GT.String) },
phoneNumber: { type: GT.String },
email: { type: GT.String },
businessName: { type: GT.String },
businessAddress: { type: GT.String },
terminalRequested: { type: GT.Boolean },
bankName: { type: GT.String },
bankBranch: { type: GT.String },
accountType: { type: GT.String },
currency: { type: GT.String },
accountNumber: { type: GT.Int },
idDocument: { type: GT.String },
}),
})

const BusinessAccountUpgradeRequestMutation = GT.Field({
extensions: {
complexity: 150,
},
type: GT.NonNull(SuccessPayload),
args: {
input: { type: GT.NonNull(BusinessAccountUpgradeRequestInput) },
},
resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => {
const {
level,
fullName,
phoneNumber,
email,
businessName,
businessAddress,
terminalRequested,
bankName,
bankBranch,
accountType,
currency,
accountNumber,
idDocument,
} = args.input

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

const result = await Accounts.businessAccountUpgradeRequest({
accountId: domainAccount.id,
level,
fullName,
phoneNumber: phoneNumber || undefined,
email: email || undefined,
businessName: businessName || undefined,
businessAddress: businessAddress || undefined,
terminalRequested: terminalRequested || undefined,
bankName: bankName || undefined,
bankBranch: bankBranch || undefined,
accountType: accountType || undefined,
currency: currency || undefined,
accountNumber: accountNumber || undefined,
idDocument: idDocument || undefined,
})

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

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

export default BusinessAccountUpgradeRequestMutation
17 changes: 17 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,22 @@ type BuildInformation {
helmRevision: Int
}

input BusinessAccountUpgradeRequestInput {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be updated to include all required fields

accountNumber: Int
accountType: String
bankBranch: String
bankName: String
businessAddress: String
businessName: String
currency: String
email: String
fullName: String!
idDocument: String
level: AccountLevel!
phoneNumber: String
terminalRequested: Boolean
}

type CallbackEndpoint {
id: EndpointId!
url: EndpointUrl!
Expand Down Expand Up @@ -741,6 +757,7 @@ type Mutation {
accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload!
accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload!
businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): SuccessPayload!
callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload!
callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload!
captchaCreateChallenge: CaptchaCreateChallengePayload!
Expand Down
Loading