diff --git a/backend/src/lambda/README.md b/backend/src/lambda/README.md new file mode 100644 index 00000000..39a20eb9 --- /dev/null +++ b/backend/src/lambda/README.md @@ -0,0 +1,33 @@ +# Lambda + +This is where our code for building our Lambda function lives. + +## Table of Contents + +1. [File Structure](#file-structure) +2. [Tutorial](#tutorial) + +### File-Structure + +The template repository is laid out as follows below. + +```bash +├── email-processor +│   ├── emails +│   └── email-template.tsx # the template for the disaster email. created using react-email +│   └── lambda-src +│   └── index.ts +│  └── ses-client.ts +│ └── build.sh # Run this to re-create lambda files +├── README.md +``` + +### Tutorial + +The logic for the lambda function resides in `lambda-src/index.ts`. Once +you edit this you can rebuild the `layer.zip` and `function.zip` using +`build.sh`. + +Then you can either re-deploy this using out terraform functions or edit +these straight from the aws console. If you would like to do this using +terraform please refer to the README in `./terraform` \ No newline at end of file diff --git a/backend/src/modules/quickbooks/service.ts b/backend/src/modules/quickbooks/service.ts index f13ae87f..ab56cc72 100644 --- a/backend/src/modules/quickbooks/service.ts +++ b/backend/src/modules/quickbooks/service.ts @@ -1,4 +1,7 @@ import dayjs from "dayjs"; +import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; +dayjs.extend(isSameOrAfter); + import { IQuickbooksClient } from "../../external/quickbooks/client"; import { withServiceErrorHandling } from "../../utilities/error"; import { IQuickbooksTransaction } from "./transaction"; @@ -176,11 +179,11 @@ export class QuickbooksService implements IQuickbooksService { const now = dayjs(); - if (!session || !externalId || now.isSameOrAfter(session.refreshExpiryTimestamp)) { + if (!session || !externalId || now.isSameOrAfter(dayjs(session.refreshExpiryTimestamp))) { throw Boom.unauthorized("Quickbooks session is expired"); } - if (now.isSameOrAfter(session.accessExpiryTimestamp)) { + if (now.isSameOrAfter(dayjs(session.accessExpiryTimestamp))) { session = await this.refreshQuickbooksSession({ refreshToken: session.refreshToken, companyId: session.companyId, @@ -198,9 +201,7 @@ export class QuickbooksService implements IQuickbooksService { const lastImport = user.company.lastQuickBooksInvoiceImportTime; const lastImportDate = lastImport ? dayjs(lastImport) : null; - const { - QueryResponse: { Invoice: invoices }, - } = await this.makeRequestToQB({ + const response = await this.makeRequestToQB({ userId, request: (session) => this.qbClient.query<{ Invoice: QBInvoice[] }>({ @@ -211,11 +212,13 @@ export class QuickbooksService implements IQuickbooksService { : `SELECT * FROM Invoice`, }), }); - if (invoices === undefined) { + if (!response || !response.QueryResponse || !response.QueryResponse.Invoice) { logMessageToFile("No new invoices to import"); return; } + const invoices = response.QueryResponse.Invoice; + const createdInvoices = await this.invoiceTransaction.createOrUpdateInvoices( invoices.map((i) => ({ companyId: user.companyId, @@ -243,9 +246,7 @@ export class QuickbooksService implements IQuickbooksService { const lastImport = user.company.lastQuickBooksPurchaseImportTime; const lastImportDate = lastImport ? dayjs(lastImport) : null; - const { - QueryResponse: { Purchase: purchases }, - } = await this.makeRequestToQB({ + const response = await this.makeRequestToQB({ userId, request: (session) => this.qbClient.query<{ Purchase: QBPurchase[] }>({ @@ -256,20 +257,25 @@ export class QuickbooksService implements IQuickbooksService { : `SELECT * FROM Purchase`, }), }); - if (purchases === undefined) { + if (!response || !response.QueryResponse || !response.QueryResponse.Purchase) { + console.log("no purchases to import"); logMessageToFile("No new purchases to import"); return; } + const purchases = response.QueryResponse.Purchase; + const createdPurchases = await this.purchaseTransaction.createOrUpdatePurchase( - purchases.map((p) => ({ - isRefund: p.Credit !== undefined ? p.Credit : false, - companyId: user.companyId, - totalAmountCents: Math.round(p.TotalAmt * 100), - quickbooksDateCreated: p.MetaData.CreateTime, - quickBooksId: parseInt(p.Id), - vendor: p.EntityRef?.type === "Vendor" ? p.EntityRef.DisplayName || p.EntityRef.GivenName : undefined, - })) + purchases.map((p) => { + return { + isRefund: p.Credit !== undefined ? p.Credit : false, + companyId: user.companyId, + totalAmountCents: Math.round(p.TotalAmt * 100), + quickbooksDateCreated: p.MetaData.CreateTime, + quickBooksId: parseInt(p.Id), + vendor: p.EntityRef?.DisplayName || p.EntityRef?.GivenName || p.EntityRef?.name || undefined, + }; + }) ); const lineItemData = purchases.flatMap((i) => { @@ -310,12 +316,12 @@ export class QuickbooksService implements IQuickbooksService { if (!session || !externalId) { throw Boom.unauthorized("Quickbooks session not found"); } - if (now.isSameOrAfter(session.refreshExpiryTimestamp)) { + if (now.isSameOrAfter(dayjs(session.refreshExpiryTimestamp))) { // Redirect to quickbooks auth? throw Boom.unauthorized("Quickbooks session is expired"); } - if (now.isSameOrAfter(session.accessExpiryTimestamp)) { + if (now.isSameOrAfter(dayjs(session.accessExpiryTimestamp))) { session = await this.refreshQuickbooksSession({ refreshToken: session.refreshToken, companyId: session.companyId, @@ -398,10 +404,18 @@ function getPurchaseLineItems(purchase: QBPurchase) { quickBooksId: parseInt(lineItem.Id), type: PurchaseLineItemType.TYPICAL, // when importing, for now we mark everything as typical description: lineItem.Description, - category: lineItem.AccountBasedExpenseLineDetail.AccountRef.value, + category: getLastAccountName(lineItem.AccountBasedExpenseLineDetail.AccountRef.name), }); } } return out; } + +function getLastAccountName(accountPath: string | undefined): string | undefined { + if (!accountPath) { + return accountPath; + } + const parts = accountPath.split(":"); + return parts[parts.length - 1]; +} diff --git a/backend/src/tests/quickbooks/purchase/update.test.ts b/backend/src/tests/quickbooks/purchase/update.test.ts index ae2dfaa4..a2949237 100644 --- a/backend/src/tests/quickbooks/purchase/update.test.ts +++ b/backend/src/tests/quickbooks/purchase/update.test.ts @@ -91,6 +91,7 @@ describe("inserting purcahse data", () => { AccountBasedExpenseLineDetail: { AccountRef: { value: "acc-ref", + name: "category", }, }, Amount: 5.5, @@ -101,6 +102,7 @@ describe("inserting purcahse data", () => { Credit: true, EntityRef: { type: "Vendor", + name: "name", DisplayName: "Testing Display Name", GivenName: "Testing given name", }, @@ -143,7 +145,7 @@ describe("inserting purcahse data", () => { quickBooksId: 2, amountCents: 550, purchaseId: purchases[0].id, - category: "acc-ref", + category: "category", description: "Testing description 2", quickbooksDateCreated: new Date(now), type: PurchaseLineItemType.TYPICAL, @@ -226,7 +228,7 @@ describe("inserting purcahse data", () => { quickBooksId: 2, amountCents: 550, purchaseId: purchases[0].id, - category: "acc-ref", + category: null, description: "Testing description 2", quickbooksDateCreated: new Date(now), type: PurchaseLineItemType.TYPICAL, @@ -271,6 +273,7 @@ describe("inserting purcahse data", () => { ], EntityRef: { type: "Vendor", + name: "name", GivenName: "Testing Given Name", }, }, @@ -318,9 +321,10 @@ describe("inserting purcahse data", () => { }, ], EntityRef: { - type: "other", - DisplayName: "SHOULD NOT EXIST", - GivenName: "SHOULD NOT EXIST", + type: "vendor", + name: "name", + DisplayName: "name", + GivenName: "name", }, }, ], @@ -339,7 +343,7 @@ describe("inserting purcahse data", () => { id: expect.anything(), dateCreated: expect.anything(), lastUpdated: expect.anything(), - vendor: null, + vendor: "name", }); const lineItemsAfter = await db @@ -363,7 +367,7 @@ describe("inserting purcahse data", () => { quickBooksId: 2, amountCents: 550, purchaseId: purchasesAfter[0].id, - category: "acc-ref", + category: null, description: "Testing description 2", quickbooksDateCreated: new Date(oneDayAgo), type: PurchaseLineItemType.TYPICAL, diff --git a/backend/src/types/quickbooks/purchase.ts b/backend/src/types/quickbooks/purchase.ts index 060843e2..daebfc47 100644 --- a/backend/src/types/quickbooks/purchase.ts +++ b/backend/src/types/quickbooks/purchase.ts @@ -14,6 +14,7 @@ export type QBPurchase = { type: string; DisplayName?: string; GivenName: string; + name: string; }; }; diff --git a/backend/src/utilities/cron-jobs/FemaDisasterDataCron.ts b/backend/src/utilities/cron-jobs/FemaDisasterDataCron.ts index 7a36c9e4..81d89a9b 100644 --- a/backend/src/utilities/cron-jobs/FemaDisasterDataCron.ts +++ b/backend/src/utilities/cron-jobs/FemaDisasterDataCron.ts @@ -54,7 +54,7 @@ export class FemaDisasterFetching implements CronJobHandler { this.qbClient = new QuickbooksClient({ clientId: process.env.QUICKBOOKS_CLIENT_ID!, clientSecret: process.env.QUICKBOOKS_CLIENT_SECRET!, - environment: process.env.NODE_ENV === "dev" ? "sandbox" : "production", + environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", }); this.quickbooksService = new QuickbooksService( diff --git a/backend/terraform/lambda.tf b/backend/terraform/lambda.tf index f9be65bb..09ad4e26 100644 --- a/backend/terraform/lambda.tf +++ b/backend/terraform/lambda.tf @@ -1,3 +1,13 @@ +# Lambda Layer +resource "aws_lambda_layer_version" "email_processor_layer" { + filename = "../src/lambda/email-processor/layer.zip" + layer_name = "${var.project_name}-email-processor-layer-${var.environment}" + compatible_runtimes = ["nodejs20.x"] + compatible_architectures = ["arm64"] + source_code_hash = filebase64sha256("../src/lambda/email-processor/layer.zip") +} + + # Lambda Function resource "aws_lambda_function" "email_processor" { filename = "../src/lambda/email-processor/function.zip" @@ -10,6 +20,8 @@ resource "aws_lambda_function" "email_processor" { memory_size = 128 architectures = ["arm64"] + layers = [aws_lambda_layer_version.email_processor_layer.arn] + environment { variables = { SES_FROM_EMAIL = var.ses_from_email diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index c18c0ead..685240ec 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -121,6 +121,7 @@ export default function LoginPage() { disabled={status.pending} className="max-h-[45px] w-fit bg-[var(--fuchsia)] hover:bg-pink hover:text-fuchsia text-white px-[20px] py-[12px] text-[16px]" > + {status.pending ? : <>} Log In diff --git a/frontend/app/notifications/notification.tsx b/frontend/app/notifications/notification.tsx index 26a6ac31..a561c2a0 100644 --- a/frontend/app/notifications/notification.tsx +++ b/frontend/app/notifications/notification.tsx @@ -7,6 +7,7 @@ import { useServerActionMutation } from "@/api/requestHandlers"; import { useState } from "react"; import { RiMore2Fill } from "react-icons/ri"; import formatDescription from "./utils"; +import { Spinner } from "@/components/ui/spinner"; interface NotificationProps { notification: NotificationType; @@ -14,7 +15,7 @@ interface NotificationProps { export default function Notification({ notification }: NotificationProps) { const [error, setError] = useState(false); const [title, setTitle] = useState(notification.notificationStatus); - const { mutate } = useServerActionMutation({ + const { mutate, isPending } = useServerActionMutation({ mutationFn: () => updateNotificationStatus(notification.id, title == "read" ? "unread" : "read"), onError: () => { setError(false); @@ -41,10 +42,12 @@ export default function Notification({ notification }: NotificationProps) {
{error &&

Error Setting Status

} diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index e6a9b082..10e22e4f 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -130,6 +130,7 @@ function SignUpContent() { disabled={status.pending} className="max-h-[45px] w-fit bg-[var(--fuchsia)] text-white hover:bg-pink hover:text-fuchsia px-[20px] py-[12px] text-[16px]" > + {status.pending ? : <>} Sign Up
diff --git a/frontend/components/dashboard/NoDataPopup.tsx b/frontend/components/dashboard/NoDataPopup.tsx index 5a8d7605..709cb36e 100644 --- a/frontend/components/dashboard/NoDataPopup.tsx +++ b/frontend/components/dashboard/NoDataPopup.tsx @@ -2,7 +2,6 @@ import { redirectToQuickbooks } from "@/api/quickbooks"; import { Button } from "@/components/ui/button"; -import Link from "next/link"; import { GoSync } from "react-icons/go"; import { isServerActionSuccess } from "@/api/types"; @@ -38,18 +37,16 @@ export default function NoDataPopup({ isOpen, onClose }: Props) {

- - - - {/* + + {/*