Skip to content
Merged
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
33 changes: 33 additions & 0 deletions backend/src/lambda/README.md
Original file line number Diff line number Diff line change
@@ -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`
56 changes: 35 additions & 21 deletions backend/src/modules/quickbooks/service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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[] }>({
Expand All @@ -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,
Expand Down Expand Up @@ -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[] }>({
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];
}
18 changes: 11 additions & 7 deletions backend/src/tests/quickbooks/purchase/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe("inserting purcahse data", () => {
AccountBasedExpenseLineDetail: {
AccountRef: {
value: "acc-ref",
name: "category",
},
},
Amount: 5.5,
Expand All @@ -101,6 +102,7 @@ describe("inserting purcahse data", () => {
Credit: true,
EntityRef: {
type: "Vendor",
name: "name",
DisplayName: "Testing Display Name",
GivenName: "Testing given name",
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -271,6 +273,7 @@ describe("inserting purcahse data", () => {
],
EntityRef: {
type: "Vendor",
name: "name",
GivenName: "Testing Given Name",
},
},
Expand Down Expand Up @@ -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",
},
},
],
Expand All @@ -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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/src/types/quickbooks/purchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type QBPurchase = {
type: string;
DisplayName?: string;
GivenName: string;
name: string;
};
};

Expand Down
2 changes: 1 addition & 1 deletion backend/src/utilities/cron-jobs/FemaDisasterDataCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions backend/terraform/lambda.tf
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <Spinner /> : <></>}
Log In
</Button>
<Button
Expand All @@ -132,7 +133,6 @@ export default function LoginPage() {
disabled={status.pending}
className="underline text-[12px] decoration-1 hover:text-gray-400 h-fit font-bold"
>
{status.pending ? <Spinner /> : <></>}
New User? Sign up
</Button>
</div>
Expand Down
7 changes: 5 additions & 2 deletions frontend/app/notifications/notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ 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;
}
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);
Expand All @@ -41,10 +42,12 @@ export default function Notification({ notification }: NotificationProps) {
<div className="self-end">
<Button
variant="secondary"
className="rounded-sm w-fit bg-fuchsia text-white hover:bg-fuchsia/80"
className="w-fit bg-fuchsia text-white hover:bg-fuchsia/80"
size="sm"
disabled={isPending}
onClick={() => mutate()}
>
{isPending ? <Spinner /> : <></>}
Mark as unread
</Button>
{error && <p className="text-sm text-fuchsia">Error Setting Status</p>}
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <Spinner /> : <></>}
Sign Up
</Button>
<Button
Expand All @@ -141,7 +142,6 @@ function SignUpContent() {
disabled={status.pending}
className="underline text-[12px] decoration-1 hover:text-gray-400 h-fit text-bold"
>
{status.pending ? <Spinner /> : <></>}
Already have an account? Log In
</Button>
</div>
Expand Down
23 changes: 10 additions & 13 deletions frontend/components/dashboard/NoDataPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -38,18 +37,16 @@ export default function NoDataPopup({ isOpen, onClose }: Props) {
</p>

<div className="flex gap-3">
<Link href="/quickbooks">
<Button
className="group h-[34px] w-fit text-white text-[14px] bg-[var(--fuchsia)] hover:bg-pink hover:text-fuchsia"
onClick={async () => {
await quickbooksAuth();
}}
>
<GoSync className="text-white group-hover:text-fuchsia" style={{ width: "14px" }} />{" "}
Sync Quickbooks
</Button>
</Link>
{/* <Link href="/upload-csv">
<Button
className="group h-[34px] w-fit text-white text-[14px] bg-[var(--fuchsia)] hover:bg-pink hover:text-fuchsia"
onClick={async () => {
await quickbooksAuth();
}}
>
<GoSync className="text-white group-hover:text-fuchsia" style={{ width: "14px" }} /> Sync
Quickbooks
</Button>
{/*<Link href="/upload-csv">
<Button className="group h-[34px] w-fit text-white text-[14px] bg-[var(--fuchsia)] hover:bg-pink hover:text-fuchsia">
<FiUpload className="text-white group-hover:text-fuchsia" style={{ width: "14px" }} />{" "}
Upload CSV
Expand Down
Loading