Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make invoice file separate field #10702

Merged
merged 4 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ env:
NODE_ENV: test
AWS_KEY: ${{ secrets.AWS_KEY }}
AWS_SECRET: ${{ secrets.AWS_SECRET }}
DISABLE_MOCK_UPLOADS: 1

jobs:
lint:
Expand Down
27 changes: 25 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ env:
API_KEY: dvl-1510egmf4a23d80342403fb599qd
CI: true

AWS_KEY: user
AWS_SECRET: password
AWS_S3_BUCKET: opencollective-e2e
AWS_S3_REGION: us-east-1
AWS_S3_API_VERSION: latest
AWS_S3_ENDPOINT: http://localhost:9000
AWS_S3_SSL_ENABLED: false
AWS_S3_FORCE_PATH_STYLE: true
GRAPHQL_ERROR_DETAILED: true
DISABLE_MOCK_UPLOADS: 1
E2E_TEST: 1
PGHOST: localhost
PGUSER: opencollective
Expand All @@ -30,8 +40,6 @@ env:
TERM: xterm
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
STRIPE_WEBHOOK_SIGNING_SECRET: ${{ secrets.STRIPE_WEBHOOK_SIGNING_SECRET }}
AWS_KEY: ${{ secrets.AWS_KEY }}
AWS_SECRET: ${{ secrets.AWS_SECRET }}

jobs:
e2e:
Expand All @@ -58,6 +66,14 @@ jobs:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
minio:
image: minio/minio:edge-cicd
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially wanted to use this image as well in #10688, but it hasn't been updated in 3 years and that was causing some issues in my tests (I don't remember where), which is why I went with the npm run minio & approach.

No blocker for me if the image is working here, we can always update later if needed.

ports:
- 9000:9000
options: --name=minio --health-cmd "curl http://localhost:9000/minio/health/live"
env:
MINIO_ROOT_USER: user
MINIO_ROOT_PASSWORD: password

steps:
# man-db trigger on apt install is taking some time
Expand All @@ -82,6 +98,13 @@ jobs:
wget /~https://github.com/stripe/stripe-cli/releases/download/v1.13.9/stripe_1.13.9_linux_x86_64.tar.gz -O /tmp/stripe_1.13.9_linux_x86_64.tar.gz
sudo tar xzvf /tmp/stripe_1.13.9_linux_x86_64.tar.gz -C /bin/

- name: Setup minio bucket
run: |
wget https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x ./mc
./mc alias set minio http://127.0.0.1:9000 user password
./mc mb --ignore-existing minio/opencollective-e2e

- name: Checkout
uses: actions/checkout@v4

Expand Down
27 changes: 27 additions & 0 deletions migrations/20250219090540-add-invoiceFile-to-expense.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Expenses', 'InvoiceFileId', {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'UploadedFiles',
key: 'id',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});

await queryInterface.addColumn('ExpenseHistories', 'InvoiceFileId', {
type: Sequelize.INTEGER,
allowNull: true,
});
},

async down(queryInterface) {
await queryInterface.removeColumn('ExpenseHistories', 'InvoiceFileId');
await queryInterface.removeColumn('Expenses', 'InvoiceFileId');
},
};
1 change: 1 addition & 0 deletions server/constants/file-kind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const SUPPORTED_FILE_KINDS = [
'TIER_LONG_DESCRIPTION',
'ACCOUNT_CUSTOM_EMAIL',
'AGREEMENT_ATTACHMENT',
'EXPENSE_INVOICE',
] as const;

export type FileKind = (typeof SUPPORTED_FILE_KINDS)[number];
51 changes: 50 additions & 1 deletion server/graphql/common/expenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,7 @@ type ExpenseData = {
payeeLocation?: Location;
items?: (Record<string, unknown> & { url?: string })[];
attachedFiles?: (Record<string, unknown> & { url: string })[];
invoiceFile?: { url: string };
collective?: Collective;
fromCollective?: Collective;
tags?: string[];
Expand Down Expand Up @@ -1676,6 +1677,22 @@ export async function prepareAttachedFiles(req: Request, attachedFiles: ExpenseD
}));
}

