├── .nvmrc ├── .husky └── pre-commit ├── .npmrc ├── .dockerignore ├── renovate.json ├── .prettierrc ├── .env.local ├── .env.staging ├── .editorconfig ├── src ├── server │ ├── index.ts │ ├── controllers │ │ ├── index.js │ │ ├── events.js │ │ ├── collectives.js │ │ ├── transactions.js │ │ ├── account-orders.js │ │ ├── members.js │ │ ├── account-contributors.js │ │ ├── hosted-collectives.ts │ │ └── account-transactions.ts │ ├── lib │ │ ├── formatting.ts │ │ ├── hyperwatch.js │ │ ├── utils.ts │ │ └── graphql.js │ ├── logger.js │ ├── app.ts │ └── routes.ts └── env.js ├── now.json ├── .gitignore ├── .babelrc ├── test ├── mocks │ ├── Collective.json │ ├── LoggedInUser.json │ ├── Event.json │ └── allEvents.json ├── server │ └── controllers │ │ ├── transactions.test.js │ │ ├── events.test.js │ │ ├── collectives.test.js │ │ ├── account-contributors.test.js │ │ ├── hosted-collectives.test.js │ │ ├── members.test.js │ │ ├── account-orders.test.js │ │ └── account-transactions.test.js └── utils.ts ├── .github ├── ISSUE_TEMPLATE │ └── config.yml └── workflows │ └── ci.yml ├── Dockerfile ├── scripts ├── git_clean.sh ├── run_test.sh └── pre-deploy.sh ├── tsconfig.json ├── graphql.config.js ├── LICENSE ├── CONTRIBUTING.md ├── eslint.config.js ├── README.md ├── package.json └── docs └── transactions.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !.babelrc 3 | !src 4 | !package.json 5 | !package-lock.json 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>opencollective/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | API_URL=http://localhost:3060 2 | API_KEY=dvl-1510egmf4a23d80342403fb599qd 3 | WEBSITE_URL=http://localhost:3000 4 | LOG_LEVEL=debug -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | API_URL=https://api-staging.opencollective.com 2 | API_KEY=09u624Pc9F47zoGLlkg1TBSbOl2ydSAq 3 | WEBSITE_URL=http://localhost:3000 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import '../env'; 2 | 3 | import app from './app'; 4 | import { logger } from './logger'; 5 | 6 | const port = process.env.PORT || 3003; 7 | 8 | app.listen(port, () => { 9 | logger.info(`Ready on http://localhost:${port}`); 10 | }); 11 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "name": "opencollective-rest", 4 | "type": "docker", 5 | "env": {}, 6 | "scale": { 7 | "sfo1": { 8 | "min": 1, 9 | "max": 1 10 | }, 11 | "bru1": { 12 | "min": 1, 13 | "max": 1 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .env 3 | .env.production 4 | node_modules 5 | npm-debug.log.* 6 | *.log 7 | yarn.lock 8 | .DS_Store 9 | .vscode/* 10 | build 11 | cypress/screenshots 12 | cypress/videos 13 | dist 14 | *.swp 15 | styleguide/index.html 16 | styleguide/build/ 17 | .vimrc 18 | .history 19 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | [ 14 | "@babel/plugin-transform-typescript", 15 | { 16 | "allowDeclareFields": true 17 | } 18 | ], 19 | "add-module-exports" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/mocks/Collective.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "Collective": { 4 | "id": 207, 5 | "slug": "brusselstogether", 6 | "name": "BrusselsTogether", 7 | "description": "We are coming together to make Brussels a great city to live and work.", 8 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 9 | "logo": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png", 10 | "currency": "EUR" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/server/controllers/index.js: -------------------------------------------------------------------------------- 1 | import accountContributors from './account-contributors'; 2 | import accountOrders from './account-orders'; 3 | import accountTransactions from './account-transactions'; 4 | import * as collectives from './collectives'; 5 | import * as events from './events'; 6 | import hostedCollectives from './hosted-collectives'; 7 | import * as members from './members'; 8 | import * as transactions from './transactions'; 9 | 10 | export default { 11 | collectives, 12 | events, 13 | members, 14 | transactions, 15 | accountContributors, 16 | accountOrders, 17 | accountTransactions, 18 | hostedCollectives, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Issue Tracker 4 | url: https://github.com/opencollective/opencollective/issues 5 | about: Repository to track issues across all our projects 6 | - name: Discord Channel 7 | url: https://discord.opencollective.com 8 | about: Engage with the Open Collective community 9 | - name: Documentation 10 | url: https://docs.opencollective.com/help/ 11 | about: Documentation for Open Collective 12 | - name: Security Policy 13 | url: https://github.com/opencollective/opencollective/security/policy 14 | about: Please report security vulnerabilities as outlined here 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.19 2 | 3 | WORKDIR /usr/src/rest 4 | 5 | # Install dependencies first 6 | COPY package*.json ./ 7 | RUN npm install --unsafe-perm 8 | 9 | COPY . . 10 | 11 | ARG PORT=3000 12 | ENV PORT $PORT 13 | 14 | ARG NODE_ENV=production 15 | ENV NODE_ENV $NODE_ENV 16 | 17 | ARG API_URL=https://api-staging.opencollective.com 18 | ENV API_URL $API_URL 19 | 20 | ARG INTERNAL_API_URL=https://api-staging-direct.opencollective.com 21 | ENV INTERNAL_API_URL $INTERNAL_API_URL 22 | 23 | ARG API_KEY=09u624Pc9F47zoGLlkg1TBSbOl2ydSAq 24 | ENV API_KEY $API_KEY 25 | 26 | RUN npm run build 27 | 28 | RUN npm prune --production 29 | 30 | EXPOSE ${PORT} 31 | 32 | CMD [ "npm", "run", "start" ] 33 | -------------------------------------------------------------------------------- /scripts/git_clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [[ `git checkout main` ]]; then 3 | for BRANCH in `git branch | grep -v "*"`; do 4 | git checkout $BRANCH > /dev/null 2>&1; 5 | if [[ `git merge main --no-edit 2> /dev/null` ]]; then 6 | echo "> $BRANCH merged with main"; 7 | if [[ `git diff main` ]]; then 8 | echo "> $BRANCH is different than main"; 9 | git reset --hard > /dev/null; 10 | else 11 | echo "> Removing branch $BRANCH"; 12 | git checkout main; 13 | git branch -D $BRANCH; 14 | fi; 15 | else 16 | git reset --hard > /dev/null; 17 | echo "Unable to merge $BRANCH with main"; 18 | fi; 19 | done; 20 | git checkout main; 21 | fi; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | // Target latest version of ECMAScript. 5 | "target": "esnext", 6 | // Search under node_modules for non-relative imports. 7 | "moduleResolution": "node", 8 | // Process & infer types from .js files. 9 | "allowJs": true, 10 | // Don't emit; allow Babel to transform files. 11 | "noEmit": true, 12 | // Enable strictest settings like strictNullChecks & noImplicitAny. 13 | "strict": false, 14 | // Import non-ES modules as default imports. 15 | "esModuleInterop": true, 16 | "checkJs": false, 17 | "downlevelIteration": true, 18 | "lib": ["esnext", "dom", "dom.iterable"], 19 | "module": "commonjs", 20 | "noUnusedLocals": true, 21 | "outDir": "dist", 22 | "skipLibCheck": true 23 | }, 24 | "include": ["src", "test", "scripts"] 25 | } 26 | -------------------------------------------------------------------------------- /graphql.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: { 3 | default: { 4 | schema: 'src/graphql/schemaV2.graphql', 5 | extensions: { 6 | endpoints: { 7 | dev: 'http://localhost:3060/graphql/v2', 8 | prod: 'https://api.opencollective.com/graphql/v2', 9 | }, 10 | pluckConfig: { 11 | globalGqlIdentifierName: 'gqlV2', 12 | gqlMagicComment: 'GraphQLV2', 13 | }, 14 | }, 15 | }, 16 | graphqlV1: { 17 | schema: 'src/graphql/schema.graphql', 18 | extensions: { 19 | endpoints: { 20 | dev: 'http://localhost:3060/graphql/v1', 21 | prod: 'https://api.opencollective.com/graphql/v1', 22 | }, 23 | pluckConfig: { 24 | globalGqlIdentifierName: 'gql', 25 | gqlMagicComment: 'GraphQL', 26 | }, 27 | }, 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /scripts/run_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "> Starting api server" 4 | if [ -z "$API_FOLDER" ]; then 5 | cd ~/api 6 | else 7 | cd $API_FOLDER 8 | fi 9 | npm start & 10 | API_PID=$! 11 | cd - 12 | 13 | # Wait for a service to be up 14 | function wait_for_service() { 15 | echo "> Waiting for $1 to be ready... " 16 | while true; do 17 | nc -z "$2" "$3" 18 | EXIT_CODE=$? 19 | if [ $EXIT_CODE -eq 0 ]; then 20 | echo "> Application $1 is up!" 21 | break 22 | fi 23 | sleep 1 24 | done 25 | } 26 | 27 | echo "" 28 | wait_for_service API 127.0.0.1 3060 29 | 30 | echo "" 31 | echo "> Starting server jest tests" 32 | TZ=UTC npx jest test/server/* $@ 33 | RETURN_CODE=$? 34 | if [ $RETURN_CODE -ne 0 ]; then 35 | echo "Error with jest tests, exiting" 36 | exit 1; 37 | fi 38 | echo "" 39 | 40 | echo "Killing all node processes" 41 | kill $API_PID; 42 | kill $REST_PID; 43 | echo "Exiting with code $RETURN_CODE" 44 | exit $RETURN_CODE 45 | -------------------------------------------------------------------------------- /src/server/controllers/events.js: -------------------------------------------------------------------------------- 1 | import { fetchEvent } from '../lib/graphql'; 2 | import { logger } from '../logger'; 3 | 4 | export async function info(req, res, next) { 5 | // Keeping the resulting info for 10m in the CDN cache 6 | res.setHeader('Cache-Control', `public, max-age=${60 * 10}`); 7 | let event; 8 | try { 9 | logger.debug('>>> events.info fetching: %s', req.params.eventSlug); 10 | event = await fetchEvent(req.params.eventSlug); 11 | event.url = `https://opencollective.com/${req.params.collectiveSlug}/events/${event.slug}`; 12 | event.attendees = `https://opencollective.com/${req.params.collectiveSlug}/events/${event.slug}/attendees.json`; 13 | logger.debug('>>> events.info event: %j', event); 14 | res.send(event); 15 | } catch (e) { 16 | if (e.message.match(/No collective found/)) { 17 | return res.status(404).send('Not found'); 18 | } 19 | logger.debug('>>> events.info error', e); 20 | return next(e); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/server/controllers/transactions.test.js: -------------------------------------------------------------------------------- 1 | import { fetchResponseWithCacheBurst, generateJWT } from '../../utils'; 2 | 3 | describe('transactions', () => { 4 | describe('Cache-Control', () => { 5 | test('requires authentication', async () => { 6 | const response = await fetchResponseWithCacheBurst('/v1/collectives/railsgirlsatl/transactions'); 7 | expect(response.statusCode).toBe(200); 8 | expect(response.headers['cache-control']).toBe('public, max-age=60'); 9 | expect(response.headers['vary']).toBe('Accept-Encoding, Authorization, Personal-Token, Api-Key'); 10 | }); 11 | 12 | test('is private when authenticated', async () => { 13 | const response = await fetchResponseWithCacheBurst('/v1/collectives/railsgirlsatl/transactions', { 14 | headers: { Authorization: `Bearer ${generateJWT()}` }, 15 | }); 16 | 17 | expect(response.statusCode).toBe(200); 18 | expect(response.headers['cache-control']).toBe('no-cache'); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/server/lib/formatting.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export const amountAsString = (amount: { currency: string; value: number }) => { 4 | const amountAsString = new Intl.NumberFormat('en-US', { style: 'currency', currency: amount.currency }).format( 5 | amount.value, 6 | ); 7 | 8 | return `${amountAsString} ${amount.currency}`; 9 | }; 10 | 11 | export const accountNameAndLegalName = (account: { name?: string; legalName?: string }) => { 12 | const legalName = account?.legalName; 13 | const name = account?.name; 14 | if (!legalName && !name) { 15 | return ''; 16 | } else if (legalName && name && legalName !== name) { 17 | return `${legalName} (${name})`; 18 | } else { 19 | return legalName || name; 20 | } 21 | }; 22 | 23 | export const shortDate = (date: string): string => { 24 | if (date) { 25 | return moment.utc(date).format('YYYY-MM-DD'); 26 | } else { 27 | return ''; 28 | } 29 | }; 30 | 31 | export const formatContact = (contact: { name?: string; email: string }) => 32 | `${contact.name ? `${contact.name} ` : ''}<${contact.email}>`; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2019 Open Collective, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | import debug from 'debug'; 6 | import dotenv from 'dotenv'; 7 | import lodash from 'lodash'; 8 | 9 | // Load extra env file on demand 10 | // e.g. `npm run dev production` -> `.env.production` 11 | const extraEnv = process.env.EXTRA_ENV || lodash.last(process.argv); 12 | const extraEnvPath = path.join(__dirname, '..', `.env.${extraEnv}`); 13 | if (fs.existsSync(extraEnvPath)) { 14 | dotenv.config({ path: extraEnvPath }); 15 | } 16 | 17 | dotenv.config(); 18 | debug.enable(process.env.DEBUG); 19 | 20 | const defaults = { 21 | PORT: 3003, 22 | NODE_ENV: 'development', 23 | REST_URL: 'http://localhost:3003', 24 | API_KEY: '09u624Pc9F47zoGLlkg1TBSbOl2ydSAq', 25 | API_URL: 'https://api-staging.opencollective.com', 26 | WEBSITE_URL: 'https://staging.opencollective.com', 27 | OC_APPLICATION: 'rest', 28 | OC_ENV: process.env.NODE_ENV || 'development', 29 | OC_SECRET: crypto.randomBytes(16).toString('hex'), 30 | }; 31 | 32 | for (const key in defaults) { 33 | if (process.env[key] === undefined) { 34 | process.env[key] = defaults[key]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/server/controllers/collectives.js: -------------------------------------------------------------------------------- 1 | import { get, pick } from 'lodash'; 2 | 3 | import { fetchCollective } from '../lib/graphql'; 4 | import { logger } from '../logger'; 5 | 6 | export async function info(req, res, next) { 7 | // Keeping the resulting image for 1h in the CDN cache (we purge that cache on deploy) 8 | res.setHeader('Cache-Control', `public, max-age=${60 * 60}`); 9 | 10 | let collective; 11 | try { 12 | collective = await fetchCollective(req.params.collectiveSlug); 13 | } catch (e) { 14 | if (e.message.match(/No collective found/)) { 15 | return res.status(404).send('Not found'); 16 | } 17 | logger.debug('>>> collectives.info error', e); 18 | return next(e); 19 | } 20 | 21 | // /!\ Do not return any private date in there, or update the cache policy 22 | const response = { 23 | ...pick(collective, ['slug', 'currency', 'image']), 24 | balance: collective.stats.balance, 25 | yearlyIncome: collective.stats.yearlyBudget, 26 | backersCount: collective.stats.backers.all, 27 | contributorsCount: Object.keys(get(collective, 'data.githubContributors') || {}).length, 28 | }; 29 | 30 | res.send(response); 31 | } 32 | -------------------------------------------------------------------------------- /src/server/logger.js: -------------------------------------------------------------------------------- 1 | import expressWinston from 'express-winston'; 2 | import winston from 'winston'; 3 | 4 | function getLogLevel() { 5 | if (process.env.LOG_LEVEL) { 6 | return process.env.LOG_LEVEL; 7 | } else if ( 8 | process.env.NODE_ENV === 'production' || 9 | process.env.NODE_ENV === 'test' || 10 | process.env.NODE_ENV === 'ci' 11 | ) { 12 | return 'warn'; 13 | } else { 14 | return 'info'; 15 | } 16 | } 17 | 18 | const logger = winston.createLogger(); 19 | 20 | const winstonConsole = new winston.transports.Console({ 21 | level: getLogLevel(), 22 | format: winston.format.combine(winston.format.colorize(), winston.format.splat(), winston.format.simple()), 23 | }); 24 | 25 | logger.add(winstonConsole); 26 | logger.exceptions.handle(winstonConsole); 27 | 28 | const loggerMiddleware = { 29 | logger: expressWinston.logger({ 30 | winstonInstance: logger, 31 | meta: false, 32 | colorize: true, 33 | msg: `{{req.ip}} {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms - {{req.headers['user-agent']}}`, 34 | ignoreRoute: (req) => req.url.match(/^\/_/), 35 | }), 36 | errorLogger: expressWinston.errorLogger({ 37 | winstonInstance: logger, 38 | }), 39 | }; 40 | 41 | export { logger, loggerMiddleware }; 42 | -------------------------------------------------------------------------------- /test/server/controllers/events.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst } from '../../utils'; 2 | 3 | const validateEvent = (event) => { 4 | expect(event).toHaveProperty('id'); 5 | expect(event).toHaveProperty('name'); 6 | expect(event).toHaveProperty('slug'); 7 | expect(event).toHaveProperty('image'); 8 | expect(event).toHaveProperty('startsAt'); 9 | expect(event).toHaveProperty('endsAt'); 10 | expect(event).toHaveProperty('timezone'); 11 | expect(event).toHaveProperty('location'); 12 | expect(event).toHaveProperty('currency'); 13 | expect(event).toHaveProperty('tiers'); 14 | expect(event).toHaveProperty('url'); 15 | expect(event).toHaveProperty('attendees'); 16 | }; 17 | 18 | describe('events', () => { 19 | describe('Cache-Control', () => { 20 | test('is public with 10 minutes max-age', async () => { 21 | const response = await fetchResponseWithCacheBurst('/veganizerbxl/events/superfilles.json'); 22 | expect(response.headers['cache-control']).toEqual('public, max-age=600'); 23 | }); 24 | }); 25 | 26 | describe('info', () => { 27 | test('return /:collectiveSlug/events/:eventSlug.json', async () => { 28 | const event = await fetchJsonWithCacheBurst('/veganizerbxl/events/superfilles.json'); 29 | validateEvent(event); 30 | }); 31 | 32 | test('return /v1/:collectiveSlug/events/:eventSlug.json', async () => { 33 | const event = await fetchJsonWithCacheBurst('/v1/veganizerbxl/events/superfilles.json'); 34 | validateEvent(event); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/server/app.ts: -------------------------------------------------------------------------------- 1 | import '../env'; 2 | 3 | import cloudflareIps from 'cloudflare-ip/ips.json'; 4 | import cookieParser from 'cookie-parser'; 5 | import express from 'express'; 6 | 7 | import hyperwatch from './lib/hyperwatch'; 8 | import { parseToBooleanDefaultFalse } from './lib/utils'; 9 | import { loggerMiddleware } from './logger'; 10 | import { loadRoutes } from './routes'; 11 | 12 | const app = express(); 13 | 14 | app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal'].concat(cloudflareIps)); 15 | 16 | app.use(express.urlencoded({ limit: '50mb', extended: true })); 17 | app.use(express.json({ limit: '50mb' })); 18 | app.use(cookieParser()); 19 | 20 | if (parseToBooleanDefaultFalse(process.env.HYPERWATCH_ENABLED)) { 21 | hyperwatch(app); 22 | } 23 | 24 | app.use(loggerMiddleware.logger); 25 | app.use(loggerMiddleware.errorLogger); 26 | 27 | // Global caching strategy 28 | app.use((req, res, next) => { 29 | if ( 30 | req.get('Authorization') || 31 | req.query.apiKey || 32 | req.get('Personal-Token') || 33 | req.query.personalToken || 34 | req.get('Authorization') 35 | ) { 36 | // Make sure authenticated requests are never cached 37 | res.setHeader('Cache-Control', 'no-cache'); 38 | res.setHeader('Pragma', 'no-cache'); 39 | res.setHeader('Expires', '0'); 40 | } else { 41 | res.setHeader('Cache-Control', 'public, max-age=60'); 42 | res.setHeader('Vary', 'Accept-Encoding, Authorization, Personal-Token, Api-Key'); 43 | } 44 | 45 | next(); 46 | }); 47 | 48 | loadRoutes(app); 49 | 50 | export default app; 51 | -------------------------------------------------------------------------------- /src/server/lib/hyperwatch.js: -------------------------------------------------------------------------------- 1 | import hyperwatch from '@hyperwatch/hyperwatch'; 2 | import expressBasicAuth from 'express-basic-auth'; 3 | import expressWs from 'express-ws'; 4 | 5 | import { logger } from '../logger'; 6 | 7 | import { parseToBooleanDefaultFalse } from './utils'; 8 | 9 | const { 10 | HYPERWATCH_ENABLED: enabled, 11 | HYPERWATCH_PATH: path, 12 | HYPERWATCH_USERNAME: username, 13 | HYPERWATCH_SECRET: secret, 14 | } = process.env; 15 | 16 | export function load(app) { 17 | const { input, lib, modules, pipeline } = hyperwatch; 18 | 19 | // Mount Hyperwatch API and Websocket 20 | if (parseToBooleanDefaultFalse(enabled)) { 21 | // We need to setup express-ws here to make Hyperwatch's websocket works 22 | if (secret) { 23 | expressWs(app); 24 | const hyperwatchBasicAuth = expressBasicAuth({ 25 | users: { [username || 'opencollective']: secret }, 26 | challenge: true, 27 | }); 28 | app.use(path || '/_hyperwatch', hyperwatchBasicAuth, hyperwatch.app.api); 29 | app.use(path || '/_hyperwatch', hyperwatchBasicAuth, hyperwatch.app.websocket); 30 | } 31 | } 32 | 33 | // Configure input 34 | 35 | const expressInput = input.express.create(); 36 | 37 | app.use(expressInput.middleware()); 38 | 39 | pipeline.registerInput(expressInput); 40 | 41 | // Configure access Logs in dev and production 42 | 43 | const consoleLogOutput = process.env.NODE_ENV === 'development' ? 'console' : 'text'; 44 | pipeline.map((log) => logger.info(lib.logger.defaultFormatter.format(log, consoleLogOutput))); 45 | 46 | // Start 47 | 48 | modules.start(); 49 | 50 | pipeline.start(); 51 | } 52 | 53 | export default load; 54 | -------------------------------------------------------------------------------- /test/server/controllers/collectives.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst, generateJWT } from '../../utils'; 2 | 3 | const validateCollective = (collective) => { 4 | expect(collective).toHaveProperty('slug'); 5 | expect(collective).toHaveProperty('currency'); 6 | expect(collective).toHaveProperty('image'); 7 | expect(collective).toHaveProperty('balance'); 8 | expect(collective).toHaveProperty('yearlyIncome'); 9 | expect(collective).toHaveProperty('backersCount'); 10 | expect(collective).toHaveProperty('contributorsCount'); 11 | }; 12 | 13 | describe('collectives', () => { 14 | describe('Cache-Control', () => { 15 | test('is public with 1 hour max-age if not authenticated', async () => { 16 | const response = await fetchResponseWithCacheBurst('/railsgirlsatl.json'); 17 | expect(response.headers['cache-control']).toEqual('public, max-age=3600'); 18 | }); 19 | 20 | test('is public when authenticated too (no private data)', async () => { 21 | const response = await fetchResponseWithCacheBurst('/railsgirlsatl.json', { 22 | headers: { Authorization: `Bearer ${generateJWT()}` }, 23 | }); 24 | expect(response.headers['cache-control']).toEqual('public, max-age=3600'); 25 | }); 26 | }); 27 | 28 | describe('info', () => { 29 | test('return /:collectiveSlug.json', async () => { 30 | const collective = await fetchJsonWithCacheBurst('/railsgirlsatl.json'); 31 | validateCollective(collective); 32 | }); 33 | 34 | test('return /v1/:collectiveSlug.json', async () => { 35 | const collective = await fetchJsonWithCacheBurst('/v1/railsgirlsatl.json'); 36 | validateCollective(collective); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import '../src/env'; 2 | 3 | import jwt from 'jsonwebtoken'; 4 | import { inject } from 'light-my-request'; 5 | 6 | import app from '../src/server/app'; 7 | 8 | const cacheBurst = `cacheBurst=${Math.round(Math.random() * 100000)}`; 9 | 10 | interface RequestOptions { 11 | method?: string; 12 | headers?: Record; 13 | body?: string; 14 | } 15 | 16 | export const fetchResponseWithCacheBurst = async (path: string, options: RequestOptions = {}) => { 17 | const pathWithCacheBurst = [path, cacheBurst].join(path.indexOf('?') === -1 ? '?' : '&'); 18 | 19 | const url = new URL(pathWithCacheBurst, 'http://localhost'); 20 | const method = (options.method || 'GET').toUpperCase(); 21 | const headers = options.headers || {}; 22 | 23 | return inject(app, { 24 | method: method as any, 25 | url: url.pathname + url.search, 26 | headers, 27 | payload: options.body, 28 | }); 29 | }; 30 | 31 | export const fetchJsonWithCacheBurst = async (path: string, options: RequestOptions = {}) => { 32 | const response = await fetchResponseWithCacheBurst(path, options); 33 | try { 34 | return JSON.parse(response.payload); 35 | } catch { 36 | return response.payload; 37 | } 38 | }; 39 | 40 | /** 41 | * Default user: 9474 (testuser+admin@opencollective.com) 42 | */ 43 | export const generateJWT = (userId = 9474): string => { 44 | return jwt.sign( 45 | { 46 | scope: 'session', 47 | sessionId: '1234567890', 48 | }, 49 | 'vieneixaGhahk2aej2pohsh2aeB1oa6o', // Default development secret 50 | { 51 | expiresIn: '1h', 52 | subject: String(userId), 53 | algorithm: 'HS256', 54 | header: { 55 | kid: 'HS256-2019-09-02', 56 | }, 57 | }, 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /test/server/controllers/account-contributors.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst, generateJWT } from '../../utils'; 2 | 3 | const validateContributor = (contributor) => { 4 | expect(contributor).toHaveProperty('account'); 5 | expect(contributor).toHaveProperty('totalDonations'); 6 | expect(contributor.account).toHaveProperty('name'); 7 | expect(contributor.account).toHaveProperty('slug'); 8 | expect(contributor.account).toHaveProperty('type'); 9 | expect(contributor.totalDonations).toHaveProperty('value'); 10 | expect(contributor.totalDonations).toHaveProperty('currency'); 11 | }; 12 | 13 | describe('account-contributors', () => { 14 | describe('Cache-Control', () => { 15 | test('is public if not authenticated', async () => { 16 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/contributors.json'); 17 | expect(response.headers['cache-control']).toEqual('public, max-age=60'); 18 | }); 19 | 20 | test('is private if authenticated', async () => { 21 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/contributors.json', { 22 | headers: { Authorization: `Bearer ${generateJWT()}` }, 23 | }); 24 | expect(response.headers['cache-control']).toEqual('no-cache'); 25 | expect(response.headers['pragma']).toEqual('no-cache'); 26 | expect(response.headers['expires']).toEqual('0'); 27 | }); 28 | }); 29 | 30 | describe('accountContributors', () => { 31 | test('return /v2/:slug/contributors.json', async () => { 32 | const result = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/contributors.json'); 33 | expect(result.totalCount).toBeGreaterThan(0); 34 | expect(Array.isArray(result.nodes)).toBe(true); 35 | if (result.nodes.length > 0) { 36 | validateContributor(result.nodes[0]); 37 | } 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/server/controllers/transactions.js: -------------------------------------------------------------------------------- 1 | import { get, pick } from 'lodash'; 2 | 3 | import { allTransactionsQuery, getTransactionQuery, graphqlRequest } from '../lib/graphql'; 4 | 5 | /** 6 | * Get array of all transactions of a collective given its slug 7 | */ 8 | export const allTransactions = async (req, res) => { 9 | try { 10 | const args = pick(req.query, ['limit', 'offset', 'type']); 11 | args.collectiveSlug = get(req, 'params.collectiveSlug'); 12 | if (args.limit) { 13 | args.limit = Number(args.limit); 14 | } 15 | if (args.offset) { 16 | args.offset = Number(args.offset); 17 | } 18 | const response = await graphqlRequest(allTransactionsQuery, args, { 19 | apiKey: req.apiKey, 20 | }); 21 | const result = get(response, 'allTransactions', []); 22 | res.send({ result }); 23 | } catch (error) { 24 | console.log(error); 25 | if (error.response && error.response.errors) { 26 | const singleError = error.response.errors[0]; 27 | res.status(400).send({ error: singleError.message }); 28 | } else if (error.response && error.response.error) { 29 | res.status(400).send({ error: error.response.error.message }); 30 | } else { 31 | res.status(400).send({ error: error.toString() }); 32 | } 33 | } 34 | }; 35 | 36 | /** 37 | * Get one transaction of a collective given its uuid 38 | */ 39 | export const getTransaction = async (req, res) => { 40 | try { 41 | const args = pick(req.params, ['id', 'uuid']); 42 | const response = await graphqlRequest(getTransactionQuery, args, { 43 | apiKey: req.apiKey, 44 | }); 45 | if (response.errors) { 46 | throw new Error(response.errors[0]); 47 | } 48 | const result = get(response, 'Transaction'); 49 | if (req.params.collectiveSlug !== result.collective.slug) { 50 | res.status(404).send({ error: 'Not a collective transaction.' }); 51 | } else { 52 | res.send({ result }); 53 | } 54 | } catch (error) { 55 | console.log(error); 56 | res.status(400).send({ error: error.toString() }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /test/server/controllers/hosted-collectives.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst } from '../../utils'; 2 | 3 | const validateHostedCollective = (collective) => { 4 | expect(collective).toHaveProperty('name'); 5 | expect(collective).toHaveProperty('slug'); 6 | expect(collective).toHaveProperty('type'); 7 | expect(collective).toHaveProperty('currency'); 8 | expect(collective).toHaveProperty('balance'); 9 | expect(collective).toHaveProperty('status'); 10 | }; 11 | 12 | describe('hosted-collectives', () => { 13 | describe('Cache-Control', () => { 14 | test('is public if not authenticated', async () => { 15 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/hosted-collectives.json'); 16 | expect(response.headers['cache-control']).toEqual('public, max-age=60'); 17 | }); 18 | 19 | test('is private if authenticated', async () => { 20 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/hosted-collectives.json', { 21 | headers: { Authorization: 'Bearer 1234567890' }, 22 | }); 23 | expect(response.headers['cache-control']).toEqual('no-cache'); 24 | expect(response.headers['pragma']).toEqual('no-cache'); 25 | expect(response.headers['expires']).toEqual('0'); 26 | }); 27 | }); 28 | 29 | describe('hostedCollectives', () => { 30 | test('return /v2/:slug/hosted-collectives.json', async () => { 31 | const collectives = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/hosted-collectives.json'); 32 | expect(collectives).toHaveProperty('limit'); 33 | expect(collectives).toHaveProperty('offset'); 34 | expect(collectives).toHaveProperty('totalCount'); 35 | expect(collectives).toHaveProperty('nodes'); 36 | expect(Array.isArray(collectives.nodes)).toBe(true); 37 | if (collectives.nodes.length > 0) { 38 | validateHostedCollective(collectives.nodes[0]); 39 | } 40 | }); 41 | 42 | test('return /v2/:slug/hosted-collectives.csv', async () => { 43 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/hosted-collectives.csv'); 44 | expect(response.headers['content-type']).toContain('text/csv'); 45 | expect(response.headers['content-disposition']).toContain('attachment'); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/server/controllers/account-orders.js: -------------------------------------------------------------------------------- 1 | import gqlV2 from 'graphql-tag'; 2 | import { intersection, pick } from 'lodash'; 3 | 4 | import { graphqlRequest } from '../lib/graphql'; 5 | import { logger } from '../logger'; 6 | 7 | const query = gqlV2` 8 | query AccountOrders( 9 | $slug: String! 10 | $filter: AccountOrdersFilter 11 | $status: [OrderStatus] 12 | $tierSlug: String 13 | $limit: Int 14 | $offset: Int 15 | ) { 16 | account(slug: $slug) { 17 | orders(filter: $filter, status: $status, tierSlug: $tierSlug, limit: $limit, offset: $offset) { 18 | limit 19 | offset 20 | totalCount 21 | nodes { 22 | fromAccount { 23 | name 24 | slug 25 | type 26 | imageUrl 27 | website 28 | twitterHandle 29 | } 30 | amount { 31 | value 32 | } 33 | tier { 34 | slug 35 | } 36 | frequency 37 | status 38 | totalDonations { 39 | value 40 | } 41 | createdAt 42 | } 43 | } 44 | } 45 | } 46 | `; 47 | 48 | const accountOrders = async (req, res) => { 49 | const variables = pick({ ...req.params, ...req.query }, ['slug', 'filter', 'status', 'tierSlug', 'limit', 'offset']); 50 | variables.limit = Number(variables.limit) || 100; 51 | variables.offset = Number(variables.offset) || 0; 52 | 53 | if (variables.status) { 54 | variables.status = intersection(variables.status.toUpperCase().split(','), [ 55 | 'ACTIVE', 56 | 'CANCELLED', 57 | 'ERROR', 58 | 'PAID', 59 | 'PENDING', 60 | ]); 61 | } else { 62 | variables.status = ['ACTIVE', 'CANCELLED', 'PAID']; 63 | } 64 | 65 | if (variables.tierSlug) { 66 | variables.filter = 'INCOMING'; 67 | } else if (variables.filter) { 68 | variables.filter = variables.filter.toUpperCase(); 69 | } 70 | 71 | try { 72 | const result = await graphqlRequest(query, variables, { version: 'v2' }); 73 | res.send(result.account.orders); 74 | } catch (err) { 75 | if (err.message.match(/No collective found/)) { 76 | return res.status(404).send('Not found'); 77 | } 78 | logger.error(`Error while fetching collective orders: ${err.message}`); 79 | res.status(400).send(`Error while fetching collective orders.`); 80 | } 81 | }; 82 | 83 | export default accountOrders; 84 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## To fork or not to fork 4 | 5 | If you want to change a simple thing, for example, fix a typo or update copy, feel free to use the GitHub web interface, that's perfect. Under the hood, it will do complex things but you don't need to think about it! 6 | 7 | ## Style 8 | 9 | For formatting and code style, we use [Prettier](https://prettier.io/) and [ESLint](https://eslint.org/). Before committing, please run: 10 | 11 | - `npm run prettier:write` 12 | - `npm run lint:fix` 13 | 14 | For the long run, we suggest to integrate these tools in your favorite code editor: 15 | 16 | - check [Prettier Editor Integration](https://prettier.io/docs/en/editors.html) 17 | - check [ESLint Editor Integrations](https://eslint.org/docs/user-guide/integrations) 18 | 19 | ## Commit convention 20 | 21 | Your commit messages should conform to the [Angular convention](https://github.com/conventional-changelog/conventional-changelog/blob/master/packages/conventional-changelog-angular/README.md). 22 | 23 | To help you follow this convention, this project is using [commitizen](https://github.com/commitizen/cz-cli). To use it: 24 | 25 | 1. run `git add` first to add your changes to Git staging area 26 | 2. use `npm run commit` to commit 27 | 28 | Note: it's not mandatory to always commit with this tool (we don't), but it's great to get introduced to the commit conventions. 29 | 30 | ## Git guidelines 31 | 32 | We do aim having a clean Git history! When submitting a Pull Request, make sure: 33 | 34 | - each commit make sense and have a self-explaining message 35 | - there is no unnecessary commits (such as "typo", "fix", "fix again", "eslint", "eslint again" or merge commits) 36 | 37 | Some tips to keep a clean Git history while working on your feature branch: 38 | 39 | - always update from main with `git pull --rebase origin main` or similar 40 | - you might have to `git push origin --force`, that's all right if you're the only one working on the feature branch 41 | - `git commit --amend` to modify your last commit with "fix", "typo", "prettier" or "eslint" modifications 42 | - `git rebase --interactive` to rewrite the history 43 | 44 | We understand Git is not always easy for everyone and want to be inclusive. If it's difficult for you to submit a Pull request with a clean Git history, that's all right, we can always [squash and merge](https://help.github.com/articles/about-pull-request-merges/#squash-and-merge-your-pull-request-commits) it. 45 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config'; 2 | import globals from 'globals'; 3 | import js from '@eslint/js'; 4 | import formatjs from 'eslint-plugin-formatjs'; 5 | import tseslint from 'typescript-eslint'; 6 | import pluginReact from 'eslint-plugin-react'; 7 | import path from 'path'; 8 | import { fileURLToPath } from 'url'; 9 | import { includeIgnoreFile } from '@eslint/compat'; 10 | import pluginJest from 'eslint-plugin-jest'; 11 | 12 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 13 | const gitignorePath = path.resolve(__dirname, '.gitignore'); 14 | 15 | export default defineConfig([ 16 | includeIgnoreFile(gitignorePath), 17 | { ignores: ['node_modules', 'dist', 'coverage', '.nyc_output', '**/*.graphql'] }, 18 | { 19 | files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], 20 | languageOptions: { globals: globals.node }, 21 | }, 22 | { 23 | files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], 24 | plugins: { js }, 25 | extends: ['js/recommended'], 26 | }, 27 | tseslint.configs.recommended, 28 | pluginReact.configs.flat.recommended, 29 | { 30 | files: ['**/*.{ts,tsx}'], 31 | plugins: { 32 | formatjs, 33 | }, 34 | rules: { 35 | '@typescript-eslint/no-explicit-any': 'warn', 36 | 'react/prop-types': 'off', 37 | 'formatjs/enforce-id': [ 38 | 'error', 39 | { 40 | idInterpolationPattern: '[sha512:contenthash:base64:6]', 41 | }, 42 | ], 43 | }, 44 | }, 45 | // Test files 46 | { 47 | // update this to match your test files 48 | files: ['**/*.spec.js', '**/*.test.{js,ts}'], 49 | plugins: { jest: pluginJest }, 50 | languageOptions: { 51 | globals: pluginJest.environments.globals.globals, 52 | }, 53 | rules: { 54 | 'jest/no-disabled-tests': 'warn', 55 | 'jest/no-focused-tests': 'error', 56 | 'jest/no-identical-title': 'error', 57 | 'jest/prefer-to-have-length': 'warn', 58 | 'jest/valid-expect': 'error', 59 | 'no-restricted-properties': [ 60 | 'error', 61 | { 62 | object: 'test', 63 | property: 'only', 64 | message: 'test.only should only be used for debugging purposes and is not allowed in production code', 65 | }, 66 | { 67 | object: 'describe', 68 | property: 'only', 69 | message: 'describe.only should only be used for debugging purposes and is not allowed in production code', 70 | }, 71 | ], 72 | }, 73 | }, 74 | ]); 75 | -------------------------------------------------------------------------------- /src/server/routes.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import type { Express } from 'express'; 3 | 4 | import { idOrUuid } from './lib/utils'; 5 | import controllers from './controllers'; 6 | 7 | const requireApiKey = (req, res, next) => { 8 | req.apiKey = req.get('Personal-Token') || req.query.personalToken || req.get('Api-Key') || req.query.apiKey; 9 | next(); 10 | }; 11 | 12 | export const loadRoutes = (app: Express) => { 13 | app.use(cors()); 14 | 15 | app.get('/', (req, res) => { 16 | res.send('This is the Open Collective REST API.'); 17 | }); 18 | 19 | /** 20 | * Prevent indexation from search engines 21 | */ 22 | app.get('/robots.txt', (req, res) => { 23 | res.setHeader('Content-Type', 'text/plain'); 24 | res.send('User-agent: *\nDisallow: /'); 25 | }); 26 | 27 | app.get('/:version(v1)?/:collectiveSlug.:format(json)', controllers.collectives.info); 28 | app.get('/:version(v1)?/:collectiveSlug/members.:format(json|csv)', controllers.members.list); 29 | app.get( 30 | '/:version(v1)?/:collectiveSlug/members/:backerType(all|users|organizations).:format(json|csv)', 31 | controllers.members.list, 32 | ); 33 | app.get( 34 | '/:version(v1)?/:collectiveSlug/tiers/:tierSlug/:backerType(all|users|organizations).:format(json|csv)', 35 | controllers.members.list, 36 | ); 37 | 38 | app.get('/:version(v1)?/:collectiveSlug/events/:eventSlug.:format(json)', controllers.events.info); 39 | app.get( 40 | '/:version(v1)?/:collectiveSlug/events/:eventSlug/:role(attendees|followers|organizers|all).:format(json|csv)', 41 | controllers.members.list, 42 | ); 43 | 44 | /* API v1 */ 45 | 46 | app.param('idOrUuid', idOrUuid); 47 | 48 | // Get transactions of a collective given its slug. 49 | app.get('/v1/collectives/:collectiveSlug/transactions', requireApiKey, controllers.transactions.allTransactions); 50 | app.get( 51 | '/v1/collectives/:collectiveSlug/transactions/:idOrUuid', 52 | requireApiKey, 53 | controllers.transactions.getTransaction, 54 | ); 55 | 56 | /* API v2 */ 57 | 58 | app.get( 59 | '/v2/:slug/tier/:tierSlug/orders/:filter(incoming)?/:status(active|cancelled|error|paid|pending)?', 60 | controllers.accountOrders, 61 | ); 62 | 63 | app.get( 64 | '/v2/:slug/orders/:filter(incoming|outgoing)?/:status(active|cancelled|error|paid|pending)?', 65 | controllers.accountOrders, 66 | ); 67 | 68 | app.all( 69 | '/v2/:slug/:reportType(hostTransactions|transactions)/:type(credit|debit)?/:kind(contribution|expense|added_funds|host_fee|host_fee_share|host_fee_share_debt|platform_tip|platform_tip_debt)?.:format(json|csv|txt)', 70 | controllers.accountTransactions, 71 | ); 72 | 73 | app.get('/v2/:slug/contributors.:format(json|csv)', controllers.accountContributors); 74 | 75 | app.all('/v2/:slug/hosted-collectives.:format(json|csv)', controllers.hostedCollectives); 76 | }; 77 | -------------------------------------------------------------------------------- /test/server/controllers/members.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst, generateJWT } from '../../utils'; 2 | 3 | const validateMember = (member) => { 4 | expect(member).toHaveProperty('MemberId'); 5 | expect(member).toHaveProperty('name'); 6 | expect(member).toHaveProperty('image'); 7 | expect(member).toHaveProperty('twitter'); 8 | expect(member).toHaveProperty('github'); 9 | expect(member).toHaveProperty('website'); 10 | expect(member).toHaveProperty('profile'); 11 | expect(member).toHaveProperty('isActive'); 12 | expect(member).toHaveProperty('lastTransactionAt'); 13 | expect(member).toHaveProperty('lastTransactionAmount'); 14 | expect(member).toHaveProperty('totalAmountDonated'); 15 | }; 16 | 17 | describe('members', () => { 18 | describe('Cache-Control', () => { 19 | test('is public if not authenticated', async () => { 20 | const response = await fetchResponseWithCacheBurst('/railsgirlsatl/members.json'); 21 | expect(response.headers['cache-control']).toEqual('public, max-age=60'); 22 | }); 23 | 24 | test('is private if authenticated', async () => { 25 | const response = await fetchResponseWithCacheBurst('/railsgirlsatl/members.json', { 26 | headers: { Authorization: `Bearer ${generateJWT()}` }, 27 | }); 28 | expect(response.headers['cache-control']).toEqual('no-cache'); 29 | expect(response.headers['pragma']).toEqual('no-cache'); 30 | expect(response.headers['expires']).toEqual('0'); 31 | }); 32 | }); 33 | 34 | describe('base', () => { 35 | test('return /:collectiveSlug/members.json', async () => { 36 | const members = await fetchJsonWithCacheBurst('/railsgirlsatl/members.json'); 37 | expect(members.length).toBeGreaterThan(5); 38 | validateMember(members[0]); 39 | }); 40 | 41 | test('return /:collectiveSlug/members/organizations.json', async () => { 42 | const organizations = await fetchJsonWithCacheBurst('/railsgirlsatl/members/organizations.json'); 43 | expect(organizations.length).toBeGreaterThan(2); 44 | validateMember(organizations[0]); 45 | expect(organizations[0].type).toEqual('ORGANIZATION'); 46 | expect(organizations[1].type).toEqual('ORGANIZATION'); 47 | }); 48 | }); 49 | 50 | describe('for event', () => { 51 | test('return /:collectiveSlug/events/:eventSlug/attendees.json', async () => { 52 | const attendees = await fetchJsonWithCacheBurst('/veganizerbxl/events/superfilles/attendees.json'); 53 | validateMember(attendees[0]); 54 | expect(attendees[0].role).toEqual('ATTENDEE'); 55 | expect(attendees[1].role).toEqual('ATTENDEE'); 56 | }); 57 | 58 | test('return /:collectiveSlug/events/:eventSlug/followers.json', async () => { 59 | const followers = await fetchJsonWithCacheBurst('/veganizerbxl/events/superfilles/followers.json'); 60 | validateMember(followers[0]); 61 | expect(followers[0].role).toEqual('FOLLOWER'); 62 | expect(followers[1].role).toEqual('FOLLOWER'); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/server/controllers/account-orders.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst, generateJWT } from '../../utils'; 2 | 3 | const validateOrder = (order) => { 4 | expect(order).toHaveProperty('fromAccount'); 5 | expect(order).toHaveProperty('amount'); 6 | expect(order).toHaveProperty('frequency'); 7 | expect(order).toHaveProperty('status'); 8 | expect(order).toHaveProperty('totalDonations'); 9 | expect(order).toHaveProperty('createdAt'); 10 | }; 11 | 12 | describe('account-orders', () => { 13 | describe('Cache-Control', () => { 14 | test('is public if not authenticated', async () => { 15 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/orders'); 16 | expect(response.headers['cache-control']).toEqual('public, max-age=60'); 17 | }); 18 | 19 | test('is private if authenticated', async () => { 20 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/orders', { 21 | headers: { Authorization: `Bearer ${generateJWT()}` }, 22 | }); 23 | expect(response.headers['cache-control']).toEqual('no-cache'); 24 | expect(response.headers['pragma']).toEqual('no-cache'); 25 | expect(response.headers['expires']).toEqual('0'); 26 | }); 27 | }); 28 | 29 | describe('accountOrders', () => { 30 | test('return /v2/:slug/orders', async () => { 31 | const orders = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/orders'); 32 | expect(orders).toHaveProperty('limit'); 33 | expect(orders).toHaveProperty('offset'); 34 | expect(orders).toHaveProperty('totalCount'); 35 | expect(orders).toHaveProperty('nodes'); 36 | expect(Array.isArray(orders.nodes)).toBe(true); 37 | if (orders.nodes.length > 0) { 38 | validateOrder(orders.nodes[0]); 39 | } 40 | }); 41 | 42 | test('return /v2/:slug/orders/incoming', async () => { 43 | const orders = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/orders/incoming'); 44 | expect(orders).toHaveProperty('nodes'); 45 | expect(Array.isArray(orders.nodes)).toBe(true); 46 | }); 47 | 48 | test('return /v2/:slug/orders/outgoing', async () => { 49 | const orders = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/orders/outgoing'); 50 | expect(orders).toHaveProperty('nodes'); 51 | expect(Array.isArray(orders.nodes)).toBe(true); 52 | }); 53 | 54 | test('return /v2/:slug/orders/incoming/active', async () => { 55 | const orders = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/orders/incoming/active'); 56 | expect(orders).toHaveProperty('nodes'); 57 | expect(Array.isArray(orders.nodes)).toBe(true); 58 | }); 59 | 60 | describe('tier orders', () => { 61 | test('return /v2/:slug/tier/:tierSlug/orders', async () => { 62 | const orders = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/tier/backers/orders'); 63 | expect(orders).toHaveProperty('nodes'); 64 | expect(Array.isArray(orders.nodes)).toBe(true); 65 | }); 66 | 67 | test('return 404 if tier does not exists', async () => { 68 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/tier/backers-not-exists/orders'); 69 | expect(response.statusCode).toBe(400); // TODO: should be 404 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Collective REST API 2 | 3 | [![Dependency Status](https://david-dm.org/opencollective/opencollective-rest/status.svg)](https://david-dm.org/opencollective/opencollective-rest) 4 | 5 | ## Foreword 6 | 7 | If you see a step below that could be improved (or is outdated), please update the instructions. We rarely go through this process ourselves, so your fresh pair of eyes and your recent experience with it, makes you the best candidate to improve them for other users. Thank you! 8 | 9 | ## Development 10 | 11 | ### Prerequisite 12 | 13 | 1. Make sure you have Node.js version >= 16. 14 | 15 | - We recommend using [nvm](https://github.com/creationix/nvm): `nvm install && nvm use`. 16 | 17 | ### Install 18 | 19 | We recommend cloning the repository in a folder dedicated to `opencollective` projects. 20 | 21 | ``` 22 | git clone git@github.com:opencollective/opencollective-rest.git opencollective/rest 23 | cd opencollective/rest 24 | npm install 25 | ``` 26 | 27 | ### Environment variables 28 | 29 | This project requires an access to the Open Collective API. You have two options: 30 | 31 | - `cp .env.staging .env` to connect to the Open Collective staging API 32 | - `cp .env.local .env` to connect to the API running locally 33 | 34 | If you decide to pick the local strategy, make sure you install and run the [opencollective-api](https://github.com/opencollective/opencollective-api) project. 35 | 36 | ### Start 37 | 38 | ``` 39 | npm run dev 40 | ``` 41 | 42 | ## Contributing 43 | 44 | Code style? Commit convention? Please check our [Contributing guidelines](CONTRIBUTING.md). 45 | 46 | TL;DR: we use [Prettier](https://prettier.io/) and [ESLint](https://eslint.org/), we do like great commit messages and clean Git history. 47 | 48 | ## Tests 49 | 50 | After starting the API and REST services locally, you can run the tests using `npm test` or more specifically: 51 | 52 | - `npm run test:server` 53 | 54 | To update: 55 | 56 | - GraphQL schema for eslint: run `npm run graphql:update` 57 | 58 | ## Deployment 59 | 60 | To deploy to staging or production, you need to be a core member of the Open Collective team. 61 | 62 | ### (Optional) Configure Slack token 63 | 64 | Setting a Slack token will post a message on `#engineering` with the changes you're 65 | about to deploy. It is not required, but you can activate it like this: 66 | 67 | 1. Go to https://api.slack.com/custom-integrations/legacy-tokens 68 | 2. Generate a token for the OpenCollective workspace 69 | 3. Add this token to your `.env` file: 70 | 71 | ```bash 72 | OC_SLACK_DEPLOY_WEBHOOK=https://hooks.slack.com/services/.... 73 | ``` 74 | 75 | ### Staging (heroku) 76 | 77 | ```bash 78 | # Before first deployment, configure staging remote 79 | git remote add staging https://git.heroku.com/oc-staging-rest-api.git 80 | 81 | # Then deploy main with 82 | npm run deploy:staging 83 | ``` 84 | 85 | URL: https://rest-staging.opencollective.com/ 86 | 87 | ### Production (heroku) 88 | 89 | ```bash 90 | # Before first deployment, configure production remote 91 | git remote add production https://git.heroku.com/oc-prod-rest-api.git 92 | 93 | # Then deploy main with 94 | npm run deploy:production 95 | ``` 96 | 97 | URL: https://rest.opencollective.com/ 98 | 99 | ## Discussion 100 | 101 | If you have any questions, ping us on [Discord](https://discord.opencollective.com). 102 | -------------------------------------------------------------------------------- /src/server/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { get, isNaN, toUpper, trim } from 'lodash'; 2 | 3 | export const getBaseApiUrl = () => { 4 | return process.env.API_URL; 5 | }; 6 | 7 | export const getGraphqlUrl = ({ apiKey, version }: { apiKey?: string; version?: string } = {}) => { 8 | if (apiKey) { 9 | return `${getBaseApiUrl()}/graphql/${version || 'v1'}?apiKey=${apiKey}`; 10 | } else { 11 | return `${getBaseApiUrl()}/graphql/${version || 'v1'}?api_key=${process.env.API_KEY}`; 12 | } 13 | }; 14 | 15 | /** 16 | * Gives the number of days between two dates 17 | */ 18 | export const days = (d1, d2 = new Date()) => { 19 | const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds 20 | return Math.round(Math.abs((new Date(d1).getTime() - new Date(d2).getTime()) / oneDay)); 21 | }; 22 | 23 | export function json2csv(json) { 24 | const lines = [`"${Object.keys(json[0]).join('","')}"`]; 25 | json.forEach((row) => { 26 | lines.push( 27 | `"${Object.values(row) 28 | .map((td) => { 29 | if (typeof td === 'string') { 30 | return td.replace(/"/g, '""').replace(/\n/g, ' '); 31 | } else if (td !== undefined && td !== null) { 32 | return td; 33 | } else { 34 | return ''; 35 | } 36 | }) 37 | .join('","')}"`, 38 | ); 39 | }); 40 | return lines.join('\n'); 41 | } 42 | 43 | function isUUID(str) { 44 | return str.length === 36 && str.match(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i); 45 | } 46 | 47 | function parseIdOrUUID(param) { 48 | if (isUUID(param)) { 49 | return Promise.resolve({ uuid: param }); 50 | } 51 | 52 | const id = parseInt(param); 53 | 54 | if (isNaN(id)) { 55 | return Promise.reject(new Error('This is not a correct id.')); 56 | } else { 57 | return Promise.resolve({ id }); 58 | } 59 | } 60 | 61 | export function idOrUuid(req, res, next, idOrUuid) { 62 | parseIdOrUUID(idOrUuid) 63 | .then(({ id, uuid }) => { 64 | if (id) { 65 | req.params.id = id; 66 | } 67 | if (uuid) { 68 | req.params.uuid = uuid; 69 | } 70 | next(); 71 | }) 72 | .catch(next); 73 | } 74 | 75 | export const parseToBooleanDefaultFalse = (value: null | undefined | string | boolean) => { 76 | if (value === null || value === undefined || value === '') { 77 | return false; 78 | } 79 | const string = value.toString().trim().toLowerCase(); 80 | return ['on', 'enabled', '1', 'true', 'yes', 1].includes(string); 81 | }; 82 | 83 | export const parseToBooleanDefaultTrue = (value: null | undefined | string | boolean) => { 84 | if (value === null || value === undefined || value === '') { 85 | return true; 86 | } 87 | const string = value.toString().trim().toLowerCase(); 88 | return !['off', 'disabled', '0', 'false', 'no', 0].includes(string); 89 | }; 90 | 91 | export const splitIds = (str?: string) => str?.split(',').map(trim) || []; 92 | 93 | export const splitEnums = (str?: string) => splitIds(str).map(toUpper); 94 | 95 | export const applyMapping = (mapping, row, meta?) => { 96 | const res = {}; 97 | Object.keys(mapping).map((key) => { 98 | const val = mapping[key]; 99 | if (typeof val === 'function') { 100 | return (res[key] = val(row, meta)); 101 | } else { 102 | return (res[key] = get(row, val)); 103 | } 104 | }); 105 | return res; 106 | }; 107 | -------------------------------------------------------------------------------- /test/server/controllers/account-transactions.test.js: -------------------------------------------------------------------------------- 1 | import { fetchJsonWithCacheBurst, fetchResponseWithCacheBurst, generateJWT } from '../../utils'; 2 | 3 | const validateTransaction = (transaction) => { 4 | expect(transaction).toHaveProperty('id'); 5 | expect(transaction).toHaveProperty('type'); 6 | expect(transaction).toHaveProperty('kind'); 7 | expect(transaction).toHaveProperty('amount'); 8 | expect(transaction).toHaveProperty('createdAt'); 9 | expect(transaction.amount).toHaveProperty('value'); 10 | expect(transaction.amount).toHaveProperty('currency'); 11 | }; 12 | 13 | describe('account-transactions', () => { 14 | describe('Cache-Control', () => { 15 | test('is public if not authenticated', async () => { 16 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/transactions.json'); 17 | expect(response.headers['cache-control']).toEqual('public, max-age=60'); 18 | }); 19 | 20 | test('is private if authenticated', async () => { 21 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/transactions.json', { 22 | headers: { Authorization: `Bearer ${generateJWT()}` }, 23 | }); 24 | expect(response.headers['cache-control']).toEqual('no-cache'); 25 | expect(response.headers['pragma']).toEqual('no-cache'); 26 | expect(response.headers['expires']).toEqual('0'); 27 | }); 28 | }); 29 | 30 | describe('accountTransactions', () => { 31 | test('return /v2/:slug/transactions.json', async () => { 32 | const transactions = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/transactions.json'); 33 | expect(transactions).toHaveProperty('limit'); 34 | expect(transactions).toHaveProperty('offset'); 35 | expect(transactions).toHaveProperty('totalCount'); 36 | expect(transactions).toHaveProperty('nodes'); 37 | expect(Array.isArray(transactions.nodes)).toBe(true); 38 | if (transactions.nodes.length > 0) { 39 | validateTransaction(transactions.nodes[0]); 40 | } 41 | }); 42 | 43 | test('return /v2/:slug/transactions.csv', async () => { 44 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/transactions.csv'); 45 | expect(response.headers['content-type']).toContain('text/csv'); 46 | expect(response.headers['content-disposition']).toContain('attachment'); 47 | }); 48 | 49 | test('return /v2/:slug/transactions.txt', async () => { 50 | const response = await fetchResponseWithCacheBurst('/v2/railsgirlsatl/transactions.txt'); 51 | expect(response.headers['content-type']).toContain('text/plain'); 52 | expect(response.headers['content-disposition']).toContain('attachment'); 53 | }); 54 | 55 | test('return /v2/:slug/transactions/credit.json', async () => { 56 | const transactions = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/transactions/credit.json'); 57 | expect(transactions).toHaveProperty('nodes'); 58 | expect(Array.isArray(transactions.nodes)).toBe(true); 59 | }); 60 | 61 | test('return /v2/:slug/transactions/debit.json', async () => { 62 | const transactions = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/transactions/debit.json'); 63 | expect(transactions).toHaveProperty('nodes'); 64 | expect(Array.isArray(transactions.nodes)).toBe(true); 65 | }); 66 | 67 | test('return /v2/:slug/transactions/credit/contribution.json', async () => { 68 | const transactions = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/transactions/credit/contribution.json'); 69 | expect(transactions).toHaveProperty('nodes'); 70 | expect(Array.isArray(transactions.nodes)).toBe(true); 71 | }); 72 | 73 | test('return /v2/:slug/hostTransactions.json', async () => { 74 | const transactions = await fetchJsonWithCacheBurst('/v2/railsgirlsatl/hostTransactions.json'); 75 | expect(transactions).toHaveProperty('nodes'); 76 | expect(Array.isArray(transactions.nodes)).toBe(true); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /scripts/pre-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Description 4 | # =========== 5 | # 6 | # Pre-deploy hook. Does the following: 7 | # 1. Shows the commits about to be pushed 8 | # 2. Ask for confirmation (exit with 1 if not confirming) 9 | # 3. Notify Slack 10 | # 11 | # 12 | # Developing 13 | # ========== 14 | # 15 | # During development, the best way to test it is to call the script 16 | # directly with `./scripts/pre-deploy.sh staging|production`. You can also set 17 | # the `SLACK_CHANNEL` to your personnal channel so you don't flood the team. 18 | # To do that, right click on your own name in Slack, `Copy link`, then 19 | # only keep the last part of the URL. 20 | # 21 | # Or you can set `PUSH_TO_SLACK` to false to echo the payload instead of 22 | # sending it. 23 | # 24 | # ------------------------------------------------------------------------------ 25 | 26 | if [ "$#" -ne 1 ]; then 27 | echo "Usage: [DEPLOY_MSG='An optional custom deploy message'] $0 staging|production" 28 | exit 1 29 | fi 30 | 31 | # ---- Variables ---- 32 | 33 | if [ "$1" == "staging" ]; then 34 | DEPLOY_ORIGIN_URL="https://git.heroku.com/oc-staging-rest-api.git" 35 | elif [ "$1" == "production" ]; then 36 | DEPLOY_ORIGIN_URL="https://git.heroku.com/oc-prod-rest-api.git" 37 | else 38 | echo "Unknwown remote $1" 39 | exit 1 40 | fi 41 | 42 | PUSH_TO_SLACK=true # Setting this to false will echo the message instead of pushing to Slack 43 | SLACK_CHANNEL="CEZUS9WH3" 44 | 45 | LOCAL_ORIGIN="origin" 46 | PRE_DEPLOY_ORIGIN="predeploy-${1}" 47 | 48 | LOCAL_BRANCH="main" 49 | PRE_DEPLOY_BRANCH="main" 50 | 51 | GIT_LOG_FORMAT_SHELL='short' 52 | GIT_LOG_FORMAT_SLACK='format: *%an* %n_%<(80,trunc)%s_%n' 53 | GIT_LOG_COMPARISON="$PRE_DEPLOY_ORIGIN/$PRE_DEPLOY_BRANCH..$LOCAL_ORIGIN/$LOCAL_BRANCH" 54 | 55 | # ---- Utils ---- 56 | 57 | function confirm() 58 | { 59 | echo -n "$@" 60 | read -e answer 61 | for response in y Y yes YES Yes Sure sure SURE OK ok Ok 62 | do 63 | if [ "$answer" == "$response" ] 64 | then 65 | return 0 66 | fi 67 | done 68 | 69 | # Any answer other than the list above is considerred a "no" answer 70 | return 1 71 | } 72 | 73 | function exit_success() 74 | { 75 | echo "🚀 Deploying now..." 76 | exit 0 77 | } 78 | 79 | # ---- Ensure we have a reference to the remote ---- 80 | 81 | git remote add $PRE_DEPLOY_ORIGIN $DEPLOY_ORIGIN_URL &> /dev/null 82 | 83 | # ---- Show the commits about to be pushed ---- 84 | 85 | # Update deploy remote 86 | echo "ℹ️ Fetching remote $1 state..." 87 | git fetch $PRE_DEPLOY_ORIGIN > /dev/null 88 | 89 | echo "" 90 | echo "-------------- New commits --------------" 91 | git --no-pager log --pretty="${GIT_LOG_FORMAT_SHELL}" $GIT_LOG_COMPARISON 92 | echo "-----------------------------------------" 93 | echo "" 94 | 95 | # ---- Ask for confirmation ---- 96 | 97 | echo "ℹ️ You're about to deploy the preceding commits from main branch to $1 server." 98 | confirm "❔ Are you sure (yes/no) > " || exit 1 99 | 100 | # ---- Slack notification ---- 101 | 102 | cd -- "$(dirname $0)/.." 103 | eval $(cat .env | grep OC_SLACK_DEPLOY_WEBHOOK=) 104 | 105 | if [ -z "$OC_SLACK_DEPLOY_WEBHOOK" ]; then 106 | # Emit a warning as we don't want the deploy to crash just because we 107 | # havn't setup a Slack token. Get yours on https://api.slack.com/custom-integrations/legacy-tokens 108 | echo "ℹ️ OC_SLACK_DEPLOY_WEBHOOK is not set, I will not notify Slack about this deploy 😞 (please do it manually)" 109 | exit_success 110 | fi 111 | 112 | ESCAPED_CHANGELOG=$( 113 | git log --pretty="${GIT_LOG_FORMAT_SLACK}" $GIT_LOG_COMPARISON \ 114 | | sed 's/"/\\\\"/g' 115 | ) 116 | 117 | if [ ! -z "$DEPLOY_MSG" ]; then 118 | CUSTOM_MESSAGE="-- _$(echo $DEPLOY_MSG | sed 's/"/\\\\"/g' | sed "s/'/\\\\'/g")_" 119 | fi 120 | 121 | read -d '' PAYLOAD << EOF 122 | { 123 | "channel": "${SLACK_CHANNEL}", 124 | "text": ":rocket: Deploying *REST* to *${1}* ${CUSTOM_MESSAGE}", 125 | "as_user": true, 126 | "attachments": [{ 127 | "text": " 128 | --------------------------------------------------------------------------------------------------- 129 | 130 | ${ESCAPED_CHANGELOG} 131 | " 132 | }] 133 | } 134 | EOF 135 | 136 | if [ $PUSH_TO_SLACK = "true" ]; then 137 | curl \ 138 | -H "Content-Type: application/json; charset=utf-8" \ 139 | -d "$PAYLOAD" \ 140 | -s \ 141 | --fail \ 142 | "$OC_SLACK_DEPLOY_WEBHOOK" \ 143 | &> /dev/null 144 | 145 | if [ $? -ne 0 ]; then 146 | echo "⚠️ I won't be able to notify slack. Please do it manually and check your OC_SLACK_DEPLOY_WEBHOOK" 147 | else 148 | echo "🔔 Slack notified about this deployment." 149 | fi 150 | else 151 | echo "Following message would be posted on Slack:" 152 | echo "$PAYLOAD" 153 | fi 154 | 155 | # Always exit with 0 to continue the deploy even if slack notification failed 156 | exit_success 157 | -------------------------------------------------------------------------------- /test/mocks/LoggedInUser.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "LoggedInUser": { 4 | "id": 2, 5 | "username": "xdamman", 6 | "firstName": "Xavier", 7 | "lastName": "Damman", 8 | "image": "https://opencollective-production.s3-us-west-1.amazonaws.com/5c825534ad62223ae6a539f6a5076d3cjpeg_1699f6e0-917c-11e6-a567-3f53b7b5f95c.jpeg", 9 | "collectives": [ 10 | { 11 | "id": 7, 12 | "slug": "tipbox", 13 | "name": "tipbox", 14 | "role": "ADMIN" 15 | }, 16 | { 17 | "id": 10, 18 | "slug": "wwcodedf", 19 | "name": "WWCode Mexico City", 20 | "role": "BACKER" 21 | }, 22 | { 23 | "id": 43, 24 | "slug": "apex", 25 | "name": "APEX", 26 | "role": "BACKER" 27 | }, 28 | { 29 | "id": 13, 30 | "slug": "wwcodedc", 31 | "name": "WWCode Washington DC", 32 | "role": "BACKER" 33 | }, 34 | { 35 | "id": 32, 36 | "slug": "chsf", 37 | "name": "Consciousness Hacking SF", 38 | "role": "BACKER" 39 | }, 40 | { 41 | "id": 6, 42 | "slug": "laprimaire", 43 | "name": "LaPrimaire.org", 44 | "role": "BACKER" 45 | }, 46 | { 47 | "id": 19, 48 | "slug": "test", 49 | "name": "TEST", 50 | "role": "ADMIN" 51 | }, 52 | { 53 | "id": 1, 54 | "slug": "opencollective", 55 | "name": "OpenCollective", 56 | "role": "ADMIN" 57 | }, 58 | { 59 | "id": 58, 60 | "slug": "mochajs", 61 | "name": "MochaJS", 62 | "role": "BACKER" 63 | }, 64 | { 65 | "id": 50, 66 | "slug": "vcr", 67 | "name": "VCR", 68 | "role": "BACKER" 69 | }, 70 | { 71 | "id": 66, 72 | "slug": "foundation", 73 | "name": "Open Collective Foundation", 74 | "role": "ADMIN" 75 | }, 76 | { 77 | "id": 104, 78 | "slug": "replaylastgoal", 79 | "name": "ReplayLastGoal", 80 | "role": "ADMIN" 81 | }, 82 | { 83 | "id": 160, 84 | "slug": "startupyoganyc", 85 | "name": "Startup Yoga NYC", 86 | "role": "BACKER" 87 | }, 88 | { 89 | "id": 193, 90 | "slug": "freeridetovote", 91 | "name": "FreeRideToVote", 92 | "role": "ADMIN" 93 | }, 94 | { 95 | "id": 207, 96 | "slug": "brusselstogether", 97 | "name": "BrusselsTogether", 98 | "role": "ADMIN" 99 | }, 100 | { 101 | "id": 208, 102 | "slug": "testcollective1", 103 | "name": "test collective", 104 | "role": "ADMIN" 105 | }, 106 | { 107 | "id": 310, 108 | "slug": "xdamman-test", 109 | "name": "xdamman-test", 110 | "role": "ADMIN" 111 | }, 112 | { 113 | "id": 316, 114 | "slug": "xdamman-test2", 115 | "name": "xdamman test2", 116 | "role": "ADMIN" 117 | }, 118 | { 119 | "id": 320, 120 | "slug": "balanced", 121 | "name": "Balanced", 122 | "role": "BACKER" 123 | }, 124 | { 125 | "id": 302, 126 | "slug": "webpack", 127 | "name": "webpack", 128 | "role": "BACKER" 129 | }, 130 | { 131 | "id": 381, 132 | "slug": "civicinnovationnetwork", 133 | "name": "CIN", 134 | "role": "BACKER" 135 | }, 136 | { 137 | "id": 458, 138 | "slug": "nomuslimban", 139 | "name": "#NoMuslimBan", 140 | "role": "BACKER" 141 | }, 142 | { 143 | "id": 530, 144 | "slug": "co-labs", 145 | "name": "Co-Labs", 146 | "role": "BACKER" 147 | }, 148 | { 149 | "id": 442, 150 | "slug": "sustainoss", 151 | "name": "SustainOSS", 152 | "role": "BACKER" 153 | }, 154 | { 155 | "id": 334, 156 | "slug": "refugeesgottalent", 157 | "name": "Refugees Got Talent", 158 | "role": "BACKER" 159 | }, 160 | { 161 | "id": 649, 162 | "slug": "mastodonbrussels", 163 | "name": "Mastodon.brussels", 164 | "role": "BACKER" 165 | }, 166 | { 167 | "id": 652, 168 | "slug": "selection-sharer", 169 | "name": "selection-sharer", 170 | "role": "ADMIN" 171 | }, 172 | { 173 | "id": 734, 174 | "slug": "webrussels", 175 | "name": "WeBrussels", 176 | "role": "ADMIN" 177 | } 178 | ] 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opencollective-rest", 3 | "version": "2.2.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/opencollective/opencollective-rest.git" 7 | }, 8 | "private": true, 9 | "engines": { 10 | "node": "20.x", 11 | "npm": "10.x" 12 | }, 13 | "dependencies": { 14 | "@apollo/client": "3.14.0", 15 | "@babel/core": "7.28.5", 16 | "@babel/plugin-transform-typescript": "7.28.5", 17 | "@babel/preset-env": "7.28.5", 18 | "@hyperwatch/hyperwatch": "4.0.0", 19 | "@json2csv/plainjs": "7.0.6", 20 | "babel-plugin-add-module-exports": "1.0.4", 21 | "cloudflare-ip": "0.0.7", 22 | "cookie-parser": "^1.4.6", 23 | "cors": "2.8.5", 24 | "debug": "4.4.3", 25 | "dotenv": "17.2.3", 26 | "express": "4.21.2", 27 | "express-basic-auth": "1.2.1", 28 | "express-winston": "4.2.0", 29 | "express-ws": "5.0.2", 30 | "graphql": "16.12.0", 31 | "graphql-request": "6.1.0", 32 | "graphql-tag": "2.12.6", 33 | "lodash": "4.17.21", 34 | "moment": "2.30.1", 35 | "node-fetch": "2.7.0", 36 | "omit-deep-lodash": "1.1.7", 37 | "react": "18.3.1", 38 | "winston": "3.18.3" 39 | }, 40 | "scripts": { 41 | "build:clean": "rm -rf dist && mkdir dist", 42 | "build:server": "babel ./src --copy-files --extensions .js,.ts -d ./dist", 43 | "build:updates": "npm --prefix node_modules/cloudflare-ip run update-list", 44 | "build": "npm run build:clean && npm run build:updates && npm run build:server", 45 | "commit": "git-cz", 46 | "depcheck": "npx @opencollective/depcheck .", 47 | "deploy:production": "./scripts/pre-deploy.sh production && git push production main", 48 | "deploy:staging": "./scripts/pre-deploy.sh staging && git push -f staging main", 49 | "dev": "nodemon src/server/index.ts -x \"babel-node --extensions .js,.ts\" . -e js,ts --ignore 'test/'", 50 | "git:clean": "./scripts/git_clean.sh", 51 | "graphql:update:local": "cp ../frontend/lib/graphql/*.graphql src/graphql/ && prettier src/graphql/*.graphql --write", 52 | "graphql:update": "npm-run-all graphql:updateV1 graphql:updateV2", 53 | "graphql:updateV1": "curl https://raw.githubusercontent.com/opencollective/opencollective-frontend/main/lib/graphql/schema.graphql --output src/graphql/schema.graphql && prettier src/graphql/schema.graphql --write", 54 | "graphql:updateV2": "curl https://raw.githubusercontent.com/opencollective/opencollective-frontend/main/lib/graphql/schemaV2.graphql --output src/graphql/schemaV2.graphql && prettier src/graphql/schemaV2.graphql --write", 55 | "lint-staged": "lint-staged", 56 | "lint:fix": "npm run lint -- --fix", 57 | "lint:quiet": "npm run lint -- --quiet", 58 | "lint": "eslint . --ext='js,ts,graphql'", 59 | "prepare": "husky", 60 | "prettier:check": "npm run prettier -- --list-different", 61 | "prettier:write": "npm run prettier -- --write", 62 | "prettier": "prettier \"**/*.@(js|ts|json|md)\" --ignore-path .eslintignore", 63 | "start": "TZ=UTC node dist/server", 64 | "test": "TZ=UTC jest", 65 | "test:watch": "TZ=UTC jest --watch", 66 | "test:server": "TZ=UTC ./scripts/run_test.sh", 67 | "test:server:coverage": "TZ=UTC ./scripts/run_test.sh --coverage --reporters=default --reporters=jest-junit", 68 | "type:check": "tsc" 69 | }, 70 | "devDependencies": { 71 | "@babel/cli": "^7.23.4", 72 | "@babel/node": "^7.25.7", 73 | "@eslint/compat": "^1.2.8", 74 | "@eslint/js": "^9.23.0", 75 | "@graphql-eslint/eslint-plugin": "^4.0.0", 76 | "@types/express": "^5.0.0", 77 | "@types/lodash": "^4.17.9", 78 | "@typescript-eslint/eslint-plugin": "^8.0.0", 79 | "@typescript-eslint/parser": "^8.0.0", 80 | "commitizen": "^4.3.0", 81 | "cz-conventional-changelog": "^3.3.0", 82 | "eslint": "^9.23.0", 83 | "eslint-plugin-formatjs": "^5.3.1", 84 | "eslint-plugin-jest": "^29.0.1", 85 | "eslint-plugin-react": "^7.37.5", 86 | "globals": "^16.3.0", 87 | "husky": "^9.0.7", 88 | "jest": "^30.0.0", 89 | "jest-junit": "^16.0.0", 90 | "jsonwebtoken": "^9.0.2", 91 | "light-my-request": "^6.6.0", 92 | "lint-staged": "^16.0.0", 93 | "nodemon": "^3.0.1", 94 | "npm-run-all2": "^8.0.0", 95 | "prettier": "^3.1.0", 96 | "typescript": "5.9.3", 97 | "typescript-eslint": "^8.38.0" 98 | }, 99 | "config": { 100 | "commitizen": { 101 | "path": "./node_modules/cz-conventional-changelog" 102 | } 103 | }, 104 | "jest": { 105 | "testPathIgnorePatterns": [ 106 | "opencollective-api/" 107 | ] 108 | }, 109 | "lint-staged": { 110 | "*.{js,json,md,graphql}": [ 111 | "prettier --write" 112 | ] 113 | }, 114 | "cacheDirectories": [ 115 | "node_modules" 116 | ], 117 | "@opencollective/depcheck": { 118 | "ignoreDirs": [ 119 | "dist" 120 | ], 121 | "specials": [ 122 | "babel", 123 | "bin", 124 | "commitizen", 125 | "eslint", 126 | "husky", 127 | "lint-staged", 128 | "typescript", 129 | "jest" 130 | ], 131 | "ignores": [ 132 | "jest", 133 | "jest-junit", 134 | "typescript", 135 | "@babel/node", 136 | "@typescript-eslint/parser", 137 | "@typescript-eslint/eslint-plugin", 138 | "@graphql-eslint/eslint-plugin" 139 | ] 140 | }, 141 | "heroku-run-build-script": true 142 | } 143 | -------------------------------------------------------------------------------- /src/server/controllers/members.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash'; 2 | import moment from 'moment'; 3 | 4 | import { simpleGraphqlRequest } from '../lib/graphql'; 5 | import { json2csv } from '../lib/utils'; 6 | import { logger } from '../logger'; 7 | 8 | // Emulate gql from graphql-tag 9 | const gql = (string) => String(string).replace(`\n`, ` `).trim(); 10 | 11 | export async function list(req, res, next) { 12 | const { collectiveSlug, eventSlug, role, tierSlug } = req.params; 13 | 14 | let backerType; 15 | switch (req.params.backerType) { 16 | case 'users': 17 | backerType = 'USER'; 18 | break; 19 | case 'organizations': 20 | backerType = 'ORGANIZATION'; 21 | break; 22 | default: 23 | backerType = null; 24 | break; 25 | } 26 | 27 | const headers = {}; 28 | 29 | // Forward Api Key or Authorization header 30 | const apiKey = req.get('Api-Key') || req.query.apiKey; 31 | const personalToken = req.get('Personal-Token') || req.query.personalToken; 32 | const authorization = req.get('Authorization'); 33 | if (authorization) { 34 | headers['Authorization'] = authorization; 35 | } else if (apiKey) { 36 | headers['Api-Key'] = apiKey; 37 | } else if (personalToken) { 38 | headers['Personal-Token'] = personalToken; 39 | } 40 | 41 | const query = gql` 42 | query collectiveMembers( 43 | $collectiveSlug: String 44 | $backerType: String 45 | $tierSlug: String 46 | $TierId: Int 47 | $limit: Int 48 | $offset: Int 49 | $role: String 50 | ) { 51 | Collective(slug: $collectiveSlug) { 52 | currency 53 | members(type: $backerType, role: $role, tierSlug: $tierSlug, TierId: $TierId, limit: $limit, offset: $offset) { 54 | id 55 | createdAt 56 | role 57 | stats { 58 | totalDonations 59 | } 60 | transactions(limit: 1) { 61 | createdAt 62 | amount 63 | currency 64 | } 65 | isActive 66 | member { 67 | type 68 | slug 69 | type 70 | name 71 | company 72 | description 73 | image 74 | website 75 | twitterHandle 76 | githubHandle 77 | connectedAccounts { 78 | id 79 | service 80 | username 81 | } 82 | ... on User { 83 | email 84 | newsletterOptIn 85 | } 86 | } 87 | tier { 88 | interval 89 | name 90 | } 91 | } 92 | } 93 | } 94 | `; 95 | const vars = { collectiveSlug: eventSlug || collectiveSlug, limit: 1000 }; 96 | if (role === 'attendees') { 97 | vars.role = 'ATTENDEE'; 98 | } 99 | if (role === 'followers') { 100 | vars.role = 'FOLLOWER'; 101 | } 102 | if (role === 'organizers') { 103 | vars.role = 'ADMIN'; 104 | } 105 | if (tierSlug) { 106 | vars.tierSlug = tierSlug; 107 | } 108 | if (backerType) { 109 | vars.backerType = backerType; 110 | } 111 | if (req.query.TierId) { 112 | vars.TierId = Number(req.query.TierId); 113 | } 114 | if (req.query.limit) { 115 | vars.limit = Number(req.query.limit); 116 | } 117 | if (req.query.offset) { 118 | vars.offset = Number(req.query.offset); 119 | } 120 | 121 | let result; 122 | try { 123 | result = await simpleGraphqlRequest(query, vars, { headers }); 124 | } catch (err) { 125 | if (err.message.match(/No collective found/)) { 126 | return res.status(404).send('Not found'); 127 | } 128 | logger.debug('>>> members.list error', err); 129 | return next(err); 130 | } 131 | 132 | const members = result.Collective.members; 133 | 134 | const mapping = { 135 | MemberId: 'id', 136 | createdAt: (r) => moment(new Date(r.createdAt)).format('YYYY-MM-DD HH:mm'), 137 | type: 'member.type', 138 | role: 'role', 139 | tier: 'tier.name', 140 | isActive: 'isActive', 141 | totalAmountDonated: (r) => (get(r, 'stats.totalDonations') || 0) / 100, 142 | currency: 'transactions[0].currency', 143 | lastTransactionAt: (r) => { 144 | return moment(r.transactions[0] && new Date(r.transactions[0].createdAt)).format('YYYY-MM-DD HH:mm'); 145 | }, 146 | lastTransactionAmount: (r) => (get(r, 'transactions[0].amount') || 0) / 100, 147 | profile: (r) => `${process.env.WEBSITE_URL}/${r.member.slug}`, 148 | name: 'member.name', 149 | company: 'member.company', 150 | description: 'member.description', 151 | image: 'member.image', 152 | email: 'member.email', 153 | newsletterOptIn: 'member.newsletterOptIn', 154 | twitter: (r) => { 155 | return r.member.twitterHandle ? `https://twitter.com/${r.member.twitterHandle}` : null; 156 | }, 157 | github: (r) => { 158 | if (r.member.githubHandle) { 159 | return `https://github.com/${r.member.githubHandle}`; 160 | } 161 | const githubAccount = r.member.connectedAccounts.find((c) => c.service === 'github'); 162 | return githubAccount ? `https://github.com/${githubAccount.username}` : null; 163 | }, 164 | website: 'member.website', 165 | }; 166 | 167 | const fields = Object.keys(mapping); 168 | 169 | const applyMapping = (row) => { 170 | const res = {}; 171 | fields.map((key) => { 172 | const val = mapping[key]; 173 | if (typeof val === 'function') { 174 | return (res[key] = val(row)); 175 | } else { 176 | return (res[key] = get(row, val)); 177 | } 178 | }); 179 | return res; 180 | }; 181 | 182 | const data = members.map(applyMapping); 183 | 184 | switch (req.params.format) { 185 | case 'csv': { 186 | const csv = json2csv(data); 187 | res.setHeader('content-type', 'text/csv'); 188 | res.send(csv); 189 | break; 190 | } 191 | 192 | default: 193 | res.send(data); 194 | break; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/server/lib/graphql.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import https from 'https'; 3 | 4 | import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; 5 | import { GraphQLClient } from 'graphql-request'; 6 | import gql from 'graphql-tag'; 7 | import nodeFetch from 'node-fetch'; 8 | import omitDeep from 'omit-deep-lodash'; 9 | 10 | import { getGraphqlUrl, parseToBooleanDefaultTrue } from './utils'; 11 | 12 | let customAgent; 13 | 14 | async function fetch(url, options = {}) { 15 | options.agent = getCustomAgent(); 16 | 17 | // Add headers to help the API identify origin of requests 18 | options.headers = options.headers || {}; 19 | options.headers['oc-env'] = process.env.OC_ENV; 20 | options.headers['oc-secret'] = process.env.OC_SECRET; 21 | options.headers['oc-application'] = process.env.OC_APPLICATION; 22 | options.headers['user-agent'] = 'opencollective-rest/1.0 node-fetch/1.0'; 23 | 24 | const result = await nodeFetch(url, options); 25 | 26 | return result; 27 | } 28 | 29 | function getCustomAgent() { 30 | if (!customAgent) { 31 | const { FETCH_AGENT_KEEP_ALIVE, FETCH_AGENT_KEEP_ALIVE_MSECS } = process.env; 32 | const keepAlive = FETCH_AGENT_KEEP_ALIVE !== undefined ? parseToBooleanDefaultTrue(FETCH_AGENT_KEEP_ALIVE) : true; 33 | const keepAliveMsecs = FETCH_AGENT_KEEP_ALIVE_MSECS ? Number(FETCH_AGENT_KEEP_ALIVE_MSECS) : 10000; 34 | const httpAgent = new http.Agent({ keepAlive, keepAliveMsecs }); 35 | const httpsAgent = new https.Agent({ keepAlive, keepAliveMsecs }); 36 | customAgent = (_parsedURL) => (_parsedURL.protocol === 'http:' ? httpAgent : httpsAgent); 37 | } 38 | return customAgent; 39 | } 40 | 41 | function getClient({ version = 'v1', apiKey } = {}) { 42 | return new ApolloClient({ 43 | link: new HttpLink({ uri: getGraphqlUrl({ version, apiKey }), fetch }), 44 | cache: new InMemoryCache({ 45 | possibleTypes: { 46 | Transaction: ['Expense', 'Order', 'Debit', 'Credit'], 47 | CollectiveInterface: ['Collective', 'Event', 'Project', 'Fund', 'Organization', 'User', 'Vendor'], 48 | Account: ['Collective', 'Host', 'Individual', 'Fund', 'Project', 'Bot', 'Event', 'Organization', 'Vendor'], 49 | AccountWithHost: ['Collective', 'Event', 'Fund', 'Project'], 50 | AccountWithParent: ['Event', 'Project'], 51 | AccountWithContributions: ['Collective', 'Organization', 'Event', 'Fund', 'Project', 'Host'], 52 | }, 53 | }), 54 | }); 55 | } 56 | 57 | export function graphqlRequest(query, variables, clientParameters) { 58 | return getClient(clientParameters) 59 | .query({ 60 | query, 61 | variables, 62 | context: { 63 | headers: clientParameters?.headers, 64 | }, 65 | }) 66 | .then((result) => omitDeep(result.data, ['__typename'])); 67 | } 68 | 69 | export function simpleGraphqlRequest(query, variables, { version = 'v1', apiKey, headers = {} } = {}) { 70 | headers['oc-env'] = process.env.OC_ENV; 71 | headers['oc-secret'] = process.env.OC_SECRET; 72 | headers['oc-application'] = process.env.OC_APPLICATION; 73 | headers['user-agent'] = 'opencollective-rest/1.0'; 74 | const client = new GraphQLClient(getGraphqlUrl({ apiKey, version }), { headers }); 75 | return client.request(query, variables); 76 | } 77 | 78 | export async function fetchCollective(collectiveSlug) { 79 | const query = gql` 80 | query fetchCollective($collectiveSlug: String) { 81 | Collective(slug: $collectiveSlug) { 82 | id 83 | slug 84 | image 85 | currency 86 | data 87 | stats { 88 | balance 89 | backers { 90 | all 91 | } 92 | yearlyBudget 93 | } 94 | } 95 | } 96 | `; 97 | 98 | const result = await graphqlRequest(query, { collectiveSlug }); 99 | return result.Collective; 100 | } 101 | 102 | /** 103 | * Fetches an event by its slug. 104 | * /!\ Do not include any private fields, as the result is cached at the CDN level. 105 | * @param {string} eventSlug - The slug of the event to fetch. 106 | * @returns {Promise} The event data. 107 | */ 108 | export async function fetchEvent(eventSlug) { 109 | const query = gql` 110 | query Collective($slug: String) { 111 | Collective(slug: $slug) { 112 | id 113 | name 114 | description 115 | longDescription 116 | slug 117 | image 118 | startsAt 119 | endsAt 120 | timezone 121 | location { 122 | name 123 | address 124 | lat 125 | long 126 | } 127 | currency 128 | tiers { 129 | id 130 | name 131 | description 132 | amount 133 | } 134 | } 135 | } 136 | `; 137 | 138 | const result = await graphqlRequest(query, { slug: eventSlug }); 139 | return result.Collective; 140 | } 141 | 142 | export const allTransactionsQuery = gql` 143 | query allTransactions($collectiveSlug: String!, $limit: Int, $offset: Int, $type: String) { 144 | allTransactions(collectiveSlug: $collectiveSlug, limit: $limit, offset: $offset, type: $type) { 145 | id 146 | uuid 147 | type 148 | amount 149 | currency 150 | hostCurrency 151 | hostCurrencyFxRate 152 | hostFeeInHostCurrency 153 | platformFeeInHostCurrency 154 | paymentProcessorFeeInHostCurrency 155 | netAmountInCollectiveCurrency 156 | createdAt 157 | host { 158 | id 159 | slug 160 | } 161 | createdByUser { 162 | id 163 | email 164 | } 165 | fromCollective { 166 | id 167 | slug 168 | name 169 | image 170 | } 171 | collective { 172 | id 173 | slug 174 | name 175 | image 176 | } 177 | paymentMethod { 178 | id 179 | service 180 | name 181 | } 182 | } 183 | } 184 | `; 185 | 186 | export const getTransactionQuery = gql` 187 | query Transaction($id: Int, $uuid: String) { 188 | Transaction(id: $id, uuid: $uuid) { 189 | id 190 | uuid 191 | type 192 | createdAt 193 | description 194 | amount 195 | currency 196 | hostCurrency 197 | hostCurrencyFxRate 198 | netAmountInCollectiveCurrency 199 | hostFeeInHostCurrency 200 | platformFeeInHostCurrency 201 | paymentProcessorFeeInHostCurrency 202 | paymentMethod { 203 | id 204 | service 205 | name 206 | } 207 | fromCollective { 208 | id 209 | slug 210 | name 211 | image 212 | } 213 | collective { 214 | id 215 | slug 216 | name 217 | image 218 | } 219 | host { 220 | id 221 | slug 222 | name 223 | image 224 | } 225 | ... on Order { 226 | order { 227 | id 228 | status 229 | subscription { 230 | id 231 | interval 232 | } 233 | } 234 | } 235 | } 236 | } 237 | `; 238 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | CI: true 7 | TZ: UTC 8 | E2E_TEST: 1 9 | NODE_ENV: ci 10 | PGHOST: localhost 11 | PGUSER: postgres 12 | REST_URL: http://localhost:3003 13 | API_URL: http://localhost:3060 14 | API_KEY: dvl-1510egmf4a23d80342403fb599qd 15 | API_FOLDER: /home/runner/work/opencollective-rest/opencollective-rest/opencollective-api 16 | REST_FOLDER: /home/runner/work/opencollective-rest/opencollective-rest 17 | PG_DATABASE: opencollective_dvl 18 | 19 | jobs: 20 | lint: 21 | runs-on: ubuntu-latest 22 | 23 | timeout-minutes: 15 24 | 25 | steps: 26 | - name: Update apt 27 | run: sudo apt-get update || exit 0 28 | 29 | - name: Checkout 30 | uses: actions/checkout@v5 31 | 32 | - name: Setup node 33 | uses: actions/setup-node@v6 34 | with: 35 | node-version-file: 'package.json' 36 | 37 | # Npm cache 38 | - name: Restore .npm cache 39 | uses: actions/cache@v4 40 | with: 41 | path: ~/.npm 42 | key: ${{ runner.os }}-npm-cache-${{ github.sha }} 43 | restore-keys: | 44 | - ${{ runner.os }}-npm-cache-${{ github.sha }} 45 | - ${{ runner.os }}-npm-cache- 46 | 47 | - name: Restore node_modules 48 | uses: actions/cache@v4 49 | id: node-modules 50 | with: 51 | path: node_modules 52 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 53 | 54 | - name: Install dependencies 55 | if: steps.node-modules.outputs.cache-hit != 'true' 56 | run: npm ci --prefer-offline --no-audit 57 | 58 | - run: npm run lint:quiet 59 | 60 | typescript: 61 | runs-on: ubuntu-latest 62 | 63 | timeout-minutes: 15 64 | 65 | steps: 66 | - name: Update apt 67 | run: sudo apt-get update || exit 0 68 | 69 | - name: Checkout 70 | uses: actions/checkout@v5 71 | 72 | - name: Setup node 73 | uses: actions/setup-node@v6 74 | with: 75 | node-version-file: 'package.json' 76 | 77 | # Npm cache 78 | - name: Restore .npm cache 79 | uses: actions/cache@v4 80 | with: 81 | path: ~/.npm 82 | key: ${{ runner.os }}-npm-cache-${{ github.sha }} 83 | restore-keys: | 84 | - ${{ runner.os }}-npm-cache-${{ github.sha }} 85 | - ${{ runner.os }}-npm-cache- 86 | 87 | - name: Restore node_modules 88 | uses: actions/cache@v4 89 | id: node-modules 90 | with: 91 | path: node_modules 92 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 93 | 94 | - name: Install dependencies 95 | if: steps.node-modules.outputs.cache-hit != 'true' 96 | run: npm ci --prefer-offline --no-audit 97 | 98 | - run: npm run type:check 99 | 100 | prettier: 101 | runs-on: ubuntu-latest 102 | 103 | timeout-minutes: 15 104 | 105 | steps: 106 | - name: Update apt 107 | run: sudo apt-get update || exit 0 108 | 109 | - name: Checkout 110 | uses: actions/checkout@v5 111 | 112 | - name: Setup node 113 | uses: actions/setup-node@v6 114 | with: 115 | node-version-file: 'package.json' 116 | 117 | # Npm cache 118 | - name: Restore .npm cache 119 | uses: actions/cache@v4 120 | with: 121 | path: ~/.npm 122 | key: ${{ runner.os }}-npm-cache-${{ github.sha }} 123 | restore-keys: | 124 | - ${{ runner.os }}-npm-cache-${{ github.sha }} 125 | - ${{ runner.os }}-npm-cache- 126 | 127 | - name: Restore node_modules 128 | uses: actions/cache@v4 129 | id: node-modules 130 | with: 131 | path: node_modules 132 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 133 | 134 | - name: Install dependencies 135 | if: steps.node-modules.outputs.cache-hit != 'true' 136 | run: npm ci --prefer-offline --no-audit 137 | 138 | - run: npm run prettier:check 139 | 140 | depcheck: 141 | runs-on: ubuntu-latest 142 | 143 | timeout-minutes: 15 144 | 145 | steps: 146 | - name: Update apt 147 | run: sudo apt-get update || exit 0 148 | 149 | - name: Checkout 150 | uses: actions/checkout@v5 151 | 152 | - name: Setup node 153 | uses: actions/setup-node@v6 154 | with: 155 | node-version-file: 'package.json' 156 | 157 | # Npm cache 158 | - name: Restore .npm cache 159 | uses: actions/cache@v4 160 | with: 161 | path: ~/.npm 162 | key: ${{ runner.os }}-npm-cache-${{ github.sha }} 163 | restore-keys: | 164 | - ${{ runner.os }}-npm-cache-${{ github.sha }} 165 | - ${{ runner.os }}-npm-cache- 166 | 167 | - name: Restore node_modules 168 | uses: actions/cache@v4 169 | id: node-modules 170 | with: 171 | path: node_modules 172 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 173 | 174 | - name: Install dependencies 175 | if: steps.node-modules.outputs.cache-hit != 'true' 176 | run: npm ci --prefer-offline --no-audit 177 | 178 | - run: npm run depcheck 179 | 180 | test: 181 | runs-on: ubuntu-24.04 182 | 183 | timeout-minutes: 30 184 | 185 | services: 186 | redis: 187 | image: redis 188 | ports: 189 | - 6379:6379 190 | options: --entrypoint redis-server 191 | postgres: 192 | image: postgres:17.6 193 | env: 194 | POSTGRES_USER: postgres 195 | POSTGRES_DB: postgres 196 | POSTGRES_HOST_AUTH_METHOD: trust 197 | ports: 198 | - 5432:5432 199 | # needed because the postgres container does not provide a healthcheck 200 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 201 | 202 | steps: 203 | - name: Update apt 204 | run: sudo apt-get update || exit 0 205 | 206 | - name: Install postgresql-client 207 | run: sudo apt-get install -y postgresql-client 208 | 209 | # Checkouts 210 | - name: Checkout (rest) 211 | uses: actions/checkout@v5 212 | 213 | - name: Setup node 214 | uses: actions/setup-node@v6 215 | with: 216 | node-version-file: 'package.json' 217 | 218 | # Npm cache 219 | - name: Restore .npm cache 220 | uses: actions/cache@v4 221 | with: 222 | path: ~/.npm 223 | key: ${{ runner.os }}-npm-cache-${{ github.sha }} 224 | restore-keys: | 225 | - ${{ runner.os }}-npm-cache-${{ github.sha }} 226 | - ${{ runner.os }}-npm-cache- 227 | 228 | # Prepare API 229 | - name: Checkout (api) 230 | uses: actions/checkout@v5 231 | with: 232 | repository: opencollective/opencollective-api 233 | path: opencollective-api 234 | 235 | - name: Restore node_modules (api) 236 | uses: actions/cache@v4 237 | id: api-node-modules 238 | with: 239 | path: opencollective-api/node_modules 240 | key: ${{ runner.os }}-api-node-modules-${{ hashFiles('opencollective-api/package-lock.json') }} 241 | 242 | - name: Install dependencies (api) 243 | working-directory: opencollective-api 244 | if: steps.api-node-modules.outputs.cache-hit != 'true' 245 | run: npm ci --prefer-offline --no-audit 246 | 247 | - name: Build (api) 248 | working-directory: opencollective-api 249 | if: steps.api-build.outputs.cache-hit != 'true' 250 | run: npm run build 251 | 252 | # Prepare Rest 253 | - name: Restore node_modules (rest) 254 | uses: actions/cache@v4 255 | id: rest-node-modules 256 | with: 257 | path: node_modules 258 | key: ${{ runner.os }}-rest-node-modules-${{ hashFiles('package-lock.json') }} 259 | 260 | - name: Install dependencies (rest) 261 | if: steps.rest-node-modules.outputs.cache-hit != 'true' 262 | run: npm ci --prefer-offline --no-audit 263 | 264 | - name: Build (rest) 265 | run: npm run build 266 | 267 | # Prepare DB 268 | - name: Restore DB 269 | working-directory: opencollective-api 270 | run: npm run db:restore 271 | 272 | - name: Migrate DB 273 | working-directory: opencollective-api 274 | run: npm run db:migrate 275 | 276 | # Run test 277 | - name: Run test 278 | run: npm run test:server:coverage 279 | 280 | - name: Upload coverage reports to Codecov 281 | uses: codecov/codecov-action@v5 282 | with: 283 | token: ${{ secrets.CODECOV_TOKEN }} 284 | slug: opencollective/opencollective-rest 285 | 286 | - name: Upload test results to Codecov 287 | if: ${{ !cancelled() }} 288 | uses: codecov/test-results-action@v1 289 | with: 290 | token: ${{ secrets.CODECOV_TOKEN }} 291 | -------------------------------------------------------------------------------- /docs/transactions.md: -------------------------------------------------------------------------------- 1 | # Transactions v2 2 | 3 | ## URL 4 | 5 | ### Account transactions 6 | 7 | All Transactions from a given Account: 8 | `https://rest.opencollective.com/v2/{slug}/transactions.csv` 9 | 10 | E.g. https://rest.opencollective.com/v2/babel/transactions.csv 11 | 12 | ### Host transactions 13 | 14 | All Transactions accounted by a given Fiscal Host: 15 | `https://rest.opencollective.com/v2/{slug}/hostTransactions.csv` 16 | 17 | E.g. https://rest.opencollective.com/v2/opensource/hostTransactions.csv 18 | 19 | ### Parameters 20 | 21 | - `limit`: default is `1000`, maximum is `10000` 22 | - `offset`: default is `0` 23 | - `type`: `CREDIT` or `DEBIT` 24 | - `kind`: comma separated list of `KIND`s 25 | - `dateFrom`: transactions after UTC date (ISO 8601) 26 | - `dateTo`: transactions before UTC date (ISO 8601) 27 | - `minAmount`: transactions more than the amount (warning, in cents!) 28 | - `maxAmount`: transactions less than the amount (warning, in cents!) 29 | - `includeIncognitoTransactions`: include incognito transactions made by the account (only for authenticated user) 30 | - `includeChildrenTransactions`: include transactions by children of the account (Projects and Events) 31 | - `includeGiftCardTransactions`: include transactions with Gift Cards issued by the account 32 | - `includeRegularTransactions`: include regular transactions of the account (default to true, use to exclude) 33 | - `account`: (hostTransactions only) transactions associated with these accounts (comma separated list of slug) 34 | - `fetchAll`: if set, will automatically paginate to fetch all results 35 | - `flattenHostFee`: if set, will generate a dedicated column for Host Fees instead of separate entries 36 | 37 | ### Authentication 38 | 39 | Create a Personal Token and pass it in the URL parameters as `personalToken`. 40 | 41 | To create a new Personal Token, go to your personal settings, then navigate to the "For Developers" section. 42 | 43 | The URL should be: https://opencollective.com/{slug}/admin/for-developers 44 | 45 | ### Tips 46 | 47 | - Replace `.csv` with `.txt` to view in plain text in the browser instead of downloading 48 | 49 | ## Fields 50 | 51 | | Name | GraphQL v2 | Description | Included? | 52 | | -------------------- | -------------------------------- | --------------------------------------------------------- | ------------------------------------------- | 53 | | date | createdAt | UTC date (ISO 8601) | 54 | | datetime | createdAt | UTC date and time with a second precision (ISO 8601) | Yes | 55 | | id | id | unique identifier for the transaction | 56 | | shortId | id | first 8 characters of the `id` | Yes | 57 | | legacyId | legacyId | auto-increment identifier for the transaction | 58 | | group | group | group identifier of the transaction | 59 | | shortGroup | group | first 8 characters of the `group` | Yes | 60 | | description | description | human readable description of the transaction | Yes | 61 | | type | type | `CREDIT` or `DEBIT` | Yes | 62 | | kind | kind | `CONTRIBUTION`, `ADDED_FUNDS`, `EXPENSE`, etc ... | Yes | 63 | | isRefund | isRefund | `REFUND` if it's a refund, empty if not | Yes | 64 | | isRefunded | isRefunded | `REFUNDED` if it was refunded, empty if not | Yes | 65 | | displayAmount | amount | user facing amount and currency as a string | Yes | 66 | | amount | amountInHostCurrency.value | accounted amount | Yes | 67 | | paymentProcessorFee | paymentProcessorFee.value | accounted payment processor fee | Yes (if flattenPaymentProcessorFee is used) | 68 | | hostFee | hostFee.value | accounted host fee | Yes (if flattenHostFee is used) | 69 | | netAmount | netAmountInHostCurrency.value | accounted amount after payment processor fees | Yes | 70 | | balance | balanceInHostCurrency.value | balance of the account after the transaction | Yes (not for hostTransactions) | 71 | | currency | netAmountInHostCurrency.currency | accounted currency | Yes | 72 | | accountSlug | account.slug | slug of the account on the main side of the transaction | Yes | 73 | | accountName | account.name | name of the account on the main side of the transaction | Yes | 74 | | accountType | account.type | type of the account on the main side of the transaction | 75 | | oppositeAccountSlug | oppositeAccount.slug | slug of the account on the opposite side | Yes | 76 | | oppositeAccountName | oppositeAccount.name | name of the account on the opposite side | Yes | 77 | | oppositeAccountType | oppositeAccount.type | type of the account on the opposite side | 78 | | hostSlug | host.slug | slug of the host accounting the transaction | 79 | | oppositeAccountName | oppositeAccount.name | name of the host accounting the transaction | 80 | | oppositeAccountType | oppositeAccount.type | type of the host accounting the transaction | 81 | | orderId | order.id | unique identifier for the order | 82 | | orderLegacyId | order.legacyId | auto-increment identifier for the order | 83 | | orderFrequency | order.frequency | frequency of the order (`ONETIME`, `MONTHLY` or `YEARLY`) | 84 | | paymentMethodService | paymentMethod.service | service of the payment method ( `STRIPE`, etc ...) | Yes | 85 | | paymentMethodType | paymentMethod.type | type of the payment method (`CREDITCARD`, etc ...) | Yes | 86 | | expenseId | expense.id | unique identifier for the expense | 87 | | expenseLegacyId | expense.legacyId | auto-increment identifier for the expense | 88 | | expenseType | expense.type | type of the expense (`INVOICE`, `RECEIPT`, etc ...) | Yes | 89 | | payoutMethodType | payoutMethod.type | type of the payout method (`PAYPAL`, etc ...) | Yes | 90 | 91 | ### Adding fields 92 | 93 | You can add a comma separated list of the fields you want to add with the `add` parameter URL. 94 | 95 | E.g. https://rest.opencollective.com/v2/babel/transactions.csv?add=orderId,paymentMethodService,paymentMethodType 96 | 97 | ### Removing fields 98 | 99 | You can add a comma separated list of the fields you want to remove with the `remove` parameter URL. 100 | 101 | E.g. https://rest.opencollective.com/v2/babel/transactions.csv?remove=displayAmount,accountSlug 102 | 103 | ### Setting fields 104 | 105 | You can define the exact fields you want to get with a comma separated list, use the `fields` parameter URL. 106 | 107 | E.g. https://rest.opencollective.com/v2/babel/transactions.csv?fields=id,type,kind,amount,netAmount,currency 108 | 109 | ## Values for properties 110 | 111 | ### Kind 112 | 113 | - `CONTRIBUTION`: a financial contribution using the regular Open Collective "contribute flow" 114 | - `ADDED_FUNDS`: an amount added by Fiscal Host admins 115 | - `EXPENSE`: an expense paid using the regular Open Collective "expense flow" or Virtual Cards 116 | - `HOST_FEE`: the host fee charged by the Fiscal Host for a transaction 117 | - `HOST_FEE_SHARE`: the share of host fee going to the platform as part of the revenue share scheme 118 | - `HOST_FEE_SHARE_DEBT`: a debt transaction credited when the host fee share can not been directly taken 119 | - `PLATFORM_TIP`: a voluntary contribution to Open Collective added on top of a regular financial contribution 120 | - `PLATFORM_TIP_DEBT`: a debt transaction credited when the platform tip can not been directly taken 121 | - `PREPAID_PAYMENT_METHOD`: amount re-credited to the account when creating a Prepaid Payment Method 122 | - `PAYMENT_PROCESSOR_COVER`: amount given by Fiscal Hosts to cover payment processor fee on refunds 123 | - `BALANCE_TRANSFER`: a contribution made to the Host or the Parent to empty the balance of an account 124 | -------------------------------------------------------------------------------- /test/mocks/Event.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "Collective": { 4 | "id": 786, 5 | "slug": "brusselstogether/events/meetup-4", 6 | "createdByUser": null, 7 | "name": "BrusselsTogether Meetup 4", 8 | "image": null, 9 | "description": null, 10 | "longDescription": "Hello Brussels 👋\n\n#BrusselsTogether is growing everyday with tons of new people and ideas to renovate our dear city!\n\nThere are now 9 projects on [#BrusselsTogether](https://opencollective.com/brusselstogether). Although they operate in different areas (civic innovation, urbanism, social inclusiveness), they all share the same purpose: rebuild the city from the bottom-up. \n\nThe next meetup will take place at the all-new BeCentral, a grass-roots initiative aiming to boost Belgium as a digital front-runner. \n\nJoin us to discover 3 ambitious projects happening in Brussels.\n\nSchedule: \n\n7 pm - Doors open \n\n7:30 pm - Introduction to #brusselstogether\n\n7:40 pm - [Share Food](https://www.facebook.com/sharefoodbrussels/) - Sharing instead of wasting\n\n7:55 pm - [Communa ASBL](https://www.facebook.com/asblCommuna/) - Turning empty buildings into socio-cultural centers \n\n8:10 pm - [Veganizer](https://opencollective.com/veganizerbxl) - Let's make Brussels more Vegan Friendly! \n\n8:30 pm - How do *YOU* make Brussels better. Pitch your idea in 60 seconds or less!\n\nGet your early bird ticket now for €5 while they last (€10 at the door). We also have a limited number of free tickets for students and people with low income as we want to make sure we don't exclude anyone. Event is also free for the members of BrusselsTogether ([become a member](https://opencollective.com/brusselstogether/donate/10/monthly)).\n\n**Why making this a paid event?**\nIf we really want this movement to succeed and to have an impact, we need to finance it, together.\nWe are a non profit and all our expenses are posted publicly on our [open collective](https://opencollective.com/brusselstogether).\n\nThank you for your support 🙏", 11 | "startsAt": "Wed May 03 2017 13:00:00 GMT-0400 (EDT)", 12 | "endsAt": "Wed May 03 2017 15:00:00 GMT-0400 (EDT)", 13 | "timezone": "Europe/Brussels", 14 | "currency": "EUR", 15 | "settings": { 16 | "style": { 17 | "hero": { 18 | "cover": { 19 | "transform": "scale(1.06)", 20 | "backgroundImage": "url(http://localhost:3000/static/images/default-header-bg.jpg)" 21 | }, 22 | "a": {} 23 | } 24 | } 25 | }, 26 | "location": { 27 | "name": "BeCentral", 28 | "address": "Cantersteen 12, 1000 Brussels", 29 | "lat": 50.845568, 30 | "long": 4.357482 31 | }, 32 | "tiers": [ 33 | { 34 | "id": 24, 35 | "slug": null, 36 | "type": "TICKET", 37 | "name": "free ticket", 38 | "description": "For students and low income people only.", 39 | "amount": null, 40 | "currency": "EUR", 41 | "maxQuantity": null 42 | }, 43 | { 44 | "id": 22, 45 | "slug": null, 46 | "type": "TICKET", 47 | "name": "regular ticket", 48 | "description": "Get your ticket for a great evening of inspiration and meeting great people. Includes a free drink and a free high five!", 49 | "amount": 1000, 50 | "currency": "EUR", 51 | "maxQuantity": null 52 | }, 53 | { 54 | "id": 20, 55 | "slug": null, 56 | "type": "TIER", 57 | "name": "sponsor", 58 | "description": "Sponsor the drinks. Pretty sure everyone will love you.", 59 | "amount": 15000, 60 | "currency": "EUR", 61 | "maxQuantity": null 62 | } 63 | ], 64 | "parentCollective": { 65 | "id": 207, 66 | "slug": "brusselstogether", 67 | "name": "BrusselsTogether", 68 | "mission": "We are on a mission to make Brussels a great city to live and work", 69 | "currency": "EUR", 70 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 71 | "image": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png", 72 | "settings": { 73 | "style": { 74 | "hero": { 75 | "cover": { 76 | "transform": "scale(1.06)", 77 | "backgroundImage": "url(https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg)", 78 | "background": "rgb(0,0,255)" 79 | }, 80 | "a": {} 81 | } 82 | }, 83 | "HostId": 1635, 84 | "superCollectiveTag": "#brusselstogether" 85 | } 86 | }, 87 | "members": [], 88 | "orders": [ 89 | { 90 | "id": 3754, 91 | "createdAt": "Wed May 03 2017 08:47:27 GMT-0400 (EDT)", 92 | "quantity": null, 93 | "processedAt": "Wed May 03 2017 08:47:27 GMT-0400 (EDT)", 94 | "publicMessage": null, 95 | "user": { 96 | "id": 4610, 97 | "name": "camille rouffiange", 98 | "image": null, 99 | "username": "obronicoco", 100 | "twitterHandle": "obronicoco", 101 | "description": null 102 | }, 103 | "tier": { 104 | "id": 24, 105 | "name": "free ticket" 106 | } 107 | }, 108 | { 109 | "id": 2616, 110 | "createdAt": "Fri Apr 28 2017 14:25:18 GMT-0400 (EDT)", 111 | "quantity": null, 112 | "processedAt": "Fri Apr 28 2017 14:25:23 GMT-0400 (EDT)", 113 | "publicMessage": null, 114 | "user": { 115 | "id": 4503, 116 | "name": "Véronique Bockstal", 117 | "image": null, 118 | "username": "vbockstal", 119 | "twitterHandle": "vbockstal", 120 | "description": "CEO BeCentral " 121 | }, 122 | "tier": null 123 | }, 124 | { 125 | "id": 2614, 126 | "createdAt": "Fri Apr 28 2017 10:38:36 GMT-0400 (EDT)", 127 | "quantity": null, 128 | "processedAt": "Fri Apr 28 2017 10:38:42 GMT-0400 (EDT)", 129 | "publicMessage": null, 130 | "user": { 131 | "id": 4499, 132 | "name": "Tina Hendriks", 133 | "image": null, 134 | "username": "tinahendriks", 135 | "twitterHandle": null, 136 | "description": null 137 | }, 138 | "tier": null 139 | }, 140 | { 141 | "id": 2613, 142 | "createdAt": "Fri Apr 28 2017 09:54:50 GMT-0400 (EDT)", 143 | "quantity": null, 144 | "processedAt": "Fri Apr 28 2017 09:54:56 GMT-0400 (EDT)", 145 | "publicMessage": null, 146 | "user": { 147 | "id": 4497, 148 | "name": "Alexandra Saveljeva", 149 | "image": null, 150 | "username": "alexandrasaveljeva", 151 | "twitterHandle": null, 152 | "description": "Artist,Filmmaker, Chairperson of NGO\"Bioetika\" in Latvia. Doing internship in WYA,organising European Arts Forum 2017" 153 | }, 154 | "tier": null 155 | }, 156 | { 157 | "id": 2549, 158 | "createdAt": "Thu Apr 20 2017 17:27:53 GMT-0400 (EDT)", 159 | "quantity": null, 160 | "processedAt": "Thu Apr 20 2017 17:27:58 GMT-0400 (EDT)", 161 | "publicMessage": null, 162 | "user": { 163 | "id": 1949, 164 | "name": "Caroline Sedda", 165 | "image": "https://d1ts43dypk8bqh.cloudfront.net/v1/avatars/6c38469a-e7a5-4d72-ab3a-9220758a93e8", 166 | "username": "carolinesedda", 167 | "twitterHandle": null, 168 | "description": null 169 | }, 170 | "tier": null 171 | }, 172 | { 173 | "id": 2548, 174 | "createdAt": "Thu Apr 20 2017 17:27:48 GMT-0400 (EDT)", 175 | "quantity": null, 176 | "processedAt": "Thu Apr 20 2017 17:27:53 GMT-0400 (EDT)", 177 | "publicMessage": null, 178 | "user": { 179 | "id": 1949, 180 | "name": "Caroline Sedda", 181 | "image": "https://d1ts43dypk8bqh.cloudfront.net/v1/avatars/6c38469a-e7a5-4d72-ab3a-9220758a93e8", 182 | "username": "carolinesedda", 183 | "twitterHandle": null, 184 | "description": null 185 | }, 186 | "tier": null 187 | }, 188 | { 189 | "id": 2496, 190 | "createdAt": "Tue Apr 18 2017 08:48:58 GMT-0400 (EDT)", 191 | "quantity": null, 192 | "processedAt": "Tue Apr 18 2017 08:49:05 GMT-0400 (EDT)", 193 | "publicMessage": null, 194 | "user": { 195 | "id": 4291, 196 | "name": "Ticto", 197 | "image": "https://scontent-lga3-1.xx.fbcdn.net/v/t1.0-9/14067500_501520080041378_2203538491740676884_n.png?oh=5da2ebdcc9db0a3c7c58a8c08a111427&oe=5987FC2D", 198 | "username": "ticto", 199 | "twitterHandle": "tictonews", 200 | "description": "Been in and around startups for a while. CEO at Ticto!" 201 | }, 202 | "tier": { 203 | "id": 20, 204 | "name": "sponsor" 205 | } 206 | }, 207 | { 208 | "id": 2422, 209 | "createdAt": "Tue Apr 11 2017 00:35:25 GMT-0400 (EDT)", 210 | "quantity": null, 211 | "processedAt": "Tue Apr 11 2017 00:35:31 GMT-0400 (EDT)", 212 | "publicMessage": null, 213 | "user": { 214 | "id": 4157, 215 | "name": "Axel Addington", 216 | "image": "https://d1ts43dypk8bqh.cloudfront.net/v1/avatars/6e52448c-416d-435d-b68b-e7a0bdd47a34", 217 | "username": "axeladdington", 218 | "twitterHandle": null, 219 | "description": "Trends profiler & values designer" 220 | }, 221 | "tier": null 222 | } 223 | ] 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/server/controllers/account-contributors.js: -------------------------------------------------------------------------------- 1 | import { Parser } from '@json2csv/plainjs'; 2 | import gqlV2 from 'graphql-tag'; 3 | import { difference, get, intersection, pick, trim } from 'lodash'; 4 | import moment from 'moment'; 5 | 6 | import { graphqlRequest } from '../lib/graphql'; 7 | import { parseToBooleanDefaultFalse } from '../lib/utils'; 8 | import { logger } from '../logger'; 9 | 10 | function json2csv(data, opts) { 11 | const parser = new Parser(opts); 12 | return parser.parse(data); 13 | } 14 | 15 | const contributorsQuery = gqlV2/* GraphQL */ ` 16 | query Contributors($slug: String, $limit: Int, $offset: Int) { 17 | account(slug: $slug) { 18 | id 19 | slug 20 | members(role: BACKER, limit: $limit, offset: $offset) { 21 | limit 22 | totalCount 23 | nodes { 24 | account { 25 | name 26 | slug 27 | type 28 | website 29 | location { 30 | address 31 | country 32 | } 33 | ... on Individual { 34 | email 35 | } 36 | activeRecurringContributions: orders( 37 | oppositeAccount: { slug: $slug } 38 | onlyActiveSubscriptions: true 39 | orderBy: { field: CREATED_AT, direction: DESC } 40 | ) { 41 | totalCount 42 | nodes { 43 | status 44 | frequency 45 | amount { 46 | value 47 | currency 48 | } 49 | tier { 50 | slug 51 | name 52 | } 53 | createdAt 54 | } 55 | } 56 | inactiveRecurringContributions: orders( 57 | oppositeAccount: { slug: $slug } 58 | status: [PAUSED, CANCELLED] 59 | orderBy: { field: CREATED_AT, direction: DESC } 60 | ) { 61 | totalCount 62 | nodes { 63 | status 64 | frequency 65 | amount { 66 | value 67 | currency 68 | } 69 | tier { 70 | slug 71 | name 72 | } 73 | createdAt 74 | } 75 | } 76 | latestContributions: transactions( 77 | limit: 1 78 | kind: CONTRIBUTION 79 | fromAccount: { slug: $slug } 80 | orderBy: { field: CREATED_AT, direction: DESC } 81 | ) { 82 | nodes { 83 | createdAt 84 | } 85 | } 86 | firstContributions: transactions( 87 | limit: 1 88 | kind: CONTRIBUTION 89 | fromAccount: { slug: $slug } 90 | orderBy: { field: CREATED_AT, direction: ASC } 91 | ) { 92 | nodes { 93 | createdAt 94 | } 95 | } 96 | } 97 | totalDonations { 98 | value 99 | currency 100 | } 101 | } 102 | } 103 | } 104 | } 105 | `; 106 | 107 | const recurringContribution = (m) => 108 | get(m, 'account.activeRecurringContributions.nodes[0]') || get(m, 'account.inactiveRecurringContributions.nodes[0]'); 109 | 110 | const csvMapping = { 111 | contributorUrl: (m) => `${process.env.WEBSITE_URL}/${m.account.slug}`, 112 | contributorName: 'account.name', 113 | contributorEmail: 'account.email', 114 | contributorWebsite: 'account.website', 115 | contributorType: 'account.type', 116 | totalContributions: 'totalDonations.value', 117 | currency: 'totalDonations.currency', 118 | address: (t) => get(t, 'account.location.address'), 119 | country: (t) => get(t, 'account.location.country'), 120 | recurringContribution: (m) => (recurringContribution(m) ? 'yes' : 'no'), 121 | recurringContributionStatus: (m) => get(recurringContribution(m), 'status'), 122 | recurringContributionTier: (m) => get(recurringContribution(m), 'tier.name'), 123 | recurringContributionAmount: (m) => get(recurringContribution(m), 'amount.value'), 124 | recurringContributionCurrency: (m) => get(recurringContribution(m), 'amount.currency'), 125 | recurringContributionFrequency: (m) => get(recurringContribution(m), 'frequency'), 126 | contributorFirstContributionDate: (m) => 127 | m.account.firstContributions.nodes[0] && 128 | moment.utc(m.account.firstContributions.nodes[0].createdAt).format('YYYY-MM-DD'), 129 | contributorLatestContributionDate: (m) => 130 | m.account.latestContributions.nodes[0] && 131 | moment.utc(m.account.latestContributions.nodes[0].createdAt).format('YYYY-MM-DD'), 132 | }; 133 | 134 | const allFields = Object.keys(csvMapping); 135 | 136 | const defaultFields = [ 137 | 'contributorUrl', 138 | 'contributorName', 139 | 'contributorEmail', 140 | 'contributorWebsite', 141 | 'contributorType', 142 | 'address', 143 | 'country', 144 | 'contributorFirstContributionDate', 145 | 'contributorLatestContributionDate', 146 | 'totalContributions', 147 | 'currency', 148 | 'recurringContribution', 149 | 'recurringContributionTier', 150 | 'recurringContributionStatus', 151 | 'recurringContributionAmount', 152 | 'recurringContributionCurrency', 153 | 'recurringContributionFrequency', 154 | ]; 155 | 156 | const applyMapping = (mapping, row) => { 157 | const res = {}; 158 | Object.keys(mapping).map((key) => { 159 | const val = mapping[key]; 160 | if (typeof val === 'function') { 161 | return (res[key] = val(row)); 162 | } else { 163 | return (res[key] = get(row, val)); 164 | } 165 | }); 166 | return res; 167 | }; 168 | 169 | const accountContributors = async (req, res) => { 170 | if (!['HEAD', 'GET'].includes(req.method)) { 171 | return res.status(405).send({ error: { message: 'Method not allowed' } }); 172 | } 173 | 174 | const variables = pick({ ...req.params, ...req.query }, ['slug', 'limit', 'offset']); 175 | variables.limit = 176 | // If HEAD, we only want count, so we set limit to 0 177 | req.method === 'HEAD' 178 | ? 0 179 | : // Else, we use the limit provided by the user, or default to 1000 180 | variables.limit 181 | ? Number(variables.limit) 182 | : 1000; 183 | variables.offset = Number(variables.offset) || 0; 184 | 185 | let fields = get(req.query, 'fields', '') 186 | .split(',') 187 | .map(trim) 188 | .filter((v) => !!v); 189 | 190 | if (fields.length === 0) { 191 | const remove = get(req.query, 'remove', '') 192 | .split(',') 193 | .map(trim) 194 | .filter((v) => !!v); 195 | 196 | const add = get(req.query, 'add', '') 197 | .split(',') 198 | .map(trim) 199 | .filter((v) => !!v); 200 | 201 | fields = difference(intersection(allFields, [...defaultFields, ...add]), remove); 202 | } 203 | 204 | const fetchAll = variables.offset ? false : parseToBooleanDefaultFalse(req.query.fetchAll); 205 | 206 | try { 207 | // Forward Api Key or Authorization header 208 | const headers = {}; 209 | const apiKey = req.get('Api-Key') || req.query.apiKey; 210 | const personalToken = req.get('Personal-Token') || req.query.personalToken; 211 | // Support Cookies for direct-download capability 212 | const authorization = req.get('Authorization') || req.cookies?.authorization; 213 | 214 | if (authorization) { 215 | headers['Authorization'] = authorization; 216 | } else if (apiKey) { 217 | headers['Api-Key'] = apiKey; 218 | } else if (personalToken) { 219 | headers['Personal-Token'] = personalToken; 220 | } 221 | 222 | let result = await graphqlRequest(contributorsQuery, variables, { version: 'v2', headers }); 223 | 224 | switch (req.params.format) { 225 | case 'txt': 226 | case 'csv': { 227 | if (req.params.format === 'csv') { 228 | res.append('Content-Type', `text/csv;charset=utf-8`); 229 | } else { 230 | res.append('Content-Type', `text/plain;charset=utf-8`); 231 | } 232 | let filename = `${variables.slug}-contributors`; 233 | filename += `.${req.params.format}`; 234 | res.append('Content-Disposition', `attachment; filename="${filename}"`); 235 | res.append('Access-Control-Expose-Headers', 'X-Exported-Rows'); 236 | res.append('X-Exported-Rows', result.account.members.totalCount); 237 | if (req.method === 'HEAD') { 238 | return res.status(200).end(); 239 | } 240 | 241 | if (result.account.members.totalCount === 0) { 242 | res.write(json2csv([], { fields })); 243 | res.write(`\n`); 244 | res.end(); 245 | return; 246 | } 247 | 248 | const mapping = pick(csvMapping, fields); 249 | 250 | const mappedResults = result.account.members.nodes.map((t) => applyMapping(mapping, t)); 251 | res.write(json2csv(mappedResults)); 252 | res.write(`\n`); 253 | 254 | if (result.account.members.totalCount > result.account.members.limit) { 255 | if (fetchAll) { 256 | do { 257 | variables.offset += result.account.members.limit; 258 | 259 | result = await graphqlRequest(contributorsQuery, variables, { version: 'v2', headers }); 260 | 261 | const mappedResults = result.account.members.nodes.map((t) => applyMapping(mapping, t)); 262 | res.write(json2csv(mappedResults, { header: false })); 263 | res.write(`\n`); 264 | } while (result.account.members.totalCount > result.account.members.limit + result.account.members.offset); 265 | } else { 266 | res.write( 267 | `Warning: totalCount is ${result.account.members.totalCount} and limit was ${result.account.members.limit}`, 268 | ); 269 | } 270 | } 271 | res.end(); 272 | break; 273 | } 274 | 275 | default: 276 | res.send(result.account.members); 277 | break; 278 | } 279 | } catch (err) { 280 | if (err.message.match(/No account found/)) { 281 | return res.status(404).send('Not account found.'); 282 | } 283 | logger.error(`Error while fetching account contributors: ${err.message}`); 284 | res.status(400).send(`Error while fetching account contributors.`); 285 | } 286 | }; 287 | 288 | export default accountContributors; 289 | -------------------------------------------------------------------------------- /test/mocks/allEvents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "allEvents": [ 4 | { 5 | "id": 1, 6 | "slug": "meetup-2", 7 | "name": "BrusselsTogether Meetup 2", 8 | "description": "Hey Brussels people,\n\nAfter a very fruitful first event in the heart of Brussels, we are back with some crunchy new projects!\n\n\\#BrusselsTogether events allow people to discover citizen initiatives, while offering the opportunity to the audience to share their ideas and get support from the community. See more on our website http://brusselstogether.org/\n\nEvents always take place in very cool and innovative places.\n\n\\#BrusselsTogether is also a growing community of transparent and inclusive projects, for which everyone can contribute! https://opencollective.com/brusselstogether\n\nSchedule \n\n7 pm - Doors open, onigiris, gyozas & local beers 🍙🍜🍻\n\n7:30 pm - Introduction to #BrusselsTogether\n\n7:40 pm - Civic innovation network - Designing a new civic model to foster collaborations between citizens\n\n7:55 pm - Déclic en Perspectives - En route to social entrepreneurship \n\n8:10 pm - Brass'Art Digitaal Café - The first artistic and digital brewery in Molenbeek\n\n8:30 pm - How do YOU make Brussels better \nPitch your idea in 60 seconds or less\n\n*Registration*\n\nWe suggest a donation of €10 for the organization of the event. Profit will go to the 3 projects.", 9 | "startsAt": "Wed Mar 01 2017 13:00:00 GMT-0500 (EST)", 10 | "endsAt": "Wed Mar 01 2017 15:00:00 GMT-0500 (EST)", 11 | "timezone": "Europe/Brussels", 12 | "location": { 13 | "name": "W-est end izakaya - Ex Bravo", 14 | "address": "Aalststraat 7, 1000 Brussels", 15 | "lat": 50.852439, 16 | "long": 4.342169 17 | }, 18 | "tiers": [ 19 | { 20 | "id": 4, 21 | "name": "free", 22 | "description": "Free ticket", 23 | "amount": 0 24 | }, 25 | { 26 | "id": 2, 27 | "name": "donate", 28 | "description": "Contribute €10 and get a high five at the event! ✋", 29 | "amount": 1000 30 | } 31 | ], 32 | "collective": { 33 | "id": 207, 34 | "slug": "brusselstogether", 35 | "name": "BrusselsTogether", 36 | "mission": "We are on a mission to make Brussels a great city to live and work", 37 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 38 | "logo": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png" 39 | } 40 | }, 41 | { 42 | "id": 4, 43 | "slug": "meetup-3", 44 | "name": "BrusselsTogether Meetup 3", 45 | "description": "Hello Brussels!\n\nAccording to the UN, by 2050 66% of the world’s population will be urban dwellers, which will profoundly affect the role of modern city-states on Earth.\n\nToday, citizens are already anticipating this futurist trend by creating numerous initiatives inside their local communities and outside of politics.\n\nIf you want to be part of the change, please come have a look to our monthly events! You will have the opportunity to meet real actors of change and question them about their purpose. \n\nWe also offer the opportunity for anyone interested to come before the audience and share their ideas in 60 seconds at the end of the event.\n\nSee more about #BrusselsTogether radical way of thinking below.\n\nhttps://brusselstogether.org/\n\nGet your ticket below and get a free drink thanks to our sponsor! 🍻🎉\n\n**Schedule**\n\n7 pm - Doors open\n\n7:30 pm - Introduction to #BrusselsTogether\n\n7:40 pm - Co-Labs, Citizen Lab of Social Innovations\n\n7:55 pm - BeCode.org, growing today’s talented youth into tomorrow’s best developers.\n\n8:10 pm - OURB, A city building network\n\n8:30 pm - How do YOU make Brussels better \nPitch your idea in 60 seconds or less\n", 46 | "startsAt": "Wed Apr 05 2017 13:00:00 GMT-0400 (EDT)", 47 | "endsAt": "Wed Apr 05 2017 15:00:00 GMT-0400 (EDT)", 48 | "timezone": "Europe/Brussels", 49 | "location": { 50 | "name": "Brass'Art Digitaal Cafe", 51 | "address": "Place communale de Molenbeek 28", 52 | "lat": 50.8540523, 53 | "long": 4.338612199999943 54 | }, 55 | "tiers": [ 56 | { 57 | "id": 12, 58 | "name": "free ticket", 59 | "description": "Free ticket", 60 | "amount": 0 61 | }, 62 | { 63 | "id": 11, 64 | "name": "sponsor", 65 | "description": "Sponsor the drinks. Pretty sure everyone will love you.", 66 | "amount": 15000 67 | } 68 | ], 69 | "collective": { 70 | "id": 207, 71 | "slug": "brusselstogether", 72 | "name": "BrusselsTogether", 73 | "mission": "We are on a mission to make Brussels a great city to live and work", 74 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 75 | "logo": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png" 76 | } 77 | }, 78 | { 79 | "id": 12, 80 | "slug": "meetup-6", 81 | "name": "BrusselsTogether Meetup 6", 82 | "description": "BrusselsTogether is delighted to invite you to its 6th Meetup of the year.\n\nFor this edition, get ready to learn on the topic of sustainable food. Exchange with great associations working in this field and taste delicious local food!\n\n#BrusselsTogether organizes monthly meetups on the first Wednesday of the month to celebrate bottom-up initiatives that make Brussels a better city to live and work. Join the community https://opencollective.com/brusselstogether\n\n**Agenda & Speakers:**\n\n7.00 pm - Doors open\n\n7.30 pm - Presentation of #BrusselsTogether\n\n7.40 pm - Géry Brusselmans, contributor of Tartine et Boterham\n\n7.50 pm - Seb Dp, contributor of FruitCollect\n\n8.00 pm - Tom Gendry, contributor of Co-oking\n\n8.10 pm - TBA", 83 | "startsAt": "Wed Jul 05 2017 13:00:00 GMT-0400 (EDT)", 84 | "endsAt": "Wed Jul 05 2017 15:00:00 GMT-0400 (EDT)", 85 | "timezone": "Europe/Brussels", 86 | "location": { 87 | "name": "DigitYser", 88 | "address": " Boulevard d'Anvers 40, 1000 Brussels", 89 | "lat": 50.857351, 90 | "long": 4.350038 91 | }, 92 | "tiers": [ 93 | { 94 | "id": 35, 95 | "name": "free ticket", 96 | "description": "Please register so that we can print a nametag for you.", 97 | "amount": 0 98 | }, 99 | { 100 | "id": 36, 101 | "name": "supporter ticket", 102 | "description": "Support the BrusselsTogether collective. Your donations matter. We'll print a nametag for you for the event :)", 103 | "amount": 500 104 | }, 105 | { 106 | "id": 34, 107 | "name": "sponsor", 108 | "description": "Sponsor the drinks. Pretty sure everyone will love you.", 109 | "amount": 15000 110 | } 111 | ], 112 | "collective": { 113 | "id": 207, 114 | "slug": "brusselstogether", 115 | "name": "BrusselsTogether", 116 | "mission": "We are on a mission to make Brussels a great city to live and work", 117 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 118 | "logo": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png" 119 | } 120 | }, 121 | { 122 | "id": 8, 123 | "slug": "meetup-4", 124 | "name": "BrusselsTogether Meetup 4", 125 | "description": "Hello Brussels 👋\n\n#BrusselsTogether is growing everyday with tons of new people and ideas to renovate our dear city!\n\nThere are now 9 projects on [#BrusselsTogether](https://opencollective.com/brusselstogether). Although they operate in different areas (civic innovation, urbanism, social inclusiveness), they all share the same purpose: rebuild the city from the bottom-up. \n\nThe next meetup will take place at the all-new BeCentral, a grass-roots initiative aiming to boost Belgium as a digital front-runner. \n\nJoin us to discover 3 ambitious projects happening in Brussels.\n\nSchedule: \n\n7 pm - Doors open \n\n7:30 pm - Introduction to #brusselstogether\n\n7:40 pm - [Share Food](https://www.facebook.com/sharefoodbrussels/) - Sharing instead of wasting\n\n7:55 pm - [Communa ASBL](https://www.facebook.com/asblCommuna/) - Turning empty buildings into socio-cultural centers \n\n8:10 pm - [Veganizer](https://opencollective.com/veganizerbxl) - Let's make Brussels more Vegan Friendly! \n\n8:30 pm - How do *YOU* make Brussels better. Pitch your idea in 60 seconds or less!\n\nGet your early bird ticket now for €5 while they last (€10 at the door). We also have a limited number of free tickets for students and people with low income as we want to make sure we don't exclude anyone. Event is also free for the members of BrusselsTogether ([become a member](https://opencollective.com/brusselstogether/donate/10/monthly)).\n\n**Why making this a paid event?**\nIf we really want this movement to succeed and to have an impact, we need to finance it, together.\nWe are a non profit and all our expenses are posted publicly on our [open collective](https://opencollective.com/brusselstogether).\n\nThank you for your support 🙏", 126 | "startsAt": "Wed May 03 2017 13:00:00 GMT-0400 (EDT)", 127 | "endsAt": "Wed May 03 2017 15:00:00 GMT-0400 (EDT)", 128 | "timezone": "Europe/Brussels", 129 | "location": { 130 | "name": "BeCentral", 131 | "address": "Cantersteen 12, 1000 Brussels", 132 | "lat": 50.845568, 133 | "long": 4.357482 134 | }, 135 | "tiers": [ 136 | { 137 | "id": 22, 138 | "name": "regular ticket", 139 | "description": "Get your ticket for a great evening of inspiration and meeting great people. Includes a free drink and a free high five!", 140 | "amount": 1000 141 | }, 142 | { 143 | "id": 20, 144 | "name": "sponsor", 145 | "description": "Sponsor the drinks. Pretty sure everyone will love you.", 146 | "amount": 15000 147 | }, 148 | { 149 | "id": 24, 150 | "name": "free ticket", 151 | "description": "For students and low income people only.", 152 | "amount": null 153 | } 154 | ], 155 | "collective": { 156 | "id": 207, 157 | "slug": "brusselstogether", 158 | "name": "BrusselsTogether", 159 | "mission": "We are on a mission to make Brussels a great city to live and work", 160 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 161 | "logo": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png" 162 | } 163 | }, 164 | { 165 | "id": 11, 166 | "slug": "meetup-5", 167 | "name": "BrusselsTogether Meetup 5", 168 | "description": "Hello Brussels 👋\n\nWe meet every first Wednesday of the month to talk about the Brussels development community.\n\n![](https://cl.ly/3p013o452u3Y/Screen%20Shot%202017-05-17%20at%202.42.35%20PM.png)\n\nJoin us to discuss with MakeSense, Our House Project and many more change makers.\n\nSchedule\n\n7 pm - Doors open \n\n7:30 pm - #BrusselsTogether - On a mission to make Brussels a great city to live and work\n\n7:40 pm - MakeSense - Turning your talents into unique superpowers to solve challenges of social entrepreneurs\n\n7:55 pm - Our House Project - Enabling effective refugee integration\n\n8:10 pm - Zero Waste X\n\n8:30 pm - How do YOU make Brussels better \nPitch your idea in 60 seconds or less\n\nThank you for your support :pray:\nGet your early bird ticket now for €5 while they last (€10 at the door). We also have a limited number of free tickets for students and people with low income as we want to make sure we don't exclude anyone. Event is also free for the members of BrusselsTogether ([become a member](https://opencollective.com/brusselstogether/donate/10/monthly)).\n\n**Why making this a paid event?**\nIf we really want this movement to succeed and to have an impact, we need to finance it, together.\nWe are a non profit and all our expenses are posted publicly on our [open collective](https://opencollective.com/brusselstogether).\n\nThank you for your support 🙏", 169 | "startsAt": "Wed Jun 07 2017 13:00:00 GMT-0400 (EDT)", 170 | "endsAt": "Wed Jun 07 2017 15:00:00 GMT-0400 (EDT)", 171 | "timezone": "Europe/Brussels", 172 | "location": { 173 | "name": "Elzenhof Elsene", 174 | "address": "Kroonlaan 12, 1050 Elsene", 175 | "lat": 50.8325036, 176 | "long": 4.3758299 177 | }, 178 | "tiers": [ 179 | { 180 | "id": 29, 181 | "name": "early bird ticket", 182 | "description": "Get those while you can. RSVP before June 1st and get the ticket at €5", 183 | "amount": 500 184 | }, 185 | { 186 | "id": 30, 187 | "name": "regular ticket", 188 | "description": "Get your ticket for a great evening of inspiration and meeting great people. Includes a free drink and a free high five!", 189 | "amount": 1000 190 | }, 191 | { 192 | "id": 31, 193 | "name": "sponsor", 194 | "description": "Sponsor the drinks. Pretty sure everyone will love you.", 195 | "amount": 15000 196 | }, 197 | { 198 | "id": 32, 199 | "name": "free ticket", 200 | "description": "For students and low income people only.", 201 | "amount": null 202 | } 203 | ], 204 | "collective": { 205 | "id": 207, 206 | "slug": "brusselstogether", 207 | "name": "BrusselsTogether", 208 | "mission": "We are on a mission to make Brussels a great city to live and work", 209 | "backgroundImage": "https://cl.ly/3s3h0W0S1R3A/brusselstogether-backgroundImage.jpg", 210 | "logo": "https://cl.ly/0Q3N193Z1e3u/BrusselsTogetherLogo.png" 211 | } 212 | } 213 | ] 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/server/controllers/hosted-collectives.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import { Parser } from '@json2csv/plainjs'; 4 | import type { RequestHandler } from 'express'; 5 | import gqlV2 from 'graphql-tag'; 6 | import { compact, get, pick, toNumber, trim } from 'lodash'; 7 | import moment from 'moment'; 8 | 9 | import { amountAsString, formatContact, shortDate } from '../lib/formatting'; 10 | import { graphqlRequest } from '../lib/graphql'; 11 | import { applyMapping, parseToBooleanDefaultFalse, parseToBooleanDefaultTrue, splitEnums } from '../lib/utils'; 12 | import { logger } from '../logger'; 13 | 14 | function json2csv(data, opts) { 15 | const parser = new Parser(opts); 16 | return parser.parse(data); 17 | } 18 | 19 | type Fields = 20 | | 'name' 21 | | 'slug' 22 | | 'type' 23 | | 'legalName' 24 | | 'description' 25 | | 'website' 26 | | 'tags' 27 | | 'currency' 28 | | 'approvedAt' 29 | | 'balance' 30 | | 'hostFeePercent' 31 | | 'adminEmails' 32 | | 'adminCount' 33 | | 'firstContributionDate' 34 | | 'lastContributionDate' 35 | | 'firstExpenseDate' 36 | | 'lastExpenseDate' 37 | | 'status' 38 | | 'dateApplied' 39 | | 'unhostedAt' 40 | | 'unfrozenAt' 41 | | 'numberOfExpensesYear' 42 | | 'valueOfExpensesYear' 43 | | 'maxExpenseValueYear' 44 | | 'numberOfPayeesYear' 45 | | 'numberOfContributionsYear' 46 | | 'valueOfContributionsYear' 47 | | 'valueOfRefundedContributionsYear' 48 | | 'valueOfHostFeeYear' 49 | | 'spentTotalYear' 50 | | 'receivedTotalYear' 51 | | 'numberOfExpensesAllTime' 52 | | 'valueOfExpensesAllTime' 53 | | 'maxExpenseValueAllTime' 54 | | 'numberOfPayeesAllTime' 55 | | 'numberOfContributionsAllTime' 56 | | 'valueOfContributionsAllTime' 57 | | 'valueOfRefundedContributionsAllTime' 58 | | 'valueOfHostFeeAllTime' 59 | | 'spentTotalAllTime' 60 | | 'receivedTotalAllTime' 61 | | 'expenseMonthlyAverageCount' 62 | | 'expenseMonthlyAverageTotal' 63 | | 'contributionMonthlyAverageCount' 64 | | 'contributionMonthlyAverageTotal' 65 | | 'spentTotalMonthlyAverage' 66 | | 'receivedTotalMonthlyAverage' 67 | | 'spentTotalYearlyAverage' 68 | | 'receivedTotalYearlyAverage'; 69 | 70 | const hostQuery = gqlV2` 71 | query HostedCollectives( 72 | $hostSlug: String! 73 | ) { 74 | host(slug: $hostSlug) { 75 | id 76 | legacyId 77 | slug 78 | name 79 | currency 80 | } 81 | } 82 | `; 83 | 84 | export const hostedCollectivesQuery = gqlV2` 85 | query HostedCollectives( 86 | $hostSlug: String! 87 | $hostCurrency: Currency! 88 | $limit: Int! 89 | $offset: Int! 90 | $sort: OrderByInput 91 | $hostFeesStructure: HostFeeStructure 92 | $searchTerm: String 93 | $type: [AccountType] 94 | $isApproved: Boolean 95 | $isFrozen: Boolean 96 | $isUnhosted: Boolean 97 | $balance: AmountRangeInput 98 | $consolidatedBalance: AmountRangeInput 99 | $currencies: [String] 100 | $includeYearSummary: Boolean! 101 | $lastYear: DateTime! 102 | $includeAllTimeSummary: Boolean! 103 | ) { 104 | host(slug: $hostSlug) { 105 | id 106 | legacyId 107 | slug 108 | name 109 | currency 110 | hostedAccounts( 111 | limit: $limit 112 | offset: $offset 113 | searchTerm: $searchTerm 114 | hostFeesStructure: $hostFeesStructure 115 | accountType: $type 116 | orderBy: $sort 117 | isApproved: $isApproved 118 | isFrozen: $isFrozen 119 | isUnhosted: $isUnhosted 120 | balance: $balance 121 | consolidatedBalance: $consolidatedBalance 122 | currencies: $currencies 123 | ) { 124 | offset 125 | limit 126 | totalCount 127 | nodes { 128 | id 129 | legacyId 130 | name 131 | legalName 132 | slug 133 | website 134 | type 135 | currency 136 | imageUrl(height: 96) 137 | isFrozen 138 | isHost 139 | tags 140 | settings 141 | createdAt 142 | unhostedAt(host: { slug: $hostSlug }) 143 | stats { 144 | id 145 | balance(currency: $hostCurrency) { 146 | value 147 | currency 148 | } 149 | } 150 | policies { 151 | id 152 | COLLECTIVE_ADMINS_CAN_SEE_PAYOUT_METHODS 153 | } 154 | ... on AccountWithHost { 155 | host { 156 | id 157 | legacyId 158 | slug 159 | } 160 | hostFeesStructure 161 | hostFeePercent 162 | approvedAt 163 | unfrozenAt 164 | hostApplication { 165 | id 166 | createdAt 167 | } 168 | hostAgreements { 169 | totalCount 170 | nodes { 171 | id 172 | title 173 | attachment { 174 | id 175 | url 176 | name 177 | type 178 | } 179 | } 180 | } 181 | yearSummary: summary(dateFrom: $lastYear) @include(if: $includeYearSummary) { 182 | expenseTotal { value, currency } 183 | expenseCount 184 | expenseMaxValue { value, currency } 185 | expenseDistinctPayee 186 | contributionCount 187 | contributionTotal { value, currency } 188 | contributionRefundedTotal { value, currency } 189 | hostFeeTotal { value, currency } 190 | spentTotal { value, currency } 191 | receivedTotal { value, currency } 192 | } 193 | allTimeSummary: summary @include(if: $includeAllTimeSummary) { 194 | expenseTotal { value, currency } 195 | expenseCount 196 | expenseMaxValue { value, currency } 197 | expenseDistinctPayee 198 | contributionCount 199 | contributionTotal { value, currency } 200 | contributionRefundedTotal { value, currency } 201 | hostFeeTotal { value, currency } 202 | spentTotal { value, currency } 203 | receivedTotal { value, currency } 204 | expenseMonthlyAverageCount: expenseAverageCount(period: MONTH) 205 | expenseMonthlyAverageTotal: expenseAverageTotal(period: MONTH) { value, currency } 206 | contributionMonthlyAverageCount: contributionAverageCount(period: MONTH) 207 | contributionMonthlyAverageTotal: contributionAverageTotal(period: MONTH) { value, currency } 208 | spentTotalMonthlyAverage: spentTotalAverage(period: MONTH) { value, currency } 209 | receivedTotalMonthlyAverage: receivedTotalAverage(period: MONTH) { value, currency } 210 | spentTotalYearlyAverage: spentTotalAverage(period: YEAR) { value, currency } 211 | receivedTotalYearlyAverage: receivedTotalAverage(period: YEAR) { value, currency } 212 | } 213 | } 214 | admins: members(role: [ADMIN]) { 215 | totalCount 216 | nodes { 217 | id 218 | account { 219 | id 220 | name 221 | legalName 222 | ... on Individual { 223 | email 224 | } 225 | } 226 | } 227 | } 228 | ... on AccountWithParent { 229 | parent { 230 | id 231 | slug 232 | name 233 | } 234 | } 235 | lastExpenseReceived: expenses(limit: 1, direction: RECEIVED, orderBy: { field: CREATED_AT, direction: DESC }) { 236 | nodes { 237 | id 238 | createdAt 239 | } 240 | } 241 | firstExpenseReceived: expenses(limit: 1, direction: RECEIVED, orderBy: { field: CREATED_AT, direction: ASC }) { 242 | nodes { 243 | id 244 | createdAt 245 | } 246 | } 247 | numberOfExpenses: expenses(direction: RECEIVED) { 248 | totalCount 249 | } 250 | firstContributionReceived: orders(limit: 1, status: [PAID, ACTIVE], orderBy: { field: CREATED_AT, direction: ASC }) { 251 | nodes { 252 | createdAt 253 | } 254 | } 255 | lastContributionReceived: orders(limit: 1, status: [PAID, ACTIVE], orderBy: { field: CREATED_AT, direction: DESC }) { 256 | nodes { 257 | createdAt 258 | } 259 | } 260 | numberOfContributions: orders(status: [PAID, ACTIVE]) { 261 | totalCount 262 | } 263 | } 264 | } 265 | } 266 | } 267 | `; 268 | 269 | const csvMapping: Record string)> = { 270 | name: 'name', 271 | slug: 'slug', 272 | type: 'type', 273 | legalName: 'legalName', 274 | description: 'description', 275 | website: 'website', 276 | currency: 'currency', 277 | tags: (account) => account.tags?.join(', '), 278 | approvedAt: (account) => shortDate(account.approvedAt), 279 | hostFeePercent: 'hostFeePercent', 280 | balance: (account) => amountAsString(account.stats.balance), 281 | adminEmails: (account) => compact(account.admins?.nodes.map((member) => formatContact(member.account))).join(', '), 282 | adminCount: (account) => account.admins?.totalCount, 283 | firstContributionDate: (account) => shortDate(account.firstContributionReceived?.nodes[0]?.createdAt), 284 | lastContributionDate: (account) => shortDate(account.lastContributionReceived?.nodes[0]?.createdAt), 285 | firstExpenseDate: (account) => shortDate(account.firstExpenseReceived?.nodes[0]?.createdAt), 286 | lastExpenseDate: (account) => shortDate(account.lastExpenseReceived?.nodes[0]?.createdAt), 287 | status: (account, host) => { 288 | if (account.host?.id !== host.id) { 289 | return 'UNHOSTED'; 290 | } else if (account.isFrozen) { 291 | return 'FROZEN'; 292 | } else { 293 | return 'ACTIVE'; 294 | } 295 | }, 296 | // Added fields 297 | dateApplied: (account) => shortDate(account.hostApplication?.createdAt), 298 | unhostedAt: (account) => shortDate(account.unhostedAt), 299 | unfrozenAt: (account) => shortDate(account.unfrozenAt), 300 | numberOfExpensesYear: (account) => account.yearSummary?.expenseCount, 301 | valueOfExpensesYear: (account) => 302 | account.yearSummary?.expenseTotal && amountAsString(account.yearSummary.expenseTotal), 303 | maxExpenseValueYear: (account) => 304 | account.yearSummary?.expenseMaxValue && amountAsString(account.yearSummary.expenseMaxValue), 305 | numberOfPayeesYear: (account) => account.yearSummary?.expenseDistinctPayee, 306 | numberOfContributionsYear: (account) => account.yearSummary?.contributionCount, 307 | valueOfContributionsYear: (account) => 308 | account.yearSummary?.contributionTotal && amountAsString(account.yearSummary.contributionTotal), 309 | valueOfRefundedContributionsYear: (account) => 310 | account.yearSummary?.contributionRefundedTotal && amountAsString(account.yearSummary.contributionRefundedTotal), 311 | valueOfHostFeeYear: (account) => 312 | account.yearSummary?.hostFeeTotal && amountAsString(account.yearSummary.hostFeeTotal), 313 | spentTotalYear: (account) => account.yearSummary?.spentTotal && amountAsString(account.yearSummary.spentTotal), 314 | receivedTotalYear: (account) => 315 | account.yearSummary?.receivedTotal && amountAsString(account.yearSummary.receivedTotal), 316 | numberOfExpensesAllTime: (account) => account.allTimeSummary?.expenseCount, 317 | valueOfExpensesAllTime: (account) => 318 | account.allTimeSummary?.expenseTotal && amountAsString(account.allTimeSummary.expenseTotal), 319 | maxExpenseValueAllTime: (account) => 320 | account.allTimeSummary?.expenseMaxValue && amountAsString(account.allTimeSummary.expenseMaxValue), 321 | numberOfPayeesAllTime: (account) => account.allTimeSummary?.expenseDistinctPayee, 322 | numberOfContributionsAllTime: (account) => account.allTimeSummary?.contributionCount, 323 | valueOfContributionsAllTime: (account) => 324 | account.allTimeSummary?.contributionTotal && amountAsString(account.allTimeSummary.contributionTotal), 325 | valueOfRefundedContributionsAllTime: (account) => 326 | account.allTimeSummary?.contributionRefundedTotal && 327 | amountAsString(account.allTimeSummary.contributionRefundedTotal), 328 | valueOfHostFeeAllTime: (account) => 329 | account.allTimeSummary?.hostFeeTotal && amountAsString(account.allTimeSummary.hostFeeTotal), 330 | spentTotalAllTime: (account) => 331 | account.allTimeSummary?.spentTotal && amountAsString(account.allTimeSummary.spentTotal), 332 | receivedTotalAllTime: (account) => 333 | account.allTimeSummary?.receivedTotal && amountAsString(account.allTimeSummary.receivedTotal), 334 | expenseMonthlyAverageCount: (account) => account.allTimeSummary?.expenseMonthlyAverageCount, 335 | expenseMonthlyAverageTotal: (account) => 336 | account.allTimeSummary?.expenseMonthlyAverageTotal && 337 | amountAsString(account.allTimeSummary.expenseMonthlyAverageTotal), 338 | contributionMonthlyAverageCount: (account) => account.allTimeSummary?.contributionMonthlyAverageCount, 339 | contributionMonthlyAverageTotal: (account) => 340 | account.allTimeSummary?.contributionMonthlyAverageTotal && 341 | amountAsString(account.allTimeSummary.contributionMonthlyAverageTotal), 342 | spentTotalMonthlyAverage: (account) => 343 | account.allTimeSummary?.spentTotalMonthlyAverage && amountAsString(account.allTimeSummary.spentTotalMonthlyAverage), 344 | receivedTotalMonthlyAverage: (account) => 345 | account.allTimeSummary?.receivedTotalMonthlyAverage && 346 | amountAsString(account.allTimeSummary.receivedTotalMonthlyAverage), 347 | spentTotalYearlyAverage: (account) => 348 | account.allTimeSummary?.spentTotalYearlyAverage && amountAsString(account.allTimeSummary.spentTotalYearlyAverage), 349 | receivedTotalYearlyAverage: (account) => 350 | account.allTimeSummary?.receivedTotalYearlyAverage && 351 | amountAsString(account.allTimeSummary.receivedTotalYearlyAverage), 352 | }; 353 | 354 | const hostedCollectives: RequestHandler<{ slug: string; format: 'csv' | 'json' }> = async (req, res) => { 355 | if (!['HEAD', 'GET'].includes(req.method)) { 356 | res.status(405).send({ error: { message: 'Method not allowed' } }); 357 | return; 358 | } 359 | try { 360 | // Forward Api Key or Authorization header 361 | const headers = {}; 362 | const apiKey = req.get('Api-Key') || req.query.apiKey; 363 | const personalToken = req.get('Personal-Token') || req.query.personalToken; 364 | // Support Cookies for direct-download capability 365 | const authorization = req.get('Authorization') || req.cookies?.authorization; 366 | 367 | if (authorization) { 368 | headers['Authorization'] = authorization; 369 | } else if (apiKey) { 370 | headers['Api-Key'] = apiKey; 371 | } else if (personalToken) { 372 | headers['Personal-Token'] = personalToken; 373 | } 374 | 375 | const hostSlug = req.params.slug; 376 | assert(hostSlug, 'Please provide a slug'); 377 | 378 | const hostResult = await graphqlRequest(hostQuery, { hostSlug }, { version: 'v2', headers }); 379 | const host = hostResult.host; 380 | assert(host, 'Could not find Host'); 381 | 382 | const fields = (get(req.query, 'fields', '') as string) 383 | .split(',') 384 | .map(trim) 385 | .filter((v) => !!v) as Fields[]; 386 | 387 | const variables = { 388 | hostSlug, 389 | hostCurrency: host.currency, 390 | limit: req.method === 'HEAD' ? 0 : req.query.limit ? toNumber(req.query.limit) : 100, 391 | offset: req.query.offset ? toNumber(req.query.offset) : 0, 392 | sort: req.query.sort && JSON.parse(req.query.sort as string), 393 | consolidatedBalance: req.query.consolidatedBalance && JSON.parse(req.query.consolidatedBalance as string), 394 | hostFeesStructure: req.query.hostFeesStructure, 395 | searchTerm: req.query.searchTerm, 396 | type: splitEnums(req.query.type as string), 397 | isApproved: req.query.isApproved ? parseToBooleanDefaultTrue(req.query.isApproved as string) : undefined, 398 | isFrozen: req.query.isFrozen ? parseToBooleanDefaultTrue(req.query.isFrozen as string) : undefined, 399 | isUnhosted: req.query.isUnhosted ? parseToBooleanDefaultTrue(req.query.isUnhosted as string) : undefined, 400 | currencies: splitEnums(req.query.currencies as string), 401 | lastYear: moment().subtract(1, 'year').toISOString(), 402 | includeYearSummary: fields.some((field) => 403 | [ 404 | 'numberOfExpensesYear', 405 | 'valueOfExpensesYear', 406 | 'maxExpenseValueYear', 407 | 'numberOfPayeesYear', 408 | 'numberOfContributionsYear', 409 | 'valueOfContributionsYear', 410 | 'valueOfRefundedContributionsYear', 411 | 'valueOfHostFeeYear', 412 | 'spentTotalYear', 413 | 'receivedTotalYear', 414 | ].includes(field), 415 | ), 416 | includeAllTimeSummary: fields.some((field) => 417 | [ 418 | 'numberOfExpensesAllTime', 419 | 'valueOfExpensesAllTime', 420 | 'maxExpenseValueAllTime', 421 | 'numberOfPayeesAllTime', 422 | 'numberOfContributionsAllTime', 423 | 'valueOfContributionsAllTime', 424 | 'valueOfRefundedContributionsAllTime', 425 | 'valueOfHostFeeAllTime', 426 | 'spentTotalAllTime', 427 | 'receivedTotalAllTime', 428 | 'expenseMonthlyAverageCount', 429 | 'expenseMonthlyAverageTotal', 430 | 'contributionMonthlyAverageCount', 431 | 'contributionMonthlyAverageTotal', 432 | 'spentTotalYearlyAverage', 433 | 'receivedTotalYearlyAverage', 434 | ].includes(field), 435 | ), 436 | }; 437 | const fetchAll = variables.offset ? false : parseToBooleanDefaultFalse(req.query.fetchAll as string); 438 | logger.debug('hostedCollectives:query', { variables, headers }); 439 | 440 | let result = await graphqlRequest(hostedCollectivesQuery, variables, { version: 'v2', headers }); 441 | 442 | switch (req.params.format) { 443 | case 'csv': { 444 | if (req.params.format === 'csv') { 445 | res.append('Content-Type', `text/csv;charset=utf-8`); 446 | } else { 447 | res.append('Content-Type', `text/plain;charset=utf-8`); 448 | } 449 | const filename = `hosted-collectives-${hostSlug}-${moment.utc().format('YYYYMMDD')}.${req.params.format}`; 450 | res.append('Content-Disposition', `attachment; filename="${filename}"`); 451 | res.append('Access-Control-Expose-Headers', 'X-Exported-Rows'); 452 | res.append('X-Exported-Rows', result.host.hostedAccounts.totalCount); 453 | if (req.method === 'HEAD') { 454 | res.status(200).end(); 455 | return; 456 | } 457 | 458 | if (result.host.hostedAccounts.totalCount === 0) { 459 | res.write(json2csv([], { fields })); 460 | res.write(`\n`); 461 | res.end(); 462 | return; 463 | } 464 | 465 | const mapping = pick(csvMapping, fields); 466 | const mappedTransactions = result.host.hostedAccounts.nodes.map((t) => applyMapping(mapping, t, result.host)); 467 | res.write(json2csv(mappedTransactions, null)); 468 | res.write(`\n`); 469 | 470 | if (result.host.hostedAccounts.totalCount > result.host.hostedAccounts.limit) { 471 | if (fetchAll) { 472 | do { 473 | variables.offset += result.host.hostedAccounts.limit; 474 | result = await graphqlRequest(hostedCollectivesQuery, variables, { version: 'v2', headers }); 475 | const mappedTransactions = result.host.hostedAccounts.nodes.map((t) => 476 | applyMapping(mapping, t, result.host), 477 | ); 478 | res.write(json2csv(mappedTransactions, { header: false })); 479 | res.write(`\n`); 480 | } while ( 481 | result.host.hostedAccounts.totalCount > 482 | result.host.hostedAccounts.limit + result.host.hostedAccounts.offset 483 | ); 484 | } else { 485 | res.write( 486 | `Warning: totalCount is ${result.host.hostedAccounts.totalCount} and limit was ${result.host.hostedAccounts.limit}`, 487 | ); 488 | } 489 | } 490 | 491 | res.end(); 492 | break; 493 | } 494 | default: 495 | res.send(result.host.hostedAccounts); 496 | break; 497 | } 498 | } catch (err) { 499 | if (err.message.match(/not found/i)) { 500 | res.status(404).send(err.message); 501 | } else { 502 | logger.error(`Error while fetching hosted collectives: ${err.message}`); 503 | logger.debug(err); 504 | if (res.headersSent) { 505 | res.end(`\nError while fetching hosted collectives.`); 506 | } else { 507 | res.status(400).send(`Error while fetching hosted collectives.`); 508 | } 509 | } 510 | } 511 | }; 512 | 513 | export default hostedCollectives; 514 | -------------------------------------------------------------------------------- /src/server/controllers/account-transactions.ts: -------------------------------------------------------------------------------- 1 | import { Parser, type ParserOptions } from '@json2csv/plainjs'; 2 | import type { RequestHandler } from 'express'; 3 | import gqlV2 from 'graphql-tag'; 4 | import { difference, get, head, intersection, isNil, pick, toUpper, trim } from 'lodash'; 5 | import moment from 'moment'; 6 | 7 | import { accountNameAndLegalName, amountAsString } from '../lib/formatting'; 8 | import { graphqlRequest } from '../lib/graphql'; 9 | import { 10 | applyMapping, 11 | parseToBooleanDefaultFalse, 12 | parseToBooleanDefaultTrue, 13 | splitEnums, 14 | splitIds, 15 | } from '../lib/utils'; 16 | import { logger } from '../logger'; 17 | 18 | function json2csv(data: object, opts: ParserOptions) { 19 | const parser = new Parser(opts); 20 | return parser.parse(data); 21 | } 22 | 23 | export const transactionsFragment = gqlV2` 24 | fragment TransactionsFragment on TransactionCollection { 25 | __typename 26 | limit 27 | offset 28 | totalCount 29 | nodes { 30 | id 31 | legacyId 32 | group 33 | type 34 | kind 35 | description(dynamic: true, full: $fullDescription) 36 | createdAt 37 | clearedAt 38 | amount { 39 | value 40 | currency 41 | } 42 | amountInHostCurrency { 43 | value 44 | currency 45 | } 46 | balanceInHostCurrency { 47 | value 48 | currency 49 | } 50 | paymentProcessorFee(fetchPaymentProcessorFee: $fetchPaymentProcessorFee) { 51 | value 52 | currency 53 | } 54 | platformFee { 55 | value 56 | currency 57 | } 58 | hostFee(fetchHostFee: $fetchHostFee) { 59 | value 60 | currency 61 | } 62 | netAmountInHostCurrency( 63 | fetchHostFee: $fetchHostFee 64 | fetchPaymentProcessorFee: $fetchPaymentProcessorFee 65 | fetchTax: $fetchTax 66 | ) { 67 | value 68 | currency 69 | } 70 | taxAmount(fetchTax: $fetchTax) { 71 | value 72 | currency 73 | } 74 | taxInfo { 75 | id 76 | type 77 | rate 78 | idNumber 79 | } 80 | account { 81 | id 82 | slug 83 | name 84 | legalName 85 | type 86 | ... on Individual { 87 | email 88 | } 89 | ... on AccountWithParent { 90 | parent { 91 | id 92 | slug 93 | name 94 | legalName 95 | type 96 | ... on Individual { email } 97 | } 98 | } 99 | } 100 | oppositeAccount { 101 | id 102 | slug 103 | name 104 | legalName 105 | type 106 | ... on Individual { 107 | email 108 | } 109 | ... on AccountWithParent { 110 | parent { 111 | id 112 | slug 113 | name 114 | legalName 115 | type 116 | ... on Individual { email } 117 | } 118 | } 119 | } 120 | host { 121 | id 122 | slug 123 | name 124 | legalName 125 | type 126 | } 127 | order { 128 | id 129 | legacyId 130 | status 131 | createdAt 132 | frequency 133 | memo 134 | processedAt 135 | customData 136 | fromAccount { 137 | id 138 | name 139 | location { 140 | address 141 | country 142 | } 143 | } 144 | accountingCategory @include(if: $hasAccountingCategoryField) { 145 | id 146 | code 147 | name 148 | } 149 | transactionImportRow @include(if: $hasTransactionImportRowField) { 150 | id 151 | sourceId 152 | description 153 | date 154 | rawValue 155 | transactionsImport { 156 | id 157 | name 158 | } 159 | amount { 160 | value 161 | currency 162 | } 163 | } 164 | } 165 | paymentMethod { 166 | service 167 | type 168 | } 169 | expense { 170 | id 171 | legacyId 172 | type 173 | tags 174 | createdAt 175 | reference 176 | transferReference 177 | payeeLocation { 178 | address 179 | country 180 | } 181 | amount: amountV2 { 182 | value 183 | currency 184 | } 185 | payoutMethod { 186 | type 187 | } 188 | accountingCategory @include(if: $hasAccountingCategoryField) { 189 | id 190 | code 191 | name 192 | } 193 | transactionImportRow @include(if: $hasTransactionImportRowField) { 194 | id 195 | sourceId 196 | description 197 | date 198 | rawValue 199 | amount { 200 | value 201 | currency 202 | } 203 | } 204 | approvedBy { 205 | slug 206 | } 207 | paidBy { 208 | slug 209 | } 210 | createdByAccount { 211 | slug 212 | } 213 | } 214 | isRefund 215 | isRefunded 216 | refundTransaction { 217 | id 218 | legacyId 219 | refundKind 220 | } 221 | refundKind 222 | merchantId 223 | } 224 | } 225 | `; 226 | 227 | /* $fetchHostFee seems not used but it is in fragment */ 228 | 229 | const transactionsQuery = gqlV2/* GraphQL */ ` 230 | query AccountTransactions( 231 | $accountingCategory: [String] 232 | $clearedFrom: DateTime 233 | $clearedTo: DateTime 234 | $dateFrom: DateTime 235 | $dateTo: DateTime 236 | $excludeAccount: [AccountReferenceInput!] 237 | $expense: ExpenseReferenceInput 238 | $expenseType: [ExpenseType] 239 | $fetchHostFee: Boolean 240 | $fetchPaymentProcessorFee: Boolean 241 | $fetchTax: Boolean 242 | $fullDescription: Boolean 243 | $group: [String] 244 | $hasAccountingCategoryField: Boolean! 245 | $hasTransactionImportRowField: Boolean! 246 | $includeChildrenTransactions: Boolean 247 | $includeGiftCardTransactions: Boolean 248 | $includeIncognitoTransactions: Boolean 249 | $includeRegularTransactions: Boolean 250 | $isRefund: Boolean 251 | $kind: [TransactionKind] 252 | $limit: Int 253 | $maxAmount: Int 254 | $merchantId: [String] 255 | $minAmount: Int 256 | $offset: Int 257 | $order: OrderReferenceInput 258 | $paymentMethodService: [PaymentMethodService] 259 | $paymentMethodType: [PaymentMethodType] 260 | $searchTerm: String 261 | $slug: String 262 | $type: TransactionType 263 | ) { 264 | transactions( 265 | account: { slug: $slug } 266 | accountingCategory: $accountingCategory 267 | clearedFrom: $clearedFrom 268 | clearedTo: $clearedTo 269 | dateFrom: $dateFrom 270 | dateTo: $dateTo 271 | excludeAccount: $excludeAccount 272 | expense: $expense 273 | expenseType: $expenseType 274 | group: $group 275 | includeChildrenTransactions: $includeChildrenTransactions 276 | includeDebts: true 277 | includeGiftCardTransactions: $includeGiftCardTransactions 278 | includeIncognitoTransactions: $includeIncognitoTransactions 279 | includeRegularTransactions: $includeRegularTransactions 280 | isRefund: $isRefund 281 | kind: $kind 282 | limit: $limit 283 | maxAmount: $maxAmount 284 | merchantId: $merchantId 285 | minAmount: $minAmount 286 | offset: $offset 287 | order: $order 288 | paymentMethodService: $paymentMethodService 289 | paymentMethodType: $paymentMethodType 290 | searchTerm: $searchTerm 291 | type: $type 292 | ) { 293 | ...TransactionsFragment 294 | } 295 | } 296 | ${transactionsFragment} 297 | `; 298 | 299 | const hostTransactionsQuery = gqlV2/* GraphQL */ ` 300 | query HostTransactions( 301 | $account: [AccountReferenceInput!] 302 | $accountingCategory: [String] 303 | $clearedFrom: DateTime 304 | $clearedTo: DateTime 305 | $dateFrom: DateTime 306 | $dateTo: DateTime 307 | $excludeAccount: [AccountReferenceInput!] 308 | $expense: ExpenseReferenceInput 309 | $expenseType: [ExpenseType] 310 | $fetchHostFee: Boolean 311 | $fetchPaymentProcessorFee: Boolean 312 | $fetchTax: Boolean 313 | $fullDescription: Boolean 314 | $group: [String] 315 | $hasAccountingCategoryField: Boolean! 316 | $hasTransactionImportRowField: Boolean! 317 | $includeChildrenTransactions: Boolean 318 | $includeHost: Boolean 319 | $isRefund: Boolean 320 | $hasDebt: Boolean 321 | $kind: [TransactionKind] 322 | $limit: Int 323 | $maxAmount: Int 324 | $merchantId: [String] 325 | $minAmount: Int 326 | $offset: Int 327 | $order: OrderReferenceInput 328 | $paymentMethodService: [PaymentMethodService] 329 | $paymentMethodType: [PaymentMethodType] 330 | $searchTerm: String 331 | $slug: String 332 | $type: TransactionType 333 | ) { 334 | transactions( 335 | account: $account 336 | accountingCategory: $accountingCategory 337 | clearedFrom: $clearedFrom 338 | clearedTo: $clearedTo 339 | dateFrom: $dateFrom 340 | dateTo: $dateTo 341 | excludeAccount: $excludeAccount 342 | expense: $expense 343 | expenseType: $expenseType 344 | group: $group 345 | host: { slug: $slug } 346 | includeChildrenTransactions: $includeChildrenTransactions 347 | includeDebts: true 348 | includeHost: $includeHost 349 | isRefund: $isRefund 350 | hasDebt: $hasDebt 351 | kind: $kind 352 | limit: $limit 353 | maxAmount: $maxAmount 354 | merchantId: $merchantId 355 | minAmount: $minAmount 356 | offset: $offset 357 | order: $order 358 | paymentMethodService: $paymentMethodService 359 | paymentMethodType: $paymentMethodType 360 | searchTerm: $searchTerm 361 | type: $type 362 | ) { 363 | ...TransactionsFragment 364 | } 365 | } 366 | ${transactionsFragment} 367 | `; 368 | 369 | const getAccountingCategory = (transaction) => { 370 | return get(transaction, 'expense.accountingCategory') || get(transaction, 'order.accountingCategory'); 371 | }; 372 | 373 | const columnNames = { 374 | datetime: 'Date & Time', 375 | effectiveDate: 'Effective Date & Time', 376 | legacyId: 'Transaction ID', 377 | group: 'Group ID', 378 | description: 'Description', 379 | type: 'Credit/Debit', 380 | kind: 'Kind', 381 | netAmount: 'Amount Single Column', 382 | debitAndCreditAmounts: 'Amount Debit/Credit Columns', 383 | currency: 'Currency', 384 | displayAmount: 'Original Currency Amount', 385 | isReverse: 'Is Reverse', 386 | isReversed: 'Is Reversed', 387 | accountingCategoryCode: 'Accounting Category Code', 388 | accountingCategoryName: 'Accounting Category Name', 389 | merchantId: 'Merchant ID', 390 | paymentMethodService: 'Payment Processor', 391 | paymentMethodType: 'Payment Method', 392 | accountSlug: 'Account Handle', 393 | accountName: 'Account Name', 394 | accountType: 'Account Type', 395 | accountEmail: 'Account Email', 396 | oppositeAccountSlug: 'Opposite Account Handle', 397 | oppositeAccountName: 'Opposite Account Name', 398 | oppositeAccountType: 'Opposite Account Type', 399 | oppositeAccountEmail: 'Opposite Account Email', 400 | parentAccountSlug: 'Parent Account Handle', 401 | parentAccountName: 'Parent Account Name', 402 | parentAccountType: 'Parent Account Type', 403 | parentAccountEmail: 'Parent Account Email', 404 | oppositeParentAccountSlug: 'Opposite Parent Account Handle', 405 | oppositeParentAccountName: 'Opposite Parent Account Name', 406 | oppositeParentAccountType: 'Opposite Parent Account Type', 407 | oppositeParentAccountEmail: 'Opposite Parent Account Email', 408 | orderLegacyId: 'Contribution ID', 409 | orderMemo: 'Contribution Memo', 410 | orderFrequency: 'Contribution Frequency', 411 | orderCustomData: 'Contribution Custom Data', 412 | orderContributorAddress: 'Contributor Address', 413 | orderContributorCountry: 'Contributor Country', 414 | expenseLegacyId: 'Expense ID', 415 | expenseType: 'Expense Type', 416 | expenseTags: 'Expense Tags', 417 | taxType: 'Tax Type', 418 | taxRate: 'Tax Rate', 419 | taxIdNumber: 'Tax ID Number', 420 | date: 'Date', 421 | id: 'Transaction GraphQL ID', 422 | shortId: 'Short Transaction ID', 423 | shortGroup: 'Short Group ID', 424 | amount: 'Gross Amount', 425 | paymentProcessorFee: 'Payment Processor Fee', 426 | expenseId: 'Expense GraphQL ID', 427 | payoutMethodType: 'Expense Payout Method Type', 428 | platformFee: 'Platform Fee', 429 | hostFee: 'Host Fee', 430 | orderId: 'Contribution GraphQL ID', 431 | reverseLegacyId: 'Reverse Transaction ID', 432 | reverseKind: 'Reverse Kind', 433 | expenseTotalAmount: 'Expense Total Amount', 434 | expenseCurrency: 'Expense Currency', 435 | expenseSubmittedByHandle: 'Expense Submitted By Handle', 436 | expenseApprovedByHandle: 'Expense Approved By Handle', 437 | expensePaidByHandle: 'Expense Paid By Handle', 438 | expenseReference: 'Expense Reference Number', 439 | expenseTransferReference: 'Expense Transfer Reference', 440 | expensePayeeAddress: 'Payee Address', 441 | expensePayeeCountry: 'Payee Country', 442 | importSourceName: 'Import Source Name', 443 | importSourceId: 'Import Source ID', 444 | importSourceDescription: 'Import Source Description', 445 | importSourceAmount: 'Import Source Amount', 446 | importSourceDate: 'Import Source Date', 447 | importSourceData: 'Import Source Data', 448 | shortRefundId: 'Short Refund Transaction ID', 449 | refundLegacyId: 'Refund Transaction ID', 450 | refundId: 'Refund ID', 451 | isRefund: 'Is Refund', 452 | isRefunded: 'Is Refunded', 453 | balance: 'Balance', 454 | hostSlug: 'Host Handle', 455 | hostName: 'Host Name', 456 | hostType: 'Host Type', 457 | orderProcessedDate: 'Contribution Processed Date', 458 | taxAmount: 'Tax Amount', 459 | }; 460 | 461 | const csvMapping = { 462 | accountingCategoryCode: (t) => getAccountingCategory(t)?.code || '', 463 | accountingCategoryName: (t) => getAccountingCategory(t)?.name || '', 464 | date: (t) => moment.utc(t.createdAt).format('YYYY-MM-DD'), 465 | datetime: (t) => moment.utc(t.createdAt).format('YYYY-MM-DDTHH:mm:ss'), 466 | effectiveDate: (t) => (t.clearedAt ? moment.utc(t.clearedAt).format('YYYY-MM-DDTHH:mm:ss') : ''), 467 | id: 'id', 468 | legacyId: 'legacyId', 469 | shortId: (t) => t.id.substr(0, 8), 470 | shortGroup: (t) => t.group.substr(0, 8), 471 | group: 'group', 472 | description: 'description', 473 | type: 'type', 474 | kind: 'kind', 475 | isRefund: (t) => (t.isRefund ? 'REFUND' : ''), 476 | isRefunded: (t) => (t.isRefunded ? 'REFUNDED' : ''), 477 | refundId: (t) => get(t, 'refundTransaction.id', ''), 478 | shortRefundId: (t) => get(t, 'refundTransaction.id', '').substr(0, 8), 479 | refundLegacyId: (t) => get(t, 'refundTransaction.legacyId', ''), 480 | refundKind: 'refundKind', 481 | isReverse: (t) => (t.isRefund ? 'REVERSE' : ''), 482 | isReversed: (t) => (t.isRefunded ? 'REVERSED' : ''), 483 | reverseId: (t) => get(t, 'refundTransaction.id', ''), 484 | reverseLegacyId: (t) => get(t, 'refundTransaction.legacyId', ''), 485 | reverseKind: 'refundKind', 486 | displayAmount: (t) => amountAsString(t.amount), 487 | amount: (t) => get(t, 'amountInHostCurrency.value', 0), 488 | creditAmount: (t) => (t.type === 'CREDIT' ? get(t, 'amountInHostCurrency.value', 0) : ''), 489 | debitAmount: (t) => (t.type === 'DEBIT' ? get(t, 'amountInHostCurrency.value', 0) : ''), 490 | paymentProcessorFee: (t) => get(t, 'paymentProcessorFee.value', 0), 491 | platformFee: (t) => get(t, 'platformFee.value', 0), 492 | hostFee: (t) => get(t, 'hostFee.value', 0), 493 | netAmount: (t) => get(t, 'netAmountInHostCurrency.value', 0), 494 | balance: (t) => get(t, 'balanceInHostCurrency.value'), 495 | currency: (t) => get(t, 'amountInHostCurrency.currency'), 496 | accountSlug: (t) => get(t, 'account.slug'), 497 | accountName: (t) => accountNameAndLegalName(t.account), 498 | accountType: (t) => get(t, 'account.type'), 499 | accountEmail: (t) => get(t, 'account.email'), 500 | oppositeAccountSlug: (t) => get(t, 'oppositeAccount.slug'), 501 | oppositeAccountName: (t) => accountNameAndLegalName(t.oppositeAccount), 502 | oppositeAccountType: (t) => get(t, 'oppositeAccount.type'), 503 | oppositeAccountEmail: (t) => get(t, 'oppositeAccount.email'), 504 | parentAccountSlug: (t) => get(t, 'account.parent.slug'), 505 | parentAccountName: (t) => accountNameAndLegalName(t.account?.parent), 506 | parentAccountType: (t) => get(t, 'account.parent.type'), 507 | parentAccountEmail: (t) => get(t, 'account.parent.email'), 508 | oppositeParentAccountSlug: (t) => get(t, 'oppositeAccount.parent.slug'), 509 | oppositeParentAccountName: (t) => accountNameAndLegalName(t.oppositeAccount?.parent), 510 | oppositeParentAccountType: (t) => get(t, 'oppositeAccount.parent.type'), 511 | oppositeParentAccountEmail: (t) => get(t, 'oppositeAccount.parent.email'), 512 | hostSlug: (t) => get(t, 'host.slug'), 513 | hostName: (t) => accountNameAndLegalName(t.host), 514 | hostType: (t) => get(t, 'host.type'), 515 | orderId: (t) => get(t, 'order.id'), 516 | orderLegacyId: (t) => get(t, 'order.legacyId'), 517 | orderFrequency: (t) => get(t, 'order.frequency'), 518 | orderContributorAddress: (t) => get(t, 'order.fromAccount.location.address'), 519 | orderContributorCountry: (t) => get(t, 'order.fromAccount.location.country'), 520 | paymentMethodService: (t) => get(t, 'paymentMethod.service'), 521 | paymentMethodType: (t) => get(t, 'paymentMethod.type'), 522 | expenseId: (t) => get(t, 'expense.id'), 523 | expenseLegacyId: (t) => get(t, 'expense.legacyId'), 524 | expenseType: (t) => get(t, 'expense.type'), 525 | expenseTags: (t) => get(t, 'expense.tags', []).join(', '), 526 | expensePayeeAddress: (t) => get(t, 'expense.payeeLocation.address'), 527 | expensePayeeCountry: (t) => get(t, 'expense.payeeLocation.country'), 528 | payoutMethodType: (t) => get(t, 'expense.payoutMethod.type'), 529 | merchantId: (t) => get(t, 'merchantId'), 530 | orderMemo: (t) => get(t, 'order.memo'), 531 | orderProcessedDate: (t) => (t.order?.processedAt ? moment.utc(t.order.processedAt).format('YYYY-MM-DD') : ''), 532 | orderCustomData: (t) => get(t, 'order.customData'), 533 | taxAmount: (t) => get(t, 'taxAmount.value', 0), 534 | taxType: (t) => get(t, 'taxInfo.type'), 535 | taxRate: (t) => get(t, 'taxInfo.rate'), 536 | taxIdNumber: (t) => get(t, 'taxInfo.idNumber'), 537 | expenseTotalAmount: (t) => get(t, 'expense.amount.value'), 538 | expenseCurrency: (t) => get(t, 'expense.amount.currency'), 539 | expenseSubmittedByHandle: (t) => get(t, 'expense.createdByAccount.slug'), 540 | expenseApprovedByHandle: (t) => 541 | get(t, 'expense.approvedBy', []) 542 | ?.map((a) => a.slug) 543 | .join(' '), 544 | expensePaidByHandle: (t) => get(t, 'expense.paidBy.slug'), 545 | expenseReference: (t) => get(t, 'expense.reference'), 546 | expenseTransferReference: (t) => get(t, 'expense.transferReference'), 547 | // Transactions import 548 | importSourceName: (t) => get(getTransactionImportRowFromTransaction(t), 'transactionsImport.name'), 549 | importSourceId: (t) => get(getTransactionImportRowFromTransaction(t), 'sourceId'), 550 | importSourceDescription: (t) => get(getTransactionImportRowFromTransaction(t), 'description'), 551 | importSourceAmount: (t) => get(getTransactionImportRowFromTransaction(t), 'amount.value'), 552 | importSourceDate: (t) => get(getTransactionImportRowFromTransaction(t), 'date'), 553 | importSourceData: (t) => get(getTransactionImportRowFromTransaction(t), 'rawValue'), 554 | }; 555 | 556 | const getTransactionImportRowFromTransaction = (transaction) => { 557 | return get(transaction, 'expense.transactionImportRow') || get(transaction, 'order.transactionImportRow'); 558 | }; 559 | 560 | const allKinds = [ 561 | 'ADDED_FUNDS', 562 | 'BALANCE_TRANSFER', 563 | 'CONTRIBUTION', 564 | 'EXPENSE', 565 | 'HOST_FEE', 566 | 'HOST_FEE_SHARE', 567 | 'HOST_FEE_SHARE_DEBT', 568 | 'PAYMENT_PROCESSOR_COVER', 569 | 'PAYMENT_PROCESSOR_FEE', 570 | 'PAYMENT_PROCESSOR_DISPUTE_FEE', 571 | 'PLATFORM_FEE', 572 | 'PLATFORM_TIP', 573 | 'PLATFORM_TIP_DEBT', 574 | 'PREPAID_PAYMENT_METHOD', 575 | 'TAX', 576 | ]; 577 | 578 | const allFields = Object.keys(csvMapping); 579 | 580 | const defaultFields = [ 581 | 'datetime', 582 | 'shortId', 583 | 'shortGroup', 584 | 'description', 585 | 'type', 586 | 'kind', 587 | 'isRefund', 588 | 'isRefunded', 589 | 'shortRefundId', 590 | 'displayAmount', 591 | 'amount', 592 | 'paymentProcessorFee', 593 | 'hostFee', 594 | 'netAmount', 595 | 'balance', 596 | 'currency', 597 | 'accountSlug', 598 | 'accountName', 599 | 'oppositeAccountSlug', 600 | 'oppositeAccountName', 601 | // Payment Method (for orders) 602 | 'paymentMethodService', 603 | 'paymentMethodType', 604 | // Type and Payout Method (for expenses) 605 | 'expenseType', 606 | 'expenseTags', 607 | 'payoutMethodType', 608 | // Extra fields 609 | 'merchantId', 610 | 'orderMemo', 611 | 'orderProcessedDate', 612 | 'refundKind', 613 | ]; 614 | 615 | type Params = { 616 | slug: string; 617 | reportType: 'hostTransactions' | 'transactions'; 618 | type?: 'credit' | 'debit'; 619 | kind?: string; 620 | format: 'json' | 'csv' | 'txt'; 621 | }; 622 | 623 | const accountTransactions: RequestHandler = async (req, res) => { 624 | if (!['HEAD', 'GET'].includes(req.method)) { 625 | res.status(405).send({ error: { message: 'Method not allowed' } }); 626 | return; 627 | } 628 | 629 | const variables: any = pick({ ...req.params, ...req.query }, [ 630 | 'account', 631 | 'accountingCategory', 632 | 'dateFrom', 633 | 'dateTo', 634 | 'clearedFrom', 635 | 'clearedTo', 636 | 'excludeAccount', 637 | 'expenseId', 638 | 'expenseType', 639 | 'group', 640 | 'hasDebt', 641 | 'includeChildrenTransactions', 642 | 'includeGiftCardTransactions', 643 | 'includeHost', 644 | 'includeIncognitoTransactions', 645 | 'includeRegularTransactions', 646 | 'isRefund', 647 | 'kind', 648 | 'limit', 649 | 'maxAmount', 650 | 'merchantId', 651 | 'minAmount', 652 | 'offset', 653 | 'orderId', 654 | 'paymentMethodService', 655 | 'paymentMethodType', 656 | 'searchTerm', 657 | 'slug', 658 | 'type', 659 | 'useFieldNames', 660 | ]); 661 | 662 | const useFieldNames = 663 | variables.useFieldNames === '1' || variables.useFieldNames === true || variables.useFieldNames === 'true'; 664 | 665 | variables.limit = 666 | // If HEAD, we only want count, so we set limit to 0 667 | req.method === 'HEAD' 668 | ? 0 669 | : // Else, we use the limit provided by the user, or default to 1000 670 | variables.limit 671 | ? Number(variables.limit) 672 | : 1000; 673 | variables.offset = Number(variables.offset) || 0; 674 | 675 | if (variables.account) { 676 | variables.account = variables.account.split(',').map((slug) => ({ slug })); 677 | } 678 | if (variables.excludeAccount) { 679 | variables.excludeAccount = variables.excludeAccount.split(',').map((slug) => ({ slug })); 680 | } 681 | 682 | if (variables.dateFrom) { 683 | variables.dateFrom = moment.utc(variables.dateFrom).toISOString(); 684 | } 685 | if (variables.dateTo) { 686 | // Detect short form (e.g: 2021-08-30) 687 | const shortDate = variables.dateTo.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/); 688 | variables.dateTo = moment.utc(variables.dateTo); 689 | // Extend to end of the day, 1 sec before midnight 690 | if (shortDate) { 691 | variables.dateTo.set('hour', 23).set('minute', 59).set('second', 59); 692 | } 693 | variables.dateTo = variables.dateTo.toISOString(); 694 | } 695 | if (variables.clearedFrom) { 696 | variables.clearedFrom = moment.utc(variables.clearedFrom).toISOString(); 697 | } 698 | if (variables.clearedTo) { 699 | // Detect short form (e.g: 2021-08-30) 700 | const shortDate = variables.clearedTo.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/); 701 | variables.clearedTo = moment.utc(variables.clearedTo); 702 | // Extend to end of the day, 1 sec before midnight 703 | if (shortDate) { 704 | variables.clearedTo.set('hour', 23).set('minute', 59).set('second', 59); 705 | } 706 | variables.clearedTo = variables.clearedTo.toISOString(); 707 | } 708 | 709 | if (variables.minAmount) { 710 | variables.minAmount = Number(variables.minAmount); 711 | } 712 | if (variables.maxAmount) { 713 | variables.maxAmount = Number(variables.maxAmount); 714 | } 715 | 716 | if (variables.type) { 717 | variables.type = intersection(variables.type.split(',').map(toUpper), ['CREDIT', 'DEBIT']); 718 | 719 | // Not a list in GraphQL for now, take the first 720 | variables.type = head(variables.type); 721 | } 722 | 723 | if (variables.kind) { 724 | variables.kind = splitEnums(variables.kind); 725 | } 726 | if (variables.paymentMethodService) { 727 | variables.paymentMethodService = splitEnums(variables.paymentMethodService); 728 | } 729 | if (variables.paymentMethodType) { 730 | variables.paymentMethodType = splitEnums(variables.paymentMethodType); 731 | } 732 | 733 | if (variables.group) { 734 | variables.group = splitIds(variables.group); 735 | } 736 | if (variables.merchantId) { 737 | variables.merchantId = splitIds(variables.merchantId); 738 | } 739 | if (variables.accountingCategory) { 740 | variables.accountingCategory = splitIds(variables.accountingCategory); 741 | } 742 | 743 | if (variables.includeIncognitoTransactions) { 744 | variables.includeIncognitoTransactions = parseToBooleanDefaultFalse(variables.includeIncognitoTransactions); 745 | } 746 | 747 | if (variables.includeChildrenTransactions) { 748 | variables.includeChildrenTransactions = parseToBooleanDefaultFalse(variables.includeChildrenTransactions); 749 | } 750 | 751 | if (variables.includeGiftCardTransactions) { 752 | variables.includeGiftCardTransactions = parseToBooleanDefaultFalse(variables.includeGiftCardTransactions); 753 | } 754 | 755 | if (variables.includeRegularTransactions) { 756 | variables.includeRegularTransactions = parseToBooleanDefaultTrue(variables.includeRegularTransactions); 757 | } 758 | 759 | if (variables.includeHost) { 760 | variables.includeHost = parseToBooleanDefaultTrue(variables.includeHost); 761 | } 762 | 763 | variables.fetchHostFee = parseToBooleanDefaultFalse(req.query.flattenHostFee as string); 764 | if (variables.fetchHostFee) { 765 | variables.kind = difference(variables.kind || allKinds, ['HOST_FEE']); 766 | } 767 | 768 | variables.fetchPaymentProcessorFee = parseToBooleanDefaultFalse(req.query.flattenPaymentProcessorFee as string); 769 | if (variables.fetchPaymentProcessorFee) { 770 | variables.kind = difference(variables.kind || allKinds, ['PAYMENT_PROCESSOR_FEE']); 771 | } 772 | 773 | variables.fetchTax = parseToBooleanDefaultFalse(req.query.flattenTax as string); 774 | if (variables.fetchTax) { 775 | variables.kind = difference(variables.kind || allKinds, ['TAX']); 776 | } 777 | 778 | if (variables.expenseType) { 779 | variables.expenseType = splitEnums(variables.expenseType); 780 | } 781 | 782 | if (variables.orderId) { 783 | variables.order = { legacyId: parseInt(variables.orderId) }; 784 | } 785 | 786 | if (variables.expenseId) { 787 | variables.expense = { legacyId: parseInt(variables.expenseId) }; 788 | } 789 | 790 | // isRefund can be false but default should be undefined 791 | if (!isNil(variables.isRefund)) { 792 | variables.isRefund = parseToBooleanDefaultFalse(variables.isRefund); 793 | } 794 | 795 | // hasDebt can be false but default should be undefined 796 | if (!isNil(variables.hasDebt)) { 797 | variables.hasDebt = parseToBooleanDefaultFalse(variables.hasDebt); 798 | } 799 | 800 | if (req.query.fullDescription) { 801 | variables.fullDescription = parseToBooleanDefaultFalse(req.query.fullDescription as string); 802 | } else { 803 | variables.fullDescription = req.params.reportType === 'hostTransactions' ? true : false; 804 | } 805 | 806 | let fields = (get(req.query, 'fields', '') as string) 807 | .split(',') 808 | .map(trim) 809 | .filter((v) => !!v); 810 | 811 | if (fields.length === 0) { 812 | const remove = (get(req.query, 'remove', '') as string) 813 | .split(',') 814 | .map(trim) 815 | .filter((v) => !!v); 816 | 817 | const add = (get(req.query, 'add', '') as string) 818 | .split(',') 819 | .map(trim) 820 | .filter((v) => !!v); 821 | 822 | const baseAllFields = 823 | req.params.reportType === 'hostTransactions' ? allFields.filter((field) => field !== 'balance') : allFields; 824 | 825 | let baseDefaultFields = defaultFields; 826 | if (!variables.fetchHostFee) { 827 | baseDefaultFields = baseDefaultFields.filter((field) => field !== 'hostFee'); 828 | } 829 | if (!variables.fetchPaymentProcessorFee) { 830 | baseDefaultFields = baseDefaultFields.filter((field) => field !== 'paymentProcessorFee'); 831 | } 832 | if (!variables.fetchTax) { 833 | // No need to remove taxAmount because it's not in the default fields 834 | // baseDefaultFields = baseDefaultFields.filter((field) => field !== 'taxAmount'); 835 | } 836 | // Remove netAmount if not needed 837 | // For later 838 | // if (!variables.fetchPaymentProcessorFee && !variables.fetchTax) { 839 | // baseDefaultFields = baseDefaultFields.filter((field) => field !== 'netAmount'); 840 | // } 841 | fields = difference(intersection(baseAllFields, [...baseDefaultFields, ...add]), remove); 842 | } 843 | 844 | const fetchAll = variables.offset ? false : parseToBooleanDefaultFalse(req.query.fetchAll as string); 845 | 846 | // Add fields info to the query, to prevent fetching what's not needed 847 | variables.hasAccountingCategoryField = fields.some((field) => field.startsWith('accountingCategory')); 848 | variables.hasTransactionImportRowField = fields.some((field) => field.startsWith('importSource')); 849 | 850 | try { 851 | // Forward Api Key or Authorization header 852 | const headers = {}; 853 | const apiKey = req.get('Api-Key') || req.query.apiKey; 854 | const personalToken = req.get('Personal-Token') || req.query.personalToken; 855 | // Support Cookies for direct-download capability 856 | const authorization = req.get('Authorization') || req.cookies?.authorization; 857 | 858 | if (authorization) { 859 | headers['Authorization'] = authorization; 860 | } else if (apiKey) { 861 | headers['Api-Key'] = apiKey; 862 | } else if (personalToken) { 863 | headers['Personal-Token'] = personalToken; 864 | } 865 | 866 | const query = req.params.reportType === 'hostTransactions' ? hostTransactionsQuery : transactionsQuery; 867 | 868 | let result = await graphqlRequest(query, variables, { version: 'v2', headers }); 869 | 870 | switch (req.params.format) { 871 | case 'txt': 872 | case 'csv': { 873 | if (req.params.format === 'csv') { 874 | res.append('Content-Type', `text/csv;charset=utf-8`); 875 | } else { 876 | res.append('Content-Type', `text/plain;charset=utf-8`); 877 | } 878 | let filename = 879 | req.params.reportType === 'hostTransactions' 880 | ? `${variables.slug}-host-transactions` 881 | : `${variables.slug}-transactions`; 882 | if (variables.dateFrom) { 883 | const until = variables.dateTo || moment.utc().toISOString(); 884 | filename += `-${variables.dateFrom.slice(0, 10)}-${until.slice(0, 10)}`; 885 | } 886 | filename += `.${req.params.format}`; 887 | res.append('Content-Disposition', `attachment; filename="${filename}"`); 888 | res.append('Access-Control-Expose-Headers', 'X-Exported-Rows'); 889 | res.append('X-Exported-Rows', result.transactions.totalCount); 890 | if (req.method === 'HEAD') { 891 | res.status(200).end(); 892 | return; 893 | } 894 | 895 | const exportFields = useFieldNames 896 | ? fields.map((field) => ({ label: columnNames[field] || field, value: field })) 897 | : fields; 898 | 899 | if (result.transactions.totalCount === 0) { 900 | res.write(json2csv([], { fields: exportFields })); 901 | res.write(`\n`); 902 | res.end(); 903 | return; 904 | } 905 | 906 | const mapping = pick(csvMapping, fields); 907 | 908 | const mappedTransactions = result.transactions.nodes.map((t) => applyMapping(mapping, t)); 909 | res.write(json2csv(mappedTransactions, { fields: exportFields })); 910 | res.write(`\n`); 911 | 912 | if (result.transactions.totalCount > result.transactions.limit) { 913 | if (fetchAll) { 914 | do { 915 | variables.offset += result.transactions.limit; 916 | 917 | result = await graphqlRequest(query, variables, { version: 'v2', headers }); 918 | 919 | const mappedTransactions = result.transactions.nodes.map((t) => applyMapping(mapping, t)); 920 | res.write(json2csv(mappedTransactions, { header: false })); 921 | res.write(`\n`); 922 | } while (result.transactions.totalCount > result.transactions.limit + result.transactions.offset); 923 | } else { 924 | res.write( 925 | `Warning: totalCount is ${result.transactions.totalCount} and limit was ${result.transactions.limit}`, 926 | ); 927 | } 928 | } 929 | res.end(); 930 | break; 931 | } 932 | 933 | default: 934 | res.send(result.transactions); 935 | break; 936 | } 937 | } catch (err) { 938 | if (err.message.match(/No account found/)) { 939 | res.status(404).send('Not account found.'); 940 | } else { 941 | logger.error(`Error while fetching collective transactions: ${err.message}`); 942 | if (res.headersSent) { 943 | res.end(`\nError while fetching account transactions.`); 944 | } else { 945 | res.status(400).send(`Error while fetching account transactions.`); 946 | } 947 | } 948 | } 949 | }; 950 | 951 | export default accountTransactions; 952 | --------------------------------------------------------------------------------