Skip to content

Commit 89bc2f9

Browse files
committed
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
1 parent ee1f9dd commit 89bc2f9

9 files changed

Lines changed: 281 additions & 0 deletions

File tree

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,5 @@ COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.local
127127
export IBEX_URL="https://api-sandbox.poweredbyibex.io"
128128
export IBEX_EMAIL=""
129129
export IBEX_PASSWORD=""
130+
131+
export GOOGLE_APPLICATION_CREDENTIALS="./firebase-key.json"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
meta {
2+
name: cashout-notification-send
3+
type: graphql
4+
seq: 2
5+
}
6+
7+
post {
8+
url: {{admin_url}}
9+
body: graphql
10+
auth: bearer
11+
}
12+
13+
auth:bearer {
14+
token: {{admin_token}}
15+
}
16+
17+
body:graphql {
18+
mutation CashoutNotificationSend($input: CashoutNotificationSendInput!) {
19+
cashoutNotificationSend(input: $input) {
20+
errors {
21+
message
22+
}
23+
success
24+
}
25+
}
26+
}
27+
28+
body:graphql:vars {
29+
{
30+
"input": {
31+
"accountId": "37590fbc-89b3-4218-abf6-5bded51b8fe7",
32+
"amount": 100.0,
33+
"currency": "JMD",
34+
"notificationCategory": "Payments"
35+
}
36+
}
37+
}

src/app/admin/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./update-user-phone"
22
export * from "./send-admin-push-notification"
3+
export * from "./send-cashout-notification"
34
export * from "./send-broadcast-notification"
45

56
import { checkedToAccountUuid, checkedToUsername } from "@domain/accounts"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { checkedToAccountUuid } from "@domain/accounts"
2+
import { checkedToNotificationCategory, GaloyNotificationCategories } from "@domain/notifications"
3+
import { checkedToDeviceToken } from "@domain/users"
4+
import { AccountsRepository, UsersRepository } from "@services/mongoose"
5+
import { NotificationsService } from "@services/notifications"
6+
7+
export const sendCashoutNotification = async (
8+
{
9+
accountId: accountIdRaw,
10+
title,
11+
body,
12+
amount,
13+
currency,
14+
notificationCategory,
15+
deviceTokens
16+
}: {
17+
accountId: string,
18+
title: string,
19+
body: string,
20+
amount: number,
21+
currency: string,
22+
notificationCategory?: string,
23+
deviceTokens?: string[]
24+
}): Promise<true | ApplicationError> => {
25+
26+
const checkedNotificationCategory = notificationCategory ? checkedToNotificationCategory(notificationCategory) : GaloyNotificationCategories.Payments
27+
28+
if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory
29+
30+
const accountId = checkedToAccountUuid(accountIdRaw)
31+
if (accountId instanceof Error) return accountId
32+
33+
const accountsRepo = AccountsRepository()
34+
const account = await accountsRepo.findByUuid(accountId)
35+
if (account instanceof Error) return account
36+
const kratosUserId = account.kratosUserId
37+
38+
let tokens: DeviceToken[] = []
39+
if (deviceTokens && deviceTokens.length > 0) {
40+
for (const token of deviceTokens) {
41+
const checkedToken = await checkedToDeviceToken(token)
42+
if (checkedToken instanceof Error) return checkedToken
43+
tokens.push(checkedToken)
44+
}
45+
} else {
46+
const usersRepo = UsersRepository()
47+
const user = await usersRepo.findById(kratosUserId)
48+
if (user instanceof Error) return user
49+
tokens = user.deviceTokens
50+
}
51+
52+
const success = await NotificationsService().adminPushNotificationFilteredSend({
53+
deviceTokens: tokens,
54+
title,
55+
body,
56+
data: { amount: amount.toString(), currency },
57+
notificationCategory: checkedNotificationCategory,
58+
notificationSettings: account.notificationSettings,
59+
})
60+
61+
return success
62+
}

