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
13 changes: 13 additions & 0 deletions src/app/accounts/business-account-upgrade-request.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AccountsRepository, UsersRepository } from "@services/mongoose"
import { IdentityRepository } from "@services/kratos"
import { notifyOpsEvent } from "@services/alerts/ops-events"
import ErpNext from "@services/frappe/ErpNext"

import {
Expand Down Expand Up @@ -101,5 +102,17 @@ export const createUpgradeRequest = async (
const requestResult = await ErpNext.postUpgradeRequest(req)
if (requestResult instanceof Error) return requestResult

notifyOpsEvent({
flow: "upgrade",
phase: "requested",
status: "pending",
accountId,
meta: {
requestId: requestResult.name,
from: String(account.level),
to: String(input.level),
},
})

return { id: requestResult.name, status: initialStatus } as UpgradeStatusResponse
}
15 changes: 14 additions & 1 deletion src/app/accounts/update-account-level.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AccountLevel } from "@domain/accounts"
import { ValidationError } from "@domain/shared"
import { notifyOpsEvent } from "@services/alerts/ops-events"
import { AccountsRepository } from "@services/mongoose"

export const updateAccountLevel = async ({
Expand All @@ -23,7 +24,19 @@ export const updateAccountLevel = async ({
const account = await accountsRepo.findById(id as AccountId)
if (account instanceof Error) return account

const previousLevel = account.level
account.level = level
if (erpParty !== undefined) account.erpParty = erpParty
return accountsRepo.update(account)
const updated = await accountsRepo.update(account)
if (updated instanceof Error) return updated

notifyOpsEvent({
flow: "upgrade",
phase: "approved",
status: "success",
accountId: updated.id,
meta: { from: String(previousLevel), to: String(level) },
})

return updated
}
11 changes: 11 additions & 0 deletions src/app/accounts/upgrade-device-account.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AccountLevel } from "@domain/accounts"
import { notifyOpsEvent } from "@services/alerts/ops-events"
import { AccountsRepository, UsersRepository } from "@services/mongoose"

export const upgradeAccountFromDeviceToPhone = async ({
Expand Down Expand Up @@ -28,5 +29,15 @@ export const upgradeAccountFromDeviceToPhone = async ({
const accountUpdated = await AccountsRepository().update(accountDevice)
if (accountUpdated instanceof Error) return accountUpdated

notifyOpsEvent({
flow: "verification",
phase: "promoted",
status: "success",
accountId: accountUpdated.id,
userId,
phone,
meta: { from: "trial", to: "verified" },
})

return accountUpdated
}
37 changes: 35 additions & 2 deletions src/app/authentication/email.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AccountAlreadyHasEmailError } from "@domain/authentication/errors"
import { notifyOpsEvent } from "@services/alerts/ops-events"
import { AuthWithEmailPasswordlessService } from "@services/kratos"
import { baseLogger } from "@services/logger"
import { UsersRepository } from "@services/mongoose"
Expand All @@ -25,6 +26,14 @@ export const addEmailToIdentity = async ({
const emailRegistrationId = await authServiceEmail.sendEmailWithCode({ email })
if (emailRegistrationId instanceof Error) return emailRegistrationId

notifyOpsEvent({
flow: "verification",
phase: "otp-sent",
status: "pending",
userId,
email,
})

const user = await UsersRepository().findById(userId)
if (user instanceof Error) return user

Expand All @@ -40,15 +49,39 @@ export const verifyEmail = async ({
}): Promise<User | KratosError | RepositoryError> => {
baseLogger.info({ emailRegistrationId }, "RequestVerifyEmail called")

const notifyFailed = (error: Error) =>
notifyOpsEvent({
flow: "verification",
phase: "otp-failed",
status: "failed",
error: error.constructor.name,
meta: { emailFlowId: emailRegistrationId },
})

const authServiceEmail = AuthWithEmailPasswordlessService()
const res = await authServiceEmail.validateCode({
code,
emailFlowId: emailRegistrationId,
})
if (res instanceof Error) return res
if (res instanceof Error) {
notifyFailed(res)
return res
}

const user = await UsersRepository().findById(res.kratosUserId)
if (user instanceof Error) return user
if (user instanceof Error) {
notifyFailed(user)
return user
}

notifyOpsEvent({
flow: "verification",
phase: "otp-verified",
status: "success",
userId: res.kratosUserId,
email: res.email,
meta: { emailFlowId: emailRegistrationId },
})

return user
}
Expand Down
45 changes: 39 additions & 6 deletions src/app/authentication/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
PhoneAccountAlreadyExistsNeedToSweepFundsError,
} from "@services/kratos"

import { notifyOpsEvent } from "@services/alerts/ops-events"
import { WalletsRepository } from "@services/mongoose"
import {
addAttributesToCurrentSpan,
Expand Down Expand Up @@ -261,6 +262,22 @@ export const loginDeviceUpgradeWithPhone = async ({
ip: IpAddress
account: Account
}): Promise<LoginDeviceUpgradeWithPhoneResult | ApplicationError> => {
const notifyUpgradeFailed = (
phase: string,
error: Error,
meta?: Record<string, string>,
) =>
notifyOpsEvent({
flow: "verification",
phase,
status: "failed",
accountId: account.id,
userId: account.kratosUserId,
phone,
error: error.constructor.name,
meta,
})

{
const limitOk = await checkFailedLoginAttemptPerIpLimits(ip)
if (limitOk instanceof Error) return limitOk
Expand All @@ -271,7 +288,10 @@ export const loginDeviceUpgradeWithPhone = async ({
}

const validCode = await isPhoneCodeValid({ phone, code })
if (validCode instanceof Error) return validCode
if (validCode instanceof Error) {
notifyUpgradeFailed("upgrade-failed", validCode)
return validCode
}

await rewardFailedLoginAttemptPerIpLimits(ip)
await rewardFailedLoginAttemptPerLoginIdentifierLimits(phone)
Expand All @@ -286,20 +306,29 @@ export const loginDeviceUpgradeWithPhone = async ({

// check if account is upgradeable
const phoneMetadata = await isAllowedToOnboard({ ip, phone })
if (phoneMetadata instanceof Error) return phoneMetadata
if (phoneMetadata instanceof Error) {
notifyUpgradeFailed("upgrade-failed", phoneMetadata)
return phoneMetadata
}

const success = await AuthWithUsernamePasswordDeviceIdService().upgradeToPhoneSchema({
phone,
userId: account.kratosUserId,
})
if (success instanceof Error) return success
if (success instanceof Error) {
notifyUpgradeFailed("upgrade-failed", success)
return success
}

const res = await upgradeAccountFromDeviceToPhone({
userId: account.kratosUserId,
phone,
phoneMetadata,
})
if (res instanceof Error) return res
if (res instanceof Error) {
notifyUpgradeFailed("upgrade-failed", res)
return res
}
return { success }
}

Expand All @@ -323,15 +352,19 @@ export const loginDeviceUpgradeWithPhone = async ({
"login.upgrade.collisionRejected": true,
"login.upgrade.collisionHasDeviceBalance": true,
})
return new PhoneAccountAlreadyExistsNeedToSweepFundsError()
const sweepError = new PhoneAccountAlreadyExistsNeedToSweepFundsError()
notifyUpgradeFailed("upgrade-collision", sweepError, { deviceHasBalance: "true" })
return sweepError
}

addAttributesToCurrentSpan({
"login.upgrade.collisionRejected": true,
"login.upgrade.collisionHasDeviceBalance": false,
})

return new PhoneAccountAlreadyExistsCannotUpgradeError()
const collisionError = new PhoneAccountAlreadyExistsCannotUpgradeError()
notifyUpgradeFailed("upgrade-collision", collisionError, { deviceHasBalance: "false" })
return collisionError
}

export const loginWithDevice = async ({
Expand Down
32 changes: 31 additions & 1 deletion src/app/authentication/phone.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PhoneAlreadyExistsError } from "@domain/authentication/errors"

import { notifyOpsEvent } from "@services/alerts/ops-events"

import { isPhoneCodeValid } from "@services/twilio"

import { UsersRepository } from "@services/mongoose"
Expand All @@ -13,7 +15,35 @@ import {
rewardFailedLoginAttemptPerLoginIdentifierLimits,
} from "./ratelimits"

export const verifyPhone = async ({
export const verifyPhone = async (args: {
userId: UserId
phone: PhoneNumber
code: PhoneCode
ip: IpAddress
}): Promise<User | ApplicationError> => {
const result = await executeVerifyPhone(args)
notifyOpsEvent(
result instanceof Error
? {
flow: "verification",
phase: "otp-failed",
status: "failed",
userId: args.userId,
phone: args.phone,
error: result.constructor.name,
}
: {
flow: "verification",
phase: "otp-verified",
status: "success",
userId: args.userId,
phone: args.phone,
},
)
return result
}

const executeVerifyPhone = async ({
userId,
phone,
code,
Expand Down
13 changes: 12 additions & 1 deletion src/app/authentication/request-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PhoneAlreadyExistsError } from "@domain/authentication/errors"
import { NotImplementedError } from "@domain/errors"
import { RateLimitConfig } from "@domain/rate-limit"
import { RateLimiterExceededError } from "@domain/rate-limit/errors"
import { notifyOpsEvent } from "@services/alerts/ops-events"
import Geetest from "@services/geetest"
import { AuthWithEmailPasswordlessService } from "@services/kratos"
import { baseLogger } from "@services/logger"
Expand Down Expand Up @@ -104,7 +105,17 @@ export const requestPhoneCodeForAuthedUser = async ({
return true
}

return TwilioClient().initiateVerify({ to: phone, channel })
const verifyResp = await TwilioClient().initiateVerify({ to: phone, channel })
if (!(verifyResp instanceof Error)) {
notifyOpsEvent({
flow: "verification",
phase: "otp-sent",
status: "pending",
userId: user.id,
phone,
})
}
return verifyResp
}

export const requestEmailCode = async ({
Expand Down
38 changes: 37 additions & 1 deletion src/app/offers/CashoutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { UnexpectedIbexResponse } from "@services/ibex/errors"
import { getBankOwnerIbexAccount } from "@services/ledger/caching"

import { RepositoryError } from "@domain/errors"
import { notifyOpsEvent, toDisplayAmount } from "@services/alerts/ops-events"
import { EmailService } from "@services/email"
import ErpNext from "@services/frappe/ErpNext"
import { BankAccountQueryError, ExchangeRateQueryError } from "@services/frappe/errors"
Expand Down Expand Up @@ -130,13 +131,48 @@ const CashoutManager = {
if (providedWallet.accountId !== settlementWallet.accountId)
return new ValidationError("Offer is not good for provided wallet.")

const displayAmount = toDisplayAmount(offer.details.payment.amount)
notifyOpsEvent({
flow: "cashout",
phase: "initiated",
status: "pending",
accountId: providedWallet.accountId,
amount: displayAmount,
meta: { offerId: id },
})

const validOffer = await ValidOffer.from(offer.details)
if (validOffer instanceof Error) return validOffer
if (validOffer instanceof Error) {
notifyOpsEvent({
flow: "cashout",
phase: "failed",
status: "failed",
accountId: providedWallet.accountId,
amount: displayAmount,
step: "validation",
error: validOffer.constructor.name,
meta: { offerId: id },
})
return validOffer
}

const executedOffer = await validOffer.execute()
if (executedOffer instanceof Error) return executedOffer
else {
EmailService.sendCashoutInitiatedEmail(executedOffer)
// On a partial failure (payment made but ERPNext submit failed after
// retry) ValidOffer.execute already emitted the terminal failed event —
// exactly one truthful terminal event per cashout.
if (executedOffer.erpSubmitted) {
notifyOpsEvent({
flow: "cashout",
phase: "succeeded",
status: "success",
accountId: providedWallet.accountId,
amount: displayAmount,
meta: { offerId: id, cashoutId: executedOffer.cashoutId },
})
}
return executedOffer
}
},
Expand Down
Loading
Loading