Skip to content
Closed
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
5 changes: 5 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ input BusinessAccountUpgradeRequestInput
email: String
fullName: String!
idDocument: String
idDocumentFile: Upload
level: AccountLevel!
phoneNumber: String
terminalRequested: Boolean
Expand Down Expand Up @@ -1799,6 +1800,10 @@ type UpgradePayload
success: Boolean!
}

"""The `Upload` scalar type represents a file upload."""
scalar Upload
@join__type(graph: PUBLIC)

"""Amount in USD cents"""
scalar USDCents
@join__type(graph: PUBLIC)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"graphql-relay": "^0.10.0",
"graphql-shield": "^7.6.4",
"graphql-tools": "^9.0.0",
"graphql-upload": "16",
"graphql-ws": "^5.13.1",
"gt3-server-node-express-sdk": "https://github.com/GaloyMoney/gt3-server-node-express-bypass#master",
"i18n": "^0.15.1",
Expand Down
48 changes: 47 additions & 1 deletion src/app/accounts/business-account-upgrade-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { AccountLevel, checkedToAccountLevel } from "@domain/accounts"
import { AccountsRepository, UsersRepository } from "@services/mongoose"
import { IdentityRepository } from "@services/kratos"
import ErpNext from "@services/frappe/ErpNext"
import { AccountUpgradeRequest } from "@services/frappe/models/AccountUpgradeRequest"
import { baseLogger } from "@services/logger"

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

Expand All @@ -21,7 +23,30 @@ type BusinessUpgradeRequestInput = {
accountType?: string
currency?: string
accountNumber?: number
idDocument?: string
idDocument?: string // Can be base64-encoded file data (data:mime;base64,...) or filename
}

// Parse base64 data URL and extract buffer and mime type
const parseBase64DataUrl = (
dataUrl: string,
): { buffer: Buffer; mimeType: string; extension: string } | null => {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
if (!match) return null

const mimeType = match[1]
const base64Data = match[2]
const buffer = Buffer.from(base64Data, "base64")

// Get file extension from mime type
const extensionMap: Record<string, string> = {
"application/pdf": "pdf",
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
}
const extension = extensionMap[mimeType] || "bin"

return { buffer, mimeType, extension }
}