src/graphql/admin/mutations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import UserUpdatePhoneMutation from "./root/mutation/user-update-phone"
88
import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map"
99
import AdminPushNotificationSendMutation from "./root/mutation/admin-push-notification-send"
1010
import AdminBroadcastSendMutation from "./root/mutation/admin-broadcast-send"
11+
import sendCashoutSettledNotification from "./root/mutation/cashout-notification-send"
1112

1213
import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete"
1314
import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate"
@@ -24,6 +25,7 @@ export const mutationFields = {
2425
businessUpdateMapInfo: BusinessUpdateMapInfoMutation,
2526
businessDeleteMapInfo: BusinessDeleteMapInfoMutation,
2627
adminPushNotificationSend: AdminPushNotificationSendMutation,
28+
cashoutNotificationSend: sendCashoutSettledNotification,
2729
adminBroadcastSend: AdminBroadcastSendMutation,
2830
},
2931
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Admin } from "@app/index";
2+
import AdminPushNotificationSendPayload from "@graphql/admin/types/payload/admin-push-notification-send";
3+
import { mapAndParseErrorForGqlResponse } from "@graphql/error-map";
4+
import { GT } from "@graphql/index";
5+
import { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload";
6+
import NotificationCategory from "@graphql/shared/types/scalar/notification-category";
7+
8+
const CashoutNotificationSendInput = GT.Input({
9+
name: "CashoutNotificationSendInput",
10+
fields: () => ({
11+
accountId: {
12+
type: GT.NonNull(GT.String),
13+
},
14+
amount: {
15+
type: GT.NonNull(GT.Float),
16+
},
17+
currency: {
18+
type: GT.NonNull(GT.String)
19+
},
20+
notificationCategory: {
21+
type: NotificationCategory,
22+
},
23+
})
24+
})
25+
26+
const sendCashoutSettledNotification = GT.Field({
27+
extensions: {
28+
complexity: 1,
29+
},
30+
type: GT.NonNull(AdminPushNotificationSendPayload),
31+
args: {
32+
input: { type: GT.NonNull(CashoutNotificationSendInput) }
33+
},
34+
resolve: async (_, args) => {
35+
36+
const { accountId, amount, currency, notificationCategory } = args.input;
37+
38+
const title = "Cashout Successful"
39+
const body = `Your cashout of $${amount.toFixed(2)} ${currency} has been processed.`
40+
41+
const success = await Admin.sendAdminPushNotification({
42+
accountId,
43+
title,
44+
body,
45+
data: { amount: String(amount), currency },
46+
notificationCategory
47+
})
48+
49+
if (success instanceof Error) {
50+
return { errors: [mapAndParseErrorForGqlResponse(success)] }
51+
}
52+
53+
return SUCCESS_RESPONSE
54+
55+
}
56+
})
57+
58+
export default sendCashoutSettledNotification

src/graphql/admin/schema.graphql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ input BusinessUpdateMapInfoInput {
146146
username: Username!
147147
}
148148

149+
input CashoutNotificationSendInput {
150+
accountId: String!
151+
amount: Float!
152+
currency: String!
153+
notificationCategory: NotificationCategory
154+
}
155+
149156
type Coordinates {
150157
latitude: Float!
151158
longitude: Float!
@@ -284,6 +291,7 @@ type Mutation {
284291
adminPushNotificationSend(input: AdminPushNotificationSendInput!): AdminPushNotificationSendPayload!
285292
businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload!
286293
businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload!
294+
cashoutNotificationSend(input: CashoutNotificationSendInput!): AdminPushNotificationSendPayload!
287295
merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload!
288296
merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload!
289297
userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload!

src/services/notifications/push-notifications.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const sendToDevice = async (
4545
batchResp.responses
4646
.forEach((r, idx) => {
4747
if (!r.success) {
48+
logger.warn({ error: r.error, token: tokens[idx] }, "Error sending notification to device")
4849
recordExceptionInCurrentSpan({
4950
error: new FirebaseMessageError(r.error as unknown as FirebaseError, tokens[idx]),
5051
level: ErrorLevel.Warn,
@@ -55,6 +56,11 @@ const sendToDevice = async (
5556
}
5657
})
5758

59+
logger.info(
60+
{ successCount: batchResp.successCount, failureCount: batchResp.failureCount },
61+
"Notification batch response",
62+
)
63+
5864
// addAttributesToCurrentSpan({
5965
// failureCount: response.failureCount,
6066
// successCount: response.successCount,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
2+
import { GaloyNotificationCategories } from "@domain/notifications"
3+
import { sendCashoutNotification } from "@app/admin/send-cashout-notification"
4+
import { NotificationsService } from "@services/notifications"
5+
import { AccountsRepository, UsersRepository } from "@services/mongoose"
6+
import { checkedToDeviceToken } from "@domain/users"
7+
8+
jest.mock("@services/notifications", () => ({
9+
NotificationsService: jest.fn(),
10+
}))
11+
12+
jest.mock("@services/mongoose", () => ({
13+
AccountsRepository: jest.fn(),
14+
UsersRepository: jest.fn(),
15+
}))
16+
17+
jest.mock("@domain/accounts", () => ({
18+
checkedToAccountUuid: (id: string) => id
19+
}))
20+
21+
jest.mock("@domain/users", () => ({
22+
checkedToDeviceToken: (token: string) => token as DeviceToken
23+
}))
24+
25+
describe("sendCashoutNotification", () => {
26+
const accountId = "account-id" as AccountUuid
27+
const title = "Test Title"
28+
const body = "Test Body"
29+
const amount = 100
30+
const currency = "USD"
31+
32+
const mockAccount = {
33+
uuid: accountId,
34+
kratosUserId: "user-id",
35+
notificationSettings: {
36+
push: {
37+
enabled: true,
38+
disabledCategories: [],
39+
}
40+
}
41+
}
42+
43+
const mockUser = {
44+
deviceTokens: ["override-token"] as DeviceToken[],
45+
}
46+
47+
const adminPushNotificationFilteredSend = jest.fn().mockResolvedValue(true)
48+
49+
beforeEach(() => {
50+
jest.clearAllMocks()
51+
; (NotificationsService as jest.Mock).mockReturnValue({
52+
adminPushNotificationFilteredSend,
53+
})
54+
; (AccountsRepository as jest.Mock).mockReturnValue({
55+
findByUuid: jest.fn().mockResolvedValue(mockAccount),
56+
})
57+
; (UsersRepository as jest.Mock).mockReturnValue({
58+
findById: jest.fn().mockResolvedValue(mockUser),
59+
})
60+
})
61+
62+
it("sends notification to user device tokens", async () => {
63+
const result = await sendCashoutNotification({
64+
accountId,
65+
title,
66+
body,
67+
amount,
68+
currency,
69+
})
70+
71+
expect(result).toBe(true)
72+
expect(adminPushNotificationFilteredSend).toHaveBeenCalledWith({
73+
deviceTokens: mockUser.deviceTokens,
74+
title,
75+
body,
76+
data: { amount: "100", currency },
77+
notificationCategory: GaloyNotificationCategories.Payments,
78+
notificationSettings: mockAccount.notificationSettings,
79+
})
80+
})
81+
82+
it("sends notification to provided device tokens override", async () => {
83+
const overrideTokens = ["override-token"] // paste given device token here
84+
const result = await sendCashoutNotification({
85+
accountId,
86+
title,
87+
body,
88+
amount,
89+
currency,
90+
deviceTokens: overrideTokens
91+
})
92+
93+
expect(result).toBe(true)
94+
expect(adminPushNotificationFilteredSend).toHaveBeenCalledWith({
95+
deviceTokens: [checkedToDeviceToken(overrideTokens[0])],
96+
title,
97+
body,
98+
data: { amount: "100", currency },
99+
notificationCategory: GaloyNotificationCategories.Payments,
100+
notificationSettings: mockAccount.notificationSettings,
101+
})
102+
// Should NOT call UsersRepository if tokens are provided
103+
expect(UsersRepository).not.toHaveBeenCalled()
104+
})
105+
})

0 commit comments

Comments
 (0)