From 7dde1c1e9c6020826eeb05071f0c1f6eae190fbc Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 25 Mar 2026 22:09:20 +0100 Subject: [PATCH] Cashout Notification feat: Cashout Settled Notification In this commit i've added : - Graph QL mutation that resolves the cashout (payment entry) notification from Admin action - At App Layer, this commit added the send cashout notification for cashout settled on Galoy Payments notification type and delegate the notification action to admin push notification filtered send from the notification service - Unit test for cashout notification in test flash unit directory. all unit tests passed End to End test notification push made and passed - GraphQL schema updated - Custom Flash Notification Category Flash Notification Categories - Notification title in en-local notification format fix: cashout notification body format fix: remove the firebase key path fix: remove notificationCategory from the Cashout resolver fix: rebuild graphql schema and update bruno cashout endpoint fix: notification title fix: rm duplicated .env.local from gitignore rebuild the graphql schema --- .../admin/cashout-notification-send.bru | 36 ++++ src/app/admin/index.ts | 1 + src/app/admin/send-cashout-notification.ts | 36 ++++ src/app/offers/storage/Redis.ts | 4 +- src/config/locales/en.json | 6 +- src/domain/notifications/index.ts | 8 +- src/domain/notifications/index.types.d.ts | 3 - src/domain/shared/MoneyAmount.ts | 177 ------------------ src/domain/shared/index.ts | 2 +- src/domain/shared/money/BTCAmount.ts | 35 ++++ src/domain/shared/money/JMDAmount.ts | 54 ++++++ src/domain/shared/money/MoneyAmount.ts | 50 +++++ src/domain/shared/money/USDAmount.ts | 74 ++++++++ src/domain/shared/money/index.ts | 5 + src/domain/shared/money/toMoneyAmount.ts | 18 ++ src/graphql/admin/mutations.ts | 2 + .../mutation/cashout-notification-send.ts | 51 +++++ src/graphql/admin/schema.graphql | 12 ++ .../payload/admin-push-notification-send.ts | 16 -- src/services/ibex/types.ts | 2 +- src/services/notifications/index.ts | 42 +---- .../notifications/push-notifications.ts | 6 + .../admin/send-cashout-notification.spec.ts | 97 ++++++++++ test/flash/unit/domain/shared/Money.spec.ts | 2 +- 24 files changed, 497 insertions(+), 242 deletions(-) create mode 100644 dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru create mode 100644 src/app/admin/send-cashout-notification.ts delete mode 100644 src/domain/shared/MoneyAmount.ts create mode 100644 src/domain/shared/money/BTCAmount.ts create mode 100644 src/domain/shared/money/JMDAmount.ts create mode 100644 src/domain/shared/money/MoneyAmount.ts create mode 100644 src/domain/shared/money/USDAmount.ts create mode 100644 src/domain/shared/money/index.ts create mode 100644 src/domain/shared/money/toMoneyAmount.ts create mode 100644 src/graphql/admin/root/mutation/cashout-notification-send.ts delete mode 100644 src/graphql/admin/types/payload/admin-push-notification-send.ts create mode 100644 test/flash/unit/app/admin/send-cashout-notification.spec.ts diff --git a/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru b/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru new file mode 100644 index 000000000..7b8db9795 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru @@ -0,0 +1,36 @@ +meta { + name: cashout-notification-send + type: graphql + seq: 2 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashoutNotificationSend($input: CashoutNotificationSendInput!) { + cashoutNotificationSend(input: $input) { + errors { + message + } + success + } + } +} + +body:graphql:vars { + { + "input": { + "accountId": "165d0bea-ea3f-4f22-9b95-ebc09a75c133", + "amount": 100.0, + "currency": "JMD" + } + } +} diff --git a/src/app/admin/index.ts b/src/app/admin/index.ts index 7e086d533..7ef6f70e6 100644 --- a/src/app/admin/index.ts +++ b/src/app/admin/index.ts @@ -1,6 +1,7 @@ export * from "./update-user-phone" // export * from "./send-admin-push-notification" // export * from "./send-broadcast-notification" +export * from "./send-cashout-notification" import { checkedToAccountUuid, checkedToUsername } from "@domain/accounts" import { IdentityRepository } from "@services/kratos" diff --git a/src/app/admin/send-cashout-notification.ts b/src/app/admin/send-cashout-notification.ts new file mode 100644 index 000000000..3a4eac74f --- /dev/null +++ b/src/app/admin/send-cashout-notification.ts @@ -0,0 +1,36 @@ +import { getI18nInstance } from "@config" +import { + NotificationsServiceError, +} from "@domain/notifications" +import { MoneyAmount } from "@domain/shared" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { PushNotificationsService } from "@services/notifications/push-notifications" + +const i18n = getI18nInstance(); + +export const sendCashoutNotification = async ( + accountId: AccountUuid, + amount: MoneyAmount, +): Promise => { + const accountsRepo = AccountsRepository() + const account = await accountsRepo.findByUuid(accountId) + if (account instanceof Error) return account + const kratosUserId = account.kratosUserId + + const usersRepo = UsersRepository() + const user = await usersRepo.findById(kratosUserId) + if (user instanceof Error) return user + + const currency = amount.currencyCode + + const result = PushNotificationsService().sendNotification({ + deviceTokens: user.deviceTokens, + title: i18n.__({ phrase: "notification.cashout.title", locale: "en" }, { currency }), + body: i18n.__({ phrase: "notification.cashout.body", locale: "en" }, { amount: amount.i18n() }), + data: { amount: String(amount), currency }, + }) + if (result instanceof NotificationsServiceError) return result + + return result +} diff --git a/src/app/offers/storage/Redis.ts b/src/app/offers/storage/Redis.ts index 9d8b0cc63..169e5503e 100644 --- a/src/app/offers/storage/Redis.ts +++ b/src/app/offers/storage/Redis.ts @@ -5,7 +5,7 @@ import { RedisCacheService } from "@services/cache" import { CacheServiceError, CacheUndefinedError, OfferNotFound } from "@domain/cache" import { baseLogger } from "@services/logger" import { randomUUID } from "crypto" -import { JMDAmount, MoneyAmount, USDAmount } from "@domain/shared" +import { JMDAmount, MoneyAmount, USDAmount, toMoneyAmountFromJSON } from "@domain/shared" import { CashoutDetails } from "../types" /** @@ -26,7 +26,7 @@ const OffersSerde = { json, (key: string, value: any) => { if (['usd', 'jmd', 'fee'].includes(key.toLowerCase()) && Array.isArray(value)) { - return MoneyAmount.fromJSON(value as [string, string]) + return toMoneyAmountFromJSON(value as [string, string]) } if (key.toLowerCase() === 'amount' && typeof value === 'string') { return BigInt(value); diff --git a/src/config/locales/en.json b/src/config/locales/en.json index cc9b2ce2c..c9bd32b95 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -36,6 +36,10 @@ "bodyDisplayCurrency": "+{{baseCurrencyAmount}}{{baseCurrencyName}} ({{displayCurrencyAmount}})", "title": "{{walletCurrency}} Transaction" } + }, + "cashout": { + "body": "Your cashout of {{amount}} has been deposited to your bank account.", + "title": "Cashout" } } -} +} \ No newline at end of file diff --git a/src/domain/notifications/index.ts b/src/domain/notifications/index.ts index 8ad2df69b..b618ae47c 100644 --- a/src/domain/notifications/index.ts +++ b/src/domain/notifications/index.ts @@ -16,17 +16,19 @@ export const NotificationChannel = { Push: "push", } as const -export const GaloyNotificationCategories = { +export const FlashNotificationCategories = { Payments: "Payments" as NotificationCategory, Balance: "Balance" as NotificationCategory, AdminPushNotification: "AdminPushNotification" as NotificationCategory, + Cashout: "Cashout" as NotificationCategory } as const export const checkedToNotificationCategory = ( notificationCategory: string, ): NotificationCategory | ValidationError => { - // TODO: add validation - if (!notificationCategory) { + + const validNotificationCategories = Object.values(FlashNotificationCategories) + if (!validNotificationCategories.includes(notificationCategory as NotificationCategory)) { return new InvalidNotificationSettingsError("Invalid notification category") } diff --git a/src/domain/notifications/index.types.d.ts b/src/domain/notifications/index.types.d.ts index 9439f7df3..32aec6cae 100644 --- a/src/domain/notifications/index.types.d.ts +++ b/src/domain/notifications/index.types.d.ts @@ -84,9 +84,6 @@ interface INotificationsService { adminPushNotificationSend( args: SendPushNotificationArgs, ): Promise - adminPushNotificationFilteredSend( - args: SendFilteredPushNotificationArgs, - ): Promise } type NotificationChannel = diff --git a/src/domain/shared/MoneyAmount.ts b/src/domain/shared/MoneyAmount.ts deleted file mode 100644 index f25a1dda2..000000000 --- a/src/domain/shared/MoneyAmount.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Money, Round } from "./bigint-money" -import { BigIntConversionError, UnsupportedCurrencyError } from "./errors" -import { ExchangeCurrencyUnit, WalletCurrency } from "./primitives" - -export abstract class MoneyAmount { - readonly money: Money - readonly currencyCode: WalletCurrency - - constructor(amount: Money | bigint | string | number, currencyCode: WalletCurrency) { - this.currencyCode = currencyCode - if (amount instanceof Money) { - this.money = amount - return - } - this.money = new Money(amount, currencyCode, Round.HALF_TO_EVEN) - } - - abstract getInstance(amount: Money): this - - multiplyBips(bips: BasisPoints): this { - return this.getInstance(this.money.multiply(bips.toString()).divide(10000)) - } - - subtract(b: this): this { - return this.getInstance(this.money.subtract(b.money)) - } - - isLesserThan(b: this): boolean { - return this.money.isLesserThan(b.money) - } - - isGreaterThan(b: this): boolean { - return this.money.isGreaterThan(b.money) - } - - isZero(): boolean { - return this.money.isEqual(0) - } - - toJson(): [string, string] { - return this.money.toJSON() - } - - fromSource(val: string, currency: WalletCurrency): this { - return this.getInstance(new Money(val, currency, Round.HALF_TO_EVEN)) - } - - static fromJSON(val: [string, string]): MoneyAmount | Error { - const [amt, currency] = val - return this.from(amt, currency as WalletCurrency) - } - - static from(amount: number | string, currency: WalletCurrency): MoneyAmount | Error { - if (currency === WalletCurrency.Usd) return USDAmount.cents(amount.toString()) - else if (currency === WalletCurrency.Jmd) return JMDAmount.cents(amount.toString()) - else return new UnsupportedCurrencyError(`Could not read currency: ${currency}`) - } -} - -export class USDAmount extends MoneyAmount { - static currencyId: IbexCurrencyId = 3 as IbexCurrencyId - - private constructor(amount: Money | bigint | string | number) { - super(amount, WalletCurrency.Usd) - } - - static cents(cents: string | bigint): USDAmount | BigIntConversionError { - try { - return new USDAmount(cents) - } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) - } - } - - // convert dollars to cents - static dollars(d: number | string): USDAmount | BigIntConversionError { - try { - const dollarAmt = new Money(d.toString(), "USDollars", Round.HALF_TO_EVEN) - const cents = USDAmount.cents(100n) - if (cents instanceof BigIntConversionError) return cents // should never happen - return new USDAmount(cents.money.multiply(dollarAmt).toFixed(2)) - } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) - } - - } - - static ZERO = new USDAmount(0) - - asCents(precision: number = 0): string { - return this.money.toFixed(precision) - } - - asDollars(precision: number = 2): string { - return this.money.divide(100).toFixed(precision) - } - - // const jmdLiability = { - // amount: BigInt(usdLiability.asCents()) * exchangeRate / 100n, - // currency: "JMD", - // } - // Rate is the ratio at which one currency can be exchanged for another. - // T:USD - convertAtRate(rate: T): T { - const converted = rate.money.multiply(this.money).divide(100) - return rate.getInstance(converted) - } - - toIbex(): number { - return Number(this.asDollars(8)) - } - - getInstance(amount: Money): this { - return new USDAmount(amount) as this - } -} - -export class JMDAmount extends MoneyAmount { - currencyCode = WalletCurrency.Jmd as WalletCurrency - - private constructor(amount: Money | bigint | string | number) { - super(amount, WalletCurrency.Jmd) - } - - static cents(c: string): JMDAmount | BigIntConversionError { - try { - return new JMDAmount(c) - } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) - } - } - - static dollars(d: number): JMDAmount | BigIntConversionError { - try { - return new JMDAmount(BigInt(d) * 100n) - } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) - } - - } - - asCents(precision: number = 0): string { - return this.money.toFixed(precision) - } - - asDollars(precision: number = 2): string { - return this.money.divide(100).toFixed(precision) - } - - getInstance(amount: Money): this { - return new JMDAmount(amount) as this - } -} - -export class BtcAmount extends MoneyAmount { - currencyCode = WalletCurrency.Btc as WalletCurrency - - private constructor(amount: Money | bigint | string | number) { - super(amount, WalletCurrency.Btc) - } - - static sats(c: string): BtcAmount | BigIntConversionError { - try { - return new BtcAmount(c) - } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) - } - } - - asSats(precision: number = 0): string { - return this.money.toFixed(precision) - } - - getInstance(amount: Money): this { - return new BtcAmount(amount) as this - } -} diff --git a/src/domain/shared/index.ts b/src/domain/shared/index.ts index 4736c24b6..d7b3f887c 100644 --- a/src/domain/shared/index.ts +++ b/src/domain/shared/index.ts @@ -3,7 +3,7 @@ import { ErrorLevel } from "./errors" export * from "./primitives" export * from "./calculator" export * from "./amount" -export * from "./MoneyAmount" +export * from "./money" export * from "./safe" export * from "./errors" export * from "./error-parsers" diff --git a/src/domain/shared/money/BTCAmount.ts b/src/domain/shared/money/BTCAmount.ts new file mode 100644 index 000000000..0bd8e5221 --- /dev/null +++ b/src/domain/shared/money/BTCAmount.ts @@ -0,0 +1,35 @@ +import Money, { Round } from "../bigint-money" +import { MoneyAmount } from "./MoneyAmount" +import { WalletCurrency } from "../primitives" +import { BigIntConversionError } from "../errors" + +export class BtcAmount extends MoneyAmount { + currencyCode = WalletCurrency.Btc as WalletCurrency + + private constructor(amount: Money | bigint | string | number) { + super(amount, WalletCurrency.Btc) + } + + static sats(c: string): BtcAmount | BigIntConversionError { + try { + return new BtcAmount(c) + } catch (error) { + return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + } + } + + asSats(precision: number = 0): string { + return this.money.toFixed(precision) + } + + getInstance(amount: Money): this { + return new BtcAmount(amount) as this + } + + i18n(): string { + return new Intl.NumberFormat("en", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(Number(this.asSats())) + " sats"; + } +} \ No newline at end of file diff --git a/src/domain/shared/money/JMDAmount.ts b/src/domain/shared/money/JMDAmount.ts new file mode 100644 index 000000000..ff2ba0c4b --- /dev/null +++ b/src/domain/shared/money/JMDAmount.ts @@ -0,0 +1,54 @@ +import Money, { Round } from "../bigint-money" +import { MoneyAmount } from "./MoneyAmount" +import { WalletCurrency } from "../primitives" +import { BigIntConversionError } from "../errors" +import { getCurrencyMajorExponent } from "@domain/fiat/display-currency" + + +export class JMDAmount extends MoneyAmount { + currencyCode = WalletCurrency.Jmd as WalletCurrency + + private constructor(amount: Money | bigint | string | number) { + super(amount, WalletCurrency.Jmd) + } + + static cents(c: string): JMDAmount | BigIntConversionError { + try { + return new JMDAmount(c) + } catch (error) { + return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + } + } + + static dollars(d: number): JMDAmount | BigIntConversionError { + try { + return new JMDAmount(BigInt(d) * 100n) + } catch (error) { + return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + } + + } + + asCents(precision: number = 0): string { + return this.money.toFixed(precision) + } + + asDollars(precision: number = 2): string { + return this.money.divide(100).toFixed(precision) + } + + getInstance(amount: Money): this { + return new JMDAmount(amount) as this + } + + i18n(): string { + const exponent = getCurrencyMajorExponent(this.currencyCode as DisplayCurrency); + return new Intl.NumberFormat("en", { + style: "currency", + currency: this.currencyCode, + currencyDisplay: "narrowSymbol", + minimumFractionDigits: exponent, + maximumFractionDigits: exponent, + }).format(Number(this.asDollars())); + } +} \ No newline at end of file diff --git a/src/domain/shared/money/MoneyAmount.ts b/src/domain/shared/money/MoneyAmount.ts new file mode 100644 index 000000000..a5b6669bc --- /dev/null +++ b/src/domain/shared/money/MoneyAmount.ts @@ -0,0 +1,50 @@ +import { Money, Round } from "../bigint-money" +import { WalletCurrency } from "../primitives" + +export abstract class MoneyAmount { + readonly money: Money + readonly currencyCode: WalletCurrency + + constructor(amount: Money | bigint | string | number, currencyCode: WalletCurrency) { + this.currencyCode = currencyCode + if (amount instanceof Money) { + this.money = amount + return + } + this.money = new Money(amount, currencyCode, Round.HALF_TO_EVEN) + } + + abstract getInstance(amount: Money): this + abstract i18n(): string + + multiplyBips(bips: BasisPoints): this { + return this.getInstance(this.money.multiply(bips.toString()).divide(10000)) + } + + subtract(b: this): this { + return this.getInstance(this.money.subtract(b.money)) + } + + isLesserThan(b: this): boolean { + return this.money.isLesserThan(b.money) + } + + isGreaterThan(b: this): boolean { + return this.money.isGreaterThan(b.money) + } + + isZero(): boolean { + return this.money.isEqual(0) + } + + toJson(): [string, string] { + return this.money.toJSON() + } + + fromSource(val: string, currency: WalletCurrency): this { + return this.getInstance(new Money(val, currency, Round.HALF_TO_EVEN)) + } +} + + + diff --git a/src/domain/shared/money/USDAmount.ts b/src/domain/shared/money/USDAmount.ts new file mode 100644 index 000000000..3c6344156 --- /dev/null +++ b/src/domain/shared/money/USDAmount.ts @@ -0,0 +1,74 @@ +import Money, { Round } from "../bigint-money" +import { MoneyAmount } from "./MoneyAmount" +import { WalletCurrency } from "../primitives" +import { BigIntConversionError } from "../errors" +import { getCurrencyMajorExponent } from "@domain/fiat/display-currency" + +export class USDAmount extends MoneyAmount { + static currencyId: IbexCurrencyId = 3 as IbexCurrencyId + + private constructor(amount: Money | bigint | string | number) { + super(amount, WalletCurrency.Usd) + } + + static cents(cents: string | bigint): USDAmount | BigIntConversionError { + try { + return new USDAmount(cents) + } catch (error) { + return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + } + } + + // convert dollars to cents + static dollars(d: number | string): USDAmount | BigIntConversionError { + try { + const dollarAmt = new Money(d.toString(), "USDollars", Round.HALF_TO_EVEN) + const cents = USDAmount.cents(100n) + if (cents instanceof BigIntConversionError) return cents // should never happen + return new USDAmount(cents.money.multiply(dollarAmt).toFixed(2)) + } catch (error) { + return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + } + + } + + static ZERO = new USDAmount(0) + + asCents(precision: number = 0): string { + return this.money.toFixed(precision) + } + + asDollars(precision: number = 2): string { + return this.money.divide(100).toFixed(precision) + } + + // const jmdLiability = { + // amount: BigInt(usdLiability.asCents()) * exchangeRate / 100n, + // currency: "JMD", + // } + // Rate is the ratio at which one currency can be exchanged for another. + // T:USD + convertAtRate(rate: T): T { + const converted = rate.money.multiply(this.money).divide(100) + return rate.getInstance(converted) + } + + toIbex(): number { + return Number(this.asDollars(8)) + } + + getInstance(amount: Money): this { + return new USDAmount(amount) as this + } + + i18n(): string { + const exponent = getCurrencyMajorExponent(this.currencyCode as DisplayCurrency); + return new Intl.NumberFormat("en", { + style: "currency", + currency: this.currencyCode, + currencyDisplay: "narrowSymbol", + minimumFractionDigits: exponent, + maximumFractionDigits: exponent, + }).format(Number(this.asDollars())); + } +} \ No newline at end of file diff --git a/src/domain/shared/money/index.ts b/src/domain/shared/money/index.ts new file mode 100644 index 000000000..ecfe3c39a --- /dev/null +++ b/src/domain/shared/money/index.ts @@ -0,0 +1,5 @@ +export * from "./MoneyAmount" +export * from "./USDAmount" +export * from "./BTCAmount" +export * from "./JMDAmount" +export * from "./toMoneyAmount" \ No newline at end of file diff --git a/src/domain/shared/money/toMoneyAmount.ts b/src/domain/shared/money/toMoneyAmount.ts new file mode 100644 index 000000000..db176f270 --- /dev/null +++ b/src/domain/shared/money/toMoneyAmount.ts @@ -0,0 +1,18 @@ +import { WalletCurrency } from "../primitives" +import { UnsupportedCurrencyError } from "../errors" +import { MoneyAmount } from "./MoneyAmount" +import { USDAmount } from "./USDAmount" +import { JMDAmount } from "./JMDAmount" + +export function toMoneyAmount( + amount: number | string, + currency: WalletCurrency, +): MoneyAmount | Error { + if (currency === WalletCurrency.Usd) return USDAmount.cents(amount.toString()) + if (currency === WalletCurrency.Jmd) return JMDAmount.cents(amount.toString()) + return new UnsupportedCurrencyError(`Could not read currency: ${currency}`) +} + +export function toMoneyAmountFromJSON(val: [string, string]): MoneyAmount | Error { + return toMoneyAmount(val[0], val[1] as WalletCurrency) +} diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 6df90eb35..070a0d7c7 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -7,6 +7,7 @@ import BusinessUpdateMapInfoMutation from "@graphql/admin/root/mutation/business import UserUpdatePhoneMutation from "./root/mutation/user-update-phone" import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map" import SendNotificationMutation from "./root/mutation/send-notification" +import sendCashoutSettledNotification from "./root/mutation/cashout-notification-send" import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" @@ -23,6 +24,7 @@ export const mutationFields = { businessUpdateMapInfo: BusinessUpdateMapInfoMutation, businessDeleteMapInfo: BusinessDeleteMapInfoMutation, sendNotification: SendNotificationMutation, + cashoutNotificationSend: sendCashoutSettledNotification, }, } diff --git a/src/graphql/admin/root/mutation/cashout-notification-send.ts b/src/graphql/admin/root/mutation/cashout-notification-send.ts new file mode 100644 index 000000000..9d39f63cf --- /dev/null +++ b/src/graphql/admin/root/mutation/cashout-notification-send.ts @@ -0,0 +1,51 @@ +import { Admin } from "@app/index"; +import { apolloErrorResponse, mapAndParseErrorForGqlResponse } from "@graphql/error-map"; +import { GT } from "@graphql/index"; +import SuccessPayload, { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload"; +import { checkedToAccountUuid } from "@domain/accounts"; +import { toMoneyAmount } from "@domain/shared"; +import { InputValidationError } from "@graphql/error"; + + +const CashoutNotificationSendInput = GT.Input({ + name: "CashoutNotificationSendInput", + fields: () => ({ + accountId: { + type: GT.NonNull(GT.String), + }, + amount: { + type: GT.NonNull(GT.Float), + }, + currency: { + type: GT.NonNull(GT.String) + } + }) +}) + +const sendCashoutSettledNotification = GT.Field({ + extensions: { + complexity: 1, + }, + type: GT.NonNull(SuccessPayload), + args: { + input: { type: GT.NonNull(CashoutNotificationSendInput) } + }, + resolve: async (_, args) => { + const { accountId, amount, currency } = args.input; + + const checkedAccountId = checkedToAccountUuid(accountId) + if (checkedAccountId instanceof Error) return apolloErrorResponse(new InputValidationError({ message: "Invalid accountId" })) + + const checkedAmount = toMoneyAmount(amount, currency) + if (checkedAmount instanceof Error) return apolloErrorResponse(new InputValidationError({ message: "Invalid amount" })) + + const success = await Admin.sendCashoutNotification(checkedAccountId, checkedAmount) + if (success instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(success)] } + } + + return SUCCESS_RESPONSE + } +}) + +export default sendCashoutSettledNotification \ No newline at end of file diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 03e986fa2..a61e39d45 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -120,6 +120,12 @@ input BusinessUpdateMapInfoInput { username: Username! } +input CashoutNotificationSendInput { + accountId: String! + amount: Float! + currency: String! +} + type Coordinates { latitude: Float! longitude: Float! @@ -256,6 +262,7 @@ type Mutation { accountUpdateStatus(input: AccountUpdateStatusInput!): AccountDetailPayload! businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload! businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload! + cashoutNotificationSend(input: CashoutNotificationSendInput!): SuccessPayload! merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! sendNotification(input: SendNotificationInput!): SendNotificationPayload! @@ -373,6 +380,11 @@ 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) """ diff --git a/src/graphql/admin/types/payload/admin-push-notification-send.ts b/src/graphql/admin/types/payload/admin-push-notification-send.ts deleted file mode 100644 index 553ef5975..000000000 --- a/src/graphql/admin/types/payload/admin-push-notification-send.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { GT } from "@graphql/index" -import IError from "@graphql/shared/types/abstract/error" - -const AdminPushNotificationSendPayload = GT.Object({ - name: "AdminPushNotificationSendPayload", - fields: () => ({ - errors: { - type: GT.NonNullList(IError), - }, - success: { - type: GT.Boolean, - }, - }), -}) - -export default AdminPushNotificationSendPayload diff --git a/src/services/ibex/types.ts b/src/services/ibex/types.ts index 085cbc149..2b52a7f8e 100644 --- a/src/services/ibex/types.ts +++ b/src/services/ibex/types.ts @@ -1,4 +1,4 @@ -import { USDAmount } from "@domain/shared/MoneyAmount" +import { USDAmount } from "@domain/shared" export type PayInvoiceArgs = { accountId: IbexAccountId, diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index e187c662a..1f0583776 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -3,7 +3,7 @@ import { WalletCurrency } from "@domain/shared" import { toCents, UsdDisplayCurrency } from "@domain/fiat" import { customPubSubTrigger, PubSubDefaultTriggers, PubSubServiceError } from "@domain/pubsub" import { - GaloyNotificationCategories, + FlashNotificationCategories, NotificationsServiceError, NotificationType, } from "@domain/notifications" @@ -59,7 +59,7 @@ export const NotificationsService = (): INotificationsService => { }) if (recipientDeviceTokens && recipientDeviceTokens.length > 0) { - const notificationCategory = GaloyNotificationCategories.Payments + const notificationCategory = FlashNotificationCategories.Payments const { title, body } = createPushNotificationContent({ type: NotificationType.LnInvoicePaid, @@ -129,7 +129,7 @@ export const NotificationsService = (): INotificationsService => { }) if (recipientDeviceTokens && recipientDeviceTokens.length > 0) { - const notificationCategory = GaloyNotificationCategories.Payments + const notificationCategory = FlashNotificationCategories.Payments const { title, body } = createPushNotificationContent({ type: NotificationType.IntraLedgerReceipt, @@ -207,7 +207,7 @@ export const NotificationsService = (): INotificationsService => { }) if (deviceTokens.length > 0) { - const notificationCategory = GaloyNotificationCategories.Payments + const notificationCategory = FlashNotificationCategories.Payments const { title, body } = createPushNotificationContent({ type, @@ -346,7 +346,7 @@ export const NotificationsService = (): INotificationsService => { if (!hasDeviceTokens) return true try { - const notificationCategory = GaloyNotificationCategories.Payments + const notificationCategory = FlashNotificationCategories.Payments const { title, body } = createPushNotificationContent({ type: "balance", @@ -394,37 +394,6 @@ export const NotificationsService = (): INotificationsService => { } } - const adminPushNotificationFilteredSend = async ({ - title, - body, - data, - deviceTokens, - notificationSettings, - notificationCategory, - }: SendFilteredPushNotificationArgs): Promise => { - const hasDeviceTokens = deviceTokens && deviceTokens.length > 0 - if (!hasDeviceTokens) return true - - try { - const result = await pushNotification.sendFilteredNotification({ - deviceTokens, - title, - body, - data, - notificationSettings, - notificationCategory, - }) - - if (result instanceof NotificationsServiceError) { - return result - } - - return true - } catch (err) { - return handleCommonNotificationErrors(err) - } - } - // trace everything except price update because it runs every 30 seconds return { priceUpdate, @@ -438,7 +407,6 @@ export const NotificationsService = (): INotificationsService => { onChainTxSent, sendBalance, adminPushNotificationSend, - adminPushNotificationFilteredSend, }, }), } diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 059934e46..714828879 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -46,6 +46,7 @@ const sendToDevice = async ( batchResp.responses .forEach((r, idx) => { if (!r.success) { + logger.warn({ error: r.error, token: tokens[idx] }, "Error sending notification to device") recordExceptionInCurrentSpan({ error: new FirebaseMessageError(r.error as unknown as FirebaseError, tokens[idx]), level: ErrorLevel.Warn, @@ -56,6 +57,11 @@ const sendToDevice = async ( } }) + logger.info( + { successCount: batchResp.successCount, failureCount: batchResp.failureCount }, + "Notification batch response", + ) + // addAttributesToCurrentSpan({ // failureCount: response.failureCount, // successCount: response.successCount, diff --git a/test/flash/unit/app/admin/send-cashout-notification.spec.ts b/test/flash/unit/app/admin/send-cashout-notification.spec.ts new file mode 100644 index 000000000..c0d1e0cbc --- /dev/null +++ b/test/flash/unit/app/admin/send-cashout-notification.spec.ts @@ -0,0 +1,97 @@ +import { sendCashoutNotification } from "@app/admin/send-cashout-notification" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { PushNotificationsService } from "@services/notifications/push-notifications" +import { getI18nInstance } from "@config" + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/users", () => ({ + UsersRepository: jest.fn(), +})) + +jest.mock("@services/notifications/push-notifications", () => ({ + PushNotificationsService: jest.fn(), +})) + +jest.mock("@config", () => { + const mockI18n = { + __: jest.fn().mockImplementation(({ phrase }, options) => `${phrase} ${JSON.stringify(options)}`), + } + return { + getI18nInstance: jest.fn(() => mockI18n), + } +}) + +describe("sendCashoutNotification", () => { + const accountId = "account-id" as AccountUuid + const amount = { + currencyCode: "USD", + i18n: () => "1.00 USD", + toString: () => "100" + } as any // MoneyAmount + + const mockAccount = { + uuid: accountId, + kratosUserId: "user-id", + } + + const mockUser = { + deviceTokens: ["token-1", "token-2"], + } + + const sendNotification = jest.fn().mockReturnValue(true) + const mockI18n = getI18nInstance() + + beforeEach(() => { + jest.clearAllMocks() + ; (AccountsRepository as jest.Mock).mockReturnValue({ + findByUuid: jest.fn().mockResolvedValue(mockAccount), + }) + ; (UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + ; (PushNotificationsService as jest.Mock).mockReturnValue({ + sendNotification, + }) + ; (getI18nInstance as jest.Mock).mockReturnValue(mockI18n) + }) + + it("sends notification to user device tokens", async () => { + const result = await sendCashoutNotification(accountId, amount) + + expect(result).toBe(true) + expect(sendNotification).toHaveBeenCalledWith({ + deviceTokens: mockUser.deviceTokens, + title: mockI18n.__({ phrase: "notification.cashout.title", locale: "en" }, { currency: amount.currencyCode }), + body: mockI18n.__({ phrase: "notification.cashout.body", locale: "en" }, { amount: amount.i18n() }), + data: { amount: String(amount), currency: amount.currencyCode }, + }) + }) + + it("returns error if account is not found", async () => { + const error = new Error("Account not found") + ; (AccountsRepository as jest.Mock).mockReturnValue({ + findByUuid: jest.fn().mockResolvedValue(error), + }) + + const result = await sendCashoutNotification(accountId, amount) + + expect(result).toBe(error) + expect(sendNotification).not.toHaveBeenCalled() + }) + + it("returns error if user is not found", async () => { + const error = new Error("User not found") + ; (UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(error), + }) + + const result = await sendCashoutNotification(accountId, amount) + + expect(result).toBe(error) + expect(sendNotification).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/domain/shared/Money.spec.ts b/test/flash/unit/domain/shared/Money.spec.ts index 90e171948..d50670547 100644 --- a/test/flash/unit/domain/shared/Money.spec.ts +++ b/test/flash/unit/domain/shared/Money.spec.ts @@ -1,4 +1,4 @@ -import { JMDAmount, USDAmount } from "@domain/shared/MoneyAmount" +import { JMDAmount, USDAmount } from "@domain/shared" import JmdAmount from "@graphql/shared/types/scalar/jmd-amount" describe("Money Amount", () => {