From 89bc2f98f68977d981bb9e78f72060fc8adb720a Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Sat, 21 Mar 2026 00:19:43 +0100 Subject: [PATCH] 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 --- .env | 2 + .../admin/cashout-notification-send.bru | 37 ++++++ src/app/admin/index.ts | 1 + src/app/admin/send-cashout-notification.ts | 62 +++++++++++ src/graphql/admin/mutations.ts | 2 + .../mutation/cashout-notification-send.ts | 58 ++++++++++ src/graphql/admin/schema.graphql | 8 ++ .../notifications/push-notifications.ts | 6 + .../admin/send-cashout-notification.spec.ts | 105 ++++++++++++++++++ 9 files changed, 281 insertions(+) create mode 100644 dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru create mode 100644 src/app/admin/send-cashout-notification.ts create mode 100644 src/graphql/admin/root/mutation/cashout-notification-send.ts create mode 100644 test/flash/unit/app/admin/send-cashout-notification.spec.ts diff --git a/.env b/.env index cbce6645a..b899f396b 100644 --- a/.env +++ b/.env @@ -127,3 +127,5 @@ COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.local export IBEX_URL="https://api-sandbox.poweredbyibex.io" export IBEX_EMAIL="" export IBEX_PASSWORD="" + +export GOOGLE_APPLICATION_CREDENTIALS="./firebase-key.json" \ No newline at end of file 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..603e7e8cb --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru @@ -0,0 +1,37 @@ +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": "37590fbc-89b3-4218-abf6-5bded51b8fe7", + "amount": 100.0, + "currency": "JMD", + "notificationCategory": "Payments" + } + } +} diff --git a/src/app/admin/index.ts b/src/app/admin/index.ts index 95a73b1dd..9fd3d7c56 100644 --- a/src/app/admin/index.ts +++ b/src/app/admin/index.ts @@ -1,5 +1,6 @@ export * from "./update-user-phone" export * from "./send-admin-push-notification" +export * from "./send-cashout-notification" export * from "./send-broadcast-notification" import { checkedToAccountUuid, checkedToUsername } from "@domain/accounts" diff --git a/src/app/admin/send-cashout-notification.ts b/src/app/admin/send-cashout-notification.ts new file mode 100644 index 000000000..7ea39a43f --- /dev/null +++ b/src/app/admin/send-cashout-notification.ts @@ -0,0 +1,62 @@ +import { checkedToAccountUuid } from "@domain/accounts" +import { checkedToNotificationCategory, GaloyNotificationCategories } from "@domain/notifications" +import { checkedToDeviceToken } from "@domain/users" +import { AccountsRepository, UsersRepository } from "@services/mongoose" +import { NotificationsService } from "@services/notifications" + +export const sendCashoutNotification = async ( + { + accountId: accountIdRaw, + title, + body, + amount, + currency, + notificationCategory, + deviceTokens + }: { + accountId: string, + title: string, + body: string, + amount: number, + currency: string, + notificationCategory?: string, + deviceTokens?: string[] + }): Promise => { + + const checkedNotificationCategory = notificationCategory ? checkedToNotificationCategory(notificationCategory) : GaloyNotificationCategories.Payments + + if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory + + const accountId = checkedToAccountUuid(accountIdRaw) + if (accountId instanceof Error) return accountId + + const accountsRepo = AccountsRepository() + const account = await accountsRepo.findByUuid(accountId) + if (account instanceof Error) return account + const kratosUserId = account.kratosUserId + + let tokens: DeviceToken[] = [] + if (deviceTokens && deviceTokens.length > 0) { + for (const token of deviceTokens) { + const checkedToken = await checkedToDeviceToken(token) + if (checkedToken instanceof Error) return checkedToken + tokens.push(checkedToken) + } + } else { + const usersRepo = UsersRepository() + const user = await usersRepo.findById(kratosUserId) + if (user instanceof Error) return user + tokens = user.deviceTokens + } + + const success = await NotificationsService().adminPushNotificationFilteredSend({ + deviceTokens: tokens, + title, + body, + data: { amount: amount.toString(), currency }, + notificationCategory: checkedNotificationCategory, + notificationSettings: account.notificationSettings, + }) + + return success +} \ No newline at end of file diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 3d5d5547a..c6849d01c 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -8,6 +8,7 @@ import UserUpdatePhoneMutation from "./root/mutation/user-update-phone" import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map" import AdminPushNotificationSendMutation from "./root/mutation/admin-push-notification-send" import AdminBroadcastSendMutation from "./root/mutation/admin-broadcast-send" +import sendCashoutSettledNotification from "./root/mutation/cashout-notification-send" import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" @@ -24,6 +25,7 @@ export const mutationFields = { businessUpdateMapInfo: BusinessUpdateMapInfoMutation, businessDeleteMapInfo: BusinessDeleteMapInfoMutation, adminPushNotificationSend: AdminPushNotificationSendMutation, + cashoutNotificationSend: sendCashoutSettledNotification, adminBroadcastSend: AdminBroadcastSendMutation, }, } 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..66b2193cc --- /dev/null +++ b/src/graphql/admin/root/mutation/cashout-notification-send.ts @@ -0,0 +1,58 @@ +import { Admin } from "@app/index"; +import AdminPushNotificationSendPayload from "@graphql/admin/types/payload/admin-push-notification-send"; +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"; +import { GT } from "@graphql/index"; +import { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload"; +import NotificationCategory from "@graphql/shared/types/scalar/notification-category"; + +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) + }, + notificationCategory: { + type: NotificationCategory, + }, + }) +}) + +const sendCashoutSettledNotification = GT.Field({ + extensions: { + complexity: 1, + }, + type: GT.NonNull(AdminPushNotificationSendPayload), + args: { + input: { type: GT.NonNull(CashoutNotificationSendInput) } + }, + resolve: async (_, args) => { + + const { accountId, amount, currency, notificationCategory } = args.input; + + const title = "Cashout Successful" + const body = `Your cashout of $${amount.toFixed(2)} ${currency} has been processed.` + + const success = await Admin.sendAdminPushNotification({ + accountId, + title, + body, + data: { amount: String(amount), currency }, + notificationCategory + }) + + 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 85b9f3043..016132bbb 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -146,6 +146,13 @@ input BusinessUpdateMapInfoInput { username: Username! } +input CashoutNotificationSendInput { + accountId: String! + amount: Float! + currency: String! + notificationCategory: NotificationCategory +} + type Coordinates { latitude: Float! longitude: Float! @@ -284,6 +291,7 @@ type Mutation { adminPushNotificationSend(input: AdminPushNotificationSendInput!): AdminPushNotificationSendPayload! businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload! businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload! + cashoutNotificationSend(input: CashoutNotificationSendInput!): AdminPushNotificationSendPayload! merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload! diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 0835b3e1b..b91974c05 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -45,6 +45,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, @@ -55,6 +56,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..3e0dbf5aa --- /dev/null +++ b/test/flash/unit/app/admin/send-cashout-notification.spec.ts @@ -0,0 +1,105 @@ + +import { GaloyNotificationCategories } from "@domain/notifications" +import { sendCashoutNotification } from "@app/admin/send-cashout-notification" +import { NotificationsService } from "@services/notifications" +import { AccountsRepository, UsersRepository } from "@services/mongoose" +import { checkedToDeviceToken } from "@domain/users" + +jest.mock("@services/notifications", () => ({ + NotificationsService: jest.fn(), +})) + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + UsersRepository: jest.fn(), +})) + +jest.mock("@domain/accounts", () => ({ + checkedToAccountUuid: (id: string) => id +})) + +jest.mock("@domain/users", () => ({ + checkedToDeviceToken: (token: string) => token as DeviceToken +})) + +describe("sendCashoutNotification", () => { + const accountId = "account-id" as AccountUuid + const title = "Test Title" + const body = "Test Body" + const amount = 100 + const currency = "USD" + + const mockAccount = { + uuid: accountId, + kratosUserId: "user-id", + notificationSettings: { + push: { + enabled: true, + disabledCategories: [], + } + } + } + + const mockUser = { + deviceTokens: ["override-token"] as DeviceToken[], + } + + const adminPushNotificationFilteredSend = jest.fn().mockResolvedValue(true) + + beforeEach(() => { + jest.clearAllMocks() + ; (NotificationsService as jest.Mock).mockReturnValue({ + adminPushNotificationFilteredSend, + }) + ; (AccountsRepository as jest.Mock).mockReturnValue({ + findByUuid: jest.fn().mockResolvedValue(mockAccount), + }) + ; (UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + }) + + it("sends notification to user device tokens", async () => { + const result = await sendCashoutNotification({ + accountId, + title, + body, + amount, + currency, + }) + + expect(result).toBe(true) + expect(adminPushNotificationFilteredSend).toHaveBeenCalledWith({ + deviceTokens: mockUser.deviceTokens, + title, + body, + data: { amount: "100", currency }, + notificationCategory: GaloyNotificationCategories.Payments, + notificationSettings: mockAccount.notificationSettings, + }) + }) + + it("sends notification to provided device tokens override", async () => { + const overrideTokens = ["override-token"] // paste given device token here + const result = await sendCashoutNotification({ + accountId, + title, + body, + amount, + currency, + deviceTokens: overrideTokens + }) + + expect(result).toBe(true) + expect(adminPushNotificationFilteredSend).toHaveBeenCalledWith({ + deviceTokens: [checkedToDeviceToken(overrideTokens[0])], + title, + body, + data: { amount: "100", currency }, + notificationCategory: GaloyNotificationCategories.Payments, + notificationSettings: mockAccount.notificationSettings, + }) + // Should NOT call UsersRepository if tokens are provided + expect(UsersRepository).not.toHaveBeenCalled() + }) +})