export async function prepareInvoiceFile(req: Request, invoiceFile: ExpenseData['invoiceFile']): Promise<UploadedFile> {
if (!invoiceFile) {
return null;
}

if (!UploadedFile.isUploadedFileURL(invoiceFile.url)) {
throw new ValidationFailed('Invalid expense invoice file url');
}

if (!(await hasProtectedUrlPermission(req, invoiceFile.url))) {
throw new ValidationFailed('Invalid expense invoice file url');
}

return await UploadedFile.getFromURL(invoiceFile.url);
}

export const prepareExpenseItemInputs = async (
req: Request,
expenseCurrency: SupportedCurrency,
Expand Down Expand Up @@ -1993,6 +2010,12 @@ export async function createExpense(
}

const expense = await sequelize.transaction(async t => {
let invoiceFileId: number;
if (expenseData.type === EXPENSE_TYPE.INVOICE && expenseData.invoiceFile) {
const invoiceFile = await prepareInvoiceFile(req, expenseData.invoiceFile);
invoiceFileId = invoiceFile.id;
}

// Create expense
const createdExpense = await models.Expense.create(
{
Expand All @@ -2009,6 +2032,7 @@ export async function createExpense(
legacyPayoutMethod: models.Expense.getLegacyPayoutMethodTypeFromPayoutMethod(payoutMethod),
amount: models.Expense.computeTotalAmountForExpense(itemsData, taxes),
AccountingCategoryId: expenseData.accountingCategory?.id,
InvoiceFileId: invoiceFileId,
data,
},
{ transaction: t },
Expand Down Expand Up @@ -2237,7 +2261,10 @@ const isValueChanging = (expense: Expense, expenseData: Partial<ExpenseData>, ke
const value = expenseData[key];
if (key === 'accountingCategory') {
return !isUndefined(value) && (value?.id ?? null) !== expense.AccountingCategoryId;
} else {
} else if (key === 'invoiceFile') {
return !isUndefined(value) && !isEqual(value, expense[key]);
}
{
return !isNil(value) && !isEqual(value, expense[key]);
}
};
Expand Down Expand Up @@ -2335,6 +2362,11 @@ export async function editExpenseDraft(
(await prepareExpenseItemInputs(req, currency, expenseData.items, { isEditing: true })) || existingExpense.items;

const attachedFiles = await prepareAttachedFiles(req, expenseData.attachedFiles);
const invoiceFile =
(expenseData.type || existingExpense.type) === EXPENSE_TYPE.INVOICE
? await prepareInvoiceFile(req, expenseData.invoiceFile)
: null;

const newExpenseValues = {
...pick(expenseData, DRAFT_EXPENSE_FIELDS),
amount: models.Expense.computeTotalAmountForExpense(items, expenseData.tax),
Expand All @@ -2344,6 +2376,7 @@ export async function editExpenseDraft(
items,
taxes: expenseData.tax,
attachedFiles: attachedFiles,
invoiceFile: invoiceFile ? { url: invoiceFile.getDataValue('url') } : null,
},
};

Expand Down Expand Up @@ -2692,6 +2725,22 @@ export async function editExpense(
);
}

if (!isUndefined(expenseData.invoiceFile) && (expenseData.type || expense.type) === EXPENSE_TYPE.INVOICE) {
const newInvoiceUploadedFile = expenseData.invoiceFile
? await prepareInvoiceFile(req, expenseData.invoiceFile)
: null;

if (expense.InvoiceFileId && (!newInvoiceUploadedFile || expense.InvoiceFileId !== newInvoiceUploadedFile.id)) {
const oldInvoiceUploadedFile = await UploadedFile.findByPk(expense.InvoiceFileId, { transaction });
await oldInvoiceUploadedFile.destroy({ transaction });
cleanExpenseData['InvoiceFileId'] = null;
}

if (newInvoiceUploadedFile) {
cleanExpenseData['InvoiceFileId'] = newInvoiceUploadedFile.id;
}
}

let status = expense.status;
if (status === 'INCOMPLETE') {
// When dealing with expenses marked as INCOMPLETE, only return to PENDING if the expense change requires Collective review
Expand Down
20 changes: 19 additions & 1 deletion server/graphql/common/uploaded-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ export async function hasUploadedFilePermission(
const attachedFileMatches = ((expense.data?.attachedFiles as { url?: string }[]) || []).some(
item => item.url && item.url === actualUrl,
);
const invoiceFileMatches = (expense.data?.invoiceFile as { url: string })?.url === actualUrl;

const fileIsInDraft = itemMatches || attachedFileMatches;
const fileIsInDraft = itemMatches || attachedFileMatches || invoiceFileMatches;

if (fileIsInDraft && (await canSeeExpenseDraftPrivateDetails(req, expense))) {
return true;
Expand Down Expand Up @@ -84,6 +85,23 @@ export async function hasUploadedFilePermission(
}
break;
}
case 'EXPENSE_INVOICE': {
const expense = await Expense.findOne({
where: {
InvoiceFileId: uploadedFile.id,
},
include: [{ association: 'fromCollective' }],
});

if (!expense) {
return req.remoteUser?.id === uploadedFile.CreatedByUserId;
}

if (await canSeeExpenseAttachments(req, expense)) {
return true;
}
break;
}
case 'EXPENSE_ATTACHED_FILE': {
const expenseAttachedFile = await ExpenseAttachedFile.findOne({
where: {
Expand Down
21 changes: 21 additions & 0 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2484,6 +2484,11 @@
(Optional) files attached to the expense
"""
attachedFiles: [ExpenseAttachedFile!]

"""
(Optional - applicable to invoice expense only) The invoice file for this expense
"""
invoiceFile: FileInfo

Check notice on line 2491 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'invoiceFile' was added to object type 'Expense'

Field 'invoiceFile' was added to object type 'Expense'
items: [ExpenseItem]

"""
Expand Down Expand Up @@ -22005,6 +22010,11 @@
"""
attachedFiles: [ExpenseAttachedFileInput!]

"""
(Optional - applicable to invoice expense only) The invoice file for this expense
"""
invoiceFile: ExpenseAttachedFileInput

Check warning on line 22016 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Input field 'invoiceFile' was added to input object type 'ExpenseCreateInput'

Input field 'invoiceFile' was added to input object type 'ExpenseCreateInput'

"""
Account to reimburse
"""
Expand Down Expand Up @@ -22210,6 +22220,11 @@
"""
attachedFiles: [ExpenseAttachedFileInput!]

"""
(Optional - applicable to invoice expense only) The invoice file for this expense
"""
invoiceFile: ExpenseAttachedFileInput

Check warning on line 22226 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Input field 'invoiceFile' was added to input object type 'ExpenseUpdateInput'

Input field 'invoiceFile' was added to input object type 'ExpenseUpdateInput'

"""
Account to reimburse
"""
Expand Down Expand Up @@ -22489,6 +22504,11 @@
"""
attachedFiles: [JSON]

"""
(Optional - applicable to invoice expense only) The invoice file for this expense
"""
invoiceFile: JSON

Check warning on line 22510 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Input field 'invoiceFile' was added to input object type 'ExpenseInviteDraftInput'

Input field 'invoiceFile' was added to input object type 'ExpenseInviteDraftInput'

"""
Account to reimburse
"""
Expand Down Expand Up @@ -23468,6 +23488,7 @@
TIER_LONG_DESCRIPTION
ACCOUNT_CUSTOM_EMAIL
AGREEMENT_ATTACHMENT
EXPENSE_INVOICE

Check warning on line 23491 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Enum value 'EXPENSE_INVOICE' was added to enum 'UploadedFileKind'

Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.
}

"""
Expand Down
4 changes: 4 additions & 0 deletions server/graphql/v2/input/ExpenseCreateInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const getExpenseCreateInputFields = (): GraphQLInputFieldConfigMap => ({
type: new GraphQLList(new GraphQLNonNull(GraphQLExpenseAttachedFileInput)),
description: '(Optional) A list of files that you want to attach to this expense',
},
invoiceFile: {
type: GraphQLExpenseAttachedFileInput,
description: '(Optional - applicable to invoice expense only) The invoice file for this expense',
},
payee: {
type: new GraphQLNonNull(GraphQLAccountReferenceInput),
description: 'Account to reimburse',
Expand Down
4 changes: 4 additions & 0 deletions server/graphql/v2/input/ExpenseInviteDraftInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,9 @@ export const GraphQLExpenseInviteDraftInput = new GraphQLInputObjectType({
type: new GraphQLList(GraphQLJSON),
description: '(Optional) A list of files that you want to attach to this expense',
},
invoiceFile: {
type: GraphQLJSON,
description: '(Optional - applicable to invoice expense only) The invoice file for this expense',
},
}),
});
4 changes: 4 additions & 0 deletions server/graphql/v2/input/ExpenseUpdateInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export const GraphQLExpenseUpdateInput = new GraphQLInputObjectType({
type: new GraphQLList(new GraphQLNonNull(GraphQLExpenseAttachedFileInput)),
description: '(Optional) A list of files that you want to attach to this expense',
},
invoiceFile: {
type: GraphQLExpenseAttachedFileInput,
description: '(Optional - applicable to invoice expense only) The invoice file for this expense',
},
payee: {
type: GraphQLNewAccountOrReferenceInput,
description: 'Account to reimburse',
Expand Down
9 changes: 9 additions & 0 deletions server/graphql/v2/mutation/ExpenseMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
payExpense,
prepareAttachedFiles,
prepareExpenseItemInputs,
prepareInvoiceFile,
rejectExpense,
releaseExpense,
requestExpenseReApproval,
Expand Down Expand Up @@ -127,6 +128,7 @@ const expenseMutations = {
'type',
'privateMessage',
'attachedFiles',
'invoiceFile',
'invoiceInfo',
'payeeLocation',
'currency',
Expand Down Expand Up @@ -225,6 +227,7 @@ const expenseMutations = {
id: attachedFile.id && idDecode(attachedFile.id, IDENTIFIER_TYPES.EXPENSE_ATTACHED_FILE),
url: attachedFile.url,
})),
invoiceFile: expense.invoiceFile,
fromCollective: requestedPayee,
accountingCategory: isNil(args.expense.accountingCategory)
? args.expense.accountingCategory // This will make sure we pass either `null` (to remove the category) or `undefined` (to keep the existing one)
Expand Down Expand Up @@ -539,6 +542,7 @@ const expenseMutations = {
const currency = expenseData.currency || collective.currency;
const items = await prepareExpenseItemInputs(req, currency, expenseData.items);
const attachedFiles = await prepareAttachedFiles(req, expenseData.attachedFiles);
const invoiceFile = await prepareInvoiceFile(req, expenseData.invoiceFile);

const payee = payeeLegacyId
? (await fetchAccountWithReference({ legacyId: payeeLegacyId }, { throwIfMissing: true }))?.minimal
Expand Down Expand Up @@ -572,6 +576,11 @@ const expenseMutations = {
data: {
items,
attachedFiles,
invoiceFile: invoiceFile
? {
url: invoiceFile.getDataValue('url'),
}
: null,
payee,
invitedByCollectiveId: fromCollective.id,
draftKey,
Expand Down
6 changes: 4 additions & 2 deletions server/graphql/v2/mutation/UploadedFileMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ const uploadedFileMutations = {
// Limit on `kind` for this first release. If removing this, remember to update the part about `parseDocument`
// as we don't want to support parsing for all kinds (e.g. Avatars).
const allKinds = args.files.map(f => f.kind);
const unSupportedKinds = difference(allKinds, ['EXPENSE_ITEM', 'EXPENSE_ATTACHED_FILE']);
const unSupportedKinds = difference(allKinds, ['EXPENSE_ITEM', 'EXPENSE_ATTACHED_FILE', 'EXPENSE_INVOICE']);
if (unSupportedKinds.length > 0) {
throw new Error(`This mutation only supports the following kinds: EXPENSE_ITEM, EXPENSE_ATTACHED_FILE`);
throw new Error(
`This mutation only supports the following kinds: EXPENSE_ITEM, EXPENSE_ATTACHED_FILE, EXPENSE_INVOICE`,
);
}

// Since we're only supporting for expense at the moment, we check the expenses scope
Expand Down
Loading
Loading