From e99a835f7cd22e684cd0e24ba58c15961af85718 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Thu, 26 Oct 2023 07:25:09 +0200 Subject: [PATCH 01/18] improve entities --- packages/server/src/api/endpoints/customer.ts | 41 +++ packages/server/src/api/endpoints/invoice.ts | 12 +- .../server/src/api/endpoints/payment.test.ts | 2 +- .../src/api/endpoints/payment_method.ts | 2 +- .../src/api/endpoints/subscription.test.ts | 6 +- .../server/src/api/endpoints/subscription.ts | 6 +- packages/server/src/api/schema.ts | 4 +- packages/server/src/database.ts | 21 +- packages/server/src/entities/customer.ts | 6 + packages/server/src/entities/invoice.ts | 18 +- packages/server/src/entities/payment.ts | 4 +- packages/server/src/entities/subscription.ts | 8 +- packages/server/src/loop.test.ts | 96 +++++-- packages/server/src/loop.ts | 253 +++++++++--------- ...xt.ts => 000_alter_column_logo_to_text.ts} | 0 ...eplace_start_and_end_with_date_invoice.ts} | 0 ...002_set_next_payment_for_subscriptions.ts} | 0 .../migrations/003_update_payment_status.ts | 20 ++ ...add_customer_make_subscription_optional.ts | 69 +++++ ...te_status_optional_invoice_subscription.ts | 67 +++++ packages/server/src/utils.test.ts | 11 +- packages/server/src/utils.ts | 5 - packages/server/test/fixtures.ts | 7 +- 23 files changed, 483 insertions(+), 175 deletions(-) rename packages/server/src/migrations/{alter_column_logo_to_text.ts => 000_alter_column_logo_to_text.ts} (100%) rename packages/server/src/migrations/{replace_start_and_end_with_date_invoice.ts => 001_replace_start_and_end_with_date_invoice.ts} (100%) rename packages/server/src/migrations/{set_next_payment_for_subscriptions.ts => 002_set_next_payment_for_subscriptions.ts} (100%) create mode 100644 packages/server/src/migrations/003_update_payment_status.ts create mode 100644 packages/server/src/migrations/004_update_invoice_add_customer_make_subscription_optional.ts create mode 100644 packages/server/src/migrations/005_update_status_optional_invoice_subscription.ts diff --git a/packages/server/src/api/endpoints/customer.ts b/packages/server/src/api/endpoints/customer.ts index cb3a9d3..84dcaa4 100644 --- a/packages/server/src/api/endpoints/customer.ts +++ b/packages/server/src/api/endpoints/customer.ts @@ -337,4 +337,45 @@ export async function customerEndpoints(server: FastifyInstance): Promise await reply.send(_subscriptions); }, }); + + server.get('/customer/:customerId/invoice', { + schema: { + operationId: 'listCustomerInvoices', + summary: 'List all invoices of a customer', + tags: ['invoice', 'customer'], + params: { + type: 'object', + required: ['customerId'], + additionalProperties: false, + properties: { + customerId: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'array', + items: { + $ref: 'Invoice', + }, + }, + 404: { + $ref: 'ErrorResponse', + }, + }, + }, + handler: async (request, reply) => { + const project = await getProjectFromRequest(request); + + const { customerId } = request.params as { customerId: string }; + + const customer = await database.customers.findOne({ _id: customerId, project }); + if (!customer) { + return reply.code(404).send({ error: 'Customer not found' }); + } + + const invoices = await database.invoices.find({ project, customer }, { populate: ['items'] }); + + await reply.send(invoices.map((invoice) => invoice.toJSON())); + }, + }); } diff --git a/packages/server/src/api/endpoints/invoice.ts b/packages/server/src/api/endpoints/invoice.ts index da661a4..cedefef 100644 --- a/packages/server/src/api/endpoints/invoice.ts +++ b/packages/server/src/api/endpoints/invoice.ts @@ -125,17 +125,15 @@ export async function invoiceEndpoints(server: FastifyInstance): Promise { return reply.code(400).send({ error: 'Missing invoiceId' }); } - const invoice = await database.invoices.findOne({ _id: invoiceId, project }, { populate: ['items'] }); + const invoice = await database.invoices.findOne( + { _id: invoiceId, project }, + { populate: ['items', 'subscription'] }, + ); if (!invoice) { return reply.code(404).send({ error: 'Invoice not found' }); } - const subscription = await database.subscriptions.findOne(invoice.subscription); - if (!subscription) { - return reply.code(404).send({ error: 'Subscription not found' }); - } - - const customer = await database.customers.findOne(subscription.customer); + const customer = await database.customers.findOne(invoice.customer); if (!customer) { return reply.code(404).send({ error: 'Customer not found' }); } diff --git a/packages/server/src/api/endpoints/payment.test.ts b/packages/server/src/api/endpoints/payment.test.ts index 8019b11..ee0a1ac 100644 --- a/packages/server/src/api/endpoints/payment.test.ts +++ b/packages/server/src/api/endpoints/payment.test.ts @@ -30,7 +30,7 @@ describe('Payment webhook endpoints', () => { amount: 1, currency: 'EUR', customer: testData.customer, - status: 'pending', + status: 'processing', type: 'verification', description: 'Verification payment', }); diff --git a/packages/server/src/api/endpoints/payment_method.ts b/packages/server/src/api/endpoints/payment_method.ts index 2ab4c2b..4b117ce 100644 --- a/packages/server/src/api/endpoints/payment_method.ts +++ b/packages/server/src/api/endpoints/payment_method.ts @@ -81,7 +81,7 @@ export async function paymentMethodEndpoints(server: FastifyInstance): Promise { expect(persistAndFlush).toHaveBeenCalledTimes(1); const [[, subscription]] = persistAndFlush.mock.lastCall as [[Customer, Subscription]]; expect(responseData._id).toStrictEqual(subscription._id); - expect(new Date(responseData.nextPayment)).toStrictEqual( - dayjs(date).add(1, 'month').startOf('day').add(1, 'hour').toDate(), + expect(dayjs(responseData.currentPeriodStart).toDate()).toStrictEqual(date); + expect(dayjs(responseData.currentPeriodEnd).toDate()).toStrictEqual( + // set ms to 0 because db does not store ms + dayjs(date).add(1, 'month').subtract(1, 'day').endOf('day').set('millisecond', 0).toDate(), ); }); diff --git a/packages/server/src/api/endpoints/subscription.ts b/packages/server/src/api/endpoints/subscription.ts index 46af0f7..732053a 100644 --- a/packages/server/src/api/endpoints/subscription.ts +++ b/packages/server/src/api/endpoints/subscription.ts @@ -3,7 +3,7 @@ import { FastifyInstance } from 'fastify'; import { getProjectFromRequest } from '~/api/helpers'; import { database } from '~/database'; import { Subscription } from '~/entities'; -import { getActiveUntilDate, getNextPaymentDate } from '~/utils'; +import { getActiveUntilDate, getPeriodFromAnchorDate } from '~/utils'; // eslint-disable-next-line @typescript-eslint/require-await export async function subscriptionEndpoints(server: FastifyInstance): Promise { @@ -76,11 +76,13 @@ export async function subscriptionEndpoints(server: FastifyInstance): Promise({ entity: () => Subscription, mappedBy: (subscription: Subscription) => subscription.customer, }, + invoices: { + reference: ReferenceType.ONE_TO_MANY, + entity: () => Invoice, + mappedBy: (invoice: Invoice) => invoice.customer, + }, project: { reference: ReferenceType.MANY_TO_ONE, entity: () => Project, diff --git a/packages/server/src/entities/invoice.ts b/packages/server/src/entities/invoice.ts index b2a651f..df891f6 100644 --- a/packages/server/src/entities/invoice.ts +++ b/packages/server/src/entities/invoice.ts @@ -1,13 +1,19 @@ import { Collection, EntitySchema, ReferenceType } from '@mikro-orm/core'; import { v4 } from 'uuid'; +import { Customer } from '~/entities/customer'; import { InvoiceItem } from '~/entities/invoice_item'; import { Currency, Payment } from '~/entities/payment'; import { Project } from '~/entities/project'; import { Subscription } from '~/entities/subscription'; import dayjs from '~/lib/dayjs'; -export type InvoiceStatus = 'draft' | 'pending' | 'paid' | 'failed'; +export type InvoiceStatus = + | 'draft' // invoice is not yet ready to be charged + | 'pending' // invoice is waiting to be charged / picked up by loop + | 'processing' // invoice is waiting for the payment to be processed + | 'paid' // invoice is paid + | 'failed'; // invoice failed to be paid export class Invoice { _id: string = v4(); @@ -15,7 +21,8 @@ export class Invoice { sequentialId!: number; items = new Collection(this); status: InvoiceStatus = 'draft'; - subscription!: Subscription; + subscription?: Subscription; + customer!: Customer; currency!: Currency; vatRate!: number; payment?: Payment; @@ -75,7 +82,8 @@ export class Invoice { toJSON(): Invoice { return { ...this, - subscription: this.subscription.toJSON(), + subscription: this.subscription?.toJSON(), + customer: this.customer.toJSON(), vatAmount: this.vatAmount, amount: this.amount, totalAmount: this.totalAmount, @@ -101,6 +109,10 @@ export const invoiceSchema = new EntitySchema({ reference: ReferenceType.MANY_TO_ONE, entity: () => Subscription, }, + customer: { + reference: ReferenceType.MANY_TO_ONE, + entity: () => Customer, + }, payment: { reference: ReferenceType.ONE_TO_ONE, entity: () => Payment, diff --git a/packages/server/src/entities/payment.ts b/packages/server/src/entities/payment.ts index ead434f..d3ab9f3 100644 --- a/packages/server/src/entities/payment.ts +++ b/packages/server/src/entities/payment.ts @@ -5,13 +5,13 @@ import { Customer } from '~/entities/customer'; import { Invoice } from '~/entities/invoice'; import { Subscription } from '~/entities/subscription'; -export type PaymentStatus = 'pending' | 'paid' | 'failed'; +export type PaymentStatus = 'processing' | 'paid' | 'failed'; export type Currency = 'EUR'; export class Payment { _id: string = v4(); - status: PaymentStatus = 'pending'; + status: PaymentStatus = 'processing'; type!: 'recurring' | 'one-off' | 'verification'; currency!: Currency; customer!: Customer; diff --git a/packages/server/src/entities/subscription.ts b/packages/server/src/entities/subscription.ts index e78d957..9bb682f 100644 --- a/packages/server/src/entities/subscription.ts +++ b/packages/server/src/entities/subscription.ts @@ -10,10 +10,11 @@ import { SubscriptionPeriod } from '~/entities/subscription_period'; export class Subscription { _id: string = v4(); anchorDate!: Date; // first date a user ever started a subscription for the object - status: 'active' | 'error' = 'active'; + status: 'processing' | 'active' | 'error' = 'active'; error?: string; lastPayment?: Date; - nextPayment!: Date; + currentPeriodStart!: Date; + currentPeriodEnd!: Date; customer!: Customer; changes = new Collection(this); createdAt: Date = new Date(); @@ -72,7 +73,8 @@ export const subscriptionSchema = new EntitySchema({ status: { type: 'string', default: 'active' }, error: { type: 'string', nullable: true }, lastPayment: { type: Date, nullable: true }, - nextPayment: { type: Date }, + currentPeriodStart: { type: Date }, + currentPeriodEnd: { type: Date }, customer: { reference: ReferenceType.MANY_TO_ONE, entity: () => Customer, diff --git a/packages/server/src/loop.test.ts b/packages/server/src/loop.test.ts index 3a188de..62bae6f 100644 --- a/packages/server/src/loop.test.ts +++ b/packages/server/src/loop.test.ts @@ -6,9 +6,8 @@ import * as databaseExports from '~/database'; import { getFixtures } from '$/fixtures'; import { Customer, Invoice, Payment, Subscription } from './entities'; -import { chargeCustomerInvoice, chargeSubscriptions } from './loop'; +import { chargeCustomerInvoice, chargePendingInvoices, chargeSubscriptions } from './loop'; import { getPaymentProvider } from './payment_providers'; -import { getPeriodFromAnchorDate } from './utils'; describe('Loop', () => { beforeAll(async () => { @@ -25,7 +24,7 @@ describe('Loop', () => { await databaseExports.database.init(); }); - it('should loop and charge for due subscriptions', async () => { + it('should loop and create invoices for due subscriptions', async () => { // given const testData = getFixtures(); @@ -81,8 +80,8 @@ describe('Loop', () => { } as unknown as databaseExports.Database); const subscription = testData.subscription; - const nextPayment = dayjs(subscription.nextPayment); - vi.setSystemTime(nextPayment.add(1, 'day').toDate()); + const currentPeriodEnd = dayjs(subscription.currentPeriodEnd); + vi.setSystemTime(currentPeriodEnd.add(1, 'day').toDate()); // when await chargeSubscriptions(); @@ -96,19 +95,86 @@ describe('Loop', () => { const itemAmounts = [(14 / 31) * 12 * 12.34, (5 / 31) * 15 * 12.34, (12 / 31) * 15 * 5.43]; expect(invoice?.amount).toStrictEqual(itemAmounts.reduce((sum, amount) => sum + Invoice.roundPrice(amount), 0)); + const updatedSubscription = Array.from(db.subscriptions.values()).at(-1); + expect(updatedSubscription?.currentPeriodStart).toStrictEqual( + currentPeriodEnd.add(1, 'day').startOf('day').toDate(), + ); + expect(updatedSubscription?.currentPeriodEnd).toStrictEqual(currentPeriodEnd.add(1, 'month').endOf('day').toDate()); + }); + + it('should loop and charge pending invoices', async () => { + // given + const testData = getFixtures(); + + const { customer } = testData; + customer.activePaymentMethod = testData.paymentMethod; + + const paymentProvider = getPaymentProvider(testData.project); + if (!paymentProvider) { + throw new Error('Payment provider not configured'); + } + await paymentProvider.createCustomer(customer); + + const db = { + invoices: new Map(), + payments: new Map(), + customers: new Map(), + subscriptions: new Map(), + }; + + db.invoices.set(testData.invoice._id, testData.invoice); + + vi.spyOn(databaseExports, 'database', 'get').mockReturnValue({ + em: { + persistAndFlush: async (_items: unknown[] | unknown) => { + const items = Array.isArray(_items) ? _items : [_items]; + + for (const item of items) { + // console.log('set', item._id, item.constructor.name); + if (item instanceof Invoice) { + db.invoices.set(item._id, item); + } else if (item instanceof Payment) { + db.payments.set(item._id, item); + } else if (item instanceof Customer) { + db.customers.set(item._id, item); + } else if (item instanceof Subscription) { + db.subscriptions.set(item._id, item); + } + } + + return Promise.resolve(); + }, + populate() { + return Promise.resolve(); + }, + }, + subscriptions: { + find: () => Array.from(db.subscriptions.values()), + }, + invoices: { + find: () => Array.from(db.invoices.values()), + }, + } as unknown as databaseExports.Database); + + // when + await chargePendingInvoices(); + + // then expect(db.payments.size).toBe(1); const payment = Array.from(db.payments.values())[0]; + + expect(db.invoices.size).toBe(1); + const invoice = Array.from(db.invoices.values()).at(0); + expect(payment).toBeDefined(); - expect(payment.status).toStrictEqual('pending'); + expect(payment.status).toStrictEqual('processing'); expect(payment.amount).toStrictEqual(invoice?.totalAmount); - const updatedSubscription = Array.from(db.subscriptions.values()).at(-1); - expect(updatedSubscription?.nextPayment).toStrictEqual( - nextPayment.add(1, 'month').startOf('day').add(1, 'hour').toDate(), - ); + expect(invoice).toBeDefined(); + expect(invoice?.status).toStrictEqual('processing'); }); - it('should apply customer balance to invoice', async () => { + it('should apply customer balance when charing', async () => { // given const testData = getFixtures(); const { invoice, customer } = testData; @@ -134,10 +200,9 @@ describe('Loop', () => { const balance = 123; customer.balance = balance; const invoiceAmount = invoice.amount; - const billingPeriod = getPeriodFromAnchorDate(invoice.date, invoice.date); // when - await chargeCustomerInvoice({ billingPeriod, invoice, customer }); + await chargeCustomerInvoice(invoice); // then expect(persistAndFlush).toBeCalledTimes(2); @@ -150,7 +215,7 @@ describe('Loop', () => { ); }); - it('should apply customer balance to invoice and keep remaining balance', async () => { + it('should apply customer balance when charging and keep remaining balance', async () => { // given const testData = getFixtures(); const { invoice, customer } = testData; @@ -176,10 +241,9 @@ describe('Loop', () => { const balance = 1000; customer.balance = balance; const invoiceAmount = invoice.amount; - const billingPeriod = getPeriodFromAnchorDate(invoice.date, invoice.date); // when - await chargeCustomerInvoice({ billingPeriod, invoice, customer }); + await chargeCustomerInvoice(invoice); // then expect(persistAndFlush).toBeCalledTimes(2); diff --git a/packages/server/src/loop.ts b/packages/server/src/loop.ts index 2184788..565d396 100644 --- a/packages/server/src/loop.ts +++ b/packages/server/src/loop.ts @@ -1,21 +1,18 @@ import { database } from '~/database'; -import { Customer, Invoice, InvoiceItem, Payment, SubscriptionPeriod } from '~/entities'; +import { Invoice, InvoiceItem, Payment, SubscriptionPeriod } from '~/entities'; import dayjs from '~/lib/dayjs'; import { log } from '~/log'; import { getPaymentProvider } from '~/payment_providers'; -import { getNextPaymentDate, getPreviousPeriod } from '~/utils'; - -const pageSize = 10; - -export async function chargeCustomerInvoice({ - billingPeriod, - customer, - invoice, -}: { - billingPeriod: { start: Date; end: Date }; - customer: Customer; - invoice: Invoice; -}): Promise { +import { getNextPeriod } from '~/utils'; + +const pageLimit = 10; // fetch 10 items at a time and process them + +export async function chargeCustomerInvoice(invoice: Invoice): Promise { + const { customer } = invoice; + if (!customer) { + throw new Error(`Invoice '${invoice._id}' has no customer`); + } + await database.em.populate(customer, ['activePaymentMethod']); if (!customer.activePaymentMethod) { log.error({ invoiceId: invoice._id, customerId: customer._id }, 'Customer has no active payment method'); @@ -39,28 +36,11 @@ export async function chargeCustomerInvoice({ // skip negative amounts (credits) and zero amounts const amount = Invoice.roundPrice(invoice.totalAmount); - if (amount <= 0) { - invoice.status = 'paid'; - log.debug({ invoiceId: invoice._id, amount }, 'Invoice set to paid as the amount is 0 or negative'); - // TODO: should we create a fake payment? - await database.em.persistAndFlush([invoice]); - return; - } - let paymentDescription = `Invoice ${invoice.number}`; - - const { subscription } = invoice; - if (subscription) { - const formatDate = (d: Date) => dayjs(d).format('DD.MM.YYYY'); - paymentDescription = `Subscription for period ${formatDate(billingPeriod.start)} - ${formatDate( - billingPeriod.end, - )}`; // TODO: think about text - log.debug({ subscriptionId: subscription._id, paymentDescription }, 'Subscription payment'); - } + const paymentDescription = `Invoice ${invoice.number}`; - const { project } = customer; + const { project } = invoice; if (!project) { - log.error({ subscriptionId: subscription._id, paymentDescription }, 'Subscription payment'); throw new Error(`Project for '${customer._id}' not configured`); } @@ -69,28 +49,37 @@ export async function chargeCustomerInvoice({ currency: project.currency, customer, type: 'recurring', - status: 'pending', + status: 'processing', description: paymentDescription, - subscription, + subscription: invoice.subscription, }); - invoice.status = 'pending'; invoice.payment = payment; - const paymentProvider = getPaymentProvider(project); - if (!paymentProvider) { - log.error({ projectId: project._id, invoiceId: invoice._id }, 'Payment provider for project not configured'); - throw new Error(`Payment provider for '${project._id}' not configured`); + if (amount > 0) { + const paymentProvider = getPaymentProvider(project); + if (!paymentProvider) { + log.error({ projectId: project._id, invoiceId: invoice._id }, 'Payment provider for project not configured'); + throw new Error(`Payment provider for '${project._id}' not configured`); + } + + await paymentProvider.chargeBackgroundPayment({ payment, project }); + } else { + // set invoice and payment to paid immediately if the amount is 0 or negative + invoice.status = 'paid'; + payment.status = 'paid'; + log.debug( + { invoiceId: invoice._id, paymentId: payment._id, amount }, + 'Invoice and payment set to paid as the amount is 0 or negative', + ); } - await paymentProvider.chargeBackgroundPayment({ payment, project }); await database.em.persistAndFlush([invoice, payment]); log.debug({ paymentId: payment._id }, 'Payment created & charged'); } let isChargingSubscriptions = false; - export async function chargeSubscriptions(): Promise { if (isChargingSubscriptions) { return; @@ -99,87 +88,77 @@ export async function chargeSubscriptions(): Promise { try { const now = new Date(); - let page = 0; - - // eslint-disable-next-line no-constant-condition - while (true) { - // get due subscriptions - const subscriptions = await database.subscriptions.find( - { nextPayment: { $lte: now }, status: 'active' }, - { - limit: pageSize, - offset: page * pageSize, - populate: ['project', 'changes', 'customer'], - }, - ); - - for await (const subscription of subscriptions) { - // TODO: should we lock subscription processing? - - const { project, customer } = subscription; - - const billingPeriod = getPreviousPeriod(subscription.nextPayment, subscription.anchorDate); - - try { - const existingInvoices = await database.invoices.find({ - date: { $gte: billingPeriod.start, $lte: billingPeriod.end }, - subscription, - }); - if (existingInvoices.length > 0) { - log.error( - { subscriptionId: subscription._id, customerId: customer._id }, - 'Invoice for period already exists', - ); - // TODO: should we just ignore this? currently this sets the subscription to error state? - throw new Error('Invoice for period already exists'); - } - - customer.invoiceCounter += 1; - - const invoice = new Invoice({ - currency: project.currency, - vatRate: project.vatRate, - sequentialId: customer.invoiceCounter, - subscription, - project, - status: 'draft', - date: new Date(), - }); - - const period = new SubscriptionPeriod(subscription, billingPeriod.start, billingPeriod.end); - period.getInvoiceItems().forEach((item) => { - invoice.items.add(item); - }); - - await database.em.persistAndFlush([customer, invoice]); - - await chargeCustomerInvoice({ billingPeriod, customer, invoice }); - - subscription.nextPayment = getNextPaymentDate(subscription.nextPayment, subscription.anchorDate); - await database.em.persistAndFlush([subscription]); - - log.debug( - { - customerId: customer._id, - nextPayment: subscription.nextPayment, - subscriptionId: subscription._id, - invoiceCounter: customer.invoiceCounter, - }, - 'Subscription charged & invoiced', + + // get due subscriptions + const subscriptions = await database.subscriptions.find( + // use an 1 hour buffer before creating an invoice + { currentPeriodEnd: { $lte: dayjs(now).subtract(1, 'hour').toDate() }, status: 'active' }, + { + limit: pageLimit, + populate: ['project', 'changes', 'customer'], + }, + ); + + for await (const subscription of subscriptions) { + // TODO: should we lock subscription processing? + + const { project, customer } = subscription; + + const billingPeriod = { start: subscription.currentPeriodStart, end: subscription.currentPeriodEnd }; + + try { + const existingInvoices = await database.invoices.find({ + date: { $gte: billingPeriod.start, $lte: billingPeriod.end }, + subscription, + }); + if (existingInvoices.length > 0) { + log.error( + { subscriptionId: subscription._id, customerId: customer._id }, + 'Invoice for period already exists', ); - } catch (e) { - log.error('Error while subscription charging:', e); - subscription.status = 'error'; - subscription.error = (e as Error)?.message || (e as string); - await database.em.persistAndFlush([subscription]); + // TODO: should we just ignore this? currently this sets the subscription to error state? + throw new Error('Invoice for period already exists'); } - } - if (subscriptions.length < pageSize) { - break; + customer.invoiceCounter += 1; + + const invoice = new Invoice({ + currency: project.currency, + vatRate: project.vatRate, + sequentialId: customer.invoiceCounter, + subscription, + project, + status: 'pending', + date: new Date(), + }); + + const period = new SubscriptionPeriod(subscription, billingPeriod.start, billingPeriod.end); + period.getInvoiceItems().forEach((item) => { + invoice.items.add(item); + }); + + const nextPeriod = getNextPeriod(billingPeriod.start, subscription.anchorDate); + subscription.currentPeriodStart = nextPeriod.start; + subscription.currentPeriodEnd = nextPeriod.end; + + await database.em.persistAndFlush([subscription, customer, invoice]); + + log.debug( + { + customerId: customer._id, + currentPeriodStart: subscription.currentPeriodStart, + currentPeriodEnd: subscription.currentPeriodEnd, + subscriptionId: subscription._id, + invoiceCounter: customer.invoiceCounter, + }, + 'Subscription charged & invoiced', + ); + } catch (e) { + log.error('Error while subscription charging:', e); + subscription.status = 'error'; + subscription.error = (e as Error)?.message || (e as string); + await database.em.persistAndFlush([subscription]); } - - page += 1; } } catch (e) { log.error(e, 'An error occurred while charging subscriptions'); @@ -188,8 +167,38 @@ export async function chargeSubscriptions(): Promise { isChargingSubscriptions = false; } +let isChargingPendingInvoices = false; +export async function chargePendingInvoices(): Promise { + if (isChargingPendingInvoices) { + return; + } + + isChargingPendingInvoices = true; + + try { + const invoices = await database.invoices.find( + { status: 'pending', payment: null }, + { + limit: pageLimit, + populate: ['project', 'payment', 'subscription', 'customer'], + }, + ); + + for await (const invoice of invoices) { + invoice.status = 'processing'; + await database.em.persistAndFlush([invoice]); + + await chargeCustomerInvoice(invoice); + } + } catch (e) { + log.error(e, 'An error occurred while charging invoices'); + } + + isChargingPendingInvoices = false; +} + export function startLoops(): void { - // charge subscriptions for past periods - void chargeSubscriptions(); - setInterval(() => void chargeSubscriptions(), 1000); // TODO: increase loop time + const loopInterval = 1000 * 1; // TODO: increase loop time + setInterval(() => void chargeSubscriptions(), loopInterval); + setInterval(() => void chargePendingInvoices(), loopInterval); } diff --git a/packages/server/src/migrations/alter_column_logo_to_text.ts b/packages/server/src/migrations/000_alter_column_logo_to_text.ts similarity index 100% rename from packages/server/src/migrations/alter_column_logo_to_text.ts rename to packages/server/src/migrations/000_alter_column_logo_to_text.ts diff --git a/packages/server/src/migrations/replace_start_and_end_with_date_invoice.ts b/packages/server/src/migrations/001_replace_start_and_end_with_date_invoice.ts similarity index 100% rename from packages/server/src/migrations/replace_start_and_end_with_date_invoice.ts rename to packages/server/src/migrations/001_replace_start_and_end_with_date_invoice.ts diff --git a/packages/server/src/migrations/set_next_payment_for_subscriptions.ts b/packages/server/src/migrations/002_set_next_payment_for_subscriptions.ts similarity index 100% rename from packages/server/src/migrations/set_next_payment_for_subscriptions.ts rename to packages/server/src/migrations/002_set_next_payment_for_subscriptions.ts diff --git a/packages/server/src/migrations/003_update_payment_status.ts b/packages/server/src/migrations/003_update_payment_status.ts new file mode 100644 index 0000000..f673b59 --- /dev/null +++ b/packages/server/src/migrations/003_update_payment_status.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +type Payment = { + _id: string; + status: 'pending' | 'processing'; +}; + +export class MigrationPaymentStatusFromPendingToProcessing extends Migration { + async up(): Promise { + await this.ctx?.table('payment').where({ status: 'pending' }).update({ + status: 'processing', + }); + } + + async down(): Promise { + await this.ctx?.table('pending').where({ status: 'processing' }).update({ + status: 'pending', + }); + } +} diff --git a/packages/server/src/migrations/004_update_invoice_add_customer_make_subscription_optional.ts b/packages/server/src/migrations/004_update_invoice_add_customer_make_subscription_optional.ts new file mode 100644 index 0000000..013baa9 --- /dev/null +++ b/packages/server/src/migrations/004_update_invoice_add_customer_make_subscription_optional.ts @@ -0,0 +1,69 @@ +import { Migration } from '@mikro-orm/migrations'; + +type Invoice = { + _id: string; + subscription__id: string; + customer__id: string; +}; + +type Subscription = { + _id: string; + customer__id: string; + anchor_date: Date; + next_payment: Date; + current_period_start: Date; + current_period_end: Date; +}; + +export class MigrationUpdateInvoiceAddCustomerAndAllowOptionalSubscription extends Migration { + async up(): Promise { + if (await this.ctx?.schema.hasColumn('invoice', 'customer__id')) { + return; + } + + // add customer to invoices directly (previously it was only set on a linked subscription) + // and make subscription optional + await this.ctx?.schema.alterTable('invoice', (table) => { + table.uuid('customer__id').nullable(); + table.setNullable('subscription__id'); + }); + + const invoices = await this.ctx?.table('invoice').select(); + for await (const invoice of invoices || []) { + const subscription = await this.ctx + ?.table('subscription') + .where({ _id: invoice.subscription__id }) + .first(); + + if (!subscription) { + throw new Error(`Subscription ${invoice.subscription__id} not found although it is required`); + } + + await this.ctx?.table('invoice').where({ _id: invoice._id }).update({ + customer__id: subscription.customer__id, + }); + } + + await this.ctx?.schema.alterTable('invoice', (table) => { + table.dropNullable('customer__id'); + }); + } + + async down(): Promise { + if (!(await this.ctx?.schema.hasColumn('invoice', 'customer__id'))) { + return; + } + + await this.ctx + ?.table('invoice') + .where({ + subscription__id: undefined, + }) + .delete(); + + await this.ctx?.schema.alterTable('invoice', (table) => { + table.dropNullable('subscription__id'); + table.dropColumn('customer__id'); + }); + } +} diff --git a/packages/server/src/migrations/005_update_status_optional_invoice_subscription.ts b/packages/server/src/migrations/005_update_status_optional_invoice_subscription.ts new file mode 100644 index 0000000..0f8840f --- /dev/null +++ b/packages/server/src/migrations/005_update_status_optional_invoice_subscription.ts @@ -0,0 +1,67 @@ +import { Migration } from '@mikro-orm/migrations'; +import dayjs from 'dayjs'; + +import { getPreviousPeriod } from '~/utils'; + +type Subscription = { + _id: string; + customer__id: string; + anchor_date: Date; + next_payment: Date; + current_period_start: Date; + current_period_end: Date; +}; + +export class MigrationReplaceNextPaymentWithCurrentPeriod extends Migration { + async up(): Promise { + if (await this.ctx?.schema.hasColumn('subscription', 'current_period_start')) { + return; + } + + await this.ctx?.schema.alterTable('subscription', (table) => { + table.date('current_period_start').nullable(); + table.date('current_period_end').nullable(); + }); + + const subscriptions = await this.ctx?.table('subscription').select(); + for await (const subscription of subscriptions || []) { + const currentPeriod = getPreviousPeriod(subscription.next_payment, subscription.anchor_date); + + await this.ctx?.table('subscription').where({ _id: subscription._id }).update({ + current_period_start: currentPeriod.start, + current_period_end: currentPeriod.end, + }); + } + + await this.ctx?.schema.alterTable('subscription', (table) => { + table.dropColumn('next_payment'); + table.dropNullable('current_period_start'); + table.dropNullable('current_period_end'); + }); + } + + async down(): Promise { + if (!(await this.ctx?.schema.hasColumn('subscription', 'current_period_start'))) { + return; + } + + await this.ctx?.schema.alterTable('subscription', (table) => { + table.date('next_payment').nullable(); + }); + + const subscriptions = await this.ctx?.table('subscription').select(); + for await (const subscription of subscriptions || []) { + const next_payment = dayjs(subscription.current_period_end).add(1, 'day').startOf('day').add(1, 'hour').toDate(); + + await this.ctx?.table('subscription').where({ _id: subscription._id }).update({ + next_payment, + }); + } + + await this.ctx?.schema.alterTable('subscription', (table) => { + table.dropNullable('next_payment'); + table.dropColumn('status'); + table.dropColumn('error'); + }); + } +} diff --git a/packages/server/src/utils.test.ts b/packages/server/src/utils.test.ts index d821b2c..0c15e94 100644 --- a/packages/server/src/utils.test.ts +++ b/packages/server/src/utils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import dayjs from '~/lib/dayjs'; -import { getActiveUntilDate, getNextPaymentDate, getPeriodFromAnchorDate, getPreviousPeriod } from './utils'; +import { getActiveUntilDate, getNextPeriod, getPeriodFromAnchorDate, getPreviousPeriod } from './utils'; describe('utils', () => { const getActiveUntilDateTests = [ @@ -68,14 +68,15 @@ describe('utils', () => { }, ); - it('should get the billing period from a next-payment date', () => { + it('should get the previous period from a date', () => { const { start, end } = getPreviousPeriod(new Date('2022-02-16'), new Date('2022-01-15')); expect(dayjs(start).format('DD.MM.YYYY')).toStrictEqual('15.01.2022'); expect(dayjs(end).format('DD.MM.YYYY')).toStrictEqual('14.02.2022'); }); - it('should get the next-payment date from the current next-payment date', () => { - const nextPayment = getNextPaymentDate(new Date('2022-01-15'), new Date('2022-01-15')); - expect(dayjs(nextPayment).format('DD.MM.YYYY')).toStrictEqual('15.02.2022'); + it('should get the next period from a date', () => { + const { start, end } = getNextPeriod(new Date('2022-01-16'), new Date('2022-01-15')); + expect(dayjs(start).format('DD.MM.YYYY')).toStrictEqual('15.02.2022'); + expect(dayjs(end).format('DD.MM.YYYY')).toStrictEqual('14.03.2022'); }); }); diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts index 627fd83..8f11871 100644 --- a/packages/server/src/utils.ts +++ b/packages/server/src/utils.ts @@ -32,8 +32,3 @@ export function getActiveUntilDate(oldActiveUntil: Date, anchorDate: Date): Date const { end } = getPeriodFromAnchorDate(oldActiveUntil, anchorDate); return end; } - -export function getNextPaymentDate(currentNextPayment: Date, anchorDate: Date): Date { - const { start } = getNextPeriod(currentNextPayment, anchorDate); - return dayjs(start).add(1, 'hour').toDate(); // add short buffer -} diff --git a/packages/server/test/fixtures.ts b/packages/server/test/fixtures.ts index f84ac82..b21635d 100644 --- a/packages/server/test/fixtures.ts +++ b/packages/server/test/fixtures.ts @@ -1,6 +1,6 @@ import { Customer, Invoice, InvoiceItem, PaymentMethod, Project, ProjectInvoiceData, Subscription } from '~/entities'; import dayjs from '~/lib/dayjs'; -import { getNextPaymentDate, getPeriodFromAnchorDate } from '~/utils'; +import { getPeriodFromAnchorDate } from '~/utils'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function getFixtures() { @@ -42,11 +42,13 @@ export function getFixtures() { const anchorDate = dayjs('2020-01-01 07:23').toDate(); + const currentPeriod = getPeriodFromAnchorDate(anchorDate, anchorDate); const subscription = new Subscription({ anchorDate, customer, project, - nextPayment: getNextPaymentDate(anchorDate, anchorDate), + currentPeriodStart: currentPeriod.start, + currentPeriodEnd: currentPeriod.end, }); subscription.changePlan({ pricePerUnit: 12.34, units: 12 }); subscription.changePlan({ pricePerUnit: 12.34, units: 15, changeDate: dayjs('2020-01-15').toDate() }); @@ -61,6 +63,7 @@ export function getFixtures() { date: end, sequentialId: 2, status: 'draft', + customer, subscription, project, }); From 01d51415ce533d4d9852c9098fdb7b47fee8181b Mon Sep 17 00:00:00 2001 From: Anbraten Date: Thu, 26 Oct 2023 07:36:07 +0200 Subject: [PATCH 02/18] lock subscription and disallow negative amount payments --- packages/server/src/loop.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server/src/loop.ts b/packages/server/src/loop.ts index 565d396..cc758bb 100644 --- a/packages/server/src/loop.ts +++ b/packages/server/src/loop.ts @@ -45,7 +45,7 @@ export async function chargeCustomerInvoice(invoice: Invoice): Promise { } const payment = new Payment({ - amount, + amount: Math.max(amount, 0), // negative amounts are not allowed currency: project.currency, customer, type: 'recurring', @@ -100,7 +100,8 @@ export async function chargeSubscriptions(): Promise { ); for await (const subscription of subscriptions) { - // TODO: should we lock subscription processing? + subscription.status = 'processing'; + await database.em.persistAndFlush([subscription]); const { project, customer } = subscription; @@ -140,6 +141,7 @@ export async function chargeSubscriptions(): Promise { const nextPeriod = getNextPeriod(billingPeriod.start, subscription.anchorDate); subscription.currentPeriodStart = nextPeriod.start; subscription.currentPeriodEnd = nextPeriod.end; + subscription.status = 'active'; await database.em.persistAndFlush([subscription, customer, invoice]); From 5c697bb1449ba3ed94abb724695177de811087cb Mon Sep 17 00:00:00 2001 From: Anbraten Date: Thu, 26 Oct 2023 07:49:25 +0200 Subject: [PATCH 03/18] improve ui --- .../pages/customers/[customerId]/index.vue | 54 ++++++++++++++++++- .../app/pages/invoices/[invoiceId]/index.vue | 38 ++++--------- packages/app/pages/invoices/index.vue | 9 ++++ .../subscriptions/[subscriptionId]/index.vue | 8 ++- packages/app/pages/subscriptions/index.vue | 16 +++--- packages/server/src/api/endpoints/invoice.ts | 17 +++--- 6 files changed, 94 insertions(+), 48 deletions(-) diff --git a/packages/app/pages/customers/[customerId]/index.vue b/packages/app/pages/customers/[customerId]/index.vue index 3c0ac0c..b63d6fa 100644 --- a/packages/app/pages/customers/[customerId]/index.vue +++ b/packages/app/pages/customers/[customerId]/index.vue @@ -96,12 +96,33 @@ + + +

Invoices

+ + + + + + + + +
diff --git a/packages/app/pages/invoices/index.vue b/packages/app/pages/invoices/index.vue index 60a275a..49c3fa1 100644 --- a/packages/app/pages/invoices/index.vue +++ b/packages/app/pages/invoices/index.vue @@ -3,6 +3,10 @@

Invoices

+ + @@ -33,6 +37,11 @@ const invoiceColumns = [ label: 'Number', sortable: true, }, + { + key: 'customer', + label: 'Customer', + sortable: true, + }, { key: 'date', label: 'Date', diff --git a/packages/app/pages/subscriptions/[subscriptionId]/index.vue b/packages/app/pages/subscriptions/[subscriptionId]/index.vue index 7ef1ee8..6ac557a 100644 --- a/packages/app/pages/subscriptions/[subscriptionId]/index.vue +++ b/packages/app/pages/subscriptions/[subscriptionId]/index.vue @@ -20,8 +20,12 @@ - - + + + + + + diff --git a/packages/app/pages/subscriptions/index.vue b/packages/app/pages/subscriptions/index.vue index f586381..3757803 100644 --- a/packages/app/pages/subscriptions/index.vue +++ b/packages/app/pages/subscriptions/index.vue @@ -12,8 +12,8 @@ - diff --git a/packages/app/pages/subscriptions/[subscriptionId]/index.vue b/packages/app/pages/subscriptions/[subscriptionId]/index.vue index 6ac557a..d605827 100644 --- a/packages/app/pages/subscriptions/[subscriptionId]/index.vue +++ b/packages/app/pages/subscriptions/[subscriptionId]/index.vue @@ -56,10 +56,7 @@