// Composable validation helpers
Expand Down Expand Up @@ -112,6 +137,27 @@ export const businessAccountUpgradeRequest = async (

if (requestResult instanceof Error) return requestResult

// Upload ID document file if provided as base64
if (input.idDocument && requestResult.name) {
const parsed = parseBase64DataUrl(input.idDocument)
if (parsed) {
const filename = `id-document-${requestResult.name}.${parsed.extension}`
const uploadResult = await ErpNext.uploadFile(
parsed.buffer,
filename,
AccountUpgradeRequest.doctype,
requestResult.name,
)
if (uploadResult instanceof Error) {
// Log warning but don't fail the request - the upgrade request was created
baseLogger.warn(
{ err: uploadResult, docname: requestResult.name },
"Failed to upload ID document, but upgrade request was created",
)
}
}
}

// Pro accounts auto-upgrade immediately (no manual approval needed)
if (checkedLevel === AccountLevel.Pro) {
const upgradeResult = await updateAccountLevel({
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 @@ -713,6 +713,7 @@ export const mapError = (error: ApplicationError): CustomApolloError => {
case "UnknownBriaEventError":
case "CouldNotFindAccountError":
case "UpgradeRequestCreateError":
case "FileUploadError":
message = `Unknown error occurred (code: ${error.name})`
return new UnknownClientError({ message, logger: baseLogger })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const BusinessAccountUpgradeRequestInput = GT.Input({
accountType: { type: GT.String },
currency: { type: GT.String },
accountNumber: { type: GT.Int },
idDocument: { type: GT.String },
idDocument: { type: GT.String, description: "Base64-encoded ID document file" },
}),
})

Expand Down
4 changes: 4 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ input BusinessAccountUpgradeRequestInput {
email: String
fullName: String!
idDocument: String
idDocumentFile: Upload
level: AccountLevel!
phoneNumber: String
terminalRequested: Boolean
Expand Down Expand Up @@ -1421,6 +1422,9 @@ type UpgradePayload {
success: Boolean!
}

"""The `Upload` scalar type represents a file upload."""
scalar Upload

"""
A wallet belonging to an account which contains a USD balance and a list of transactions.
"""
Expand Down
30 changes: 29 additions & 1 deletion src/services/frappe/ErpNext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FrappeConfig } from "@config"
import { USDAmount } from "@domain/shared"
import { baseLogger } from "@services/logger"
import axios from "axios"
import FormData from "form-data"

import {
JournalEntryDraftError,
Expand All @@ -11,6 +12,7 @@ import {
JournalEntryDeleteError,
UpgradeRequestCreateError,
UpgradeRequestQueryError,
FileUploadError,
} from "./errors"
import {
AccountUpgradeRequest,
Expand Down Expand Up @@ -188,7 +190,7 @@ class ErpNext {
)

const request = detailResp.data?.data
if (!data) return new UpgradeRequestQueryError("No data in detail response")
if (!request) return new UpgradeRequestQueryError("No data in detail response")
return AccountUpgradeRequest.fromErpnext(request)
} catch (err) {
baseLogger.error(
Expand All @@ -198,6 +200,32 @@ class ErpNext {
return new UpgradeRequestQueryError(err)
}
}

async uploadFile(
fileBuffer: Buffer,
filename: string,
doctype: string,
docname: string,
): Promise<{ file_url: string } | FileUploadError> {
const formData = new FormData()
formData.append("file", fileBuffer, { filename })
formData.append("doctype", doctype)
formData.append("docname", docname)
formData.append("is_private", "1")

try {
const resp = await axios.post(`${this.url}/api/method/upload_file`, formData, {
headers: {
...formData.getHeaders(),
Authorization: this.headers.Authorization,
},
})
return { file_url: resp.data.message?.file_url || resp.data.file_url }
} catch (err) {
baseLogger.error({ err, filename, doctype, docname }, "Error uploading file to ERPNext")
return new FileUploadError(err)
}
}
}

// Only instantiate if config is available, otherwise export a null-safe placeholder
Expand Down
1 change: 1 addition & 0 deletions src/services/frappe/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export class JournalEntrySubmitError extends ErpNextError {}
export class JournalEntryDeleteError extends ErpNextError {}
export class UpgradeRequestCreateError extends ErpNextError {}
export class UpgradeRequestQueryError extends ErpNextError {}
export class FileUploadError extends ErpNextError {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import { InvalidAccountStatusError, InvalidAccountLevelError } from "@domain/errors"

import { FileUploadError } from "@services/frappe/errors"

// Mock all external dependencies
const mockFindAccountById = jest.fn()
const mockFindUserById = jest.fn()
const mockGetIdentity = jest.fn()
const mockCreateUpgradeRequest = jest.fn()
const mockUpdateAccountLevel = jest.fn()
const mockUploadFile = jest.fn()

jest.mock("@services/mongoose", () => ({
AccountsRepository: () => ({
Expand All @@ -28,6 +31,7 @@ jest.mock("@services/frappe/ErpNext", () => ({
__esModule: true,
default: {
createUpgradeRequest: (...args: any[]) => mockCreateUpgradeRequest(...args),
uploadFile: (...args: any[]) => mockUploadFile(...args),
},
}))

Expand Down Expand Up @@ -64,6 +68,7 @@ describe("businessAccountUpgradeRequest", () => {
mockGetIdentity.mockResolvedValue(baseIdentity)
mockCreateUpgradeRequest.mockResolvedValue({ name: "REQ-001" })
mockUpdateAccountLevel.mockResolvedValue(true)
mockUploadFile.mockResolvedValue({ file_url: "/files/test.pdf" })
})

describe("successful requests", () => {
Expand Down Expand Up @@ -318,4 +323,61 @@ describe("businessAccountUpgradeRequest", () => {
expect(result).toBe(true)
})
})

describe("file upload via base64", () => {
const base64PdfData = "data:application/pdf;base64,dGVzdCBwZGYgY29udGVudA=="

it("uploads file when base64 data is provided in idDocument", async () => {
const result = await businessAccountUpgradeRequest({
accountId: "account-123" as any,
level: 2,
fullName: "Test User",
idDocument: base64PdfData,
})

expect(result).toBe(true)
expect(mockUploadFile).toHaveBeenCalledWith(
expect.any(Buffer),
"id-document-REQ-001.pdf",
"Account Upgrade Request",
"REQ-001",
)
})

it("succeeds even when file upload fails", async () => {
mockUploadFile.mockResolvedValue(new FileUploadError("Upload failed"))

const result = await businessAccountUpgradeRequest({
accountId: "account-123" as any,
level: 2,
fullName: "Test User",
idDocument: base64PdfData,
})

expect(result).toBe(true)
})

it("does not upload file when idDocument is not base64", async () => {
const result = await businessAccountUpgradeRequest({
accountId: "account-123" as any,
level: 2,
fullName: "Test User",
idDocument: "passport.pdf", // Just a filename, not base64
})

expect(result).toBe(true)
expect(mockUploadFile).not.toHaveBeenCalled()
})

it("does not upload file when idDocument is not provided", async () => {
const result = await businessAccountUpgradeRequest({
accountId: "account-123" as any,
level: 2,
fullName: "Test User",
})

expect(result).toBe(true)
expect(mockUploadFile).not.toHaveBeenCalled()
})
})
})
Loading