diff --git a/.env.default b/.env.default index 43ad37edc..e4c2733b5 100644 --- a/.env.default +++ b/.env.default @@ -30,6 +30,11 @@ LOG_MAX_FILES=365 NEXTAUTH_URL="http://localhost" NEXTAUTH_SECRET=cake_is_love_cake_is_life +# Stripe +STRIPE_SECRET_KEY=sk_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_... +STRIPE_WEBHOOK_SECRET=whsec_... + # Password Hashing and Encryption PASSWORD_SALT_ROUNDS="12" PASSWORD_ENCRYPTION_KEY="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" # Must be 256 bits (43 characters) long diff --git a/README.md b/README.md index 40b63ad81..bb1944914 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ npm run lint To auto-fix linting errors run ```bash -npm run lint -- --fix +npm run lint:fix ``` ## Migration from omegaweb basic @@ -92,3 +92,16 @@ npm run dobbelOmega:run ``` If you are connected to our test database on openStack, make sure to be on the ntnu network to be able to connect. + +## Testing + +To run the tests run +```bash +npm run docker:test +``` + +The tests can also be run outside of docker using +```bash +npm run test +``` +but this requires starting a database manually. diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 48dba40ae..a89c38c36 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -25,7 +25,9 @@ services: API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY} FEIDE_CLIENT_ID: ${FEIDE_CLIENT_ID} FEIDE_CLIENT_SECRET: ${FEIDE_CLIENT_SECRET} - MAIL_SERVER: ${MAIL_SERVER} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} MAIL_DOMAIN: ${MAIL_DOMAIN} DOMAIN: ${DOMAIN} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} diff --git a/eslint.config.ts b/eslint.config.ts index 59f117624..476267703 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -173,7 +173,7 @@ const eslintConfig = defineConfig([ // specify the maximum depth callbacks can be nested 'max-nested-callbacks': [ 'error', - 3 + 4 ], // disallow the omission of parentheses when invoking a constructor with no arguments 'new-parens': 'error', diff --git a/jest.config.ts b/jest.config.ts index 8cd923b0e..b07c2f385 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -16,6 +16,11 @@ const config: Config = { // This is needed becaue jest doesn't handle the this code is inside node_modules '^@/prisma-dobbel-omega/(.*)$': '/node_modules/.prisma-dobbel-omega/$1', }, + globals: { + 'ts-jest': { + useESM: true, + }, + } } export default async function jestConfig() { diff --git a/package-lock.json b/package-lock.json index 6169a6852..fab9f4745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@prisma/client": "^7.2.0", "@react-email/components": "^0.5.7", "@react-email/render": "^1.4.0", + "@stripe/react-stripe-js": "^3.3.0", + "@stripe/stripe-js": "^5.9.2", "bcrypt": "^5.1.1", "eslint-plugin-react-hooks": "^6.1.1", "html5-qrcode": "^2.3.8", @@ -39,6 +41,7 @@ "sass": "^1.93.2", "server-only": "^0.0.1", "sharp": "^0.34.4", + "stripe": "^17.7.0", "unified": "^11.0.5", "uuid": "^10.0.0", "winston": "^3.18.3", @@ -3549,6 +3552,27 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz", + "integrity": "sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz", + "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4889,6 +4913,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5894,7 +5945,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -6926,7 +6976,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7327,7 +7376,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -11592,6 +11640,20 @@ "node": ">=10.13.0" } }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12735,6 +12797,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", + "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/package.json b/package.json index 2764878f7..a60a5ac6f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "lint:fix": "eslint --fix --quiet", "test": "IGNORE_SERVER_ONLY=true jest", "docker:dev": "./development-entrypoint.sh", "docker:test": "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from projectnext --attach projectnext --no-log-prefix", @@ -27,6 +28,8 @@ "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@stripe/react-stripe-js": "^3.3.0", + "@stripe/stripe-js": "^5.9.2", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@react-email/components": "^0.5.7", @@ -53,6 +56,7 @@ "remark-rehype": "^11.1.2", "sass": "^1.93.2", "server-only": "^0.0.1", + "stripe": "^17.7.0", "sharp": "^0.34.4", "unified": "^11.0.5", "uuid": "^10.0.0", diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss new file mode 100644 index 000000000..f2fa6b148 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss @@ -0,0 +1,32 @@ +@use "@/styles/ohma"; + +.LedgerAccountBalance { + display: grid; + grid-template-columns: auto 1fr auto; + grid-auto-flow: row; + grid-auto-columns: max-content; + column-gap: 2*ohma.$gap; + white-space: nowrap; + overflow: hidden; + + // @include ohma.screenMobile { + // grid-template-columns: auto 1fr; + // } + // align-items: center; + // justify-content: space-between; +} + +.amountRow { + display: contents; + font-size: ohma.$fonts-xxl; +} + +.feesRow { + display: contents; + font-size: ohma.$fonts-l; +} + +.total { + text-align: right; + // width: 100%; // ensures it stretches to the container +} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx new file mode 100644 index 000000000..42caf3c73 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx @@ -0,0 +1,26 @@ +import styles from './LedgerAccountBalance.module.scss' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { displayAmount } from '@/lib/currency/convert' +import { calculateLedgerAccountBalanceAction } from '@/services/ledger/accounts/actions' + +type Props = { + ledgerAccountId: number, + showFees?: boolean, +} + +export default async function LedgerAccountBalance({ ledgerAccountId: accountId, showFees }: Props) { + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ params: { id: accountId } })) + + return
+
+
Saldo
+
{displayAmount(balance.amount)}
+
Muenter
+
+ {showFees &&
+
Avgifter
+
{displayAmount(balance.fees)}
+
Muenter
+
} +
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx new file mode 100644 index 000000000..bdf8f13b6 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx @@ -0,0 +1,32 @@ +'use client' + +import Button from '@/components/UI/Button' +import { updateLedgerAccountAction } from '@/services/ledger/accounts/actions' +import { useRouter } from 'next/navigation' +import type { LedgerAccount } from '@/prisma-generated-pn-types' + +export default function LedgerAccountFreezeButton({ + ledgerAccount, + className +}: { ledgerAccount: LedgerAccount, className?: string }) { + const { refresh } = useRouter() + + const toggleFrozen = async () => { + await updateLedgerAccountAction({ + params: { + id: ledgerAccount.id, + }, + }, { + data: { + frozen: !ledgerAccount.frozen, + } + }) + refresh() + } + + return ( + + ) +} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss new file mode 100644 index 000000000..cb9944138 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss @@ -0,0 +1,5 @@ +@use "@/styles/ohma"; + +.ledgerAccountListTable { + @include ohma.table(); +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx new file mode 100644 index 000000000..8ddebb3ca --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx @@ -0,0 +1,31 @@ +'use client' + +import styles from './LedgerAccountList.module.scss' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import { LedgerAccountPagingProvider, LedgerAccountPagingContext } from '@/contexts/paging/LedgerAccountPaging' +import Link from 'next/link' + +export default function LedgerAccountList() { + return + + + + + + + + + + + + + + }/> + +
NavnSaldo
{account.name}19.19 Klinguende Muente
+
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.module.scss new file mode 100644 index 000000000..1b6b2bbc8 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.module.scss @@ -0,0 +1,19 @@ +@use "@/styles/ohma"; + +.frozenStatus { + color: red; + + .frozenWarningHidden { + visibility: hidden; + } +} + +.ledgerAccountOverviewButtons { + margin-top: 3*ohma.$gap; + display: flex; + flex-direction: row; + + .rightAligned { + margin-left: auto; + } +} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx new file mode 100644 index 000000000..1ec1ec44d --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -0,0 +1,68 @@ +import styles from './LedgerAccountOverviewCard.module.scss' +import LedgerAccountBalance from './LedgerAccountBalance' +import LedgerAccountFreezeButton from './LedgerAccountFreezeButton' +import Card from '@/components/UI/Card' +import DepositModal from '@/components/Ledger/Modals/DepositModal' +import PayoutModal from '@/components/Ledger/Modals/PayoutModal' +import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' +import { ServerSession } from '@/auth/session/ServerSession' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faWarning } from '@fortawesome/free-solid-svg-icons' +import type { LedgerAccount } from '@/prisma-generated-pn-types' + +type Props = { + ledgerAccount: LedgerAccount, + showFees?: boolean, + showDepositButton?: boolean, + showPayoutButton?: boolean, + showDeactivateButton?: boolean, +} + +const getCustomerSessionClientSecret = async () => { + const { user } = await ServerSession.fromNextAuth() + + if (!user) { + return undefined + } + + const customerSessionResult = await createStripeCustomerSessionAction({ params: { userId: user.id } }) + if (!customerSessionResult.success) { + return undefined + } + + return customerSessionResult.data.customerSessionClientSecret +} + +export default async function LedgerAccountOverview({ + showFees, + ledgerAccount, + showPayoutButton, + showDepositButton, + showDeactivateButton, +}: Props) { + const customerSessionClientSecret = showDepositButton + ? await getCustomerSessionClientSecret() + : undefined + + return + +
+ { +

+ Kontoen er fryst; Ingen transaksjoner kan utføres. +

+ } +
+
+ { + showDepositButton && + + } + { showPayoutButton && } + { + showDeactivateButton && + + } +
+
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx new file mode 100644 index 000000000..f9524fbf4 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -0,0 +1,41 @@ +import PaymentMethodList from '@/components/Ledger/Modals/PaymentMethodList' +import PaymentMethodModal from '@/components/Ledger/Modals/PaymentMethodModal' +import Card from '@/components/UI/Card' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { readUserAction } from '@/services/users/actions' +import BooleanIndicator from '@/components/UI/BooleanIndicator' +import { readSavedPaymentMethodsAction } from '@/services/stripeCustomers/actions' +import Link from 'next/link' +import { faArrowRight } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +type Props = { + userId: number, +} + +export default async function LedgerAccountPaymentMethods({ userId }: Props) { + const user = unwrapActionReturn(await readUserAction({ params: { id: userId } })) // TODO: Change to better method + const savedPaymentMethodsResult = await readSavedPaymentMethodsAction({ params: { userId } }) + const savedPaymentMethods = savedPaymentMethodsResult.success + ? savedPaymentMethodsResult.data + : [] + + const hasBankCard = savedPaymentMethods.length > 0 + const hasStudentCard = user.studentCard !== null + + return +

Bankkort

+

+ Du kan lagre kortinformasjonen din for senere betalinger. + Kortinformasjonen lagres kun hos betalingsleverandøren vår, Stripe, og ikke på våre tjenere. +

+ + +

NTNU-kort

+

For å benytte Kiogeskabet på Lophtet må et NTNU-kort være registrert.

+

Kortnummer: {hasStudentCard ? user.studentCard : 'ikke registrert'}

+ + Gå til siden for kortregistrering + +
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx new file mode 100644 index 000000000..21e4ede83 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx @@ -0,0 +1,20 @@ +import Card from '@/components/UI/Card' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowRight } from '@fortawesome/free-solid-svg-icons' +import Link from 'next/link' + +type Props = { + ledgerAccountId: number, + transactionsHref?: string, +} + +export default function LedgerAccountTransactionSummary({ transactionsHref }: Props) { + return + { + transactionsHref && + + Se alle transaksjoner + + } + +} diff --git a/src/app/_components/Ledger/Modals/DepositModal.module.scss b/src/app/_components/Ledger/Modals/DepositModal.module.scss new file mode 100644 index 000000000..6541f78f8 --- /dev/null +++ b/src/app/_components/Ledger/Modals/DepositModal.module.scss @@ -0,0 +1,10 @@ +@use "@/styles/ohma"; + +.checkoutFormContainer { + width: 500px; // TODO: Is there a better way to do this? + margin: ohma.$gap * 3; +} + +.paymentDetails { + min-height: 50px; +} diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx new file mode 100644 index 000000000..48d5c80db --- /dev/null +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -0,0 +1,147 @@ +'use client' + +import styles from './DepositModal.module.scss' +import Form from '@/components/Form/Form' +import PopUp from '@/components/PopUp/PopUp' +import NumberInput from '@/components/UI/NumberInput' +import Button from '@/components/UI/Button' +import { convertAmount } from '@/lib/currency/convert' +import { createActionError } from '@/services/actionError' +import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/constants' +import Checkbox from '@/components/UI/Checkbox' +import TextInput from '@/components/UI/TextInput' +import { createDepositAction } from '@/services/ledger/movements/actions' +import { lazy, useRef, useState } from 'react' +import type { PaymentProvider } from '@/prisma-generated-pn-types' +import type { ExpandedPayment } from '@/services/ledger/payments/types' +import type { StripePaymentRef } from '@/components/Stripe/StripePayment' + +// Avoid loading the Stripe components until they are needed +const StripePayment = lazy(() => import('@/components/Stripe/StripePayment')) +const StripeProvider = lazy(() => import('@/components/Stripe/StripeProvider')) + +const defaultPaymentProvider: PaymentProvider = 'STRIPE' +const paymentProviderNames: Record = { + STRIPE: 'Stripe', + MANUAL: 'Manuell Betaling', +} + +type Props = { + ledgerAccountId: number, + customerSessionClientSecret?: string, +} + +export default function DepositModal({ ledgerAccountId, customerSessionClientSecret }: Props) { + const [funds, setFunds] = useState(MINIMUM_PAYMENT_AMOUNT) + // TODO: Actually use manual fees + // eslint-disable-next-line + const [manualFees, setManualFees] = useState(0) + const [selectedProvider, setSelectedProvider] = useState(defaultPaymentProvider) + + const stripePaymentRef = useRef(null) + + const confirmPayment = async (payment: ExpandedPayment) => { + // Stripe payments are the only payments that need confirmation + if (payment.provider !== 'STRIPE') return 'Ukjent betalingsleverandør.' + + // The client secret key should be set after creation + const clientSecret = payment.stripePayment?.clientSecret + if (!clientSecret) return 'Noe gikk galt ved opprettelse av betalingen.' + + // The stripe payment ref should be set when using stripe + const current = stripePaymentRef.current + if (!current) return 'Noe gikk galt ved innhenting av Stripe.' + + // Call the stripe payment ref to confirm the payment + const confirmError = await current.confirmPayment(clientSecret) + if (confirmError) return confirmError + + return null + } + + const handleSubmit = async () => { + // If the stripe payment ref is set, validate the input + if (stripePaymentRef.current) { + const submitError = await stripePaymentRef.current.submit() + if (submitError) return createActionError('UNKNOWN ERROR', submitError) + } + + // Call the server action to create the deposit + const createResult = await createDepositAction({ + params: { ledgerAccountId, funds, manualFees, provider: selectedProvider } + }) + if (!createResult.success) return createResult + + // The returned transaction should have a payment + const transaction = createResult.data + const payment = transaction.payment + if (!payment) return createActionError('UNKNOWN ERROR', 'Noe gikk galt ved opprettelse av betalingen.') + + // Confirm the payment if its needed + if (payment.state === 'PENDING') { + const confirmError = await confirmPayment(payment) + if (confirmError) return createActionError('UNKNOWN ERROR', confirmError) + } + + return { success: true, data: undefined } as const + } + + return } + > +
+

Nytt innskudd

+
+ setFunds(convertAmount(e.target.value))} + required + /> + +
+ Betal med... + + {Object.entries(paymentProviderNames).map(([provider, name]) => ( + + ))} +
+ + {selectedProvider === 'STRIPE' && ( + + + + )} + + {selectedProvider === 'MANUAL' && ( +
+ Jeg bruker dette med omhu. + setManualFees(convertAmount(e.target.value))} + required + /> + +
+ )} + +
+
+} diff --git a/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx b/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx new file mode 100644 index 000000000..a0deee871 --- /dev/null +++ b/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx @@ -0,0 +1,16 @@ +import Checkbox from '@/components/UI/Checkbox' +import TextInput from '@/components/UI/TextInput' + +type Props = { + bankAccountNumber?: string, +} + +export default function ManualPaymentInput({ bankAccountNumber }: Props) { + return ( +
+ + + +
+ ) +} diff --git a/src/app/_components/Ledger/Modals/PaymentMethodList.module.scss b/src/app/_components/Ledger/Modals/PaymentMethodList.module.scss new file mode 100644 index 000000000..c75f1e2c7 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodList.module.scss @@ -0,0 +1,24 @@ +@use '@/styles/ohma'; + +.paymentMethodElement { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + + border-radius: ohma.$rounding; + border-width: 2px; + border-style: solid; + border-color: ohma.$colors-gray-600; + + margin: ohma.$gap 0; + padding: 2*ohma.$gap; + + font-size: ohma.$fonts-l; + align-items: center; +} + +.deletePaymentMethodButton { + font-size: ohma.$fonts-l; + color: ohma.$colors-gray-600; + justify-self: end; + @include ohma.roundBtn; +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Modals/PaymentMethodList.tsx b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx new file mode 100644 index 000000000..0385960ef --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx @@ -0,0 +1,47 @@ +'use client' + +import styles from './PaymentMethodList.module.scss' +import { deleteSavedPaymentMethodAction } from '@/services/stripeCustomers/actions' +import { faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { useRouter } from 'next/navigation' +import type { FilteredPaymentMethod } from '@/services/stripeCustomers/types' + +type Params = { + paymentMethods: FilteredPaymentMethod[], +} + +export default function PaymentMethodList({ paymentMethods }: Params) { + const router = useRouter() + + const displayPaymentMethod = ({ type, card }: FilteredPaymentMethod) => { + switch (type) { + case 'card': + return <> + {card?.brand.toUpperCase()} + **** **** **** {card?.last4} + (utløper {card?.exp_month}/{card?.exp_year}) + + default: + return <>{type.toUpperCase()} + } + } + + const removePaymentMethod = async (id: string) => { + await deleteSavedPaymentMethodAction({ params: { paymentMethodId: id } }) + router.refresh() + } + + return ( +
    + {paymentMethods.length > 0 ? paymentMethods.map((method) => ( +
  • + {displayPaymentMethod(method)} + +
  • + )) :

    Du har ingen lagrede betalingskort.

    } +
+ ) +} diff --git a/src/app/_components/Ledger/Modals/PaymentMethodModal.module.scss b/src/app/_components/Ledger/Modals/PaymentMethodModal.module.scss new file mode 100644 index 000000000..1af857906 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodModal.module.scss @@ -0,0 +1,10 @@ +@use "@/styles/ohma"; + +.bankCardFormContainer { + width: 500px; // TODO: Is there a better way to do this? + margin: ohma.$gap * 3; +} + +.paymentDetails { + min-height: 50px; +} diff --git a/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx b/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx new file mode 100644 index 000000000..2ac6f7d63 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx @@ -0,0 +1,57 @@ +'use client' + +import styles from './PaymentMethodModal.module.scss' +import PopUp from '@/app/_components/PopUp/PopUp' +import Button from '@/app/_components/UI/Button' +import Form from '@/components/Form/Form' +import StripePayment from '@/components/Stripe/StripePayment' +import StripeProvider from '@/components/Stripe/StripeProvider' +import { createActionError } from '@/services/actionError' +import { createSetupIntentAction } from '@/services/stripeCustomers/actions' +import { useRef } from 'react' +import type { StripePaymentRef } from '@/components/Stripe/StripePayment' + +type PropTypes = { + userId: number, +} + +export default function PaymentMethodModal({ userId }: PropTypes) { + const stripePaymentRef = useRef(null) + + const handleSubmit = async () => { + if (!stripePaymentRef.current) return createActionError('UNKNOWN ERROR', 'Noe gikk galt ved innhenting av Stripe.') + + const submitError = await stripePaymentRef.current.submit() + + if (submitError) return createActionError('BAD DATA', submitError) + + const createSetupIntentResult = await createSetupIntentAction({ params: { userId } }) + + if (!createSetupIntentResult.success) return createSetupIntentResult + + const setupError = await stripePaymentRef.current.confirmSetup(createSetupIntentResult.data.setupIntentClientSecret) + + if (setupError) return createActionError('UNKNOWN ERROR', setupError) + + return { + success: true, + data: undefined, + } as const + } + + return ( + } + > +

Legg til bankkort

+
+
+ + + +
+
+
+ ) +} diff --git a/src/app/_components/Ledger/Modals/PayoutModal.module.scss b/src/app/_components/Ledger/Modals/PayoutModal.module.scss new file mode 100644 index 000000000..ca4dec49c --- /dev/null +++ b/src/app/_components/Ledger/Modals/PayoutModal.module.scss @@ -0,0 +1,14 @@ +@use "@/styles/ohma"; + +.checkoutFormContainer { + width: 500px; // TODO: Is there a better way to do this? + margin: ohma.$gap * 3; +} + +.paymentDetails { + min-height: 50px; +} + +.submitButton { + width: 200px; +} diff --git a/src/app/_components/Ledger/Modals/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx new file mode 100644 index 000000000..1d788608d --- /dev/null +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -0,0 +1,53 @@ +'use client' + +import styles from './PayoutModal.module.scss' +import Form from '@/components/Form/Form' +import PopUp from '@/components/PopUp/PopUp' +import NumberInput from '@/components/UI/NumberInput' +import Button from '@/components/UI/Button' +import { convertAmount } from '@/lib/currency/convert' +import { configureAction } from '@/services/configureAction' +import { createPayoutAction } from '@/services/ledger/movements/actions' +import { useState } from 'react' + +type Props = { + ledgerAccountId: number, + defaultFunds?: number, + defaultFees?: number, +} + +export default function PayoutModal({ ledgerAccountId, defaultFunds = 0, defaultFees = 0 }: Props) { + const [funds, setFunds] = useState(defaultFunds) + const [fees, setFees] = useState(defaultFees) + + return } + > +

Ny utbetaling

+
+
+ setFunds(convertAmount(e.target.value))} + /> + setFees(convertAmount(e.target.value))} + /> + +
+
+} diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.module.scss b/src/app/_components/Ledger/Transactions/LedgerTransactionList.module.scss new file mode 100644 index 000000000..c4826ed86 --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.module.scss @@ -0,0 +1,5 @@ +@use '@/styles/ohma'; + +.transactionList { + @include ohma.table(); +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx new file mode 100644 index 000000000..64be1086a --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -0,0 +1,48 @@ +'use client' + +import styles from './LedgerTransactionList.module.scss' +import LedgerTransactionRow from './LedgerTransactionRow' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import { LedgerTransactionPagingProvider, LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTransactionPaging' + +type Props = { + accountId: number, + showFees?: boolean, +} + +export default function TransactionList({ accountId, showFees }: Props) { + return + + } + wrapper={children => + + + + + + + + + {showFees && } + + + + {children} + +
DatoBeskrivelseStatusBeløpSaldoendringGebyrendring
+ } + /> + {/* TODO: Add message "Her var det tomt! Hva med å ta seg en tur innom Kiogeskapet?" when no transaksjons exist. */} +
+} diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss new file mode 100644 index 000000000..f3e91839f --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss @@ -0,0 +1,5 @@ +@use '@/styles/ohma'; + +.rightAlign { + text-align: right; +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx new file mode 100644 index 000000000..e8b2aaa3a --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -0,0 +1,28 @@ +import styles from './LedgerTransactionRow.module.scss' +import { displayAmount } from '@/lib/currency/convert' +import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/types' + +type Props = { + transaction: ExpandedLedgerTransaction, + accountId: number, + showFees?: boolean, +} + +export default function LedgerTransactionRow({ transaction, accountId, showFees }: Props) { + const totalFunds = ( + transaction.ledgerEntries?.reduce((sum, entry) => sum + Math.abs(entry.funds), 0) + + Math.abs(transaction.payment?.funds ?? 0) + ) / 2 + + const fundsChange = transaction.ledgerEntries.find(entry => entry.ledgerAccountId === accountId)?.funds ?? null + const feesChange = transaction.ledgerEntries.find(entry => entry.ledgerAccountId === accountId)?.fees ?? null + + return + {transaction.createdAt.toLocaleString()} + {transaction.purpose} + {transaction.state} + {displayAmount(totalFunds)} + {fundsChange !== null ? displayAmount(fundsChange) : '-'} + {showFees && {feesChange !== null ? displayAmount(feesChange) : '-'}} + +} diff --git a/src/app/_components/NavBar/UserNavigation.tsx b/src/app/_components/NavBar/UserNavigation.tsx index e6f70c252..b205d995f 100644 --- a/src/app/_components/NavBar/UserNavigation.tsx +++ b/src/app/_components/NavBar/UserNavigation.tsx @@ -5,7 +5,7 @@ import BorderButton from '@/UI/BorderButton' import useClickOutsideRef from '@/hooks/useClickOutsideRef' import useOnNavigation from '@/hooks/useOnNavigation' import UserDisplayName from '@/components/User/UserDisplayName' -import { faCog, faMoneyBill, faQrcode, faSignOut, faUser } from '@fortawesome/free-solid-svg-icons' +import { faCog, faMoneyBillWave, faSignOut, faUser, faQrcode } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import Link from 'next/link' import { useState } from 'react' @@ -54,13 +54,13 @@ export default function UserNavigation({ profile }: PropTypes) {

OmegaId

- - + +

Konto

-

Instillinger

+

Innstillinger

diff --git a/src/app/_components/NavBar/navDef.ts b/src/app/_components/NavBar/navDef.ts index 9fc977755..b19ad6816 100644 --- a/src/app/_components/NavBar/navDef.ts +++ b/src/app/_components/NavBar/navDef.ts @@ -142,7 +142,7 @@ export const itemsForMenu: NavItem[] = [ icon: faIdCard, }, { - name: 'Admin', + name: 'Administrasjon', href: '/admin', show: 'admin', icon: faTools, diff --git a/src/app/_components/PagingWrappers/EndlessScroll.tsx b/src/app/_components/PagingWrappers/EndlessScroll.tsx index 343fdf85e..1893d4d24 100644 --- a/src/app/_components/PagingWrappers/EndlessScroll.tsx +++ b/src/app/_components/PagingWrappers/EndlessScroll.tsx @@ -16,13 +16,15 @@ import type { PagingContext } from '@/contexts/paging/PagingGenerator' type PropTypes = { pagingContext: PagingContext, renderer: (data: Data, i: number) => React.ReactNode, + wrapper?: (children: React.ReactNode) => React.ReactNode, loadingInfoClassName?: string, } export default function EndlessScroll({ pagingContext, loadingInfoClassName, - renderer + renderer, + wrapper = children => <>{children}, }: PropTypes) { const context = useContext(pagingContext) @@ -73,7 +75,7 @@ export default function EndlessScroll - {renderedPageData} + {wrapper(renderedPageData)} { loading ? ( @@ -81,10 +83,10 @@ export default function EndlessScroll - Ingen flere å laste inn + Ingen flere å laste inn. ) diff --git a/src/app/_components/PopUp/PopUp.module.scss b/src/app/_components/PopUp/PopUp.module.scss index 348f423fa..cf28411b7 100644 --- a/src/app/_components/PopUp/PopUp.module.scss +++ b/src/app/_components/PopUp/PopUp.module.scss @@ -13,6 +13,7 @@ max-height: 95svh; background-color: ohma.$colors-white; > .overflow { + overflow-x: visible; overflow-y: auto; margin: 0; padding: 0; @@ -23,6 +24,8 @@ .closeBtn { background-color: ohma.$colors-red; + font-size: ohma.$fonts-xl; + color: ohma.$colors-white; @include ohma.roundBtn(ohma.$colors-red); } diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index 45d816594..63df288d0 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -1,29 +1,38 @@ 'use client' + import styles from './PopUp.module.scss' import useKeyPress from '@/hooks/useKeyPress' import { PopUpContext } from '@/contexts/PopUp' import useClickOutsideRef from '@/hooks/useClickOutsideRef' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faX } from '@fortawesome/free-solid-svg-icons' +import { faXmark } from '@fortawesome/free-solid-svg-icons' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useContext, useEffect, useState, useRef, useCallback, useEffectEvent } from 'react' import type { ReactNode, CSSProperties } from 'react' import type { PopUpKeyType } from '@/contexts/PopUp' export type PropTypes = { children: ReactNode, - showButtonContent: ReactNode, + customShowButton?: (open: () => void) => ReactNode, + showButtonContent?: ReactNode, showButtonClass?: string, - popUpKey: PopUpKeyType, showButtonStyle?: CSSProperties, + storeInUrl?: boolean, + popUpKey: PopUpKeyType } export default function PopUp({ popUpKey, children, + customShowButton, showButtonContent, showButtonClass, showButtonStyle, + storeInUrl = false, }: PropTypes) { + const router = useRouter() + const searchParams = useSearchParams() + const pathName = usePathname() const [isOpen, setIsOpen] = useState(false) const popUpContext = useContext(PopUpContext) @@ -63,7 +72,7 @@ export default function PopUp({
{ children } @@ -77,17 +86,63 @@ export default function PopUp({ } }, [children, isOpen, popUpKey, teleport, ref]) + const handleSearchParamsChange = useEffectEvent(() => { + if (!storeInUrl) return + + const params = new URLSearchParams(searchParams.toString()) + const keyInUrl = params.get('pop-up-key') === popUpKey + + if (keyInUrl && !isOpen) { + setIsOpen(true) + } + }) + + useEffect(() => { + handleSearchParamsChange() + }, [searchParams]) + + const handleIsOpenChange = useEffectEvent(() => { + if (!storeInUrl) return + + const params = new URLSearchParams(searchParams.toString()) + const keyInUrl = params.get('pop-up-key') === popUpKey + + if (isOpen) { + // Set the pop-up key in the URL to indicate that this pop-up is open. + // There should only be one pop-up open at a time, so we can just set it directly. + params.set('pop-up-key', String(popUpKey)) + } else if (keyInUrl) { + // We check if the key is our key to avoid removing another pop-up's key. + params.delete('pop-up-key') + } + + const newUrl = `${pathName}?${params.toString()}` + const oldUrl = `${pathName}?${searchParams.toString()}` + + if (newUrl !== oldUrl) { + router.replace(`${pathName}?${params.toString()}`) + } + }) + + useEffect(() => { + handleIsOpenChange() + }, [isOpen]) + const handleOpening = useCallback(() => { setIsOpen(true) }, []) - return ( - - ) + return <>{ + customShowButton ? ( + customShowButton(handleOpening) + ) : ( + + ) + } } diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx new file mode 100644 index 000000000..452d28661 --- /dev/null +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -0,0 +1,61 @@ +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import React, { useImperativeHandle } from 'react' + +export type StripePaymentRef = { + submit: () => Promise; + confirmPayment: (clientSecret: string) => Promise; + confirmSetup: (clientSecret: string) => Promise; +} + +type Props = { + ref?: React.Ref, +} + +export default function StripePayment({ ref }: Props) { + const stripe = useStripe() + const elements = useElements() + + useImperativeHandle(ref, () => ({ + submit: async () => { + if (!stripe || !elements) return 'Stripe er ikke initalisert enda.' + + const { error } = await elements.submit() + + if (error) return error.message || 'En feil oppsto når betalingen skulle sendes inn.' + + return null + }, + confirmPayment: async (clientSecret: string) => { + if (!stripe || !elements) return 'Stripe ikke initialisert enda.' + + const { error } = await stripe.confirmPayment({ + clientSecret, + elements, + confirmParams: { + return_url: window.location.href, + }, + }) + + if (error) return error.message || 'En feil oppsto når betalingen skulle bekreftes.' + + return null + }, + confirmSetup: async (clientSecret: string) => { + if (!stripe || !elements) return 'Stripe ikke initialisert enda.' + + const { error } = await stripe.confirmSetup({ + clientSecret, + elements, + confirmParams: { + return_url: window.location.href, + }, + }) + + if (error) return error.message || 'En feil oppsto ved lagring av informasjon.' + + return null + } + })) + + return +} diff --git a/src/app/_components/Stripe/StripeProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx new file mode 100644 index 000000000..c643a021c --- /dev/null +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -0,0 +1,32 @@ +'use client' + +import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/constants' +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' +import type { ReactNode } from 'react' + +if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + throw new Error('Stripe publishable key not set') +} + +const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + +type Props = { + children?: ReactNode, + mode: 'payment' | 'setup', + amount?: number, + customerSessionClientSecret?: string, +} + +export default function StripeProvider({ children, mode, amount, customerSessionClientSecret }: Props) { + return ( + + {children} + + ) +} diff --git a/src/app/_components/UI/BooleanIndicator.tsx b/src/app/_components/UI/BooleanIndicator.tsx new file mode 100644 index 000000000..94bc1d18b --- /dev/null +++ b/src/app/_components/UI/BooleanIndicator.tsx @@ -0,0 +1,12 @@ +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +type Props = { + value: boolean, +} + +export default function BooleanIndicator({ value }: Props) { + return value + ? + : +} diff --git a/src/app/_components/UI/Button.module.scss b/src/app/_components/UI/Button.module.scss index 4529b9fc0..6c5b7d45d 100644 --- a/src/app/_components/UI/Button.module.scss +++ b/src/app/_components/UI/Button.module.scss @@ -1,19 +1,19 @@ @use "@/styles/ohma"; .primary { - @include ohma.btn(ohma.$colors-primary); + @include ohma.btn(ohma.$colors-primary, ohma.$colors-black); } .secondary { - @include ohma.btn(ohma.$colors-secondary); + @include ohma.btn(ohma.$colors-secondary, ohma.$colors-black); } .green { - @include ohma.btn(ohma.$colors-green); + @include ohma.btn(ohma.$colors-green, ohma.$colors-white); } .red { - @include ohma.btn(ohma.$colors-red); + @include ohma.btn(ohma.$colors-red, ohma.$colors-white); } .button:disabled { diff --git a/src/app/_components/UI/Card.module.scss b/src/app/_components/UI/Card.module.scss new file mode 100644 index 000000000..e6864d51d --- /dev/null +++ b/src/app/_components/UI/Card.module.scss @@ -0,0 +1,16 @@ +@use "@/styles/ohma"; + +$background: ohma.$colors-white; + +.Card { + border-radius: ohma.$cardRounding; + padding: ohma.$cardRounding; + box-shadow: 0 3px 6px rgba(0, 0, 0, .16), 0 3px 6px rgba(0, 0, 0, .23); // TODO: Make this reusable + margin: 5*ohma.$gap 0; + overflow: hidden; + background-color: $background; + + > h2 { + font-size: ohma.$fonts-xl; + } +} \ No newline at end of file diff --git a/src/app/_components/UI/Card.tsx b/src/app/_components/UI/Card.tsx new file mode 100644 index 000000000..200d3faab --- /dev/null +++ b/src/app/_components/UI/Card.tsx @@ -0,0 +1,18 @@ +import styles from './Card.module.scss' +import type { ReactNode } from 'react' + +type PropTypes = { + children?: ReactNode, + heading?: string, +} + +export default function Card({ children, heading }: PropTypes) { + return ( +
+ {heading &&

{heading}

} +
+ {children} +
+
+ ) +} diff --git a/src/app/_components/UI/NumberInput.tsx b/src/app/_components/UI/NumberInput.tsx index ade5f5b34..4460e7f33 100644 --- a/src/app/_components/UI/NumberInput.tsx +++ b/src/app/_components/UI/NumberInput.tsx @@ -15,7 +15,7 @@ export default function NumberInput({ }: PropTypes) { return (
- +
) diff --git a/src/app/admin/SlideSidebar.tsx b/src/app/admin/SlideSidebar.tsx index 09e6d8404..30a4ed68c 100644 --- a/src/app/admin/SlideSidebar.tsx +++ b/src/app/admin/SlideSidebar.tsx @@ -19,6 +19,7 @@ import { faHouse, faShop, faListDots, + faMoneyBillWave, } from '@fortawesome/free-solid-svg-icons' import type { IconDefinition } from '@fortawesome/free-solid-svg-icons' @@ -117,7 +118,7 @@ const navigations = [ href: '/admin/default-permissions' }, { - title: 'Api Nøkler', + title: 'API Nøkler', href: '/admin/api-keys' }, ], @@ -213,6 +214,18 @@ const navigations = [ }, ] }, + { + header: { + title: 'Økonomi', + icon: faMoneyBillWave, + }, + links: [ + { + title: 'Kontoer', + href: '/admin/accounts' + }, + ] + }, { header: { title: 'Annet', diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx new file mode 100644 index 000000000..679b42b33 --- /dev/null +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -0,0 +1,27 @@ +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' +import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' +import { readLedgerAccountAction } from '@/services/ledger/accounts/actions' +import { notFound } from 'next/navigation' + +type Props = { + params: Promise<{ + accountId: string, + }>, +} + +export default async function LedgerAccount({ params }: Props) { + const accountId = Number((await params).accountId) + + if (!accountId) { + notFound() + } + + const ledgerAccount = unwrapActionReturn(await readLedgerAccountAction({ params: { ledgerAccountId: accountId } })) + + return
+ + {/* Add link to products overview */} + +
+} diff --git a/src/app/admin/accounts/[accountId]/transactions/page.tsx b/src/app/admin/accounts/[accountId]/transactions/page.tsx new file mode 100644 index 000000000..baba5a3b6 --- /dev/null +++ b/src/app/admin/accounts/[accountId]/transactions/page.tsx @@ -0,0 +1,18 @@ +import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' +import { notFound } from 'next/navigation' + +type Props = { + params: Promise<{ + accountId: string, + }>, +} + +export default async function LedgerAccountTransactions({ params }: Props) { + const accountId = Number((await params).accountId) + + if (!accountId) { + notFound() + } + + return +} diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx new file mode 100644 index 000000000..89303f4a7 --- /dev/null +++ b/src/app/admin/accounts/page.tsx @@ -0,0 +1,5 @@ +import LedgerAccountList from '@/components/Ledger/Accounts/LedgerAccountList' + +export default async function LedgerAccounts() { + return +} diff --git a/src/app/admin/cabin-product/[product]/page.tsx b/src/app/admin/cabin-product/[product]/page.tsx index 35330307b..df2c1022a 100644 --- a/src/app/admin/cabin-product/[product]/page.tsx +++ b/src/app/admin/cabin-product/[product]/page.tsx @@ -6,7 +6,7 @@ import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { displayDate } from '@/lib/dates/displayDate' import SimpleTable from '@/app/_components/Table/SimpleTable' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import Link from 'next/link' export default async function CabinProduct({ @@ -61,7 +61,7 @@ export default async function CabinProduct({ ]} body={product.CabinProductPrice.map(priceObj => [ priceObj.description, - displayPrice(priceObj.price), + displayAmount(priceObj.price), displayDate(priceObj.PricePeriod.validFrom, false), priceObj.memberShare.toString(), priceObj.cronInterval ?? '', diff --git a/src/app/admin/product/[productId]/page.tsx b/src/app/admin/product/[productId]/page.tsx index 61a6603ca..6b39b98e0 100644 --- a/src/app/admin/product/[productId]/page.tsx +++ b/src/app/admin/product/[productId]/page.tsx @@ -2,7 +2,7 @@ import styles from './page.module.scss' import ProductForm from '@/app/admin/product/productForm' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import { readProductAction } from '@/services/shop/actions' import { v4 as uuid } from 'uuid' import Link from 'next/link' @@ -33,7 +33,7 @@ export default async function ProductPage({ params }: PropTypes) { {product.ShopProduct.map(shopProduct => {shopProduct.shop.name} {shopProduct.active ? 'AKTIV' : 'INAKTIV'} - {displayPrice(shopProduct.price, false)} + {displayAmount(shopProduct.price, false)} )} diff --git a/src/app/admin/shop/[shop]/EditProductForShopForm.tsx b/src/app/admin/shop/[shop]/EditProductForShopForm.tsx index 0d3432cd6..392f22436 100644 --- a/src/app/admin/shop/[shop]/EditProductForShopForm.tsx +++ b/src/app/admin/shop/[shop]/EditProductForShopForm.tsx @@ -5,7 +5,7 @@ import Form from '@/app/_components/Form/Form' import Checkbox from '@/app/_components/UI/Checkbox' import NumberInput from '@/app/_components/UI/NumberInput' import TextInput from '@/app/_components/UI/TextInput' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import type { ExtendedProduct } from '@/services/shop/product/types' @@ -31,6 +31,6 @@ export function EditProductForShopForm({ } - + } diff --git a/src/app/admin/shop/[shop]/page.tsx b/src/app/admin/shop/[shop]/page.tsx index fff6f9c8e..519661353 100644 --- a/src/app/admin/shop/[shop]/page.tsx +++ b/src/app/admin/shop/[shop]/page.tsx @@ -4,7 +4,7 @@ import FindProductForm from './FindProductForm' import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' import PopUp from '@/app/_components/PopUp/PopUp' import { unwrapActionReturn } from '@/app/redirectToErrorPage' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import { sortObjectsByName } from '@/lib/sortObjects' import { readShopAction, readProductsAction } from '@/services/shop/actions' import { faPencil } from '@fortawesome/free-solid-svg-icons' @@ -77,7 +77,7 @@ export default async function Shop({ params }: PropTypes) { {product.name} {product.description} - {displayPrice(product.price, false)} + {displayAmount(product.price, false)} )} diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts new file mode 100644 index 000000000..cdd4236ad --- /dev/null +++ b/src/app/api/stripe-event/route.ts @@ -0,0 +1,37 @@ +import logger from '@/lib/logger' +import { stripe } from '@/lib/stripe' +import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' + +export async function POST(req: Request) { + if (!process.env.STRIPE_WEBHOOK_SECRET) { + return new Response('Invalid server-side configuration', { status: 500 }) + } + + const stripeSignature = req.headers.get('stripe-signature') + const body = await req.text() + + if (!stripeSignature) { + return new Response('Stripe signature missing', { status: 400 }) + } + + const event = stripe.webhooks.constructEvent(body, stripeSignature, process.env.STRIPE_WEBHOOK_SECRET) + + // Check if the event is one of the expected types + if (event.type !== 'charge.succeeded' && event.type !== 'charge.updated') { + logger.warn(`Unhandled Stripe event received: ${event.type}`) + return new Response('', { status: 200 }) + } + + // Validate the event data types we need + if (typeof event.data.object.balance_transaction !== 'string' || typeof event.data.object.payment_intent !== 'string') { + return new Response('', { status: 200 }) + } + + try { + await stripeWebhookCallback(event) + } catch { + return new Response('Server-side error confirming deposit', { status: 500 }) + } + + return new Response('', { status: 200 }) +} diff --git a/src/app/cabin/book/CabinPriceCalculator.tsx b/src/app/cabin/book/CabinPriceCalculator.tsx index fa199ca03..a8b055e86 100644 --- a/src/app/cabin/book/CabinPriceCalculator.tsx +++ b/src/app/cabin/book/CabinPriceCalculator.tsx @@ -1,5 +1,5 @@ import SimpleTable from '@/app/_components/Table/SimpleTable' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import { calculateCabinBookingPrice, calculateTotalCabinBookingPrice } from '@/services/cabin/booking/cabinPriceCalculator' import type { CabinProductExtended } from '@/services/cabin/product/constants' import type { CabinPriceCalculatorReturnType } from '@/services/cabin/booking/cabinPriceCalculator' @@ -49,9 +49,9 @@ export default function CabinPriceCalculator({ const displayName = priceRow.product.name + (description ? ` (${description})` : '') tableBody.push([ displayName, - displayPrice(priceRow.productPrice.price), + displayAmount(priceRow.productPrice.price), priceRow.amount.toString(), - displayPrice(priceRow.amount * priceRow.productPrice.price) + displayAmount(priceRow.amount * priceRow.productPrice.price) ]) } @@ -60,6 +60,6 @@ export default function CabinPriceCalculator({ header={['Produkt', 'Pris per natt', 'Antall', 'Total Pris']} body={tableBody} /> -

Total pris {displayPrice(totalPrice)}

+

Total pris {displayAmount(totalPrice)}

} diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx new file mode 100644 index 000000000..5fa678bb4 --- /dev/null +++ b/src/app/checkout/page.tsx @@ -0,0 +1,9 @@ +import Button from '@/components/UI/Button' + +export default async function Checkout() { + return
+

Betaling

+

Her kommer kassen din!

+ +
+} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 63b64e64e..0cf1330ca 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -35,6 +35,7 @@ type PropTypes = { export default async function RootLayout({ children }: PropTypes) { const session = await getServerSession(authOptions) + const defaultPermissionsRes = await readDefaultPermissionsAction() const defaultPermissions = defaultPermissionsRes.success ? defaultPermissionsRes.data : [] const profile = session?.user ? diff --git a/src/app/users/[username]/(user-admin)/Nav.tsx b/src/app/users/[username]/(user-admin)/Nav.tsx index e32739907..7e8520333 100644 --- a/src/app/users/[username]/(user-admin)/Nav.tsx +++ b/src/app/users/[username]/(user-admin)/Nav.tsx @@ -2,7 +2,7 @@ import styles from './Nav.module.scss' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import Link from 'next/link' -import { faCircleDot, faCog, faHatWizard, faKey, faPaperPlane } from '@fortawesome/free-solid-svg-icons' +import { faCircleDot, faCog, faHatWizard, faMoneyBillWave, faKey, faPaperPlane } from '@fortawesome/free-solid-svg-icons' import { usePathname } from 'next/navigation' type PropTypes = { @@ -32,6 +32,12 @@ export default function Nav({ username }: PropTypes) { > + + + + + + +
+} diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx new file mode 100644 index 000000000..49c296651 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -0,0 +1,13 @@ +import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' +// import { getUser } from '@/auth/session/getUser' + +export default async function Transactions() { + // const { user } = await getUser({ + // userRequired: true, + // shouldRedirect: true, + // }) + + const account = { id: 2 } + + return +} diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx index 50a36eac1..c156dfa15 100644 --- a/src/app/users/[username]/page.tsx +++ b/src/app/users/[username]/page.tsx @@ -141,7 +141,7 @@ export default async function User({ params }: PropTypes) {
{canAdministrate && -

Instillinger

+

Innstillinger

} {profile.user.id === session?.user?.id && ( @@ -150,10 +150,8 @@ export default async function User({ params }: PropTypes) {

Logg ut

- ) - } + )}
-
diff --git a/src/auth/nextAuth/authOptions.ts b/src/auth/nextAuth/authOptions.ts index 80a9aab16..ddc5d5c62 100644 --- a/src/auth/nextAuth/authOptions.ts +++ b/src/auth/nextAuth/authOptions.ts @@ -184,4 +184,18 @@ export const authOptions: AuthOptions = { newUser: '/register', }, adapter: VevenAdapter(prisma), + logger: { + // TODO: Before going to production we should use the proper logger here! + error(code, metadata) { + // Overwrite to use warnings in stead to reduce + // noise from invalid JWT in development. + console.warn('NextAuth Error:', code, metadata) + }, + warn(code) { + console.warn('NextAuth Warning:', code) + }, + debug(code, metadata) { + console.debug('NextAuth Debug:', code, metadata) + }, + }, } diff --git a/src/auth/session/ServerSession.ts b/src/auth/session/ServerSession.ts index 13fe4ae99..c820e0344 100644 --- a/src/auth/session/ServerSession.ts +++ b/src/auth/session/ServerSession.ts @@ -9,14 +9,18 @@ import { getServerSession as getSessionNextAuth } from 'next-auth' export class ServerSession extends Session { public static async fromNextAuth(): Promise | Session<'HAS_USER'>> { - const { - user = null, - permissions = await permissionOperations.readDefaultPermissions({ - bypassAuth: true, - }), - memberships = [], - } = await getSessionNextAuth(authOptions) ?? {} - return new Session({ user, permissions, memberships }) + const session = await getSessionNextAuth(authOptions) + + if (!session) { + const defaultPermissions = await permissionOperations.readDefaultPermissions({ bypassAuth: true }) + return ServerSession.fromDefaultPermissions(defaultPermissions) + } + + return new Session({ + user: session.user, + permissions: session.permissions, + memberships: session.memberships, + }) } /** diff --git a/src/contexts/paging/LedgerAccountPaging.tsx b/src/contexts/paging/LedgerAccountPaging.tsx new file mode 100644 index 000000000..c9ad5d6f9 --- /dev/null +++ b/src/contexts/paging/LedgerAccountPaging.tsx @@ -0,0 +1,17 @@ +'use client' + +import { generatePaging } from './PagingGenerator' +import { readLedgerAccountPageAction } from '@/services/ledger/accounts/actions' +import type { LedgerAccount, LedgerAccountType } from '@/prisma-generated-pn-types' + +export type PageSizeTransactions = 10 + +export const [LedgerAccountPagingContext, LedgerAccountPagingProvider] = generatePaging< + LedgerAccount, + { id: number }, + PageSizeTransactions, + { accountType?: LedgerAccountType } +>({ + fetcher: (paging) => readLedgerAccountPageAction({ params: paging }), + getCursor: ({ lastElement }) => ({ id: lastElement.id }) +}) diff --git a/src/contexts/paging/LedgerTransactionPaging.tsx b/src/contexts/paging/LedgerTransactionPaging.tsx new file mode 100644 index 000000000..0f4f747b9 --- /dev/null +++ b/src/contexts/paging/LedgerTransactionPaging.tsx @@ -0,0 +1,18 @@ +'use client' + +import { generatePaging } from './PagingGenerator' +import { readLedgerTransactionPageAction } from '@/services/ledger/transactions/actions' +import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/types' + +// TODO: Might be possible to cleanup? Why is size a type??? +export type PageSizeTransactions = 10 + +export const [LedgerTransactionPagingContext, LedgerTransactionPagingProvider] = generatePaging< + ExpandedLedgerTransaction, + { id: number }, + PageSizeTransactions, + { accountId: number } +>({ + fetcher: (paging) => readLedgerTransactionPageAction({ params: paging }), + getCursor: ({ lastElement }) => ({ id: lastElement.id }), +}) diff --git a/src/lib/money/ConfigVars.ts b/src/lib/currency/config.ts similarity index 96% rename from src/lib/money/ConfigVars.ts rename to src/lib/currency/config.ts index c7006bf02..4289ebbee 100644 --- a/src/lib/money/ConfigVars.ts +++ b/src/lib/currency/config.ts @@ -1,3 +1 @@ - - export const currencySymbol = 'Klinguende Meunt' diff --git a/src/lib/currency/convert.ts b/src/lib/currency/convert.ts new file mode 100644 index 000000000..0672af4db --- /dev/null +++ b/src/lib/currency/convert.ts @@ -0,0 +1,21 @@ +import { currencySymbol } from './config' + +// TODO: Verify that @Pauliusj doesn't implement a similar function +// I haven't :) -Paulius +export function convertAmount(amount: string | number): number { + if (typeof amount === 'string') { + amount = amount.replace(',', '.') + } + + return Math.round(Number(amount) * 100) +} + +export function displayAmount(amount: number, short: boolean = true, withSign: boolean = false): string { + const convertedAmount = amount / 100 + const amountString = convertedAmount.toFixed(2) + if (short) return amountString + + const sign = convertedAmount > 0 ? '+' : '-' + + return `${withSign && convertedAmount !== 0 ? sign : ''}${amountString} ${currencySymbol}` +} diff --git a/src/lib/money/convert.ts b/src/lib/money/convert.ts deleted file mode 100644 index 6a97a20d9..000000000 --- a/src/lib/money/convert.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { currencySymbol } from './ConfigVars' - -/** - * Converts a price from kroner to ører - * @param price The price in kroner - * @returns The price in øre as an integer - */ -export const convertPrice = (price: string | number): number => Math.floor(Number(price) * 100) - -export function displayPrice(price: number, short: boolean = true): string { - const convertedPrice = price / 100 - const priceString = convertedPrice.toFixed(2) - if (short) return priceString - - return `${priceString} ${currencySymbol}` -} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 000000000..79c2b2229 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,11 @@ +import { isBuildPhase } from './isBuildPhase' +import Stripe from 'stripe' + +// Stripe can only be initialized with a secret key on the server side. +// During the build phase, the stripe key might not be set. +// To avoid build-time errors, we skip the check during the build phase. +if (!process.env.STRIPE_SECRET_KEY && !isBuildPhase()) { + throw new Error('Stripe secret key not set.') +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'fake-key', { telemetry: false }) diff --git a/src/prisma/schema/group.prisma b/src/prisma/schema/group.prisma index e9008d7ac..191ded271 100644 --- a/src/prisma/schema/group.prisma +++ b/src/prisma/schema/group.prisma @@ -31,12 +31,14 @@ enum GroupType { // 'Group' should never be created by itself. It should always be created // with a reference to one specific type of group. model Group { - id Int @id @default(autoincrement()) - groupType GroupType - memberships Membership[] - omegaOrder OmegaOrder @relation(fields: [order], references: [order], onDelete: Restrict) - order Int //The order the group is in currently. - + id Int @id @default(autoincrement()) + groupType GroupType + memberships Membership[] + omegaOrder OmegaOrder @relation(fields: [order], references: [order]) + order Int //The order the group is in currently. + ledgerAccount LedgerAccount? + + // The different types of groups: class Class? committee Committee? interestGroup InterestGroup? diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma new file mode 100644 index 000000000..25e9b45e2 --- /dev/null +++ b/src/prisma/schema/ledger.prisma @@ -0,0 +1,208 @@ +// Join table between groups and their ledger accounts +model GroupLedgerAccount { + id Int @id @default(autoincrement()) + // TODO: Finnish this + + // group Group @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) + // groupId Int + // ledgerAccount LedgerAccount @relation(fields: [ledgerAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + // ledgerAccountId Int + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Index on IDs for faster look up + // @@index([groupId]) + // @@index([ledgerAccountId]) +} + +// In theory the type of a ledger accounts could be inferred from its relations, +// but to simplify logic an enum is used. In addition this also +// makes the ledger account type known even after the relation is lost. +// Say for example if a user is deleted. +enum LedgerAccountType { + // User ledger accounts may be attached to users and + // may pay for items and event registrations. + USER + // Group ledger accounts may be attached to groups and + // can receive money from shops and events registrations. + GROUP +} + +// A ledger account is equivalent to you real life bank account +// It is used to store internal funds for either users or groups +model LedgerAccount { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) + userId Int? @unique + group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull, onUpdate: Cascade) + groupId Int? @unique + type LedgerAccountType + frozen Boolean @default(false) // If true, no transactions may be made on this account. + name String? // Optional display name for the account, only used for group accounts + payoutAccountNumber String? // For display only, only used for group accounts + + ledgerEntries LedgerEntry[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Book keeping for why a transaction was created +// Only used for informing the user, no effect on actual logic in the ledger itself. +enum LedgerTransactionPurpose { + SHOP_PURCHASE + EVENT_PAYMENT + DEPOSIT + PAYOUT + REFUND +} + +// All ledger transactions start as pending and become +// either failed, succeeded or canceled. No other +// transitions are possible. +enum LedgerTransactionState { + PENDING + FAILED + SUCCEEDED + CANCELED +} + +// The system uses a double-entry accounting. In engineering terms this means that +// ledger transactions obey Kirchhoff's first law. That is: +// sum of ledger entries + sum of payouts = sum of all payments +// Either all or none ledger entries and payouts in a transaction are valid. +// Payments track their own state as they depend on the external payment provider. +model LedgerTransaction { + id Int @id @default(autoincrement()) + purpose LedgerTransactionPurpose + state LedgerTransactionState + reason String? // If the transaction failed, this is the reason why. + + ledgerEntries LedgerEntry[] + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId Int? @unique + + // Relevant relations to other tables based un purpose + // purchase Purchase + // deposit Deposit + // payout Payout + // refund + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model LedgerEntry { + id Int @id @default(autoincrement()) + // The funds this ledger entry moves + // Credit when > 0, debit when < 0 + funds Int + // Fees are the fees incurred during payment. This does not effect users balance and are only used for book keeping. + // Optional since fees might not be known until payment is confirmed + // Must be non-null when completing a transaction. It must be explicitly set to 0 to indicate no fees. + fees Int? + // The account which should be credited/debited on completions + ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) + ledgerAccountId Int + // The transaction this ledger entry is part of + // Accounts are credited only when the transaction succeeds. + // Accounts are debited immediately when the transaction is created, + // but this is reversed in case the transaction fails (essentially the funds are reserved) + ledgerTransaction LedgerTransaction @relation(fields: [ledgerTransactionId], references: [id]) + ledgerTransactionId Int // The entry is only valid once the transaction is valid + + // TODO: Should we have updated and created at per ledger entry? + // TODO: Add indexes for fields which are used for look up often to increase performance + // @@index([ledgerAccountId]) + // @@index([ledgerTransactionId]) + // @@index([paymentId]) + + // Only one ledger entry for a given account may be present in a transaction + // This is for simplicity as they could always be merged + @@unique([ledgerTransactionId, ledgerAccountId]) +} + +enum PaymentProvider { + STRIPE + MANUAL +} + +enum PaymentState { + // Payment created, but not external API call made + PENDING + // Awaiting response from payment provider (webhook) + PROCESSING + // Failed webhook received :( + FAILED + // Succeed webhook received with correct funds + SUCCEEDED + // Cancel webhook confirmation received - NOT that we initiated a cancel + // set the transaction state to canceled for that + CANCELED +} + +// Payments represent external movement of funds and may be both incoming and outgoing. +// Incoming payments are for example when users deposit money into their account. +// Outgoing payments are for example when we payout money to committees. +model Payment { + id Int @id @default(autoincrement()) + // The funds that was requested for this payment + // Use to confirm that the correct funds was captured + // Note: positive = going into the ledger, negative = going out of the ledger + funds Int + // The fees this payment incurred + fees Int? + // The reason for the payment, displayed on the stripe dashboard + descriptionLong String? + // The text displayed on the bank statement + descriptionShort String? + // The life cycle state of this payment + state PaymentState @default(PENDING) + // The responsible provider for this payment + provider PaymentProvider + // Only one of the following relations may be set + // depending on the payment provider used + stripePayment StripePayment? + manualPayment ManualPayment? + + // Which ledger transaction this payment is part + ledgerTransaction LedgerTransaction? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model StripePayment { + payment Payment @relation(fields: [paymentId], references: [id]) + paymentId Int @id + + paymentIntentId String? @unique + // The key the fronted uses to confirm the payment intent + clientSecret String? @unique + + // Which ledger entries have used this payment + // Useful in case payment goes through, + // then user can be credited unused funds + // into their account. + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Bookkeeping for when funds are transferred to or from the ledger manually by administrators. +// Important: The actual funds transferred is `funds` minus `fees`! +// Example: Say PhaestCom has earned 50'000.00 funds and 1000.00 fees. +// Then, the actual bank transfer should equate to 49'000.00. +model ManualPayment { + payment Payment @relation(fields: [paymentId], references: [id]) + paymentId Int @id + + // The bank account number where the money was sent to/from. + // This is only for our own bookkeeping. When sending funds out + // of the system it has to be transferred manually by an admin! + bankAccountNumber String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index a386411dc..dc1c52ba4 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -12,45 +12,75 @@ enum RelationshipStatus { } model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique - firstname String @default("[Fjernet]") - lastname String @default("[Fjernet]") - bio String @default("") - relationshipStatus RelationshipStatus @default(NOT_SPECIFIED) - relationshipStatusText String @default("") - archived Boolean @default(false) + id Int @id @default(autoincrement()) + username String @unique + email String @unique + firstname String @default("[Fjernet]") + lastname String @default("[Fjernet]") + bio String @default("") + relationshipStatus RelationshipStatus @default(NOT_SPECIFIED) + relationshipStatusText String @default("") + archived Boolean @default(false) acceptedTerms DateTime? - imageConsent Boolean @default(false) //if the user has consented to being photographed + imageConsent Boolean @default(false) //if the user has consented to being photographed sex SEX? allergies String? mobile String? emailVerified DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // is also updated manually - image Image? @relation(fields: [imageId], references: [id]) + image Image? @relation(fields: [imageId], references: [id]) imageId Int? - studentCard String? @unique - LockerReservation LockerReservation[] - omegaQuote OmegaQuote[] - memberships Membership[] - credentials Credentials? - feideAccount FeideAccount? + studentCard String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // is also updated manually + + // Authentication info used for logging in. + credentials Credentials? + feideAccount FeideAccount? + + // Memberships to groups (committees, interest groups, classes, etc...). + memberships Membership[] + + // Lockers used by the user. + LockerReservation LockerReservation[] + + // Omega quotes posted by the user. + omegaQuote OmegaQuote[] + + // Which ledger account (i.e. internal bank account) and + // stripe customer this user is associated with. + ledgerAccount LedgerAccount? + stripeCustomer StripeCustomer? + + // What notifications the user whiches to received and + // which mailing lists the user is on. notificationSubscriptions NotificationSubscription[] mailingLists MailingListUser[] - admissionTrials AdmissionTrial[] @relation(name: "user") - registeredAdmissionTrial AdmissionTrial[] @relation(name: "registeredBy") - Application Application[] - EventRegistration EventRegistration[] + // Which admissions (a.k.a. "opptak") the user has taken + // and which admissions thay have registered for others. + admissionTrials AdmissionTrial[] @relation(name: "user") + registeredAdmissionTrial AdmissionTrial[] @relation(name: "registeredBy") + + // The user's applications to committees. + Application Application[] + + // Which events the user has registered for + // and which events they have created. + EventRegistration EventRegistration[] + Event Event[] + + // Which dots (a.k.a. "prikker") the user has received and given. dots DotWrapper[] @relation(name: "dot_user") dotsAccused DotWrapper[] @relation(name: "dot_accuser") - Event Event[] + // The queue used to determine who is registering cards at Kiogeskabet. + registerStudentCardQueue RegisterStudentCardQueue[] + + // Which cabin bookings the user has made. cabinBooking Booking[] @relation() + // The flairs to display next to the user's name. flairs Flair[] @relation(name: "userFlairs") // We need to explicitly mark the combination of 'id', 'username' and 'email' as @@ -58,6 +88,8 @@ model User { @@unique([id, username, email]) } +// This model primaraly exists to keep the password hash separate from the user table. +// This is to reduce the risk of leaking the password hashes. model Credentials { user User @relation(fields: [userId, username, email], references: [id, username, email], onDelete: Cascade, onUpdate: Cascade) userId Int @unique @@ -71,16 +103,36 @@ model Credentials { @@unique([userId, username, email]) } +// Associates each user with their Feide account. model FeideAccount { id String @id accessToken String @db.Text email String @unique expiresAt DateTime issuedAt DateTime - userId Int @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId Int @unique +} + +// Associates each user with their Stripe customer id. +model StripeCustomer { + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId Int @id + customerId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// When a user wants to register their student card, they are put in this queue. +// Then they must scan their card with the card reader at Kiogeskabet. +model RegisterStudentCardQueue { + userId Int @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiry DateTime } +// TODO: Someone should add a comment for ContactDetails because I have noe idea what it is for. Is it for anonymous users? model ContactDetails { id Int @id @default(autoincrement()) name String diff --git a/src/prisma/seeder/src/development/seedDevGroups.ts b/src/prisma/seeder/src/development/seedDevGroups.ts index d23ed8904..1c5eede32 100644 --- a/src/prisma/seeder/src/development/seedDevGroups.ts +++ b/src/prisma/seeder/src/development/seedDevGroups.ts @@ -13,14 +13,14 @@ export default async function seedDevGroups(prisma: PrismaClient) { await prisma.committee.create({ data: { - name: 'Harambe\'s komité', + name: 'Harambes komité', shortName: 'harcom', committeeArticle: { create: { - name: 'Harambe\'s komité', + name: 'Harambes komité', coverImage: { create: { - name: 'Harambe\'s bilde' + name: 'Harambes bilde' } } } @@ -35,6 +35,12 @@ export default async function seedDevGroups(prisma: PrismaClient) { create: { groupType: 'COMMITTEE', order: order.order, + ledgerAccount: { + create: { + name: 'Kontoen til Harambes komité', + type: 'GROUP', + }, + }, }, }, logoImage: { diff --git a/src/prisma/seeder/src/development/seedDevUsers.ts b/src/prisma/seeder/src/development/seedDevUsers.ts index 2f620ec88..fd10e8f8b 100644 --- a/src/prisma/seeder/src/development/seedDevUsers.ts +++ b/src/prisma/seeder/src/development/seedDevUsers.ts @@ -193,6 +193,11 @@ export default async function seedDevUsers(prisma: PrismaClient) { id: harambeImage.id } }, + ledgerAccount: { + create: { + type: 'USER', + }, + }, emailVerified: new Date(), acceptedTerms: new Date(), }, @@ -255,6 +260,11 @@ export default async function seedDevUsers(prisma: PrismaClient) { passwordHash, }, }, + ledgerAccount: { + create: { + type: 'USER', + }, + }, emailVerified: new Date(), acceptedTerms: new Date(), }, diff --git a/src/services/cabin/product/schemas.ts b/src/services/cabin/product/schemas.ts index 813fbbb95..f58ce0669 100644 --- a/src/services/cabin/product/schemas.ts +++ b/src/services/cabin/product/schemas.ts @@ -1,5 +1,5 @@ import { Zpn } from '@/lib/fields/zpn' -import { convertPrice } from '@/lib/money/convert' +import { convertAmount } from '@/lib/currency/convert' import { BookingType } from '@/prisma-generated-pn-types' import { z } from 'zod' @@ -8,7 +8,7 @@ const baseSchema = z.object({ amount: z.coerce.number().int().min(0), name: z.string().min(2), description: z.string().min(0).max(20), - price: z.coerce.number().min(0).transform((val) => convertPrice(val)), + price: z.coerce.number().min(0).transform((val) => convertAmount(val)), validFrom: z.coerce.date(), cronInterval: Zpn.simpleCronExpression(), memberShare: z.coerce.number().min(0).max(100), diff --git a/src/services/ledger/accounts/actions.ts b/src/services/ledger/accounts/actions.ts new file mode 100644 index 000000000..a3c4aff6a --- /dev/null +++ b/src/services/ledger/accounts/actions.ts @@ -0,0 +1,10 @@ +'use server' + +import { ledgerAccountOperations } from './operations' +import { makeAction } from '@/services/serverAction' + +export const calculateLedgerAccountBalanceAction = makeAction(ledgerAccountOperations.calculateBalance) +export const readLedgerAccountAction = makeAction(ledgerAccountOperations.read) +export const readLedgerAccountPageAction = makeAction(ledgerAccountOperations.readPage) +export const updateLedgerAccountAction = makeAction(ledgerAccountOperations.update) + diff --git a/src/services/ledger/accounts/auth.ts b/src/services/ledger/accounts/auth.ts new file mode 100644 index 000000000..70b786d12 --- /dev/null +++ b/src/services/ledger/accounts/auth.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/services/ledger/accounts/operations.ts b/src/services/ledger/accounts/operations.ts new file mode 100644 index 000000000..1102a5ad2 --- /dev/null +++ b/src/services/ledger/accounts/operations.ts @@ -0,0 +1,256 @@ +import { ledgerAccountSchemas } from './schemas' +import { ServerError } from '@/services/error' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { defineOperation } from '@/services/serviceOperation' +import { RequireNothing } from '@/auth/authorizer/RequireNothing' +import { z } from 'zod' +import type { LedgerAccount } from '@/prisma-generated-pn-types' +import { LedgerAccountType } from '@/prisma-generated-pn-types' +import type { Balance, BalanceRecord } from './types' + +export const ledgerAccountOperations = { + /** + * Creates a new ledger account for given user or group. + * + * Will throw an error if both `userId` and `groupId` are set, or if neither are set. + * + * @param data.userId The ID of the user to create the account for. + * @param data.groupId The ID of the group to create the account for. + * + * @returns The created account. + */ + create: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + dataSchema: ledgerAccountSchemas.create, + operation: async ({ prisma, data }): Promise => { + const type = data.userId === undefined ? 'GROUP' : 'USER' + + if (data.userId === undefined && data.groupId === undefined) { + throw new ServerError('BAD PARAMETERS', 'Enten bruker-id eller gruppe-id må være spesifisert.') + } + + if (data.userId !== undefined && data.groupId !== undefined) { + throw new ServerError('BAD PARAMETERS', 'Både bruker-id og gruppe-id kan ikke være spesifisert samtidig.') + } + + return prisma.ledgerAccount.create({ + data: { + userId: data.userId, + groupId: data.groupId, + payoutAccountNumber: data.payoutAccountNumber, + frozen: data.frozen, + type, + } + }) + }, + }), + + /** + * Reads details of a ledger account by ledger account id, user id, or group id. + * If searching by userId/groupId the account will be created if it does not exist. + * + * **Note**: The balance of an account is not included in the response. + * Use the `calculateBalance` method to get the balance. + * + * @param params.userId The ID of the user to read the account for. + * @param params.groupId The ID of the group to read the account for. + * @param params.ledgerAccountId The ID of the ledger account to read. + * + * @returns The account details. + */ + read: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.union([ + z.object({ + userId: z.number(), + groupId: z.undefined(), + ledgerAccountId: z.undefined(), + }), + z.object({ + groupId: z.number(), + userId: z.undefined(), + ledgerAccountId: z.undefined(), + }), + z.object({ + groupId: z.undefined(), + userId: z.undefined(), + ledgerAccountId: z.number(), + }), + ]), + operation: async ({ prisma, session, params }): Promise => { + // If searching by ledger account id we don't want to create a new account if it doesn't exist. + if (params.ledgerAccountId !== undefined) { + return await prisma.ledgerAccount.findUniqueOrThrow({ + where: { + id: params.ledgerAccountId, + }, + }) + } + + // If searching by userId/groupId we want to create the account if it doesn't exist. + // TODO: Is this something we want? + + const account = await prisma.ledgerAccount.findUnique({ + where: { + userId: params.userId, + groupId: params.groupId, + }, + }) + + if (account) return account + + return ledgerAccountOperations.create({ session, data: params }) + }, + }), + + readPage: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: readPageInputSchemaObject( + z.number(), + z.object({ + id: z.number(), + }), + z.object({ + accountType: z.nativeEnum(LedgerAccountType).optional(), + }), + ), + operation: async ({ params: { paging }, prisma }) => + // TODO: Add balance to each account + await prisma.ledgerAccount.findMany({ + where: { + type: paging.details.accountType, + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + ...cursorPageingSelection(paging.page), + }) + + }), + + /** + * Updates a ledger account with the given data. + * + * @param params.id The ID of the account to update. + * @param data The data to update the account with. + * + * @returns The updated account. + */ + update: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + id: z.number(), + }), + dataSchema: ledgerAccountSchemas.update, + operation: async ({ prisma, params, data }) => prisma.ledgerAccount.update({ + where: { + id: params.id, + }, + data, + }) + }), + + /** + * Calculates the balance and fees of a ledger account. + * Optionally takes a transaction ID to calculate the balance up until that transaction. + * + * @warning Non-existent accounts will be treated as having a balance of zero. + * + * @param params.ids The IDs of the accounts to calculate the balance for. + * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. + * + * @returns The balances of the ledger accounts. + */ + calculateBalances: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + ids: z.number().array(), + atTransactionId: z.number().optional(), + }), + operation: async ({ prisma, params }): Promise => { + const balanceArray = await prisma.ledgerEntry.groupBy({ + by: ['ledgerAccountId'], + where: { + // Select which accounts we want to calculate the balance for + ledgerAccountId: { + in: params.ids, + }, + // Since transaction ids are sequential we can use the less than operator + // to filter for all the transactions that happened before the given one. + // This is useful in case we need to know the balance in the past. + ledgerTransactionId: { + lte: params.atTransactionId, + }, + // Credit and debit ledger entries are valid under slight different conditions. + OR: [ + { + // If the amount is greater than zero the entry is a credit (i.e. giving money). + funds: { gt: 0 }, + // The receiver should (logically) only receive the money if the transaction succeeded. + ledgerTransaction: { state: 'SUCCEEDED' }, + }, + { + // If the amount is less than zero the entry is a debit (i.e. taking money). + funds: { lt: 0 }, + // The amount should be deducted from the source if the transaction succeeded (obviously) + // OR when the transaction is pending. This is our way of reserving the funds + // until the transaction is complete. + ledgerTransaction: { state: { in: ['PENDING', 'SUCCEEDED'] } }, + }, + ], + }, + // Select what fields we should sum + _sum: { + funds: true, + fees: true, + }, + }) + + // Convert the array to an object as it's more convenient for lookups and + // replace all nulls with zeros to handle accounts with no entries yet. + // Set the balance of accounts that have no entries to zero. + const balanceRecord = Object.fromEntries([ + ...params.ids.map(id => [id, { amount: 0, fees: 0 }]), + ...balanceArray.map(balance => [ + balance.ledgerAccountId, + { + amount: balance._sum.funds ?? 0, + fees: balance._sum.fees ?? 0 + } + ]) + ]) + + return balanceRecord + } + }), + + /** + * Calcultates the balance of a single account. Under the hood it simply uses `calculateBalances`. + * + * @warning In case a ledger account with the provided id doesn't exist a balance of zero will be returned! + * + * @param params.id The ID of the account to calculate the balance for. + * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. + * + * @returns The balances of the ledger accounts. + */ + calculateBalance: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + id: z.number(), + atTransactionId: z.number().optional(), + }), + operation: async ({ params }): Promise => { + const balances = await ledgerAccountOperations.calculateBalances({ + params: { + ids: [params.id], + atTransactionId: params.atTransactionId, + }, + }) + + return balances[params.id] + } + }), +} diff --git a/src/services/ledger/accounts/schemas.ts b/src/services/ledger/accounts/schemas.ts new file mode 100644 index 000000000..a68540cd9 --- /dev/null +++ b/src/services/ledger/accounts/schemas.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +const ledgerAcccountSchema = z.object({ + userId: z.number().optional(), + groupId: z.number().optional(), + payoutAccountNumber: z.string().optional(), + frozen: z.boolean().optional(), +}) + +export const ledgerAccountSchemas = { + create: ledgerAcccountSchema.pick({ + userId: true, + groupId: true, + payoutAccountNumber: true, + frozen: true, + }).refine( + data => (data.userId === undefined) !== (data.groupId === undefined), + 'Bruker- eller gruppe-ID må være satt.' + ), + + update: ledgerAcccountSchema.partial().pick({ + payoutAccountNumber: true, + frozen: true, + }) +} diff --git a/src/services/ledger/accounts/types.ts b/src/services/ledger/accounts/types.ts new file mode 100644 index 000000000..34bcb86f3 --- /dev/null +++ b/src/services/ledger/accounts/types.ts @@ -0,0 +1,10 @@ +// NOTE: `amount` and `fees` are stored as integers representing +// hundredths (1/100) of a Kluengende Muent. +// (We should have a name for this. "Kluengende Cent"? "Kluengende Muentling"?) +export type Balance = { + amount: number, + fees: number, +} + +// TODO: Should this also be partial? It cannot possibly contain all number IDs. +export type BalanceRecord = Record diff --git a/src/services/ledger/movements/actions.ts b/src/services/ledger/movements/actions.ts new file mode 100644 index 000000000..a6cb59ad8 --- /dev/null +++ b/src/services/ledger/movements/actions.ts @@ -0,0 +1,7 @@ +'use server' + +import { ledgerMovementOperations } from './operations' +import { makeAction } from '@/services/serverAction' + +export const createDepositAction = makeAction(ledgerMovementOperations.createDeposit) +export const createPayoutAction = makeAction(ledgerMovementOperations.createPayout) diff --git a/src/services/ledger/movements/operations.ts b/src/services/ledger/movements/operations.ts new file mode 100644 index 000000000..49245ad35 --- /dev/null +++ b/src/services/ledger/movements/operations.ts @@ -0,0 +1,112 @@ +import { ledgerTransactionOperations } from '@/services/ledger/transactions/operations' +import { paymentOperations } from '@/services/ledger/payments/operations' +import { defineOperation } from '@/services/serviceOperation' +import { RequireNothing } from '@/auth/authorizer/RequireNothing' +import { z } from 'zod' +import { PaymentProvider } from '@/prisma-generated-pn-types' + +// `ledgerMovementOperations` provides functions to orchestrate account related actions, +// such as depositing funds or creating payouts. If the ledger is needed for +// other purposes, such as creating a transaction, it should be done through +// `ledgerTransactionOperations`. + +export const ledgerMovementOperations = { + /** + * Creates a deposit transaction, which is a deposit of funds into the ledger. + * + * @params params.amount The amount to be deposited. + * @params params.ledgerAccountId The ID of the ledger account where the funds will be deposited. + * + * @return The created transaction representing the deposit operation. + */ + createDeposit: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + opensTransaction: true, + paramsSchema: z.object({ + ledgerAccountId: z.number(), + provider: z.nativeEnum(PaymentProvider), + funds: z.coerce.number().nonnegative(), + manualFees: z.coerce.number().nonnegative().default(0), + }), + operation: async ({ prisma, params }) => { + const transaction = await prisma.$transaction(async tx => { + const payment = await paymentOperations.create({ + params: { + provider: params.provider, + funds: params.funds, + manualFees: params.manualFees, + descriptionLong: 'Innskudd til veven', + descriptionShort: 'Innskudd', + }, + prisma: tx, + }) + + return await ledgerTransactionOperations.create({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [{ + ledgerAccountId: params.ledgerAccountId, + funds: params.funds, + }], + paymentId: payment.id, + }, + prisma: tx, + }) + }) + + if (transaction.payment?.state === 'PENDING') { + transaction.payment = await paymentOperations.initiate({ + params: { paymentId: transaction.payment.id }, + }) + } + + return transaction + } + }), + + /** + * Creates a payout transaction, which is a withdrawal of funds from the ledger. + * + * @params params.amount The amount to be withdrawn. + * @params params.fees The fees associated with the payout. + * @params params.ledgerAccountId The ID of the ledger account from which the funds will be withdrawn. + * + * @returns The created transaction representing the payout operation. + */ + createPayout: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + ledgerAccountId: z.number(), + funds: z.number().nonnegative().default(0), + fees: z.number().nonnegative().default(0), + }).refine((data) => data.funds || data.fees, 'Både beløp og avgifter kan ikke være 0 samtidig.'), + opensTransaction: true, + operation: async ({ prisma, params }) => prisma.$transaction(async tx => { + const payment = await paymentOperations.create({ + params: { + provider: 'MANUAL', + descriptionLong: 'Utbetaling fra veven', + descriptionShort: 'Utbetaling', + funds: -params.funds, + manualFees: -params.fees, + }, + prisma: tx, + }) + + const transaction = await ledgerTransactionOperations.create({ + params: { + purpose: 'PAYOUT', + ledgerEntries: [{ + ledgerAccountId: params.ledgerAccountId, + funds: -params.funds, + fees: -params.fees, + }], + paymentId: payment.id, + }, + prisma: tx, + }) + + return transaction + }) + }), +} diff --git a/src/services/ledger/movements/schemas.ts b/src/services/ledger/movements/schemas.ts new file mode 100644 index 000000000..965965ffc --- /dev/null +++ b/src/services/ledger/movements/schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const ledgerMovementSchemas = { + createDeposit: z.object({ + }), + + // export const createPayoutSchema = z.object({ + // funds: z.coerce.number().nonnegative(), + // fees: z.coerce.number().nonnegative(), + // }).refine((data) => data.funds || data.fees, "Både beløp og avgifter kan ikke være 0 samtidig."); +} diff --git a/src/services/ledger/payments/constants.ts b/src/services/ledger/payments/constants.ts new file mode 100644 index 000000000..98685cb0c --- /dev/null +++ b/src/services/ledger/payments/constants.ts @@ -0,0 +1 @@ +export const MINIMUM_PAYMENT_AMOUNT = 50_00 // In hundredths of Kluengende Muente diff --git a/src/services/ledger/payments/operations.ts b/src/services/ledger/payments/operations.ts new file mode 100644 index 000000000..26dcef628 --- /dev/null +++ b/src/services/ledger/payments/operations.ts @@ -0,0 +1,134 @@ +import { stripe } from '@/lib/stripe' +import { ServerError } from '@/services/error' +import { defineOperation } from '@/services/serviceOperation' +import { RequireNothing } from '@/auth/authorizer/RequireNothing' +import { PaymentProvider } from '@/prisma-generated-pn-types' +import { z } from 'zod' + + +export const paymentOperations = { + /** + * Creates a new payment record in the db. + * Important: This method does not call external APIs to enable it to be used in transactions. + * Call `initiate` to actually begin collecting the payment. + */ + create: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + funds: z.number(), + descriptionLong: z.string().optional(), + descriptionShort: z.string().optional(), + provider: z.nativeEnum(PaymentProvider), + manualFees: z.number().optional(), + bankAccountNumber: z.string().optional(), + }), + operation: async ({ prisma, params }) => prisma.payment.create({ + data: { + provider: params.provider, + funds: params.funds, + + ...(params.provider === 'STRIPE' && { + create: {}, + }), + + // Manual payments are special in that they automatically succeed + // and fees are determined manually by the user. + ...(params.provider === 'MANUAL' && { + state: 'SUCCEEDED', + fees: params.manualFees, + manualPayment: { + create: { + bankAccountNumber: params.bankAccountNumber, + }, + }, + }) + }, + include: { + stripePayment: true, + manualPayment: true, + } + }), + }), + + /** + * Calls the external API to begin collecting the payment. + * + * @warning Do not call this method for manual payments! It will fail. + */ + initiate: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + paymentId: z.number(), + }), + // This method does not actually open a transaction. However, it cannot be used + // inside a transaction as it does external API calls which cannot be reversed. + opensTransaction: true, + operation: async ({ prisma, params }) => { + const payment = await prisma.payment.findUniqueOrThrow({ + where: { + id: params.paymentId, + }, + select: { + funds: true, + provider: true, + state: true, + descriptionLong: true, + descriptionShort: true, + }, + }) + + if (payment.state !== 'PENDING') { + throw new ServerError('BAD PARAMETERS', 'Betalingen har allerede blitt forespurt.') + } + + if (payment.provider === 'MANUAL') { + throw new ServerError('BAD PARAMETERS', 'Manuelle betalinger trenger ikke å startes.') + } + + if (payment.provider === 'STRIPE') { + const paymentIntent = await stripe.paymentIntents.create({ + amount: payment.funds, + currency: 'nok', + description: payment.descriptionLong ?? undefined, + statement_descriptor_suffix: payment.descriptionShort ?? undefined, + // Stripe allows us to attach arbitrary metadata to payment intents + // Currently, we don't use this for anything, but it might be + // useful in the future. + metadata: { + projectNextPaymentId: params.paymentId, + }, + }, { + // The idempotency key makes it so that multiple requests with the + // same key return the same result. This is useful in case + // initiate payment is accidentally called twice. + idempotencyKey: `project-next-payment-id-${params.paymentId}`, + }) + + if (paymentIntent.client_secret === null) { + throw new ServerError('UNKNOWN ERROR', 'Noe gikk galt med forespørselen til Stripe.') + } + + return await prisma.payment.update({ + where: { + id: params.paymentId, + }, + data: { + stripePayment: { + update: { + paymentIntentId: paymentIntent.id, + }, + }, + state: 'PROCESSING', + }, + include: { + stripePayment: true, + manualPayment: true, + } + }) + } + + // If we reach here, the payment provider is unknown. + throw new ServerError('SERVER ERROR', 'Prøvde å forespørre betalingsleverandør som ikke er støttet.') + }, + }), +} diff --git a/src/services/ledger/payments/stripeWebhookCallback.ts b/src/services/ledger/payments/stripeWebhookCallback.ts new file mode 100644 index 000000000..ba2d6a03f --- /dev/null +++ b/src/services/ledger/payments/stripeWebhookCallback.ts @@ -0,0 +1,128 @@ +import logger from '@/lib/logger' +import { stripe } from '@/lib/stripe' +import { prisma } from '@/prisma/client' +import type Stripe from 'stripe' +import type { PaymentState } from '@/prisma-generated-pn-types' + +/** + * Utility function which extracts the `latest_charge.balance_transaction` object + * from the provided payment intent object if it exists. + * + * @param paymentIntent + * @returns + */ +function extractBalanceTransaction(paymentIntent: Stripe.PaymentIntent): Stripe.BalanceTransaction | null { + const latestCharge = paymentIntent.latest_charge + + if (!latestCharge || typeof latestCharge !== 'object') { + logger.error( + 'Stripe payment intent event was missing latest charge object.' + + `'latest_charge': ${latestCharge}` + ) + return null + } + + const balanceTransaction = latestCharge.balance_transaction + + if (!balanceTransaction || typeof balanceTransaction !== 'object') { + logger.error( + 'Stripe payment intent event was missing balance transaction object.' + + `'balance_transaction': ${balanceTransaction}` + ) + return null + } + + return balanceTransaction +} + +// Map between Stripe event types and our internal payment states. +const EVENT_TYPE_TO_STATE: Partial> = { + 'payment_intent.canceled': 'CANCELED', + 'payment_intent.succeeded': 'SUCCEEDED', + 'payment_intent.payment_failed': 'FAILED', +} + +/** + * The function which is called when we receive a payment intent event from Stripe. + * It expects that the fields `latest_charge.balance_transaction` are expanded. + * (This is configured in the Stripe dashboard.) + * + * @warning This callback assumes that the Stripe payment intents always have the capture method "automatic". + * If this ever changes this function needs to be changed to handle uncaptured payments. + * (That is payments which are authorized, but we have not actually taken the money yet.) + * + * This is not implemented using `ServiceMethod` because it does not need any of its features. + * Firstly, the webhook callback is not part of the interface of the payment service. + * This function will only ever be used one place. Secondly, authentication and data validation + * is already handled by the Stripe package. + * + * @param paymentIntent The payment intent object received in the webhook. + * It is expected that `latest_charge.balance_transaction` is expanded. + * + * @returns An appropriate `Response`. + */ +export async function stripeWebhookCallback(event: Stripe.Event): Promise { + const paymentState = EVENT_TYPE_TO_STATE[event.type] + + if (!paymentState) { + logger.error('Received unsupported Stripe event type.') + return new Response('Unsupported Stripe event type', { status: 400 }) + } + + // TypeScript cannot figure out that the above if statement narrows the possible event type + // so we'll have to assert this our selves + const paymentIntent = event.data.object as Stripe.PaymentIntent + + // Declare fee, it will be undefined by default + // which is what we want for the canceled and failed events + let fee + + // If the payment succeeded we'll extract the fee + if (event.type === 'payment_intent.succeeded') { + const balanceTransaction = extractBalanceTransaction(paymentIntent) + + if (!balanceTransaction) { + logger.error('Received successful payment intent event without balance transaction object.') + return new Response('', { status: 400 }) + } + + fee = balanceTransaction.fee + } + + // Update the db model with the updated values + const stripePayment = await prisma.stripePayment.update({ + where: { + paymentIntentId: paymentIntent.id, + payment: { + state: { + // Guard against changing final state + // This should never happen, but you can never be too careful + in: ['PENDING', 'PROCESSING', paymentState] + }, + }, + }, + data: { + payment: { + update: { + fees: fee, + state: paymentState, + }, + } + }, + select: { + paymentId: true, + }, + }) + + // We only allow one payment attempt per payment intent. + // If this failed we cancel the payment intent to make sure it cannot be used in the future. + if (event.type === 'payment_intent.payment_failed') { + stripe.paymentIntents.cancel( + paymentIntent.id, + {}, + { idempotencyKey: `project-next-payment-id-${stripePayment.paymentId}` }, + ) + } + + return new Response('', { status: 200 }) +} diff --git a/src/services/ledger/payments/types.ts b/src/services/ledger/payments/types.ts new file mode 100644 index 000000000..e53db6267 --- /dev/null +++ b/src/services/ledger/payments/types.ts @@ -0,0 +1,8 @@ +import type { Prisma } from '@/prisma-generated-pn-types' + +export type ExpandedPayment = Prisma.PaymentGetPayload<{ + include: { + stripePayment: true, + manualPayment: true, + }, +}> diff --git a/src/services/ledger/transactions/actions.ts b/src/services/ledger/transactions/actions.ts new file mode 100644 index 000000000..bce5bd079 --- /dev/null +++ b/src/services/ledger/transactions/actions.ts @@ -0,0 +1,6 @@ +'use server' + +import { ledgerTransactionOperations } from './operations' +import { makeAction } from '@/services/serverAction' + +export const readLedgerTransactionPageAction = makeAction(ledgerTransactionOperations.readPage) diff --git a/src/services/ledger/transactions/calculateFees.ts b/src/services/ledger/transactions/calculateFees.ts new file mode 100644 index 000000000..3a2539aab --- /dev/null +++ b/src/services/ledger/transactions/calculateFees.ts @@ -0,0 +1,77 @@ +import type { BalanceRecord } from '@/services/ledger/accounts/types' + +/** + * Calculates fees proportional to the ratio between `entryAmount` and `totalAmount`. + * + * **Example:** Say an account has amount = 100 Kl.M. and fees = 20 Kl.M. + * Deducting 25 Kl.M. is 25% of the total amount, so the fees deducted + * should also be 25% of the total fees, i.e., 5 Kl.M. + */ +export function feesFormula(entryAmount: number, totalAmount: number, totalFees: number) { + if (entryAmount === 0 || totalAmount === 0) return 0 + + const fees = Math.trunc(totalFees * entryAmount / totalAmount) + + // Clamp fees to have same sign as amount + // and never exceed total fees. + if (entryAmount > 0) { + return Math.min(Math.max(fees, 0), totalFees) + } + return Math.min(Math.max(fees, -totalFees), 0) +} + +/** + * Calculates the fees for debit ledger entries (funds < 0) based on + * the balances of the accounts which are deducted. + */ +export function calculateDebitFees(ledgerEntries: { funds: number, ledgerAccountId: number }[], balances: BalanceRecord) { + const debitLedgerEntries = ledgerEntries.filter(entry => entry.funds < 0) + + return Object.fromEntries(debitLedgerEntries.map(entry => { + const balance = balances[entry.ledgerAccountId] + + if (!balance) throw Error(`Balance for ledger account nr. ${entry.ledgerAccountId} not provided.`) + + return [entry.ledgerAccountId, feesFormula(entry.funds, balance.amount, balance.fees)] + })) +} + +/** + * Calculates the fees for credit ledger entries (funds > 0) based on + * the total amount and total fees of in the transaction. + */ +export function calculateCreditFees( + ledgerEntries: { funds: number, fees: number | null, ledgerAccountId: number }[], + payment: { funds: number, fees: number | null } | null, +) { + // If payment is attached but fees are null, + // return null until it completes. + if (payment && payment.fees === null) return null + + const creditLedgerEntries = ledgerEntries.filter(entry => entry.funds > 0) + const debitLedgerEntries = ledgerEntries.filter(entry => entry.funds < 0) + + const sum = (...values: (number | null | undefined)[]) => + values.reduce((total, value) => total + (value ?? 0), 0) + + let totalFunds = sum( + ...debitLedgerEntries.map(entry => -entry.funds), + payment?.funds, + ) + let totalFees = sum( + ...debitLedgerEntries.map(entry => -(entry.fees ?? 0)), + payment?.fees, + ) + + return Object.fromEntries(creditLedgerEntries.map(entry => { + const fees = feesFormula(entry.funds, totalFunds, totalFees) + + // Subtract the from the totals to ensure + // that the sum of all fees ends up exactly + // equal to `totalFees`. + totalFunds -= entry.funds + totalFees -= fees + + return [entry.ledgerAccountId, fees] + })) +} diff --git a/src/services/ledger/transactions/determineTransactionState.ts b/src/services/ledger/transactions/determineTransactionState.ts new file mode 100644 index 000000000..97501deaf --- /dev/null +++ b/src/services/ledger/transactions/determineTransactionState.ts @@ -0,0 +1,188 @@ +import type { ExpandedLedgerTransaction } from './types' +import type { BalanceRecord } from '@/services/ledger/accounts/types' +import type { LedgerTransactionState, PaymentState } from '@/prisma-generated-pn-types' + +type LedgerTransactionTransition = { + state: LedgerTransactionState, + reason?: string, +} + +type LedgerTransactionRuleContext = { + transaction: ExpandedLedgerTransaction, + balances: BalanceRecord, + frozenAccountIds: Set, +} + +type LedgerTransactionRule = (context: LedgerTransactionRuleContext) => LedgerTransactionTransition | null + +/** + * Determines the state of a given transaction. + */ +export async function determineTransactionState( + context: LedgerTransactionRuleContext +): Promise { + // NOTE: The order of the rules are important! + // Fee checks must run only after payment completes + // since fees aren't set earlier. + const rules: LedgerTransactionRule[] = [ + noTerminalState, + noFrozenAccounts, + noFailedPayment, + amountAndFeesHaveSameSigns, + validAmountSum, + sufficientBalances, + transfersComplete, + noNullFees, + validFeesSum, + ] + + for (const rule of rules) { + const state = rule(context) + + if (state) return state + } + + return { state: 'SUCCEEDED' } +} + +/** + * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) + * can never change state. + */ +function noTerminalState( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + if (transaction.state !== 'PENDING') return { state: transaction.state } + + return null +} + +function noFrozenAccounts( + { transaction, frozenAccountIds }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + const hasFrozenAccount = transaction.ledgerEntries.some(entry => frozenAccountIds.has(entry.ledgerAccountId)) + + if (hasFrozenAccount) return { state: 'FAILED', reason: 'En eller flere kontoer er frossene.' } + + return null +} + +/** + * If any payment has failed, the entire transaction has failed. + */ +function noFailedPayment( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + const okStates: PaymentState[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] + const hasFailedPayment = transaction.payment && !okStates.includes(transaction.payment.state) + + if (hasFailedPayment) return { state: 'FAILED', reason: 'Betaling mislyktes.' } + + return null +} + +/** + * Check that ledger entries, payment and manual transfer have correct signs. + * + * Mathematically: `amount >= 0 <=> fees >= 0` and `amount <= 0 <=> fees <= 0`. + */ +function amountAndFeesHaveSameSigns( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + // Helper function which return true when a and b have same signs or at least + // one of a and b are falsy. + const sameSigns = (a?: number | null, b?: number | null) => !a || !b || Math.sign(a) === Math.sign(b) + + const validEntries = transaction.ledgerEntries.every(entry => sameSigns(entry.funds, entry.fees)) + const validTransfer = !transaction.payment || sameSigns(transaction.payment.funds, transaction.payment.fees) + + if (!validEntries || !validTransfer) return { state: 'FAILED', reason: 'Ugyldige beløp og/eller gebyrer.' } + + return null +} + +/** + * Kirchhoff's first law! The sum of all amounts must be zero. + * I.e. money must come from somewhere and go to somewhere. + */ +function validAmountSum( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + // NOTE: Since the number of entries in a transaction is very low (max two) we can + // sum the amounts and fees in memory rather than doing a database aggregation. + const totalLedgerEntryFunds = transaction.ledgerEntries.reduce((sum, entry) => sum + entry.funds, 0) + const paymentFunds = transaction.payment?.funds ?? 0 + + if (totalLedgerEntryFunds !== paymentFunds) return { state: 'FAILED', reason: 'Ugyldig totalbeløp.' } + + return null +} + +/** + * If an entry is debit (amount < 0), its referenced account must + * have a positive balance after the transaction succeeds. + */ +function sufficientBalances( + { transaction, balances }: LedgerTransactionRuleContext, +): LedgerTransactionTransition | null { + const debitLedgerAccountIds = transaction.ledgerEntries + .filter(entry => entry.funds < 0) + .map(entry => entry.ledgerAccountId) + const debitBalances = debitLedgerAccountIds.map(id => balances[id]) + + if (debitBalances.some(balance => !balance)) { + throw new Error('Missing balance in balance record.') + } + + const hasNegativeBalance = debitBalances.some(balance => balance.amount < 0 || balance.fees < 0) + + if (hasNegativeBalance) return { state: 'FAILED', reason: 'Ikke nok midler for å utføre transaksjonen.' } + + return null +} + +/** + * If any payment is pending, the transaction is pending. + */ +function transfersComplete( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + // Since we have checked for failure states above, + // we can simply check that the transfer has not succeeded. + const hasPendingTransfer = transaction.payment && transaction.payment.state !== 'SUCCEEDED' + + if (hasPendingTransfer) return { state: 'PENDING' } + + return null +} + +/** + * All fees must be non-null. + */ +function noNullFees( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + const hasNullFees = + transaction.ledgerEntries.some(entry => entry.fees === null) || + transaction.payment?.fees === null + + if (hasNullFees) return { state: 'FAILED', reason: 'Manglende gebyrer.' } + + return null +} + +/** + * Fees must also follow Kirchhoff's first law. + */ +function validFeesSum( + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + // NOTE: Since the number of entries in a transaction is very low (max two) we can + // sum the amounts and fees in memory rather than doing a database aggregation. + const totalLedgerEntryFees = transaction.ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) + const paymentFees = transaction.payment?.fees ?? 0 + + if (totalLedgerEntryFees !== paymentFees) return { state: 'FAILED', reason: 'Ugyldig sum av gebyrer.' } + + return null +} diff --git a/src/services/ledger/transactions/operations.ts b/src/services/ledger/transactions/operations.ts new file mode 100644 index 000000000..39899a172 --- /dev/null +++ b/src/services/ledger/transactions/operations.ts @@ -0,0 +1,240 @@ +import { calculateCreditFees, calculateDebitFees } from './calculateFees' +import { determineTransactionState } from './determineTransactionState' +import { ledgerAccountOperations } from '@/services/ledger/accounts/operations' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { ServerError } from '@/services/error' +import { defineOperation } from '@/services/serviceOperation' +import { RequireNothing } from '@/auth/authorizer/RequireNothing' +import { z } from 'zod' +import type { ExpandedLedgerTransaction } from './types' +import type { Prisma } from '@/prisma-generated-pn-types' +import { LedgerTransactionPurpose } from '@/prisma-generated-pn-types' + +export const ledgerTransactionOperations = { + /** + * Reads a single transaction including its ledger entries, payment and manual transfer (if any). + */ + read: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + id: z.number(), + }), + operation: async ({ prisma, params }) => { + const transaction = await prisma.ledgerTransaction.findUniqueOrThrow({ + where: { + id: params.id, + }, + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, + }, + }) + + return transaction + } + }), + + /** + * Read several ledger transactions including its ledger entries, payment and manual transfer (if any). + */ + readPage: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: readPageInputSchemaObject( + z.number(), + z.object({ + id: z.number(), + }), + z.object({ + accountId: z.number(), + }), + ), + operation: async ({ prisma, params }) => await prisma.ledgerTransaction.findMany({ + where: { + ledgerEntries: { + some: { + ledgerAccountId: params.paging.details.accountId, + }, + }, + }, + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + ...cursorPageingSelection(params.paging.page) + }) + }), + + /** + * Tries to advance the transactions state to a terminal state. + * Also, updates the fees if possible. + */ + advance: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + id: z.number(), + }), + operation: async ({ prisma, params }) => { + let transaction: ExpandedLedgerTransaction = await ledgerTransactionOperations.read({ + params: { id: params.id }, + }) + + const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment) + + // Update credit fees if they could be calculated. + // Credit fees are null while the payment is pending, since + // the final fees are unknown until the payment is completed. + if (creditFees) { + const creditEntries = transaction.ledgerEntries.filter(entry => entry.funds > 0) + + const ledgerEntryUpdateInput = creditEntries.map(entry => ({ + where: { + id: entry.id, + }, + data: { + fees: creditFees[entry.ledgerAccountId], + }, + })) satisfies Prisma.LedgerEntryUpdateWithWhereUniqueWithoutLedgerTransactionInput[] // X_x + + // TODO: Figure out a way to not throw here. + await prisma.ledgerTransaction.update({ + where: { + id: params.id, + state: 'PENDING', // Protect against modifying a completed transaction. + }, + data: { + ledgerEntries: { + update: ledgerEntryUpdateInput, + }, + }, + }) + + transaction.ledgerEntries.forEach(entry => { + entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees + }) + } + + const balances = await ledgerAccountOperations.calculateBalances({ + params: { + ids: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), + atTransactionId: transaction.id, + }, + }) + + // Find frozen accounts, if any, among the involved ledger accounts. + const frozenAccounts = await prisma.ledgerAccount.findMany({ + where: { + id: { + in: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), + }, + frozen: true, + } + }) + const frozenAccountIds = new Set(frozenAccounts.map(account => account.id)) + + const transition = await determineTransactionState({ transaction, balances, frozenAccountIds }) + + // We use `updateMany` in stead of just `update` here because + // we don't want to throw in case the record is not found. + await prisma.ledgerTransaction.updateMany({ + where: { + id: params.id, + state: 'PENDING', // Protect against changing final state. + }, + data: transition, + }) + + transaction = await ledgerTransactionOperations.read({ + params: { id: params.id }, + }) + + return transaction + } + }), + + /** + * Create a new transaction on the ledger with the given entries and optionally + * link to the provided payment and/or manual transfer. + * + * The fees transferred are automatically calculated. + * + * The lifecycle of the transaction is automatically handled by the system. + */ + create: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO, + paramsSchema: z.object({ + purpose: z.nativeEnum(LedgerTransactionPurpose), + ledgerEntries: z.object({ + funds: z.number(), + fees: z.number().optional(), + ledgerAccountId: z.number(), + }).array(), + paymentId: z.number().optional(), + }), + operation: async ({ prisma, params }) => { + // Calculate the balance for all accounts which are going to be deducted. + const debitEntries = params.ledgerEntries.filter(entry => entry.funds < 0) + const balances = await ledgerAccountOperations.calculateBalances({ + params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, + }) + + // Check that the relevant accounts have enough balance to do the transaction. + // NOTE: This is check is only to avoid calling the db unnecessarily. + // The actual validation is handled in the `advance` function. + const hasInsufficientBalance = debitEntries.some( + entry => (balances[entry.ledgerAccountId]?.amount ?? 0) + entry.funds < 0 + ) + if (hasInsufficientBalance) { + throw new ServerError('BAD PARAMETERS', 'Konto har for lav balanse for å utføre transaksjonen.') + } + + // Calculate and set fees for the debit entries + const fees = calculateDebitFees(params.ledgerEntries, balances) + const entries = params.ledgerEntries.map(entry => ({ + ...entry, + fees: entry.fees ?? fees[entry.ledgerAccountId] ?? null + })) + + const { id } = await prisma.ledgerTransaction.create({ + data: { + purpose: params.purpose, + state: 'PENDING', + ledgerEntries: { + create: entries, + }, + paymentId: params.paymentId, + }, + select: { + id: true, + }, + }) + + const transaction: ExpandedLedgerTransaction = await ledgerTransactionOperations.advance({ + params: { + id, + }, + }) + + if (transaction.state === 'FAILED') { + // TODO: Better error message. + throw new ServerError('BAD PARAMETERS', transaction.reason ?? 'Transaksjonen feilet av ukjent årsak.') + } + + return transaction + } + }), +} diff --git a/src/services/ledger/transactions/types.ts b/src/services/ledger/transactions/types.ts new file mode 100644 index 000000000..a4de877cc --- /dev/null +++ b/src/services/ledger/transactions/types.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@/prisma-generated-pn-types' + +export type ExpandedLedgerTransaction = Prisma.LedgerTransactionGetPayload<{ + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, + } +}> diff --git a/src/services/notifications/email/mailHandler.ts b/src/services/notifications/email/mailHandler.ts index f064c20ce..324b7b044 100644 --- a/src/services/notifications/email/mailHandler.ts +++ b/src/services/notifications/email/mailHandler.ts @@ -5,7 +5,8 @@ import type SMTPPool from 'nodemailer/lib/smtp-pool' import type SMTPTransport from 'nodemailer/lib/smtp-transport' import type Mail from 'nodemailer/lib/mailer' -const PROD = process.env.NODE_ENV === 'production' +const isProd = process.env.NODE_ENV === 'production' +const isTest = process.env.NODE_ENV === 'test' type Transporter = nodemailer.Transporter @@ -43,7 +44,7 @@ class MailHandler { } async setupTransporter() { - if (PROD) { + if (isProd) { this.transporter = nodemailer.createTransport(TRANSPORT_OPTIONS) this.resolveSetup() console.log('Email setup in production') @@ -69,7 +70,7 @@ class MailHandler { } async getTestAccount(): Promise { - if (PROD) { + if (isProd) { throw new Error('TestAccount should only be used in development') } @@ -87,6 +88,8 @@ class MailHandler { } async handleNewMail() { + if (isTest) return + const transporter = await this.getTransporter() const responsePromises = [] @@ -104,7 +107,7 @@ class MailHandler { console.log(`MAIL SENT: ${response.envelope.from} -> (${response.envelope.to.join(' ')})`) console.log(response.response) - if (!PROD) { + if (!isProd) { console.log(`Preview: ${nodemailer.getTestMessageUrl(response as SMTPTransport.SentMessageInfo)}`) } }) @@ -115,7 +118,7 @@ class MailHandler { } async sendBulkMail(data: Mail.Options[]) { - const testSender = PROD ? null : (await this.getTestAccount()).user + const testSender = isProd ? null : (await this.getTestAccount()).user const queue = data .map(mailData => ({ diff --git a/src/services/shop/product/schemas.ts b/src/services/shop/product/schemas.ts index 1d4e48275..c683e5950 100644 --- a/src/services/shop/product/schemas.ts +++ b/src/services/shop/product/schemas.ts @@ -1,12 +1,12 @@ import { Zpn } from '@/lib/fields/zpn' -import { convertPrice } from '@/lib/money/convert' +import { convertAmount } from '@/lib/currency/convert' import { z } from 'zod' const baseSchema = z.object({ shopId: z.coerce.number().int(), name: z.string().min(3), description: z.string(), - price: z.coerce.number().int().min(1).transform((val) => convertPrice(val)), + price: z.coerce.number().int().min(1).transform((val) => convertAmount(val)), barcode: z.string().or(z.number()).optional(), active: Zpn.checkboxOrBoolean({ label: 'Active' }), productId: z.coerce.number().int(), diff --git a/src/services/stripeCustomers/actions.ts b/src/services/stripeCustomers/actions.ts new file mode 100644 index 000000000..db3a90bfa --- /dev/null +++ b/src/services/stripeCustomers/actions.ts @@ -0,0 +1,9 @@ +'use server' + +import { stripeCustomerOperations } from './operations' +import { makeAction } from '@/services/serverAction' + +export const createStripeCustomerSessionAction = makeAction(stripeCustomerOperations.createSession) +export const createSetupIntentAction = makeAction(stripeCustomerOperations.createSetupIntent) +export const readSavedPaymentMethodsAction = makeAction(stripeCustomerOperations.readSavedPaymentMethods) +export const deleteSavedPaymentMethodAction = makeAction(stripeCustomerOperations.deleteSavedPaymentMethod) diff --git a/src/services/stripeCustomers/operations.ts b/src/services/stripeCustomers/operations.ts new file mode 100644 index 000000000..ba22ae636 --- /dev/null +++ b/src/services/stripeCustomers/operations.ts @@ -0,0 +1,230 @@ +import { ServerError } from '@/services/error' +import { defineOperation } from '@/services/serviceOperation' +import { stripe } from '@/lib/stripe' +import { RequireUserId } from '@/auth/authorizer/RequireUserId' +import { RequireNothing } from '@/auth/authorizer/RequireNothing' +import { z } from 'zod' + +export const stripeCustomerOperations = { + /** + * If a user already has a Stripe customer associated it is returned. + * Otherwise, a new customer is created, associated in the DB, and returned. + */ + readOrCreate: defineOperation({ + // No one should ever be able to retrieve the customer id of another user. NOT EVEN ADMINS! + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + operation: async ({ params: { userId }, prisma }) => { + // We query the user table and not the StripeCustomer table here + // because we also need to fetch the user's email and name in + // case the Stripe customer does not exist and we need to create it. + const { stripeCustomer, ...user } = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + select: { + stripeCustomer: { + select: { + customerId: true, + }, + }, + email: true, + firstname: true, + lastname: true, + } + }) + + // Stripe customers have only a single name field. + const name = `${user.firstname} ${user.lastname}` + + // If the user doesn't already have a Stripe customer, we need to create one. + if (!stripeCustomer) { + // The information we store in the customer is only for out convienience + // when looking at the Stripe dashboard. This information is never actually + // used in the code. + const customer = await stripe.customers.create({ + email: user.email, + name, + metadata: { + userId: userId.toString(), + }, + }) + + // We use upsert here since two simultaneous requests could try to + // create the customer record in our database at the same time. + // (This has actually happened during testing.) + return await prisma.stripeCustomer.upsert({ + where: { + userId, + }, + create: { + userId, + customerId: customer.id, + }, + update: { + // Dont update anything. Let the first created account win. + }, + select: { + customerId: true, + }, + }) + } + + // Otherwise, we can just return the existing customer. + // But, we'll first verify that it is not deleted and that + // the stored information are up to date. + + const customer = await stripe.customers.retrieve(stripeCustomer.customerId) + + if (customer.deleted) { + // This should never happen as we never delete customers in Stripe. + throw new ServerError( + 'SERVER ERROR', + 'Stripe kunden tilknyttet brukeren er slettet. Vennligst kontakt Vevcom.', + ) + } + + if (customer.email !== user.email || customer.name !== `${user.firstname} ${user.lastname}`) { + await stripe.customers.update(stripeCustomer.customerId, { + email: user.email, + name, + metadata: { + userId: userId.toString(), + }, + }) + } + + return { + customerId: stripeCustomer.customerId, + } + } + }), + + /** + * Creates a Stripe customer session which allows the frontend to manage the saved payment methods + * for the user. This session is a one time use object and needs to be created each time it is needed. + * + * If the user does not have a Stripe customer associated it will be created automatically. + */ + createSession: defineOperation({ + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + operation: async ({ params: { userId } }) => { + const { customerId } = await stripeCustomerOperations.readOrCreate({ params: { userId } }) + + // I havent seen much about this customer session API on the internet. + // I guess it must be rather new? Here is a link to the docs in case you wonder how it works: + // https://docs.stripe.com/payments/accept-a-payment-deferred?platform=web&type=payment#save-payment-methods + // https://docs.stripe.com/api/customer_sessions/create + const customerSession = await stripe.customerSessions.create({ + components: { + payment_element: { + enabled: true, + features: { + // Show all payment methods, even those that are "limited" or "unspecified" in display. + payment_method_allow_redisplay_filters: ['always', 'limited', 'unspecified'], + // Enable avaialble payment methods to be shown for the user. + payment_method_redisplay: 'enabled', + // Max allowed by Stripe, not that anyone will ever reach this lol. + payment_method_redisplay_limit: 10, + // Allow removal of payment methods. + payment_method_remove: 'enabled', + // Allow new payment methods to be added. + payment_method_save: 'enabled', + // Specify that new payment methods will be used manually by the user. + // (As opposed to automatically by the server, for example a subscription.) + payment_method_save_usage: 'on_session', + } + } + }, + customer: customerId, + }) + + // The customer session is a one time use object, so we don't need to (nor should we) store it in the DB. + + // Only return what is needed by the frontend. + return { + customerSessionClientSecret: customerSession.client_secret, + } + } + }), + + /** + * Creates a setup intent for adding a new payment method to the user's customer account in Stripe. + */ + createSetupIntent: defineOperation({ + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + operation: async ({ params: { userId } }) => { + const customerId: string = (await stripeCustomerOperations.readOrCreate({ params: { userId } })).customerId + + const setupIntent = await stripe.setupIntents.create({ customer: customerId }) + + if (!setupIntent.client_secret) { + throw new ServerError( + 'UNKNOWN ERROR', + 'Noe gikk galt ved opprettelse av betalingsmetode.', + ) + } + + return { + setupIntentClientSecret: setupIntent.client_secret, + } + } + }), + + /** + * Returns a filtered list of saved payment methods for the user. + */ + readSavedPaymentMethods: defineOperation({ + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + operation: async ({ params: { userId } }) => { + const customerId: string = (await stripeCustomerOperations.readOrCreate({ params: { userId } })).customerId + + const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + }) + + // Filter out only the necessary information to return to the frontend. + // This is to avoid leaking any sensitive information. + const filteredPaymentMethods = paymentMethods.data.map(paymentMethod => ({ + id: paymentMethod.id, + type: paymentMethod.type, + card: paymentMethod.card && { + brand: paymentMethod.card.brand, + last4: paymentMethod.card.last4, + exp_month: paymentMethod.card.exp_month, + exp_year: paymentMethod.card.exp_year, + }, + })) + + return filteredPaymentMethods + } + }), + + /** + * Deletes (or "detaches" in Stripe lingo) a saved payment method from the user's customer account in Stripe. + */ + deleteSavedPaymentMethod: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: This should probably be authed? + paramsSchema: z.object({ + paymentMethodId: z.string(), + }), + operation: async ({ params: { paymentMethodId } }) => { + await stripe.paymentMethods.detach(paymentMethodId) + + return { + success: true + } + } + }), +} diff --git a/src/services/stripeCustomers/types.ts b/src/services/stripeCustomers/types.ts new file mode 100644 index 000000000..8bb714725 --- /dev/null +++ b/src/services/stripeCustomers/types.ts @@ -0,0 +1,7 @@ +import type Stripe from 'stripe' + +export type FilteredPaymentMethod = { + id: string, + type: Stripe.PaymentMethod.Type, + card?: Pick, +} diff --git a/src/services/users/constants.ts b/src/services/users/constants.ts index cfe0341e8..b38713c55 100644 --- a/src/services/users/constants.ts +++ b/src/services/users/constants.ts @@ -17,6 +17,7 @@ export const userFieldsToExpose = [ 'acceptedTerms', 'sex', 'allergies', + 'studentCard', 'imageConsent', 'relationshipStatus', 'relationshipStatusText', diff --git a/src/services/users/operations.ts b/src/services/users/operations.ts index 8fedd12ff..228e74ad7 100644 --- a/src/services/users/operations.ts +++ b/src/services/users/operations.ts @@ -51,7 +51,10 @@ export const userOperations = { }, }) - setTimeout(() => sendUserInvitationEmail(user), 1000) + // Don't send mail during testing. + if (process.env.NODE_ENV !== 'test') { + setTimeout(() => sendUserInvitationEmail(user), 1000) + } // The timeout is here to make sure the user is fully created before we send the email. // If we don't wait the validation token will be generated first, and will not be valid since // the user has changed after the token was generated. diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 44552b240..c99f64637 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -40,14 +40,14 @@ } } -@mixin btn($color: colors.$white) { +@mixin btn($color: colors.$white, $textColor: colors.$black) { text-align: center; text-decoration: none; font-size: fonts.$m; background: $color; padding: 2*variables.$gap; margin: variables.$gap; - color: colors.$black; + color: $textColor; border: none; transition: .5s background; &:hover { diff --git a/tests/services/context.test.ts b/tests/services/context.test.ts new file mode 100644 index 000000000..12e5029de --- /dev/null +++ b/tests/services/context.test.ts @@ -0,0 +1,47 @@ +import { prisma as globalPrisma } from '@/prisma/client' +import { defineOperation } from '@/services/serviceOperation' +import { Session } from '@/auth/session/Session' +import { RequireNothing } from '@/auth/authorizer/RequireNothing' +import { describe, test, expect } from '@jest/globals' +import type { ServiceOperationContext } from '@/services/serviceOperation' + +const returnContextInfo = defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + operation: async ({ prisma, session }) => ({ + inTransaction: '$transaction' in prisma, + apiKeyId: session.apiKeyId, + }) +}) + +const callReturnContextInfo = defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + operation: async () => returnContextInfo({}) +}) + +describe('context', () => { + const apiKeySession = Session.fromJsObject({ + apiKeyId: 0, + user: null, + memberships: [], + permissions: [], + }) + const emptySession = Session.empty() + + const contexts: ServiceOperationContext[] = [ + { session: emptySession, prisma: globalPrisma, bypassAuth: false }, + { session: apiKeySession, prisma: globalPrisma, bypassAuth: false }, + { session: emptySession, prisma: globalPrisma, bypassAuth: true }, + ] + + test.each(contexts)('should work', async (context) => { + const expected = { + inTransaction: '$transaction' in context.prisma, + apiKeyId: context.session?.apiKeyId, + } + + for (const func of [returnContextInfo, callReturnContextInfo]) { + const res = await func(context) + expect(res).toMatchObject(expected) + } + }) +}) diff --git a/tests/services/ledger/calculateFees.test.ts b/tests/services/ledger/calculateFees.test.ts new file mode 100644 index 000000000..09784945a --- /dev/null +++ b/tests/services/ledger/calculateFees.test.ts @@ -0,0 +1,46 @@ +import { feesFormula } from '@/services/ledger/transactions/calculateFees' +import { describe, expect, test } from '@jest/globals' + +type FeeInputOutput = [ + { + entryAmount: number, + totalAmount: number, + totalFees: number, + }, + number +] + +describe('ledger entry fees calculation', () => { + const expectedInputOutput: FeeInputOutput[] = [ + // "Normal" cases + [{ entryAmount: 100, totalAmount: 100, totalFees: 10 }, 10], + [{ entryAmount: 50, totalAmount: 100, totalFees: 10 }, 5], + // Flooring required + [{ entryAmount: 33, totalAmount: 100, totalFees: 10 }, 3], + [{ entryAmount: 25, totalAmount: 100, totalFees: 10 }, 2], + // Zero amount + [{ entryAmount: 0, totalAmount: 100, totalFees: 10 }, 0], + // Insufficient balance + [{ entryAmount: 100, totalAmount: 0, totalFees: 10 }, 0], + [{ entryAmount: 0, totalAmount: 0, totalFees: 10 }, 0], + // No fees + [{ entryAmount: 10, totalAmount: 10, totalFees: 0 }, 0], + [{ entryAmount: 0, totalAmount: 10, totalFees: 0 }, 0], + // Exceeding maximum + [{ entryAmount: 100, totalAmount: 10, totalFees: 9 }, 9], + [{ entryAmount: 100, totalAmount: 1, totalFees: 8 }, 8], + ] + + // NOTE: We use `toBeCloseTo` to handle +0 and -0 correctly. + // Since fees are always integers it has no effect on the precision. + + test.each(expectedInputOutput)('credit ledger entry fees', ({ entryAmount, totalAmount, totalFees }, expectedFees) => { + const fees = feesFormula(entryAmount, totalAmount, totalFees) + expect(fees).toBeCloseTo(expectedFees) + }) + + test.each(expectedInputOutput)('debit ledger entry fees', ({ entryAmount, totalAmount, totalFees }, expectedFees) => { + const fees = feesFormula(-entryAmount, totalAmount, totalFees) + expect(fees).toBeCloseTo(-expectedFees) + }) +}) diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts new file mode 100644 index 000000000..0efd6e6a8 --- /dev/null +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -0,0 +1,9 @@ +import { describe, test } from '@jest/globals' + +describe('ledger accounts', () => { + // const testEntries = [ + // [100_00, [{ amount: 100_00, fees: 10_00 }]], + // ] + test('balance', async () => { + }) +}) diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts new file mode 100644 index 000000000..be3cc0896 --- /dev/null +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -0,0 +1,142 @@ +import { allSettledOrThrow } from 'tests/utils' +import { prisma } from '@/prisma/client' +import { ledgerAccountOperations } from '@/services/ledger/accounts/operations' +import { userOperations } from '@/services/users/operations' +import { paymentOperations } from '@/services/ledger/payments/operations' +import { ledgerTransactionOperations } from '@/services/ledger/transactions/operations' +import { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' + +const TEST_ACCOUNT_COUNT = 3 +const INITIAL_BALANCE = { amount: 100_00, fees: 10_00 } + +describe('ledger transactions', () => { + const testAccountIds: number[] = [] + + // Set up ledger accounts + beforeAll(async () => { + // TODO: Create utility to create test accounts + await allSettledOrThrow(Array.from({ length: TEST_ACCOUNT_COUNT }).map(async (_, i) => { + const username = `testuser${i + 1}` + + const testUser = await userOperations.create({ + data: { + email: `${username}@example.com`, + firstname: 'Test', + lastname: 'User', + username, + }, + bypassAuth: true, + }) + + const testAccount = await ledgerAccountOperations.create({ + data: { + userId: testUser.id, + }, + bypassAuth: true, + }) + + testAccountIds.push(testAccount.id) + })) + }) + + afterEach(async () => { + await prisma.ledgerEntry.deleteMany({}) + await prisma.ledgerTransaction.deleteMany({}) + }) + + describe('external transactions', () => { + + }) + + describe('internal transactions', () => { + beforeEach(async () => { + await allSettledOrThrow(testAccountIds.map(async accountId => { + const manualPayment = await paymentOperations.create({ + params: { + funds: INITIAL_BALANCE.amount, + provider: 'MANUAL', + manualFees: INITIAL_BALANCE.fees, + }, + }) + + await ledgerTransactionOperations.create({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [{ + ledgerAccountId: accountId, + funds: INITIAL_BALANCE.amount, + }], + paymentId: manualPayment.id, + } + }) + }) + ) + }) + + const validLedgerEntries: number[][] = [ + // No entries + [], + // Transfer between two accounts + [100_00, -100_00], + // Transfer between three accounts - two debits and one credit + [100_00, -50_00, -50_00], + // Transfer between three accounts - two credits and one debit + [-100_00, 50_00, 50_00], + ] + + test.each(validLedgerEntries)('valid internal transactions', async (...entries) => { + const transaction = await ledgerTransactionOperations.create({ + params: { + ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), + purpose: 'DEPOSIT', + }, + }) + + expect(transaction).toMatchObject({ + state: 'SUCCEEDED', + }) + + const balances = await ledgerAccountOperations.calculateBalances({ + params: { ids: testAccountIds }, + }) + + entries.forEach((amount, i) => { + const accountId = testAccountIds[i] + const balance = balances[accountId] + + expect(balance.amount).toBe(INITIAL_BALANCE.amount + amount) + }) + }) + + const invalidLedgerEntries: number[][] = [ + // Only one entry + [100], + [-100], + // Non-zero sum + [100_00, -99_00], + [-1919, 1000_00], + [100_00, -50_00, -50_01], + ] + + test.each(invalidLedgerEntries)('invalid internal transactions', async (...entries) => { + const transactionPromise = ledgerTransactionOperations.create({ + params: { + ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), + purpose: 'DEPOSIT', + }, + }) + + await expect(transactionPromise).rejects.toThrow() + + const balances = await ledgerAccountOperations.calculateBalances({ + params: { ids: testAccountIds }, + }) + + testAccountIds.forEach(accountId => { + const balance = balances[accountId] + + expect(balance.amount).toBe(INITIAL_BALANCE.amount) + }) + }) + }) +}) diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts new file mode 100644 index 000000000..6a8da4334 --- /dev/null +++ b/tests/services/ledger/payments.test.ts @@ -0,0 +1,89 @@ + +// TODO: +// jest.mock('@/lib/stripe', () => ({ +// stripe: { +// paymentIntent: { +// create: jest.fn(), +// cancel: jest.fn(), +// }, +// }, +// })) + +import { Smorekopp } from '@/services/error' +import { paymentOperations } from '@/services/ledger/payments/operations' +import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' +import { prisma } from '@/prisma/client' +import { PaymentProvider } from '@/prisma-generated-pn-types' +import { describe, test, expect, beforeEach, beforeAll } from '@jest/globals' +import type Stripe from 'stripe' + +const TEST_PAYMENT_DEFAULTS = { + ledgerAccountId: 0, + amount: 100, // 1 kr + provider: 'STRIPE', + description: 'Test betaling', + descriptor: 'Test betaling', +} + +describe.skip('payments', () => { + beforeAll(async () => { + await prisma.ledgerAccount.createMany({ + data: Array(2).fill({ type: 'USER' }), + }) + }) + + beforeEach(async () => { + await prisma.ledgerEntry.deleteMany() + await prisma.payment.deleteMany() + }) + + test.each([PaymentProvider.MANUAL, PaymentProvider.STRIPE])('payment flow', async (provider) => { + let payment = await paymentOperations.create({ + params: { + ...TEST_PAYMENT_DEFAULTS, + provider, + }, + }) + + if (payment.state === 'PENDING') { + payment = await paymentOperations.initiate({ + params: { + paymentId: payment.id, + }, + }) + + stripeWebhookCallback({ + type: 'payment_intent.succeeded', + data: { + object: { + amount: payment.amount, + latest_charge: { + balance_transaction: { + fee: payment.amount / 100, + }, + }, + }, + }, + } as Stripe.PaymentIntentSucceededEvent) + } + + expect(payment).toMatchObject({ + state: 'SUCCEEDED', + }) + }) + + test('initiate manual payment', async () => { + const payment = await paymentOperations.create({ + params: { + ledgerAccountId: 0, + amount: 100, // 1 kr + provider: 'MANUAL', + description: 'Test betaling', + descriptor: 'Test betaling', + }, + }) + + expect(paymentOperations.initiate({ params: { paymentId: payment.id } })) + .rejects.toThrow(new Smorekopp('BAD DATA')) + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts index 2caefe6ce..48c77f9bc 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,11 @@ import seed from '@/prisma/seeder/src/seeder' -import { beforeAll } from '@jest/globals' +import { beforeAll, jest } from '@jest/globals' + +// React email rendering uses dynamic imports which are not supported in Jest by default. +// We mock the render function to avoid issues during tests. +jest.mock('@react-email/render', () => ({ + render: jest.fn().mockImplementation(() => 'Email rendering is disabled during tests.'), +})) beforeAll( async () => await seed(false, false, false), diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 000000000..74894e617 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,27 @@ +/** + * Waits for all promises to settle and returns their results. + * Throws an error if any promise rejects, with `cause` containing all rejection reasons. + * + * This is useful for ensuring that all asynchronous operations complete before proceeding. + * Specifically, in cases where multiple database operations are ongoing even if one fails. + * + * @param promises Array of promises to wait for. + * @returns Resolved values of all fulfilled promises. + * @throws {Error} If any promise rejects. + */ +export async function allSettledOrThrow(promises: Promise[]): Promise { + const results = await Promise.allSettled(promises) + + const rejected = results.filter(result => result.status === 'rejected') + rejected.forEach(result => { + console.error('Promise rejected:', result.reason) + }) + if (rejected.length > 0) { + throw new Error('Some promises rejected.', { + cause: rejected.map(result => result.reason), + }) + } + + const fulfilled = results.filter(result => result.status === 'fulfilled') + return fulfilled.map(result => result.value) +}