diff --git a/apps/api/server.js b/apps/api/server.js index 5026ab4a2a..493a0da62b 100644 --- a/apps/api/server.js +++ b/apps/api/server.js @@ -53,6 +53,7 @@ const { SyncMailchimpSetupWorker, SyncMailchimpUpdateWorker, SyncMailchimpEndedWorker, + setupPaymentUserEventHooks, } = require('@orbiting/backend-modules-payments') const loaderBuilders = { @@ -81,20 +82,31 @@ const MailScheduler = require('@orbiting/backend-modules-mail/lib/scheduler') const mail = require('@orbiting/backend-modules-republik-crowdfundings/lib/Mail') -const { Queue } = require('@orbiting/backend-modules-job-queue') - -const queue = Queue.getInstance() -queue.registerWorker(StripeWebhookWorker) -queue.registerWorker(StripeCustomerCreateWorker) -queue.registerWorker(SyncAddressDataWorker) -queue.registerWorker(ConfirmSetupTransactionalWorker) -queue.registerWorker(ConfirmCancelTransactionalWorker) -queue.registerWorker(ConfirmRevokeCancellationTransactionalWorker) -queue.registerWorker(NoticeEndedTransactionalWorker) -queue.registerWorker(NoticePaymentFailedTransactionalWorker) -queue.registerWorker(SyncMailchimpSetupWorker) -queue.registerWorker(SyncMailchimpUpdateWorker) -queue.registerWorker(SyncMailchimpEndedWorker) +const { Queue, GlobalQueue } = require('@orbiting/backend-modules-job-queue') + +function setupQueue(context, monitorQueueState = undefined) { + const queue = Queue.createInstance(GlobalQueue, { + context, + connectionString: process.env.DATABASE_URL, + monitorStateIntervalSeconds: monitorQueueState, + }) + + queue.registerWorkers([ + StripeWebhookWorker, + StripeCustomerCreateWorker, + SyncAddressDataWorker, + ConfirmSetupTransactionalWorker, + ConfirmCancelTransactionalWorker, + ConfirmRevokeCancellationTransactionalWorker, + NoticeEndedTransactionalWorker, + NoticePaymentFailedTransactionalWorker, + SyncMailchimpSetupWorker, + SyncMailchimpUpdateWorker, + SyncMailchimpEndedWorker, + ]) + + return queue +} const { LOCAL_ASSETS_SERVER, @@ -204,7 +216,9 @@ const run = async (workerId, config) => { const connectionContext = await ConnectionContext.create(applicationName) + const queue = setupQueue(connectionContext) await queue.start() + setupPaymentUserEventHooks(connectionContext) const createGraphQLContext = (defaultContext) => { const loaders = {} @@ -356,6 +370,7 @@ const runOnce = async () => { ) } + const queue = setupQueue(connectionContext, 120) await queue.start() PaymentsService.start(context.pgdb) diff --git a/packages/backend-modules/job-queue/__test__/queue.test.ts b/packages/backend-modules/job-queue/__test__/queue.test.ts index ce7af47068..c107b7d368 100644 --- a/packages/backend-modules/job-queue/__test__/queue.test.ts +++ b/packages/backend-modules/job-queue/__test__/queue.test.ts @@ -6,6 +6,7 @@ import { } from '@testcontainers/postgresql' import { JobState } from '../lib/types' import { Job, SendOptions } from 'pg-boss' +import { ConnectionContext } from '@orbiting/backend-modules-types' const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -40,20 +41,20 @@ describe('pg-boss worker test', () => { beforeAll(async () => { postgresContainer = await new PostgreSqlContainer().start() - queue = new Queue({ - application_name: 'job-queue', - connectionString: postgresContainer.getConnectionUri(), - }) + queue = new Queue( + { + application_name: 'job-queue', + connectionString: postgresContainer.getConnectionUri(), + }, + // mock connection context because we dont use it in this test + {} as ConnectionContext, + ) queue.registerWorker(DemoWorker).registerWorker(DemoErrorWorker) await queue.start() await queue.startWorkers() }, 60000) - beforeEach(async () => { - // console.log(await queue.getQueues()) - }) - afterAll(async () => { await queue.stop() }, 30000) diff --git a/packages/backend-modules/job-queue/lib/queue.ts b/packages/backend-modules/job-queue/lib/queue.ts index a21d244f85..5c37460874 100644 --- a/packages/backend-modules/job-queue/lib/queue.ts +++ b/packages/backend-modules/job-queue/lib/queue.ts @@ -1,28 +1,56 @@ import PgBoss from 'pg-boss' import { Worker, WorkerJobArgs, WorkerQueueName } from './types' -import { getConfig } from './config' +import { ConnectionContext } from '@orbiting/backend-modules-types' + +export const GlobalQueue = Symbol('Global PGBoss queue') + +type WorkerConstructor = new ( + pgBoss: PgBoss, + context: ConnectionContext, +) => Worker export class Queue { - static instance: Queue + static instances: Record = {} protected readonly pgBoss: PgBoss + protected readonly context: ConnectionContext protected workers = new Map>, Worker>() - static getInstance(): Queue { - if (!this.instance) { - const config = getConfig() - this.instance = new Queue({ - application_name: config.queueApplicationName, + static createInstance( + id: symbol = GlobalQueue, + config: { + connectionString: string + monitorStateIntervalSeconds?: number + context: ConnectionContext + }, + ) { + this.instances[id] = new Queue( + { + application_name: id.description, connectionString: config.connectionString, - monitorStateIntervalSeconds: 120, - }) + monitorStateIntervalSeconds: config.monitorStateIntervalSeconds, + }, + config.context, + ) + + return this.instances[id] + } + + static getInstance(id: symbol = GlobalQueue): Queue { + if (!this.instances[id]) { + throw new Error('Unknown queue instance') } - return this.instance + return this.instances[id] } - constructor(options: PgBoss.ConstructorOptions) { + constructor(options: PgBoss.ConstructorOptions, context: ConnectionContext) { + if (typeof options.monitorStateIntervalSeconds === 'undefined') { + delete options.monitorStateIntervalSeconds + } + this.pgBoss = new PgBoss(options) + this.context = context this.pgBoss.on('error', (error) => { console.error('[JobQueue]: %s', error) @@ -32,12 +60,19 @@ export class Queue { }) } - registerWorker(worker: new (pgBoss: PgBoss) => Worker): Queue { - const workerInstance = new worker(this.pgBoss) + registerWorker(worker: WorkerConstructor): Queue { + const workerInstance = new worker(this.pgBoss, this.context) this.workers.set(workerInstance.queue, workerInstance) return this } + registerWorkers(workers: WorkerConstructor[]): Queue { + for (const worker of workers) { + this.registerWorker(worker) + } + return this + } + async start() { await this.pgBoss.start() diff --git a/packages/backend-modules/job-queue/lib/workers/base.ts b/packages/backend-modules/job-queue/lib/workers/base.ts index 50cb280f63..98bb630f37 100644 --- a/packages/backend-modules/job-queue/lib/workers/base.ts +++ b/packages/backend-modules/job-queue/lib/workers/base.ts @@ -1,16 +1,19 @@ import PgBoss, { Job, ScheduleOptions, SendOptions } from 'pg-boss' import { BasePayload, Worker, WorkerQueue } from '../types' +import { ConnectionContext } from '@orbiting/backend-modules-types' export abstract class BaseWorker> implements Worker { protected pgBoss: PgBoss + protected readonly context: ConnectionContext abstract readonly queue: WorkerQueue readonly options: SendOptions = { retryLimit: 3, retryDelay: 1000 } // abstract performOptions?: PgBoss.WorkOptions | undefined - constructor(pgBoss: PgBoss) { + constructor(pgBoss: PgBoss, context: ConnectionContext) { this.pgBoss = pgBoss + this.context = context } abstract perform(jobs: Job[]): Promise diff --git a/packages/backend-modules/job-queue/package.json b/packages/backend-modules/job-queue/package.json index 74035b3370..23c89ad6ba 100644 --- a/packages/backend-modules/job-queue/package.json +++ b/packages/backend-modules/job-queue/package.json @@ -6,6 +6,7 @@ "main": "build/index.js", "types": "build/@types", "dependencies": { + "@orbiting/backend-modules-types": "*", "pg-boss": "^10.1.1", "debug": "^4.3.5" }, diff --git a/packages/backend-modules/mail/templates/payment_successful_gift_voucher.html b/packages/backend-modules/mail/templates/payment_successful_gift_voucher.html new file mode 100644 index 0000000000..c8d66a3928 --- /dev/null +++ b/packages/backend-modules/mail/templates/payment_successful_gift_voucher.html @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+

Guten Tag

+

+ + Ihre Zahlung ist erfolgreich bei uns eingegangen. + +

+ + {{#if voucher_codes}} +

+ Herzlichen Dank, dass Sie die Republik mit einer + Geschenk-Mitgliedschaft unterstützen! +

+

+ Für die Geschenk-Mitgliedschaft erhalten Sie + nachfolgend einen 8-Zeichen-Code. +

+

+ {{voucher_codes}} +

+

+ Sie können diesen der beschenkten Person mit einem Mittel + Ihrer Wahl überreichen: sofort per E-Mail, traditionell + per Briefpost oder originell als Schrift auf einem Kuchen. +

+

+ Um den Geschenkcode einzulösen, muss der neue Besitzer + oder die neue Besitzerin nur auf die Seite + {{link_claim}} gehen. Und ihn + dort eingeben. +

+ {{/if}} + +
    + {{#options}} {{#if this.isOTypeGoodie}} +
  • {{this.oamount}} {{this.olabel}}
  • + {{/if}} {{/options}} +
+ +

+ Vielen Dank! {{#if voucher_codes}} +
Und viel Freude beim Verschenken der Republik. + {{/if}} +

+

Ihre Crew der Republik

+
+

+ +
+ Republik AG
+ Sihlhallenstrasse 1, CH-8004 Zürich
+ www.republik.ch
+ kontakt@republik.ch +

+
+
+ + diff --git a/packages/backend-modules/mail/templates/subscription_created_gift_subscription.html b/packages/backend-modules/mail/templates/subscription_created_gift_subscription.html new file mode 100644 index 0000000000..adf2b88ca9 --- /dev/null +++ b/packages/backend-modules/mail/templates/subscription_created_gift_subscription.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+

Guten Tag

+ +

Sie haben soeben ein Geschenk eingelöst.

+

+ Sie können ab sofort + das Magazin lesen, an + sämtlichen Debatten teilnehmen + und die Republik + mit Ihren Freunden oder Feinden teilen. +

+ +

Wir wünschen Ihnen viel Vergnügen mit der Republik.

+

Ihre Crew der Republik

+
+

+ +
+ Republik AG
+ Sihlhallenstrasse 1, CH-8004 Zürich
+ www.republik.ch
+ kontakt@republik.ch +

+
+
+ + diff --git a/packages/backend-modules/migrations/migrations/20241212140600-alter-order-table-for-gifts.js b/packages/backend-modules/migrations/migrations/20241212140600-alter-order-table-for-gifts.js new file mode 100644 index 0000000000..58da03000c --- /dev/null +++ b/packages/backend-modules/migrations/migrations/20241212140600-alter-order-table-for-gifts.js @@ -0,0 +1,10 @@ +const run = require('../run.js') + +const dir = 'packages/backend-modules/payments/migrations/sql' +const file = '20241212140600-alter-order-table-for-gifts' + +exports.up = (db) => + run(db, dir, `${file}-up.sql`) + +exports.down = (db) => + run(db, dir, `${file}-down.sql`) diff --git a/packages/backend-modules/migrations/migrations/20241212144915-gift-code-table.js b/packages/backend-modules/migrations/migrations/20241212144915-gift-code-table.js new file mode 100644 index 0000000000..8fe6c742b4 --- /dev/null +++ b/packages/backend-modules/migrations/migrations/20241212144915-gift-code-table.js @@ -0,0 +1,10 @@ +const run = require('../run.js') + +const dir = 'packages/backend-modules/payments/migrations/sql' +const file = '20241212144915-gift-code-table' + +exports.up = (db) => + run(db, dir, `${file}-up.sql`) + +exports.down = (db) => + run(db, dir, `${file}-down.sql`) diff --git a/packages/backend-modules/migrations/migrations/20250120094008-gift-voucher-order-id.js b/packages/backend-modules/migrations/migrations/20250120094008-gift-voucher-order-id.js new file mode 100644 index 0000000000..e254e2b36a --- /dev/null +++ b/packages/backend-modules/migrations/migrations/20250120094008-gift-voucher-order-id.js @@ -0,0 +1,10 @@ +const run = require('../run.js') + +const dir = 'packages/backend-modules/payments/migrations/sql' +const file = '20250120094008-gift-voucher-order-id' + +exports.up = (db) => + run(db, dir, `${file}-up.sql`) + +exports.down = (db) => + run(db, dir, `${file}-down.sql`) diff --git a/packages/backend-modules/payments/express/index.ts b/packages/backend-modules/payments/express/index.ts index 48b4fe0173..6971378fab 100644 --- a/packages/backend-modules/payments/express/index.ts +++ b/packages/backend-modules/payments/express/index.ts @@ -1,19 +1,14 @@ import { handleStripeWebhook } from './webhook/stripe' import type { Request, Response, Router } from 'express' import bodyParser from 'body-parser' +import { PgDb } from 'pogi' -export default async ( - server: Router, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _t: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _redis: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _connectionContext: any, -) => { +export default async (server: Router, pgdb: PgDb) => { server.post( '/webhooks/:company/stripe', bodyParser.raw({ type: '*/*' }), - (req: Request, res: Response) => handleStripeWebhook(req, res), + (req: Request, res: Response) => { + return handleStripeWebhook(req, res, { pgdb }) + }, ) } diff --git a/packages/backend-modules/payments/express/webhook/stripe.ts b/packages/backend-modules/payments/express/webhook/stripe.ts index 2952e2f92f..f0a8ef7f98 100644 --- a/packages/backend-modules/payments/express/webhook/stripe.ts +++ b/packages/backend-modules/payments/express/webhook/stripe.ts @@ -1,33 +1,36 @@ +import Stripe from 'stripe' +import { PgDb } from 'pogi' +import type { Request, Response } from 'express' import { Queue } from '@orbiting/backend-modules-job-queue' import { Company } from '../../lib/types' import { StripeWebhookWorker } from '../../lib/workers/StripeWebhookWorker' -import type { Request, Response } from 'express' -import Stripe from 'stripe' -import { Payments } from '../../lib/payments' +import { WebhookService } from '../../lib/services/WebhookService' -export async function handleStripeWebhook(req: Request, res: Response) { +export async function handleStripeWebhook( + req: Request, + res: Response, + ctx: { pgdb: PgDb }, +) { try { - const payments = Payments.getInstance() + const webhookService = new WebhookService(ctx.pgdb) const company = getCompanyName(req.params['company']) - const event = payments.verifyWebhookForCompany(company, req) + const event = webhookService.verifyWebhook(company, req) if (!event.livemode && !isInStripeTestMode()) { console.log('skipping test event in live mode') return res.sendStatus(304) } - const alreadySeen = await payments.findWebhookEventBySourceId( - event.id, - ) + const alreadySeen = await webhookService.getEvent(event.id) if (alreadySeen) { - // if we have already logged the webhook we can retrun + // if we have already logged the webhook we can return const status = alreadySeen.processed ? 200 : 204 return res.sendStatus(status) } - const e = await payments.logWebhookEvent({ + const e = await webhookService.logEvent({ source: 'stripe', sourceId: event.id, company: company, diff --git a/packages/backend-modules/payments/graphql/resolvers/Offer.ts b/packages/backend-modules/payments/graphql/resolvers/Offer.ts new file mode 100644 index 0000000000..5b9e8cd45d --- /dev/null +++ b/packages/backend-modules/payments/graphql/resolvers/Offer.ts @@ -0,0 +1,12 @@ +import { Offer } from '../../lib/shop' + +export = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + __resolveType: (offer: Offer, _args: never, _context: never) => { + if (offer.id.startsWith('GIFT_')) { + return 'GiftOffer' + } + + return 'SubscriptionOffer' + }, +} diff --git a/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts b/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts index b5a9d876a1..66a616a37e 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts @@ -2,16 +2,21 @@ import { GraphqlContext } from '@orbiting/backend-modules-types' import { Shop, - Offers, checkIntroductoryOfferEligibility, + activeOffers, } from '../../../lib/shop' import { Payments } from '../../../lib/payments' import { default as Auth } from '@orbiting/backend-modules-auth' import { requiredCustomFields } from '../../../lib/shop/utils' +import { Company } from '../../../lib/types' type CreateCheckoutSessionArgs = { offerId: string promoCode?: string + complimentaryItems: { + id: string + quantity: number + }[] options?: { uiMode?: 'HOSTED' | 'CUSTOM' | 'EMBEDDED' promocode?: string @@ -26,9 +31,7 @@ export = async function createCheckoutSession( args: CreateCheckoutSessionArgs, ctx: GraphqlContext, ) { - Auth.ensureUser(ctx.user) - - const shop = new Shop(Offers) + const shop = new Shop(activeOffers()) const offer = await shop.getOfferById(args.offerId, { promoCode: args.promoCode, @@ -42,19 +45,9 @@ export = async function createCheckoutSession( throw new Error('Unknown offer') } - let customerId = ( - await Payments.getInstance().getCustomerIdForCompany( - ctx.user.id, - offer.company, - ) - )?.customerId + if (offer?.requiresLogin) Auth.ensureUser(ctx.user) - if (!customerId) { - customerId = await Payments.getInstance().createCustomer( - offer.company, - ctx.user.id, - ) - } + const customerId = await getCustomer(offer.company, ctx.user?.id) const sess = await shop.generateCheckoutSession({ offer: offer, @@ -67,6 +60,7 @@ export = async function createCheckoutSession( customFields: requiredCustomFields(ctx.user), metadata: args?.options?.metadata, returnURL: args?.options?.returnURL, + complimentaryItems: args.complimentaryItems, }) return { @@ -76,3 +70,19 @@ export = async function createCheckoutSession( url: sess.url, } } + +async function getCustomer(company: Company, userId?: string) { + if (!userId) { + return undefined + } + + const customerId = ( + await Payments.getInstance().getCustomerIdForCompany(userId, company) + )?.customerId + + if (customerId) { + return customerId + } + + return await Payments.getInstance().createCustomer(company, userId) +} diff --git a/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts new file mode 100644 index 0000000000..603d174551 --- /dev/null +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts @@ -0,0 +1,31 @@ +import { GraphqlContext } from '@orbiting/backend-modules-types' +import { GiftShop } from '../../../lib/shop/gifts' +import { default as Auth } from '@orbiting/backend-modules-auth' +import { t } from '@orbiting/backend-modules-translate' + +type RedeemGiftResult = { + aboType: string + starting: Date +} + +export = async function redeemGiftVoucher( + _root: never, // eslint-disable-line @typescript-eslint/no-unused-vars + args: { voucherCode: string }, + ctx: GraphqlContext, +): Promise { + Auth.ensureUser(ctx.user) + + const giftShop = new GiftShop(ctx.pgdb) + try { + const res = await giftShop.redeemVoucher(args.voucherCode, ctx.user.id) + + return { + aboType: res.aboType, + starting: res.starting, + } + } catch (e) { + console.error(e) + + throw new Error(t('api/unexpected')) + } +} diff --git a/packages/backend-modules/payments/graphql/resolvers/_queries/getOffer.ts b/packages/backend-modules/payments/graphql/resolvers/_queries/getOffer.ts index 26c4ddacff..a25b62d725 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_queries/getOffer.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_queries/getOffer.ts @@ -1,7 +1,7 @@ import { GraphqlContext } from '@orbiting/backend-modules-types' import { + activeOffers, checkIntroductoryOfferEligibility, - Offers, Shop, } from '../../../lib/shop' @@ -10,7 +10,7 @@ export = async function getOffer( args: { offerId: string; promoCode?: string }, ctx: GraphqlContext, ) { - return new Shop(Offers).getOfferById(args.offerId, { + return new Shop(activeOffers()).getOfferById(args.offerId, { promoCode: args.promoCode, withIntroductoryOffer: await checkIntroductoryOfferEligibility( ctx.pgdb, diff --git a/packages/backend-modules/payments/graphql/resolvers/_queries/getOffers.ts b/packages/backend-modules/payments/graphql/resolvers/_queries/getOffers.ts index c821471c20..beff8c335c 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_queries/getOffers.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_queries/getOffers.ts @@ -1,7 +1,7 @@ import { GraphqlContext } from '@orbiting/backend-modules-types' import { + activeOffers, checkIntroductoryOfferEligibility, - Offers, Shop, } from '../../../lib/shop' @@ -10,7 +10,7 @@ export = async function getOffers( args: { promoCode?: string }, ctx: GraphqlContext, ) { - return new Shop(Offers).getOffers({ + return new Shop(activeOffers()).getOffers({ promoCode: args.promoCode, withIntroductoryOffer: await checkIntroductoryOfferEligibility( ctx.pgdb, diff --git a/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts b/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts new file mode 100644 index 0000000000..1ca14a2389 --- /dev/null +++ b/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Auth from '@orbiting/backend-modules-auth' +import { GraphqlContext } from '@orbiting/backend-modules-types' +import { GiftVoucherRepo } from '../../../lib/database/GiftVoucherRepo' +import { PgDb } from 'pogi' +import { normalizeVoucher } from '../../../lib/shop/gifts' + +type GiftVoucherValidationResult = { + type?: string + valid: boolean + isLegacyVoucher: boolean +} + +export = async function ( + _root: never, + args: { voucherCode: string }, + ctx: GraphqlContext, +): Promise { + Auth.ensureUser(ctx.user) + + const base32Voucher = normalizeVoucher(args.voucherCode) + if (base32Voucher) { + const voucher = await new GiftVoucherRepo(ctx.pgdb).getVoucherByCode( + base32Voucher, + ) + + if (voucher && voucher.redeemedAt === null) { + return { + type: voucher.giftId, + valid: true, + isLegacyVoucher: false, + } + } + } + + if (await isfLegacyVoucher(ctx.pgdb, args.voucherCode)) { + return { + type: 'MEMBERSHIP', + valid: true, + isLegacyVoucher: true, + } + } + + return { + valid: false, + isLegacyVoucher: false, + } +} + +async function isfLegacyVoucher(pgdb: PgDb, code: string) { + const res = await pgdb.public.memberships.findOne({ voucherCode: code }) + if (res) return true + return false +} diff --git a/packages/backend-modules/payments/graphql/schema-types.ts b/packages/backend-modules/payments/graphql/schema-types.ts index 5481bda061..e39599679a 100644 --- a/packages/backend-modules/payments/graphql/schema-types.ts +++ b/packages/backend-modules/payments/graphql/schema-types.ts @@ -78,13 +78,37 @@ type CheckoutSession { url: String } -type Offer { +interface Offer { id: ID! company: CompanyName! name: String! price: Price! customPrice: CustomPrice discount: Discount + allowPromotions: Boolean + complimentaryItems: [ComplimentaryItem!] +} + +type SubscriptionOffer implements Offer { + id: ID! + company: CompanyName! + name: String! + price: Price! + customPrice: CustomPrice + discount: Discount + allowPromotions: Boolean + complimentaryItems: [ComplimentaryItem!] +} + +type GiftOffer implements Offer { + id: ID! + company: CompanyName! + name: String! + price: Price! + customPrice: CustomPrice + discount: Discount + allowPromotions: Boolean + complimentaryItems: [ComplimentaryItem!] } type Price { @@ -115,6 +139,27 @@ type Product { defaultPrice: Price } +type ComplimentaryItem { + id: String! + maxQuantity: Int! +} + +type GiftVoucherValidationResult { + type: String! + valid: Boolean! + isLegacyVoucher: Boolean! +} + +type RedeemGiftResult { + aboType: String! + starting: DateTime! +} + +input ComplimentaryItemOrder { + id: String! + quantity: Int! +} + input CheckoutSessionOptions { customPrice: Int uiMode: CheckoutUIMode diff --git a/packages/backend-modules/payments/graphql/schema.ts b/packages/backend-modules/payments/graphql/schema.ts index 1223b57755..429243bb4d 100644 --- a/packages/backend-modules/payments/graphql/schema.ts +++ b/packages/backend-modules/payments/graphql/schema.ts @@ -8,10 +8,12 @@ schema { type queries { getOffers(promoCode: String): [Offer!]! getOffer(offerId: ID!, promoCode: String): Offer + validateGiftVoucher(voucherCode: String!): GiftVoucherValidationResult } type mutations { - createCheckoutSession(offerId: ID!, promoCode: String options: CheckoutSessionOptions): CheckoutSession + redeemGiftVoucher(voucherCode: String): RedeemGiftResult + createCheckoutSession(offerId: ID!, promoCode: String, complimentaryItems: [ComplimentaryItemOrder] options: CheckoutSessionOptions): CheckoutSession cancelMagazineSubscription(args: CancelSubscription): Boolean createStripeCustomerPortalSession(companyName: CompanyName): CustomerPortalSession } diff --git a/packages/backend-modules/payments/lib/config.ts b/packages/backend-modules/payments/lib/config.ts index 1e9f52aece..26675aa888 100644 --- a/packages/backend-modules/payments/lib/config.ts +++ b/packages/backend-modules/payments/lib/config.ts @@ -16,6 +16,9 @@ export type Config = { REPUBLIK_STRIPE_SUBSCRIPTION_TAX_ID: string REPUBLIK_STRIPE_PAYMENTS_CONFIG_ID: string PROJECT_R_STRIPE_PAYMENTS_CONFIG_ID: string + REPUBLIK_3_MONTH_GIFT_COUPON: string + PROJECT_R_3_MONTH_GIFT_COUPON: string + PROJECT_R_YEARLY_GIFT_COUPON: string } export function getConfig(): Config { @@ -64,6 +67,21 @@ export function getConfig(): Config { 'PAYMENTS_REPUBLIK_STRIPE_PAYMENTS_CONFIG_ID not set', ) + assert( + typeof process.env.PAYMENTS_PROJECT_R_3_MONTH_GIFT_COUPON !== 'undefined', + 'PAYMENTS_PROJECT_R_3_MONTH_GIFT_COUPON not set', + ) + + assert( + typeof process.env.PAYMENTS_REPUBLIK_3_MONTH_GIFT_COUPON !== 'undefined', + 'PAYMENTS_REPUBLIK_3_MONTH_GIFT_COUPON not set', + ) + + assert( + typeof process.env.PAYMENTS_PROJECT_R_YEARLY_GIFT_COUPON !== 'undefined', + 'PAYMENTS_PROJECT_R_YEARLY_GIFT_COUPON not set', + ) + return { SCHEMA_NAME: DEFAULT_SCHEMA_NAME, SHOP_BASE_URL: process.env.SHOP_BASE_URL, @@ -84,5 +102,11 @@ export function getConfig(): Config { process.env.PAYMENTS_PROJECT_R_STRIPE_PAYMENTS_CONFIG_ID, REPUBLIK_STRIPE_PAYMENTS_CONFIG_ID: process.env.PAYMENTS_REPUBLIK_STRIPE_PAYMENTS_CONFIG_ID, + PROJECT_R_3_MONTH_GIFT_COUPON: + process.env.PAYMENTS_PROJECT_R_3_MONTH_GIFT_COUPON, + PROJECT_R_YEARLY_GIFT_COUPON: + process.env.PAYMENTS_PROJECT_R_YEARLY_GIFT_COUPON, + REPUBLIK_3_MONTH_GIFT_COUPON: + process.env.PAYMENTS_PROJECT_R_3_MONTH_GIFT_COUPON, } } diff --git a/packages/backend-modules/payments/lib/database/BillingRepo.ts b/packages/backend-modules/payments/lib/database/BillingRepo.ts index 49f6f69077..f23dca9e74 100644 --- a/packages/backend-modules/payments/lib/database/BillingRepo.ts +++ b/packages/backend-modules/payments/lib/database/BillingRepo.ts @@ -6,7 +6,7 @@ import { Invoice, InvoiceRepoArgs, Order, - PaymentItemLocator, + SelectCriteria, STATUS_TYPES, Subscription, SubscriptionArgs, @@ -16,24 +16,31 @@ import { import { ACTIVE_STATUS_TYPES } from '../types' export type OrderArgs = { + userId?: string + customerEmail?: string + metadata?: any company: Company status: 'paid' | 'unpaid' externalId: string - invoiceId: string + invoiceId?: string subscriptionId?: string + shippingAddressId?: string } export type OrderRepoArgs = { - userId: string + userId?: string + customerEmail?: string + metadata?: any company: Company status: 'paid' | 'unpaid' externalId: string invoiceId?: string subscriptionId?: string + shippingAddressId?: string } export interface PaymentBillingRepo { - getSubscription(by: PaymentItemLocator): Promise + getSubscription(by: SelectCriteria): Promise getUserSubscriptions( userId: string, onlyStatus?: SubscriptionStatus[], @@ -43,18 +50,18 @@ export interface PaymentBillingRepo { args: SubscriptionArgs, ): Promise updateSubscription( - by: PaymentItemLocator, + by: SelectCriteria, args: SubscriptionUpdateArgs, ): Promise getUserOrders(userId: string): Promise getOrder(orderId: string): Promise saveOrder(order: OrderRepoArgs): Promise - getInvoice(by: PaymentItemLocator): Promise + getInvoice(by: SelectCriteria): Promise saveInvoice(userId: string, args: any): Promise - updateInvoice(by: PaymentItemLocator, args: any): Promise - getCharge(by: PaymentItemLocator): Promise + updateInvoice(by: SelectCriteria, args: any): Promise + getCharge(by: SelectCriteria): Promise saveCharge(args: any): Promise - updateCharge(by: PaymentItemLocator, args: any): Promise + updateCharge(by: SelectCriteria, args: any): Promise getActiveUserSubscription(userId: string): Promise } @@ -80,7 +87,7 @@ export class BillingRepo implements PaymentBillingRepo { return this.#pgdb.payments.orders.insertAndGet(order) } - getSubscription(by: PaymentItemLocator): Promise { + getSubscription(by: SelectCriteria): Promise { return this.#pgdb.payments.subscriptions.findOne(by) } @@ -118,13 +125,13 @@ export class BillingRepo implements PaymentBillingRepo { } updateSubscription( - by: PaymentItemLocator, + by: SelectCriteria, args: SubscriptionUpdateArgs, ): Promise { return this.#pgdb.payments.subscriptions.updateAndGetOne(by, args) } - getInvoice(by: PaymentItemLocator): Promise { + getInvoice(by: SelectCriteria): Promise { return this.#pgdb.payments.invoices.findOne(by) } @@ -135,13 +142,13 @@ export class BillingRepo implements PaymentBillingRepo { }) } - updateInvoice(by: PaymentItemLocator, args: any): Promise { + updateInvoice(by: SelectCriteria, args: any): Promise { return this.#pgdb.payments.invoices.updateAndGet(by, { ...args, }) } - getCharge(by: PaymentItemLocator) { + getCharge(by: SelectCriteria) { return this.#pgdb.payments.charges.findOne(by) } @@ -150,7 +157,7 @@ export class BillingRepo implements PaymentBillingRepo { } updateCharge( - charge: PaymentItemLocator, + charge: SelectCriteria, args: ChargeUpdate, ): Promise { return this.#pgdb.payments.charges.updateAndGet(charge, args) diff --git a/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts b/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts new file mode 100644 index 0000000000..05a34fedfe --- /dev/null +++ b/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts @@ -0,0 +1,28 @@ +import { PgDb } from 'pogi' +import { Voucher } from '../shop/gifts' +import { Company } from '../types' + +type UpdateableVoucher = { + redeemedBy: string | null + redeemedForCompany: Company | null + redeemedAt: Date | null +} + +export class GiftVoucherRepo { + #pgdb + constructor(pgdb: PgDb) { + this.#pgdb = pgdb + } + + getVoucherByCode(code: string): Promise { + return this.#pgdb.payments.giftVouchers.findOne({ code }) + } + + saveVoucher(voucher: Voucher): Promise { + return this.#pgdb.payments.giftVouchers.insert(voucher) + } + + updateVoucher(id: string, voucher: UpdateableVoucher): Promise { + return this.#pgdb.payments.giftVouchers.update({ id }, voucher) + } +} diff --git a/packages/backend-modules/payments/lib/database/UserRepo.ts b/packages/backend-modules/payments/lib/database/UserRepo.ts index 8b5e75340f..9435e18a42 100644 --- a/packages/backend-modules/payments/lib/database/UserRepo.ts +++ b/packages/backend-modules/payments/lib/database/UserRepo.ts @@ -10,6 +10,16 @@ export type Address = { country: string | undefined } +export type AddressRow = { + id: string + name: string + city: string | null + line1: string | null + line2: string | null + postalCode: string | null + country: string | undefined +} + type UserUpdateArgs = { firstName?: string | null lastName?: string | null @@ -31,11 +41,11 @@ export class UserDataRepo { return this.#pgdb.public.users.updateAndGet({ id: userId }, args) } - insertAddress(args: Address) { + insertAddress(args: Address): Promise { return this.#pgdb.public.addresses.insertAndGet(args) } - updateAddress(addressId: string, args: Address) { + updateAddress(addressId: string, args: Address): Promise { return this.#pgdb.public.addresses.updateAndGet({ id: addressId }, args) } } diff --git a/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts index 5131497e38..5913957d44 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts @@ -8,15 +8,39 @@ import { PaymentProvider } from '../../providers/provider' import { mapSubscriptionArgs } from './subscriptionCreated' import { mapInvoiceArgs } from './invoiceCreated' import { SyncAddressDataWorker } from '../../workers/SyncAddressDataWorker' -import { mapChargeArgs } from './invoicePaymentSucceded' +import { mapChargeArgs } from './invoicePaymentSucceeded' +import { ConnectionContext } from '@orbiting/backend-modules-types' +import { GiftShop } from '../../shop/gifts' +import { sendGiftPurchaseMail } from '../../transactionals/sendTransactionalMails' +import { UserDataRepo } from '../../database/UserRepo' + +type PaymentWebhookContext = { + paymentService: PaymentService +} & ConnectionContext export async function processCheckoutCompleted( - paymentService: PaymentService, + ctx: PaymentWebhookContext, company: Company, event: Stripe.CheckoutSessionCompletedEvent, ) { - const customerId = event.data.object.customer as string + switch (event.data.object.mode) { + case 'subscription': { + return handleSubscription(ctx, company, event) + } + case 'payment': { + return handlePayment(ctx, company, event) + } + } +} +async function handleSubscription( + ctx: PaymentWebhookContext, + company: Company, + event: Stripe.CheckoutSessionCompletedEvent, +) { + const paymentService = ctx.paymentService + + const customerId = event.data.object.customer as string if (!customerId) { console.log('No stripe customer provided; skipping') return @@ -83,7 +107,7 @@ export async function processCheckoutCompleted( await paymentService.saveCharge(chargeArgs) } catch (e) { if (e instanceof Error) { - console.log('Error recording charge: %s', e.message) + console.log(`Error recording charge: ${e.message}`) } } } @@ -92,7 +116,8 @@ export async function processCheckoutCompleted( } } - await paymentService.saveOrder(userId, { + await paymentService.saveOrder({ + userId: userId, company: company, externalId: event.data.object.id, invoiceId: invoiceId as string, @@ -130,10 +155,117 @@ export async function processCheckoutCompleted( ) : undefined, ]) - return } +async function handlePayment( + ctx: PaymentWebhookContext, + company: Company, + event: Stripe.CheckoutSessionCompletedEvent, +) { + const giftShop = new GiftShop(ctx.pgdb) + + const sess = await PaymentProvider.forCompany(company).getCheckoutSession( + event.data.object.id, + ) + + if (!sess) { + throw new Error('checkout session does not exist') + } + + if (!sess.line_items) { + throw new Error( + 'checkout session has no line items unable to process checkout', + ) + } + + let userId = undefined + if (typeof sess.customer !== 'undefined') { + userId = + (await ctx.paymentService.getUserIdForCompanyCustomer( + company, + sess.customer as string, + )) ?? undefined + } + + let paymentStatus = event.data.object.payment_status + if (paymentStatus === 'no_payment_required') { + // no payments required are treated as paid + paymentStatus = 'paid' + } + + const lineItems = sess.line_items.data.map((line) => { + return { + lineItemId: line.id, + externalPriceId: line.price!.id, + priceLookupKey: line.price!.lookup_key, + description: line.description, + quantity: line.quantity, + price: line.amount_total, + priceSubtotal: line.amount_subtotal, + taxAmount: line.amount_tax, + discountAmount: line.amount_discount, + } + }) + + console.log(sess.shipping_details) + + let addressId: string | undefined = undefined + if (sess.shipping_details) { + const shippingAddress = sess.shipping_details.address! + const data = { + name: sess.shipping_details.name!, + city: shippingAddress.city, + line1: shippingAddress.line1, + line2: shippingAddress.line2, + postalCode: shippingAddress.postal_code, + country: new Intl.DisplayNames(['de-CH'], { type: 'region' }).of( + shippingAddress.country!, + ), + } + addressId = (await new UserDataRepo(ctx.pgdb).insertAddress(data)).id + } + + const orderDraft = { + userId: userId, + customerEmail: + typeof userId === 'undefined' ? sess.customer_details!.email! : undefined, + company: company, + metadata: sess.metadata, + externalId: event.data.object.id, + status: paymentStatus as 'paid' | 'unpaid', + shippingAddressId: addressId, + } + + const order = await ctx.paymentService.saveOrder(orderDraft) + + const orderLineItems = lineItems.map((i) => { + return { orderId: order.id, ...i } + }) + + await ctx.pgdb.payments.orderLineItems.insert(orderLineItems) + + const giftCodes = [] + for (const item of lineItems) { + if (item.priceLookupKey?.startsWith('GIFT')) { + const code = await giftShop.generateNewVoucher({ + company: company, + orderId: order.id, + giftId: item.priceLookupKey, + }) + giftCodes.push(code) + } + } + + await sendGiftPurchaseMail( + { + email: sess.customer_details!.email!, + voucherCode: giftCodes[0].code.replace(/(\w{4})(\w{4})/, '$1-$2'), + }, + ctx.pgdb, + ) +} + async function syncUserNameData( paymentService: PaymentService, userId: string, diff --git a/packages/backend-modules/payments/lib/handlers/stripe/invoiceCreated.ts b/packages/backend-modules/payments/lib/handlers/stripe/invoiceCreated.ts index 48837e5575..6e4b70713d 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/invoiceCreated.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/invoiceCreated.ts @@ -2,7 +2,7 @@ import Stripe from 'stripe' import { PaymentService } from '../../payments' import { Company, InvoiceArgs } from '../../types' import { PaymentProvider } from '../../providers/provider' -import { isPledgeBased } from './utils' +import { isPledgeBased, secondsToMilliseconds } from './utils' export async function processInvoiceCreated( paymentService: PaymentService, @@ -10,7 +10,7 @@ export async function processInvoiceCreated( event: Stripe.InvoiceCreatedEvent, ) { const customerId = event.data.object.customer as string - const externalInvocieId = event.data.object.id as string + const externalInvoiceId = event.data.object.id as string const userId = await paymentService.getUserIdForCompanyCustomer( company, @@ -21,13 +21,13 @@ export async function processInvoiceCreated( throw new Error(`Unknown customer ${customerId}`) } - if (await paymentService.getInvoice({ externalId: externalInvocieId })) { - console.log('invoice has already saved; skipping [%s]', externalInvocieId) + if (await paymentService.getInvoice({ externalId: externalInvoiceId })) { + console.log(`invoice has already saved; skipping [${externalInvoiceId}]`) return } const invoice = await PaymentProvider.forCompany(company).getInvoice( - externalInvocieId, + externalInvoiceId, ) if (!invoice) { throw new Error(`Unknown invoice ${event.data.object.id}`) @@ -35,8 +35,7 @@ export async function processInvoiceCreated( if (!invoice.subscription) { console.log( - 'Only subscription invoices currently not supported [%s]', - event.id, + `Only subscription invoices currently not supported [${event.id}]`, ) return } @@ -46,7 +45,7 @@ export async function processInvoiceCreated( ) if (isPledgeBased(sub?.metadata)) { - console.log('pledge invoice event [%s]; skipping', event.id) + console.log(`pledge invoice event [${event.id}]; skipping`) return } @@ -80,8 +79,12 @@ export function mapInvoiceArgs( discounts: invoice.discounts, metadata: invoice.metadata, externalId: invoice.id, - periodStart: new Date(invoice.lines.data[0].period.start * 1000), - periodEnd: new Date(invoice.lines.data[0].period.end * 1000), + periodStart: new Date( + secondsToMilliseconds(invoice.lines.data[0].period.start), + ), + periodEnd: new Date( + secondsToMilliseconds(invoice.lines.data[0].period.end), + ), status: invoice.status as any, externalSubscriptionId: invoice.subscription as string, } diff --git a/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceded.ts b/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceeded.ts similarity index 82% rename from packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceded.ts rename to packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceeded.ts index e7f8f6c0e4..9d18d6814d 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceded.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceeded.ts @@ -2,8 +2,9 @@ import Stripe from 'stripe' import { PaymentService } from '../../payments' import { Company } from '../../types' import { PaymentProvider } from '../../providers/provider' +import { secondsToMilliseconds } from './utils' -export async function processInvociePaymentSucceded( +export async function processInvoicePaymentSucceeded( paymentService: PaymentService, company: Company, event: Stripe.InvoicePaymentSucceededEvent, @@ -13,20 +14,19 @@ export async function processInvociePaymentSucceded( ) if (!i) { - console.error('not processing event: stripe invoice not found') + console.log('not processing event: stripe invoice not found') return } const invoice = await paymentService.getInvoice({ externalId: i.id }) if (!invoice) { - console.log('invoice not saved localy') - throw Error('invoice not saved localy') + throw Error('invoice not saved locally') } const incoiceCharge = i.charge as Stripe.Charge if (!incoiceCharge) { - console.error('no charge assoicated with the invoice not found') + console.error('no charge associated with the invoice not found') return } const args = mapChargeArgs(company, invoice.id, incoiceCharge) @@ -66,6 +66,6 @@ export function mapChargeArgs( amountRefunded: charge.amount_refunded, paymentMethodType: paymentMethodType, fullyRefunded: charge.refunded, - createdAt: new Date(charge.created * 1000), + createdAt: new Date(secondsToMilliseconds(charge.created)), } } diff --git a/packages/backend-modules/payments/lib/handlers/stripe/invoiceUpdate.ts b/packages/backend-modules/payments/lib/handlers/stripe/invoiceUpdate.ts index f993a4c937..e17d27a2a1 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/invoiceUpdate.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/invoiceUpdate.ts @@ -18,13 +18,13 @@ export async function processInvoiceUpdated( ) if (!invoice) { - console.error('unknown invoice %s', event.data.object.id) + console.error(`unknown invoice ${event.data.object.id}`) return } if (!invoice.subscription) { console.error( - 'Only subscription invocies are suppored invoice %s', + 'Only subscription invoices are supported invoice %s', event.data.object.id, ) return @@ -34,7 +34,7 @@ export async function processInvoiceUpdated( invoice.subscription as string, ) if (isPledgeBased(sub?.metadata)) { - console.log('pledge invoice event [%s]; skipping', event.id) + console.log(`pledge invoice event [${event.id}]; skipping`) } await paymentService.updateInvoice( diff --git a/packages/backend-modules/payments/lib/handlers/stripe/paymentFailed.ts b/packages/backend-modules/payments/lib/handlers/stripe/paymentFailed.ts index 39e2a06f09..4f3cd79744 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/paymentFailed.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/paymentFailed.ts @@ -28,12 +28,12 @@ export async function processPaymentFailed( ) if (!sub) { - console.log('Unknown stripe subscription %s', sub) + console.log(`Unknown stripe subscription ${sub}`) return } if (isPledgeBased(sub?.metadata)) { - console.log('pledge based subscription [%s]; skipping', sub.id) + console.log(`pledge based subscription [${sub.id}]; skipping`) return } @@ -43,18 +43,14 @@ export async function processPaymentFailed( if (!subscription) { console.log( - 'subscription %s not present in subscription table; skipping event %s', - externalSubId, - event.id, + `subscription ${externalSubId} not present in subscription table; skipping event ${event.id}`, ) return } if (sub.status !== 'past_due') { console.log( - 'stripe subscription %s for subscription %s is not in past_due state, nothing to do', - sub.id, - subscription.id, + `stripe subscription ${sub.id} for subscription ${subscription.id} is not in past_due state, nothing to do`, ) return } diff --git a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionCreated.ts b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionCreated.ts index 90833d14bd..154b3e75b2 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionCreated.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionCreated.ts @@ -1,8 +1,12 @@ import Stripe from 'stripe' import { PaymentService } from '../../payments' import { Company, SubscriptionArgs } from '../../types' -import { getSubscriptionType } from './utils' +import { getSubscriptionType, secondsToMilliseconds } from './utils' import { PaymentProvider } from '../../providers/provider' +import { Queue } from '@orbiting/backend-modules-job-queue' +import { ConfirmGiftSubscriptionTransactionalWorker } from '../../workers/ConfirmGiftSubscriptionTransactionalWorker' +import { REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN } from '../../shop/gifts' +import { SyncMailchimpSetupWorker } from '../../workers/SyncMailchimpSetupWorker' export async function processSubscriptionCreated( paymentService: PaymentService, @@ -25,8 +29,7 @@ export async function processSubscriptionCreated( await paymentService.getSubscription({ externalId: externalSubscriptionId }) ) { console.log( - 'subscription has already saved; skipping [%s]', - externalSubscriptionId, + `subscription has already saved; skipping [${externalSubscriptionId}]`, ) return } @@ -42,6 +45,31 @@ export async function processSubscriptionCreated( const args = mapSubscriptionArgs(company, subscription) await paymentService.setupSubscription(userId, args) + const isGiftSubscription = subscription.metadata[REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN] === 'GIFT' + + if (!isGiftSubscription) { + // only start mail and mailchimp sync jobs if subscription is created from gift and not checkout + return + } + + const queue = Queue.getInstance() + + await Promise.all([ + queue.send( + 'payments:transactional:confirm:gift:subscription', + { + $version: 'v1', + eventSourceId: event.id, + userId: userId, + }, + ), + queue.send('payments:mailchimp:sync:setup', { + $version: 'v1', + eventSourceId: event.id, + userId: userId, + }), + ]) + return } @@ -53,8 +81,10 @@ export function mapSubscriptionArgs( company: company, type: getSubscriptionType(sub?.items.data[0].price.product as string), externalId: sub.id, - currentPeriodStart: new Date(sub.current_period_start * 1000), - currentPeriodEnd: new Date(sub.current_period_end * 1000), + currentPeriodStart: new Date( + secondsToMilliseconds(sub.current_period_start), + ), + currentPeriodEnd: new Date(secondsToMilliseconds(sub.current_period_end)), status: sub.status, metadata: sub.metadata, } diff --git a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts index 8015dfe44e..f85fba5f43 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts @@ -4,14 +4,22 @@ import { Company } from '../../types' import { Queue } from '@orbiting/backend-modules-job-queue' import { NoticeEndedTransactionalWorker } from '../../workers/NoticeEndedTransactionalWorker' import { SyncMailchimpEndedWorker } from '../../workers/SyncMailchimpEndedWorker' +import { + getMailSettings, + REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY, +} from '../../mail-settings' +import { secondsToMilliseconds } from './utils' +import { REPUBLIK_PAYMENTS_CANCEL_REASON } from '../../shop/gifts' export async function processSubscriptionDeleted( paymentService: PaymentService, _company: Company, event: Stripe.CustomerSubscriptionDeletedEvent, ) { - const endTimestamp = (event.data.object.ended_at || 0) * 1000 - const canceledAtTimestamp = (event.data.object.canceled_at || 0) * 1000 + const endTimestamp = secondsToMilliseconds(event.data.object.ended_at || 0) + const canceledAtTimestamp = secondsToMilliseconds( + event.data.object.canceled_at || 0, + ) await paymentService.disableSubscription( { externalId: event.data.object.id }, @@ -19,6 +27,9 @@ export async function processSubscriptionDeleted( endedAt: new Date(endTimestamp), canceledAt: new Date(canceledAtTimestamp), }, + event.data.object.metadata[REPUBLIK_PAYMENTS_CANCEL_REASON] === 'UPGRADE' + ? { keepMembership: true } + : undefined, ) const customerId = event.data.object.customer as string @@ -31,23 +42,29 @@ export async function processSubscriptionDeleted( throw Error(`User for ${customerId} does not exists`) } - const queue = Queue.getInstance() + const mailSettings = getMailSettings( + event.data.object.metadata[REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY], + ) + + if (mailSettings['notice:ended']) { + const queue = Queue.getInstance() - await Promise.all([ - queue.send( - 'payments:transactional:notice:ended', - { + await Promise.all([ + queue.send( + 'payments:transactional:notice:ended', + { + $version: 'v1', + eventSourceId: event.id, + userId: userId, + }, + ), + queue.send('payments:mailchimp:sync:ended', { $version: 'v1', eventSourceId: event.id, userId: userId, - }, - ), - queue.send('payments:mailchimp:sync:ended', { - $version: 'v1', - eventSourceId: event.id, - userId: userId, - }), - ]) + }), + ]) + } return } diff --git a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts index 22d0d9f6b8..fe5105bafc 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts @@ -5,12 +5,20 @@ import { Queue } from '@orbiting/backend-modules-job-queue' import { ConfirmCancelTransactionalWorker } from '../../workers/ConfirmCancelTransactionalWorker' import { SyncMailchimpUpdateWorker } from '../../workers/SyncMailchimpUpdateWorker' import { ConfirmRevokeCancellationTransactionalWorker } from '../../workers/ConfirmRevokeCancellationTransactionalWorker' +import { + getMailSettings, + REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY, +} from '../../mail-settings' +import { secondsToMilliseconds } from './utils' export async function processSubscriptionUpdate( paymentService: PaymentService, company: Company, event: Stripe.CustomerSubscriptionUpdatedEvent, ) { + const mailSettings = getMailSettings( + event.data.object.metadata[REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY], + ) const cancelAt = event.data.object.cancel_at const canceledAt = event.data.object.canceled_at const cancellationComment = event.data.object.cancellation_details?.comment @@ -20,17 +28,21 @@ export async function processSubscriptionUpdate( await paymentService.updateSubscription({ company: company, externalId: event.data.object.id, - currentPeriodStart: new Date(event.data.object.current_period_start * 1000), - currentPeriodEnd: new Date(event.data.object.current_period_end * 1000), + currentPeriodStart: new Date( + secondsToMilliseconds(event.data.object.current_period_start), + ), + currentPeriodEnd: new Date( + secondsToMilliseconds(event.data.object.current_period_end), + ), status: event.data.object.status, metadata: event.data.object.metadata, cancelAt: typeof cancelAt === 'number' - ? new Date(cancelAt * 1000) + ? new Date(secondsToMilliseconds(cancelAt)) : (cancelAt as null | undefined), canceledAt: typeof canceledAt === 'number' - ? new Date(canceledAt * 1000) + ? new Date(secondsToMilliseconds(canceledAt)) : (cancelAt as null | undefined), cancellationComment: typeof cancellationComment === 'string' ? cancellationComment : null, @@ -46,7 +58,7 @@ export async function processSubscriptionUpdate( const previousCanceledAt = event.data.previous_attributes?.canceled_at const revokedCancellationDate = typeof previousCanceledAt === 'number' - ? new Date(previousCanceledAt * 1000) + ? new Date(secondsToMilliseconds(previousCanceledAt)) : (previousCanceledAt as null | undefined) const isCancellationRevoked = !cancelAt && !!revokedCancellationDate @@ -99,7 +111,7 @@ export async function processSubscriptionUpdate( ]) } - if (cancelAt) { + if (cancelAt && mailSettings['confirm:cancel']) { const customerId = event.data.object.customer as string const userId = await paymentService.getUserIdForCompanyCustomer( diff --git a/packages/backend-modules/payments/lib/handlers/stripe/utils.ts b/packages/backend-modules/payments/lib/handlers/stripe/utils.ts index 170cef986d..b2b0dbf0b1 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/utils.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/utils.ts @@ -20,3 +20,7 @@ export function getSubscriptionType(productId: string): SubscriptionType { export function isPledgeBased(metadata: any) { return 'pledgeId' in metadata } + +export function secondsToMilliseconds(seconds: number): number { + return seconds * 1000 +} diff --git a/packages/backend-modules/payments/lib/mail-settings.ts b/packages/backend-modules/payments/lib/mail-settings.ts new file mode 100644 index 0000000000..78128db95a --- /dev/null +++ b/packages/backend-modules/payments/lib/mail-settings.ts @@ -0,0 +1,56 @@ +type MailSettingKey = + | 'confirm:revoke_cancellation' + | 'confirm:setup' + | 'confirm:cancel' + | 'notice:ended' +type MailSettings = Record + +export const REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY = + 'republik.payments.mail.settings' + +const baseSettings: MailSettings = { + 'notice:ended': true, + 'confirm:revoke_cancellation': true, + 'confirm:cancel': true, + 'confirm:setup': true, +} + +export function getMailSettings(overwriteString?: string) { + const settings = { ...baseSettings } + + if (!overwriteString) { + return settings + } + + for (const setting of overwriteString.split(',')) { + const [name, value] = setting.split('=') + if (name in settings) { + settings[name as MailSettingKey] = parseValue(value) + } + } + + return settings +} + +function parseValue(value: string) { + switch (value) { + case 'true': + case 'enabled': + case 'on': + return true + case 'false': + case 'disabled': + case 'off': + return false + default: + return true + } +} + +export function serializeMailSettings(settings: Partial) { + return Object.keys(settings) + .map((s) => { + return `${s}=${settings[s as MailSettingKey]}` + }) + .join(',') +} diff --git a/packages/backend-modules/payments/lib/payments.ts b/packages/backend-modules/payments/lib/payments.ts index bb164aaf0c..52f2ca3525 100644 --- a/packages/backend-modules/payments/lib/payments.ts +++ b/packages/backend-modules/payments/lib/payments.ts @@ -7,53 +7,50 @@ import { Order, Subscription, SubscriptionArgs, - PaymentItemLocator, + SelectCriteria, InvoiceUpdateArgs, - Webhook, - ACTIVE_STATUS_TYPES, SubscriptionStatus, - Address, ChargeUpdate, ChargeInsert, - NOT_STARTED_STATUS_TYPES, } from './types' import assert from 'node:assert' -import { Queue } from '@orbiting/backend-modules-job-queue' -import { StripeCustomerCreateWorker } from './workers/StripeCustomerCreateWorker' -import { UserRow } from '@orbiting/backend-modules-types' -import { - sendCancelConfirmationMail, - sendEndedNoticeMail, - sendPaymentFailedNoticeMail, - sendRevokeCancellationConfirmationMail, - sendSetupSubscriptionMail, -} from './transactionals/sendTransactionalMails' -import { enforceSubscriptions } from '@orbiting/backend-modules-mailchimp' -import { getConfig } from './config' -import { - PaymentWebhookRepo, - WebhookArgs, - WebhookRepo, -} from './database/WebhookRepo' +import { ConnectionContext, UserRow } from '@orbiting/backend-modules-types' import { CustomerRepo, PaymentCustomerRepo } from './database/CutomerRepo' import { BillingRepo, OrderArgs, - OrderRepoArgs, PaymentBillingRepo, } from './database/BillingRepo' import { UserDataRepo } from './database/UserRepo' +import { CustomerInfoService } from './services/CustomerInfoService' // eslint-disable-next-line @typescript-eslint/no-require-imports const { UserEvents } = require('@orbiting/backend-modules-auth') export const Companies: readonly Company[] = ['PROJECT_R', 'REPUBLIK'] as const -const RegionNames = new Intl.DisplayNames(['de-CH'], { type: 'region' }) +export function setupPaymentUserEventHooks(context: ConnectionContext) { + const cs = new CustomerInfoService(context.pgdb) + + UserEvents.onSignedIn(async ({ userId }: { userId: string }) => { + if (process.env.PAYMENTS_CREATE_CUSTOMERS_ON_LOGIN === 'true') { + await cs.ensureUserHasCustomerIds(userId) + } + }) + + UserEvents.onEmailUpdated( + async (args: { userId: string; newEmail: string }) => { + await Promise.all( + Companies.map((c) => + cs.updateCustomerEmail(c, args.userId, args.newEmail), + ), + ) + }, + ) +} export class Payments implements PaymentService { static #instance: PaymentService protected pgdb: PgDb - protected webhooks: PaymentWebhookRepo protected customers: PaymentCustomerRepo protected billing: PaymentBillingRepo protected users: UserDataRepo @@ -75,300 +72,9 @@ export class Payments implements PaymentService { constructor(pgdb: PgDb) { this.pgdb = pgdb - this.webhooks = new WebhookRepo(pgdb) this.customers = new CustomerRepo(pgdb) this.billing = new BillingRepo(pgdb) this.users = new UserDataRepo(pgdb) - - UserEvents.onSignedIn(async ({ userId }: { userId: string }) => { - if (process.env.PAYMENTS_CREATE_CUSTOMERS_ON_LOGIN === 'true') { - await this.ensureUserHasCustomerIds(userId) - } - }) - UserEvents.onEmailUpdated( - async (args: { userId: string; newEmail: string }) => { - await Promise.all( - Companies.map((c) => - this.updateCustomerEmail(c, args.userId, args.newEmail), - ), - ) - }, - ) - } - - async sendSetupSubscriptionTransactionalMail({ - subscriptionExternalId, - userId, - invoiceId, - }: { - subscriptionExternalId: string - userId: string - invoiceId: string - }): Promise { - const subscription = await this.billing.getSubscription({ - externalId: subscriptionExternalId, - }) - - if (!subscription) { - throw new Error( - `Subscription [${subscriptionExternalId}] does not exist in the Database`, - ) - } - - if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { - throw new Error( - `not sending transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, - ) - } - const userRow = await this.getUser(userId) - - const invoice = await this.billing.getInvoice({ id: invoiceId }) - if (!invoice) { - throw new Error( - `Invoice ${invoiceId} does not exist in the database, not able to send subscription setup confirmation transactional mail.`, - ) - } - // send mail - await sendSetupSubscriptionMail( - { subscription, invoice, email: userRow.email }, - this.pgdb, - ) - } - - async sendCancelConfirmationTransactionalMail({ - subscriptionExternalId, - userId, - }: { - subscriptionExternalId: string - userId: string - }): Promise { - const subscription = await this.billing.getSubscription({ - externalId: subscriptionExternalId, - }) - - const userRow = await this.users.findUserById(userId) - - if (!subscription) { - throw new Error( - `Subscription [${subscriptionExternalId}] does not exist in the Database`, - ) - } - - if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { - throw new Error( - `not sending cancellation confirmation transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, - ) - } - - if (!subscription.cancelAt || !subscription.canceledAt) { - throw new Error( - `Subscription ${subscriptionExternalId} is not cancelled, not sending cancellation confirmation transactional`, - ) - } - - if (!userRow?.email) { - throw new Error( - `Could not find email for user with id ${userId}, not sending cancellation confirmation transactional`, - ) - } - - await sendCancelConfirmationMail( - { - endDate: subscription.cancelAt, - cancellationDate: subscription.canceledAt, - type: subscription.type, - userId: userId, - email: userRow.email, - }, - this.pgdb, - ) - } - - async sendRevokeCancellationConfirmationTransactionalMail({ - subscriptionExternalId, - userId, - revokedCancellationDate, - }: { - subscriptionExternalId: string - userId: string - revokedCancellationDate: Date - }): Promise { - const subscription = await this.billing.getSubscription({ - externalId: subscriptionExternalId, - }) - - const userRow = await this.getUser(userId) - - if (!subscription) { - throw new Error( - `Subscription [${subscriptionExternalId}] does not exist in the Database`, - ) - } - - if (subscription.cancelAt) { - throw new Error( - `Subscription ${subscriptionExternalId} is still cancelled, not sending revoke cancellation confirmation transactional`, - ) - } - - if (!userRow.email) { - throw new Error( - `Could not find email for user with id ${userId}, not sending revoke cancellation confirmation transactional`, - ) - } - - await sendRevokeCancellationConfirmationMail( - { - currentEndDate: subscription.currentPeriodEnd, - revokedCancellationDate, - type: subscription.type, - userId, - email: userRow.email, - }, - this.pgdb, - ) - } - - async sendSubscriptionEndedNoticeTransactionalMail({ - userId, - subscriptionExternalId, - cancellationReason, - }: { - userId: string - subscriptionExternalId: string - cancellationReason?: string - }): Promise { - const subscription = await this.billing.getSubscription({ - externalId: subscriptionExternalId, - }) - - const userRow = await this.users.findUserById(userId) - - if (!subscription) { - throw new Error( - `Subscription [${subscriptionExternalId}] does not exist in the Database`, - ) - } - - if (ACTIVE_STATUS_TYPES.includes(subscription.status)) { - throw new Error( - `not sending ended notice transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, - ) - } - - if (!subscription.endedAt) { - throw new Error( - `Subscription ${subscriptionExternalId} has not ended, not sending ended notice transactional`, - ) - } - - if (!userRow?.email) { - throw new Error( - `Could not find email for user with id ${userId}, not sending ended notice transactional`, - ) - } - - await sendEndedNoticeMail( - { subscription, cancellationReason, email: userRow.email }, - this.pgdb, - ) - } - - async sendNoticePaymentFailedTransactionalMail({ - userId, - subscriptionExternalId, - invoiceExternalId, - }: { - userId: string - subscriptionExternalId: string - invoiceExternalId: string - }): Promise { - const subscription = await this.billing.getSubscription({ - externalId: subscriptionExternalId, - }) - - const invoice = await this.billing.getInvoice({ - externalId: invoiceExternalId, - }) - - const userRow = await this.users.findUserById(userId) - - if (!subscription) { - throw new Error( - `Subscription [${subscriptionExternalId}] does not exist in the Database`, - ) - } - - if (!invoice) { - throw new Error( - `Invoice ${invoiceExternalId} does not exist in the Database`, - ) - } - - if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { - throw new Error( - `not sending payment failed notice transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, - ) - } - - if (invoice.status !== 'open') { - throw new Error( - `not sending payment failed notice transactional for subscription ${subscriptionExternalId}, invoice ${invoiceExternalId} is not in state open but ${invoice.status}`, - ) - } - - if (subscription.endedAt) { - throw new Error( - `Subscription ${subscriptionExternalId} has ended, not sending failed payment notice transactional`, - ) - } - - if (!userRow?.email) { - throw new Error( - `Could not find email for user with id ${userId}, not sending failed payment notice transactional`, - ) - } - - await sendPaymentFailedNoticeMail( - { subscription, invoice, email: userRow.email }, - this.pgdb, - ) - } - - async syncMailchimpSetupSubscription({ - userId, - subscriptionExternalId, - }: { - userId: string - subscriptionExternalId: string - }): Promise { - const subscribeToOnboardingMails = await this.isUserFirstTimeSubscriber( - userId, - subscriptionExternalId, - ) - - // sync to mailchimp - await enforceSubscriptions({ - userId: userId, - subscribeToOnboardingMails: subscribeToOnboardingMails, - subscribeToEditorialNewsletters: true, - pgdb: this.pgdb, - }) - } - - async syncMailchimpUpdateSubscription({ - userId, - }: { - userId: string - subscriptionExternalId: string - }): Promise { - // sync to mailchimp - await enforceSubscriptions({ - userId: userId, - subscribeToOnboardingMails: false, - subscribeToEditorialNewsletters: false, - pgdb: this.pgdb, - }) } getSubscriptionInvoices(subscriptionId: string): Promise { @@ -423,7 +129,7 @@ export class Payments implements PaymentService { } } - async getCharge(by: PaymentItemLocator) { + async getCharge(by: SelectCriteria) { return await this.billing.getCharge(by) } @@ -431,16 +137,16 @@ export class Payments implements PaymentService { return await this.billing.saveCharge(args) } - async updateCharge(by: PaymentItemLocator, args: ChargeUpdate): Promise { + async updateCharge(by: SelectCriteria, args: ChargeUpdate): Promise { return this.billing.updateCharge(by, args) } - async getInvoice(by: PaymentItemLocator): Promise { + async getInvoice(by: SelectCriteria): Promise { return await this.billing.getInvoice(by) } async updateInvoice( - by: PaymentItemLocator, + by: SelectCriteria, args: InvoiceUpdateArgs, ): Promise { return await this.billing.updateInvoice(by, args) @@ -550,7 +256,7 @@ export class Payments implements PaymentService { return this.billing.getUserSubscriptions(userId, only) } - getSubscription(by: PaymentItemLocator): Promise { + getSubscription(by: SelectCriteria): Promise { return this.billing.getSubscription(by) } @@ -579,8 +285,11 @@ export class Payments implements PaymentService { } async disableSubscription( - locator: PaymentItemLocator, + locator: SelectCriteria, args: any, + options?: { + keepMembership: boolean + }, ): Promise { const tx = await this.pgdb.transactionBegin() const txRepo = new BillingRepo(tx) @@ -598,12 +307,14 @@ export class Payments implements PaymentService { ) } - await tx.query( - `SELECT public.remove_user_from_role(:userId, 'member');`, - { - userId: sub.userId, - }, - ) + if (options?.keepMembership !== true) { + await tx.query( + `SELECT public.remove_user_from_role(:userId, 'member');`, + { + userId: sub.userId, + }, + ) + } await tx.transactionCommit() @@ -615,35 +326,6 @@ export class Payments implements PaymentService { } } - async ensureUserHasCustomerIds(userId: string): Promise { - const tasks = Companies.map(async (company) => { - const customer = await this.pgdb.payments.stripeCustomers.findOne({ - userId, - company, - }) - - if (!customer) { - await Queue.getInstance().send( - 'payments:stripe:customer:create', - { - $version: 'v1', - userId, - company, - }, - { - priority: 1000, - singletonKey: `stripe:customer:create:for:${userId}:${company}`, - singletonHours: 1, - retryLimit: 5, - retryDelay: 500, - }, - ) - } - }) - - await Promise.all(tasks) - } - async createCustomer(company: Company, userId: string): Promise { const user = await this.users.findUserById(userId) @@ -678,17 +360,17 @@ export class Payments implements PaymentService { await this.customers.saveCustomerIdForCompany(user.id, company, customerId) if (!oldCustomerData || oldCustomerData?.customerId === null) { - // backfill into lagacy table to prevent new customers from beeing created if the old checkout is used - // get rid of this as soon as posible... + // backfill into legacy table to prevent new customers from being created if the old checkout is used + // get rid of this as soon as possible... try { - const { id: lagacyCompanyId } = + const { id: legacyCompanyId } = await this.pgdb.public.companies.findOne({ name: company, }) await this.pgdb.public.stripeCustomers.insert({ id: customerId, userId: userId, - companyId: lagacyCompanyId, + companyId: legacyCompanyId, }) } catch (e) { console.error(e) @@ -698,31 +380,8 @@ export class Payments implements PaymentService { return customerId } - async updateCustomerEmail(company: Company, userId: string, email: string) { - const customer = await this.customers.getCustomerIdForCompany( - userId, - company, - ) - if (!customer) { - return null - } - - PaymentProvider.forCompany(company).updateCustomerEmail( - customer.customerId, - email, - ) - } - - async saveOrder(userId: string, order: OrderArgs): Promise { - const args: OrderRepoArgs = { - userId: userId, - company: order.company, - externalId: order.externalId, - status: order.status, - invoiceId: order.invoiceId, - subscriptionId: order.subscriptionId, - } - return await this.billing.saveOrder(args) + async saveOrder(order: OrderArgs): Promise { + return await this.billing.saveOrder(order) } async updateUserName( @@ -747,104 +406,10 @@ export class Payments implements PaymentService { } } - async updateUserAddress( - userId: string, - addressData: Address, - ): Promise { - const tx = await this.pgdb.transactionBegin() - const txUserRepo = new UserDataRepo(tx) - try { - const user = await txUserRepo.findUserById(userId) - if (!user) { - throw Error(`User ${userId} does not exist`) - } - - const args = { - name: `${user.firstName} ${user.lastName}`, - city: addressData.city, - line1: addressData.line1, - line2: addressData.line2, - postalCode: addressData.postal_code, - country: RegionNames.of(addressData.country!), - } - - if (user.addressId) { - await tx.public.addresses.update({ id: user.addressId }, args) - } else { - const address = await txUserRepo.insertAddress(args) - - await txUserRepo.updateUser(user.id, { - addressId: address.id, - }) - } - - tx.transactionCommit() - return user - } catch (e) { - console.log(e) - await tx.transactionRollback() - throw e - } - } - static getInstance(): PaymentService { this.assertRunning() return this.#instance } - - verifyWebhookForCompany(company: string, req: any): T { - let whsec - switch (company) { - case 'PROJECT_R': - whsec = getConfig().PROJECT_R_STRIPE_ENDPOINT_SECRET - break - case 'REPUBLIK': - whsec = getConfig().REPUBLIK_STRIPE_ENDPOINT_SECRET - break - default: - throw Error(`Unsupported company ${company}`) - } - - assert( - typeof whsec === 'string', - `Webhook secret for ${company} is not configured`, - ) - - const event = PaymentProvider.forCompany(company).verifyWebhook( - req, - whsec, - ) - - return event - } - - logWebhookEvent(webhook: WebhookArgs): Promise> { - return this.webhooks.insertWebhookEvent(webhook) - } - findWebhookEventBySourceId(sourceId: string): Promise | null> { - return this.webhooks.findWebhookEventBySourceId(sourceId) - } - markWebhookAsProcessed(sourceId: string): Promise> { - return this.webhooks.updateWebhookEvent(sourceId, { processed: true }) - } - - private async getUser(userId: string): Promise { - return this.pgdb.public.users.findOne({ id: userId }) - } - - private async isUserFirstTimeSubscriber( - userId: string, - subscriptionExternalId: string, - ): Promise { - const memberships = await this.pgdb.public.memberships.find({ - userId: userId, - }) - const subscriptions = await this.pgdb.payments.subscriptions.find({ - 'externalId !=': subscriptionExternalId, - status: NOT_STARTED_STATUS_TYPES, - }) - return !(memberships?.length > 0 || subscriptions?.length > 0) - } } /* @@ -860,14 +425,15 @@ export interface PaymentService { userId: string, args: SubscriptionArgs, ): Promise - getSubscription(by: PaymentItemLocator): Promise + getSubscription(by: SelectCriteria): Promise updateSubscription(args: SubscriptionArgs): Promise disableSubscription( - by: PaymentItemLocator, + by: SelectCriteria, args: { endedAt: Date canceledAt: Date }, + options?: { keepMembership: boolean }, ): Promise getCustomerIdForCompany( userId: string, @@ -877,81 +443,20 @@ export interface PaymentService { copmany: Company, customerId: string, ): Promise - ensureUserHasCustomerIds(userId: string): Promise createCustomer(company: Company, userId: string): Promise - updateCustomerEmail(company: Company, userId: string, email: string): any listUserOrders(userId: string): Promise getOrder(id: string): Promise - saveOrder(userId: string, order: OrderArgs): Promise + saveOrder(order: OrderArgs): Promise getSubscriptionInvoices(subscriptionId: string): Promise - getInvoice(by: PaymentItemLocator): Promise + getInvoice(by: SelectCriteria): Promise saveInvoice(userId: string, args: InvoiceArgs): Promise - getCharge(by: PaymentItemLocator): Promise + getCharge(by: SelectCriteria): Promise saveCharge(args: ChargeInsert): Promise - updateCharge(by: PaymentItemLocator, args: ChargeUpdate): Promise - updateInvoice( - by: PaymentItemLocator, - args: InvoiceUpdateArgs, - ): Promise - verifyWebhookForCompany(company: string, req: any): T - logWebhookEvent(webhook: WebhookArgs): Promise> - findWebhookEventBySourceId(sourceId: string): Promise | null> - markWebhookAsProcessed(sourceId: string): Promise> + updateCharge(by: SelectCriteria, args: ChargeUpdate): Promise + updateInvoice(by: SelectCriteria, args: InvoiceUpdateArgs): Promise updateUserName( userId: string, firstName: string, lastName: string, ): Promise - updateUserAddress(userId: string, addressData: Address): Promise - sendSetupSubscriptionTransactionalMail({ - subscriptionExternalId, - userId, - invoiceId, - }: { - subscriptionExternalId: string - userId: string - invoiceId: string - }): Promise - sendCancelConfirmationTransactionalMail({ - subscriptionExternalId, - userId, - }: { - subscriptionExternalId: string - userId: string - }): Promise - syncMailchimpSetupSubscription({ - userId, - subscriptionExternalId, - }: { - userId: string - subscriptionExternalId: string - }): Promise - syncMailchimpUpdateSubscription({ userId }: { userId: string }): Promise - sendSubscriptionEndedNoticeTransactionalMail({ - userId, - subscriptionExternalId, - cancellationReason, - }: { - userId: string - subscriptionExternalId: string - cancellationReason?: string - }): Promise - sendNoticePaymentFailedTransactionalMail({ - userId, - subscriptionExternalId, - invoiceExternalId, - }: { - userId: string - subscriptionExternalId: string - invoiceExternalId: string - }): Promise - sendRevokeCancellationConfirmationTransactionalMail({ - subscriptionExternalId, - userId, - revokedCancellationDate, - }: { - subscriptionExternalId: string - userId: string - revokedCancellationDate: Date - }): Promise } diff --git a/packages/backend-modules/payments/lib/providers/base.ts b/packages/backend-modules/payments/lib/providers/base.ts index 80857467b2..f8139068ab 100644 --- a/packages/backend-modules/payments/lib/providers/base.ts +++ b/packages/backend-modules/payments/lib/providers/base.ts @@ -35,4 +35,5 @@ export interface PaymentProviderActions { locale: 'auto' | 'de' | 'en' | 'fr' }, ): Promise + getCheckoutSession(id: string): Promise } diff --git a/packages/backend-modules/payments/lib/providers/stripe.ts b/packages/backend-modules/payments/lib/providers/stripe.ts index 955e9d1d7c..0677160d52 100644 --- a/packages/backend-modules/payments/lib/providers/stripe.ts +++ b/packages/backend-modules/payments/lib/providers/stripe.ts @@ -140,6 +140,14 @@ export class StripeProvider implements PaymentProviderActions { return sess.url } + async getCheckoutSession( + id: string, + ): Promise { + return this.#stripe.checkout.sessions.retrieve(id, { + expand: ['line_items', 'line_items.data.price.product'], + }) + } + verifyWebhook(req: any, secret: string): any { const signature = req.headers['stripe-signature'] diff --git a/packages/backend-modules/payments/lib/services/CustomerInfoService.ts b/packages/backend-modules/payments/lib/services/CustomerInfoService.ts new file mode 100644 index 0000000000..4748ccfafa --- /dev/null +++ b/packages/backend-modules/payments/lib/services/CustomerInfoService.ts @@ -0,0 +1,105 @@ +import { PgDb } from 'pogi' +import { CustomerRepo } from '../database/CutomerRepo' +import { Company, Address } from '../types' +import { PaymentProvider } from '../providers/provider' +import { Companies } from '../payments' +import { Queue } from '@orbiting/backend-modules-job-queue' +import { StripeCustomerCreateWorker } from '../workers/StripeCustomerCreateWorker' +import { UserDataRepo } from '../database/UserRepo' +import { UserRow } from '@orbiting/backend-modules-types' + +const RegionNames = new Intl.DisplayNames(['de-CH'], { type: 'region' }) + +export class CustomerInfoService { + #pgdb + #customers: CustomerRepo + + constructor(pgdb: PgDb) { + this.#pgdb = pgdb + this.#customers = new CustomerRepo(pgdb) + } + + async updateCustomerEmail(company: Company, userId: string, email: string) { + const customer = await this.#customers.getCustomerIdForCompany( + userId, + company, + ) + if (!customer) { + return null + } + + PaymentProvider.forCompany(company).updateCustomerEmail( + customer.customerId, + email, + ) + } + + async updateUserAddress( + userId: string, + addressData: Address, + ): Promise { + const tx = await this.#pgdb.transactionBegin() + const txUserRepo = new UserDataRepo(tx) + try { + const user = await txUserRepo.findUserById(userId) + if (!user) { + throw Error(`User ${userId} does not exist`) + } + + const args = { + name: `${user.firstName} ${user.lastName}`, + city: addressData.city, + line1: addressData.line1, + line2: addressData.line2, + postalCode: addressData.postal_code, + country: RegionNames.of(addressData.country!), + } + + if (user.addressId) { + await tx.public.addresses.update({ id: user.addressId }, args) + } else { + const address = await txUserRepo.insertAddress(args) + + await txUserRepo.updateUser(user.id, { + addressId: address.id, + }) + } + + tx.transactionCommit() + return user + } catch (e) { + console.log(e) + await tx.transactionRollback() + throw e + } + } + + async ensureUserHasCustomerIds(userId: string): Promise { + const tasks = Companies.map(async (company) => { + const customer = await this.#pgdb.payments.stripeCustomers.findOne({ + userId, + company, + }) + + if (!customer) { + await Queue.getInstance().send( + 'payments:stripe:customer:create', + { + $version: 'v1', + userId, + company, + }, + { + priority: 1000, + singletonKey: `stripe:customer:create:for:${userId}:${company}`, + singletonHours: 1, + retryLimit: 5, + retryDelay: 500, + }, + ) + } + }) + + await Promise.all(tasks) + } +} diff --git a/packages/backend-modules/payments/lib/services/MailNotificationService.ts b/packages/backend-modules/payments/lib/services/MailNotificationService.ts new file mode 100644 index 0000000000..64d39e45a6 --- /dev/null +++ b/packages/backend-modules/payments/lib/services/MailNotificationService.ts @@ -0,0 +1,351 @@ +import { PgDb } from 'pogi' +import { NOT_STARTED_STATUS_TYPES, ACTIVE_STATUS_TYPES } from '../types' +import { + sendCancelConfirmationMail, + sendEndedNoticeMail, + sendPaymentFailedNoticeMail, + sendRevokeCancellationConfirmationMail, + sendSetupGiftMail, + sendSetupSubscriptionMail, +} from '../transactionals/sendTransactionalMails' +import { UserDataRepo } from '../database/UserRepo' +import { BillingRepo } from '../database/BillingRepo' +import { enforceSubscriptions } from '@orbiting/backend-modules-mailchimp' + +export class MailNotificationService { + #pgdb: PgDb + #users: UserDataRepo + #billing: BillingRepo + + constructor(pgdb: PgDb) { + this.#pgdb = pgdb + this.#users = new UserDataRepo(pgdb) + this.#billing = new BillingRepo(pgdb) + } + + async sendSetupSubscriptionTransactionalMail({ + subscriptionExternalId, + userId, + invoiceId, + }: { + subscriptionExternalId: string + userId: string + invoiceId: string + }): Promise { + const subscription = await this.#billing.getSubscription({ + externalId: subscriptionExternalId, + }) + + if (!subscription) { + throw new Error( + `Subscription [${subscriptionExternalId}] does not exist in the Database`, + ) + } + + if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { + throw new Error( + `not sending transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, + ) + } + const userRow = await this.#users.findUserById(userId) + if (!userRow) { + throw new Error('unknown user') + } + + const invoice = await this.#billing.getInvoice({ id: invoiceId }) + if (!invoice) { + throw new Error( + `Invoice ${invoiceId} does not exist in the database, not able to send subscription setup confirmation transactional mail.`, + ) + } + // send mail + await sendSetupSubscriptionMail( + { subscription, invoice, email: userRow.email }, + this.#pgdb, + ) + } + + async sendCancelConfirmationTransactionalMail({ + subscriptionExternalId, + userId, + }: { + subscriptionExternalId: string + userId: string + }): Promise { + const subscription = await this.#billing.getSubscription({ + externalId: subscriptionExternalId, + }) + + const userRow = await this.#users.findUserById(userId) + + if (!subscription) { + throw new Error( + `Subscription [${subscriptionExternalId}] does not exist in the Database`, + ) + } + + if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { + throw new Error( + `not sending cancellation confirmation transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, + ) + } + + if (!subscription.cancelAt || !subscription.canceledAt) { + throw new Error( + `Subscription ${subscriptionExternalId} is not cancelled, not sending cancellation confirmation transactional`, + ) + } + + if (!userRow?.email) { + throw new Error( + `Could not find email for user with id ${userId}, not sending cancellation confirmation transactional`, + ) + } + + await sendCancelConfirmationMail( + { + endDate: subscription.cancelAt, + cancellationDate: subscription.canceledAt, + type: subscription.type, + userId: userId, + email: userRow.email, + }, + this.#pgdb, + ) + } + + async sendRevokeCancellationConfirmationTransactionalMail({ + subscriptionExternalId, + userId, + revokedCancellationDate, + }: { + subscriptionExternalId: string + userId: string + revokedCancellationDate: Date + }): Promise { + const subscription = await this.#billing.getSubscription({ + externalId: subscriptionExternalId, + }) + + const userRow = await this.#users.findUserById(userId) + if (!userRow) { + throw new Error('unknown user') + } + + if (!subscription) { + throw new Error( + `Subscription [${subscriptionExternalId}] does not exist in the Database`, + ) + } + + if (subscription.cancelAt) { + throw new Error( + `Subscription ${subscriptionExternalId} is still cancelled, not sending revoke cancellation confirmation transactional`, + ) + } + + if (!userRow.email) { + throw new Error( + `Could not find email for user with id ${userId}, not sending revoke cancellation confirmation transactional`, + ) + } + + await sendRevokeCancellationConfirmationMail( + { + currentEndDate: subscription.currentPeriodEnd, + revokedCancellationDate, + type: subscription.type, + userId, + email: userRow.email, + }, + this.#pgdb, + ) + } + + async sendSubscriptionEndedNoticeTransactionalMail({ + userId, + subscriptionExternalId, + cancellationReason, + }: { + userId: string + subscriptionExternalId: string + cancellationReason?: string + }): Promise { + const subscription = await this.#billing.getSubscription({ + externalId: subscriptionExternalId, + }) + + const userRow = await this.#users.findUserById(userId) + + if (!subscription) { + throw new Error( + `Subscription [${subscriptionExternalId}] does not exist in the Database`, + ) + } + + if (ACTIVE_STATUS_TYPES.includes(subscription.status)) { + throw new Error( + `not sending ended notice transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, + ) + } + + if (!subscription.endedAt) { + throw new Error( + `Subscription ${subscriptionExternalId} has not ended, not sending ended notice transactional`, + ) + } + + if (!userRow?.email) { + throw new Error( + `Could not find email for user with id ${userId}, not sending ended notice transactional`, + ) + } + + await sendEndedNoticeMail( + { subscription, cancellationReason, email: userRow.email }, + this.#pgdb, + ) + } + + async sendNoticePaymentFailedTransactionalMail({ + userId, + subscriptionExternalId, + invoiceExternalId, + }: { + userId: string + subscriptionExternalId: string + invoiceExternalId: string + }): Promise { + const subscription = await this.#billing.getSubscription({ + externalId: subscriptionExternalId, + }) + + const invoice = await this.#billing.getInvoice({ + externalId: invoiceExternalId, + }) + + const userRow = await this.#users.findUserById(userId) + + if (!subscription) { + throw new Error( + `Subscription [${subscriptionExternalId}] does not exist in the Database`, + ) + } + + if (!invoice) { + throw new Error( + `Invoice ${invoiceExternalId} does not exist in the Database`, + ) + } + + if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { + throw new Error( + `not sending payment failed notice transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, + ) + } + + if (invoice.status !== 'open') { + throw new Error( + `not sending payment failed notice transactional for subscription ${subscriptionExternalId}, invoice ${invoiceExternalId} is not in state open but ${invoice.status}`, + ) + } + + if (subscription.endedAt) { + throw new Error( + `Subscription ${subscriptionExternalId} has ended, not sending failed payment notice transactional`, + ) + } + + if (!userRow?.email) { + throw new Error( + `Could not find email for user with id ${userId}, not sending failed payment notice transactional`, + ) + } + + await sendPaymentFailedNoticeMail( + { subscription, invoice, email: userRow.email }, + this.#pgdb, + ) + } + + async sendSetupGiftSubscriptionTransactionalMail({ + subscriptionExternalId, + userId, + }: { + subscriptionExternalId: string + userId: string + }): Promise { + const subscription = await this.#billing.getSubscription({ + externalId: subscriptionExternalId, + }) + + if (!subscription) { + throw new Error( + `Subscription [${subscriptionExternalId}] does not exist in the Database`, + ) + } + + if (!ACTIVE_STATUS_TYPES.includes(subscription.status)) { + throw new Error( + `not sending transactional for subscription ${subscriptionExternalId} with status ${subscription.status}`, + ) + } + const userRow = await this.#users.findUserById(userId) + if (!userRow) { + throw new Error('unknown user') + } + + await sendSetupGiftMail({ email: userRow.email }, this.#pgdb) + } + + async syncMailchimpSetupSubscription({ + userId, + subscriptionExternalId, + }: { + userId: string + subscriptionExternalId: string + }): Promise { + const subscribeToOnboardingMails = await this.isUserFirstTimeSubscriber( + userId, + subscriptionExternalId, + ) + + // sync to mailchimp + await enforceSubscriptions({ + userId: userId, + subscribeToOnboardingMails: subscribeToOnboardingMails, + subscribeToEditorialNewsletters: true, + pgdb: this.#pgdb, + }) + } + + async syncMailchimpUpdateSubscription({ + userId, + }: { + userId: string + }): Promise { + // sync to mailchimp + await enforceSubscriptions({ + userId: userId, + subscribeToOnboardingMails: false, + subscribeToEditorialNewsletters: false, + pgdb: this.#pgdb, + }) + } + + private async isUserFirstTimeSubscriber( + userId: string, + subscriptionExternalId: string, + ): Promise { + const memberships = await this.#pgdb.public.memberships.find({ + userId: userId, + }) + const subscriptions = await this.#pgdb.payments.subscriptions.find({ + 'externalId !=': subscriptionExternalId, + status: NOT_STARTED_STATUS_TYPES, + }) + return !(memberships?.length > 0 || subscriptions?.length > 0) + } +} + + diff --git a/packages/backend-modules/payments/lib/services/WebhookService.ts b/packages/backend-modules/payments/lib/services/WebhookService.ts new file mode 100644 index 0000000000..087f2ee13a --- /dev/null +++ b/packages/backend-modules/payments/lib/services/WebhookService.ts @@ -0,0 +1,54 @@ +import { PgDb } from 'pogi' +import { WebhookArgs, WebhookRepo } from '../database/WebhookRepo' +import { getConfig } from '../config' +import { PaymentProvider } from '../providers/provider' +import { Webhook } from '../types' +import assert from 'node:assert' + +export class WebhookService { + #repo: WebhookRepo + + constructor(pgdb: PgDb) { + this.#repo = new WebhookRepo(pgdb) + } + + getEvent(id: string): Promise | null> { + return this.#repo.findWebhookEventBySourceId(id) + } + + logEvent(webhook: WebhookArgs): Promise> { + return this.#repo.insertWebhookEvent(webhook) + } + + markEventAsProcessed(id: string) { + return this.#repo.updateWebhookEvent(id, { + processed: true, + }) + } + + verifyWebhook(company: string, req: any): T { + let whsec + switch (company) { + case 'PROJECT_R': + whsec = getConfig().PROJECT_R_STRIPE_ENDPOINT_SECRET + break + case 'REPUBLIK': + whsec = getConfig().REPUBLIK_STRIPE_ENDPOINT_SECRET + break + default: + throw Error(`Unsupported company ${company}`) + } + + assert( + typeof whsec === 'string', + `Webhook secret for ${company} is not configured`, + ) + + const event = PaymentProvider.forCompany(company).verifyWebhook( + req, + whsec, + ) + + return event + } +} diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index 892ff7bbc9..acc30929b7 100644 --- a/packages/backend-modules/payments/lib/shop/Shop.ts +++ b/packages/backend-modules/payments/lib/shop/Shop.ts @@ -1,6 +1,6 @@ import Stripe from 'stripe' import { Company } from '../types' -import { Offer } from './offers' +import { Offer, ComplimentaryItemOrder } from './offers' import { ProjectRStripe, RepublikAGStripe } from '../providers/stripe' import { getConfig } from '../config' import { User } from '@orbiting/backend-modules-types' @@ -30,12 +30,14 @@ export class Shop { metadata, customFields, returnURL, + complimentaryItems, }: { offer: Offer uiMode: 'HOSTED' | 'CUSTOM' | 'EMBEDDED' - customerId: string + customerId?: string discounts?: string[] customPrice?: number + complimentaryItems?: ComplimentaryItemOrder[] metadata?: Record returnURL?: string customFields?: Stripe.Checkout.SessionCreateParams.CustomField[] @@ -58,20 +60,35 @@ export class Shop { ...uiConfig, mode: checkoutMode, customer: customerId, - line_items: [lineItem], + line_items: [ + lineItem, + ...(complimentaryItems?.map(promoItemToLineItem) || []), + ], currency: offer.price?.currency, discounts: discounts?.map((id) => ({ coupon: id })), locale: 'de', billing_address_collection: offer.company === 'PROJECT_R' ? 'required' : 'auto', - custom_fields: customFields, + shipping_address_collection: complimentaryItems?.length + ? { + allowed_countries: ['CH'], + } + : undefined, + custom_fields: offer.requiresLogin ? customFields : undefined, payment_method_configuration: getPaymentConfigId(offer.company), - subscription_data: { - metadata: { - ...metadata, - ...offer.metaData, - }, + metadata: { + ...metadata, + ...offer.metaData, }, + subscription_data: + offer.type === 'SUBSCRIPTION' + ? { + metadata: { + ...metadata, + ...offer.metaData, + }, + } + : undefined, consent_collection: { terms_of_service: 'required', }, @@ -91,7 +108,6 @@ export class Shop { const price = ( await this.#stripeAdapters[offer.company].prices.list({ active: true, - type: 'recurring', lookup_keys: [offer.defaultPriceLookupKey], expand: ['data.product'], }) @@ -122,21 +138,27 @@ export class Shop { const priceData = ( await this.#stripeAdapters[company].prices.list({ active: true, - type: 'recurring', lookup_keys: lookupKeys, expand: ['data.product'], }) ).data - return Promise.all( - offers.map(async (offer) => { - const price = priceData.find( - (p) => p.lookup_key === offer.defaultPriceLookupKey, - )! - - return this.mergeOfferData(offer, price, options) - }), - ) + return ( + await Promise.allSettled( + offers.map(async (offer) => { + const price = priceData.find( + (p) => p.lookup_key === offer.defaultPriceLookupKey, + )! + + return this.mergeOfferData(offer, price, options) + }), + ) + ).reduce((acc: Offer[], res) => { + if (res.status === 'fulfilled') { + acc.push(res.value) + } + return acc + }, []) } private async mergeOfferData( @@ -145,7 +167,6 @@ export class Shop { options?: { promoCode?: string; withIntroductoryOffer?: boolean }, ): Promise { const discount = await this.getIndrodcuturyOfferOrPromotion(base, options) - return { ...base, productId: (price.product as Stripe.Product).id, @@ -233,11 +254,15 @@ export class Shop { return promition.data[0] } - private genLineItem(offer: Offer, customPrice?: number) { - if (offer.customPrice && typeof customPrice !== 'undefined') { + public genLineItem(offer: Offer, customPrice?: number) { + if ( + offer.type === 'SUBSCRIPTION' && + offer.customPrice && + typeof customPrice !== 'undefined' + ) { return { price_data: { - product: offer.productId, + product: offer.productId!, unit_amount: Math.max(offer.customPrice.min, customPrice), currency: offer.price!.currency, recurring: offer.customPrice!.recurring, @@ -248,7 +273,7 @@ export class Shop { } return { - price: offer.price?.id, + price: offer.price!.id, tax_rates: offer.taxRateId ? [offer.taxRateId] : undefined, quantity: 1, } @@ -286,6 +311,14 @@ export async function checkIntroductoryOfferEligibility( return false } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function promoItemToLineItem(_item: ComplimentaryItemOrder) { + return { + price: 'price_1QQUCcFHX910KaTH9SKJhFZI', + quantity: 1, + } +} + function checkoutUIConfig( uiMode: 'HOSTED' | 'CUSTOM' | 'EMBEDDED', offer: Offer, diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts new file mode 100644 index 0000000000..5d6f39e302 --- /dev/null +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -0,0 +1,648 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { PgDb } from 'pogi' +import { Company } from '../types' +import Stripe from 'stripe' +import { CrockfordBase32 } from 'crockford-base32' +import { ProjectRStripe, RepublikAGStripe } from '../providers/stripe' +import { CustomerRepo } from '../database/CutomerRepo' +import { Payments } from '../payments' +import { activeOffers } from './offers' +import { Shop } from './Shop' +import dayjs from 'dayjs' +import { GiftVoucherRepo } from '../database/GiftVoucherRepo' +import createLogger from 'debug' +import { + REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY, + serializeMailSettings, +} from '../mail-settings' +import { getConfig } from '../config' +import { secondsToMilliseconds } from '../handlers/stripe/utils' + +const logger = createLogger('payments:gifts') + +export type ApplyGiftResult = { + id?: string + aboType: string + company: Company + starting: Date +} + +export type Gift = { + id: string + company: Company + offer: string + coupon: string + valueType: 'FIXED' | 'PERCENTAGE' + value: number + duration: number + durationUnit: 'year' | 'month' +} + +export type Voucher = { + id?: string + orderId: string | null + code: string + giftId: string + issuedBy: Company + redeemedBy: string | null + redeemedForCompany: Company | null + redeemedAt: Date | null +} + +type PLEDGE_ABOS = 'ABO' | 'MONTHLY_ABO' | 'YEARLY_ABO' | 'BENEFACTOR_ABO' +type SUBSCRIPTIONS = 'YEARLY_SUBSCRIPTION' | 'MONTHLY_SUBSCRIPTION' +type PRODUCT_TYPE = PLEDGE_ABOS | SUBSCRIPTIONS + +export function normalizeVoucher(voucherCode: string): string | null { + try { + const code = CrockfordBase32.decode(voucherCode) + return CrockfordBase32.encode(code) + } catch { + return null + } +} + +function newVoucherCode() { + const bytes = new Uint8Array(5) + crypto.getRandomValues(bytes) + return CrockfordBase32.encode(Buffer.from(bytes)) +} + +const GIFTS: Gift[] = [ + { + id: 'GIFT_YEARLY', + duration: 1, + durationUnit: 'year', + offer: 'YEARLY', + coupon: getConfig().PROJECT_R_YEARLY_GIFT_COUPON, + company: 'PROJECT_R', + value: 100, + valueType: 'PERCENTAGE', + }, + { + id: 'MONTHLY_SUBSCRPTION_GIFT_3', + duration: 3, + durationUnit: 'month', + offer: 'MONTHLY', + coupon: getConfig().REPUBLIK_3_MONTH_GIFT_COUPON, + company: 'REPUBLIK', + value: 100, + valueType: 'PERCENTAGE', + }, +] + +export const REPUBLIK_PAYMENTS_SUBSCRIPTION_UPGRADED_FROM = + 'republik.payments.subscription.upgraded-from' + +export const REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN = + 'republik.payments.subscription.origin' + +export const REPUBLIK_PAYMENTS_CANCEL_REASON = + 'republik.payments.system.cancel.reason' + +export class GiftShop { + #pgdb: PgDb + #giftRepo: GiftVoucherRepo + #stripeAdapters: Record = { + PROJECT_R: ProjectRStripe, + REPUBLIK: RepublikAGStripe, + } + + constructor(pgdb: PgDb) { + this.#pgdb = pgdb + this.#giftRepo = new GiftVoucherRepo(pgdb) + } + + async generateNewVoucher({ + company, + orderId, + giftId, + }: { + company: Company + orderId?: string + giftId: string + }) { + const voucher: Voucher = { + orderId: orderId || null, + issuedBy: company, + code: newVoucherCode(), + giftId: giftId, + redeemedAt: null, + redeemedBy: null, + redeemedForCompany: null, + } + + this.#giftRepo.saveVoucher(voucher) + + return voucher + } + + async redeemVoucher( + voucherCode: string, + userId: string, + ): Promise { + const code = normalizeVoucher(voucherCode) + if (!code) { + throw new Error('voucher is invalid') + } + + const voucher = await this.#giftRepo.getVoucherByCode(code) + if (!voucher) { + throw new Error('Unknown voucher') + } + + if (voucher.redeemedAt !== null) { + throw new Error('gift has already been redeemed') + } + + const gift = await this.getGift(voucher.giftId) + if (!gift) { + throw new Error('unknown gift unsuspected system error') + } + + const current = await this.getCurrentUserAbo(userId) + + const abo = await this.applyGift(userId, current, gift) + + await this.markVoucherAsRedeemed({ + voucher, + userId, + company: abo.company, + }) + + return abo + } + + private async applyGift( + userId: string, + current: { type: string; id: string } | null, + gift: Gift, + ): Promise { + if (!current) { + // create new subscription with the gift if the user has no active subscription + return this.applyGiftToNewSubscription(userId, gift) + } + + switch (current.type) { + case 'ABO': + return this.applyGiftToMembershipAbo(userId, current.id, gift) + case 'MONTHLY_ABO': + return this.applyGiftToMonthlyAbo(userId, current.id, gift) + case 'YEARLY_ABO': + return this.applyGiftToYearlyAbo(userId, current.id, gift) + case 'BENEFACTOR_ABO': + return this.applyGiftToBenefactor(userId, current.id, gift) + case 'YEARLY_SUBSCRIPTION': + return this.applyGiftToYearlySubscription(userId, current.id, gift) + case 'MONTHLY_SUBSCRIPTION': + return this.applyGiftToMonthlySubscription(userId, current.id, gift) + default: + throw Error('Gifts not supported for this mabo') + } + } + + private async getGift(id: string) { + return GIFTS.find((gift) => gift.id === id) || null + } + + private async markVoucherAsRedeemed(args: { + voucher: Voucher + userId: string + company: Company + }) { + this.#giftRepo.updateVoucher(args.voucher.id!, { + redeemedAt: new Date(), + redeemedBy: args.userId, + redeemedForCompany: args.company, + }) + } + + private async getCurrentUserAbo( + userId: string, + ): Promise<{ type: PRODUCT_TYPE; id: string } | null> { + const result = await this.#pgdb.query( + `SELECT + "id", + "type"::text as "type" + FROM payments.subscriptions + WHERE + "userId" = :userId + AND status in ('active') + UNION + SELECT + m.id, + mt."name"::text as "type" + FROM memberships m + JOIN "membershipTypes" mt + ON m."membershipTypeId" = mt.id + WHERE m."userId" = :userId + AND m.active = true`, + { userId }, + ) + + if (!result.length) { + return null + } + + return result[0] + } + + private async applyGiftToNewSubscription( + userId: string, + gift: Gift, + ): Promise { + const cRepo = new CustomerRepo(this.#pgdb) + + const customerId = await this.getCustomerId(cRepo, gift.company, userId) + + const shop = new Shop(activeOffers()) + const offer = (await shop.getOfferById(gift.offer))! + + const subscription = await this.#stripeAdapters[ + gift.company + ].subscriptions.create({ + customer: customerId, + items: [shop.genLineItem(offer)], + coupon: gift.coupon, + collection_method: 'send_invoice', + cancel_at_period_end: true, + days_until_due: 14, + metadata: { + [REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN]: 'GIFT', + }, + }) + + if (subscription.latest_invoice) { + await this.#stripeAdapters[gift.company].invoices.finalizeInvoice( + subscription.latest_invoice.toString(), + ) + } + return { aboType: offer.id, company: gift.company, starting: new Date() } + } + + private async applyGiftToMembershipAbo( + _userId: string, + membershipId: string, + gift: Gift, + ): Promise { + const tx = await this.#pgdb.transactionBegin() + try { + const endDate = await this.getMembershipEndDate(tx, membershipId) + + const newMembershipPeriod = + await tx.public.membershipPeriods.insertAndGet({ + membershipId: membershipId, + beginDate: endDate.toDate(), + endDate: endDate.add(gift.duration, gift.durationUnit).toDate(), + kind: 'GIFT', + }) + + logger('new membership period created %s', newMembershipPeriod.id) + + await tx.transactionCommit() + + return { + id: membershipId, + aboType: 'ABO', + company: 'PROJECT_R', + starting: endDate.toDate(), + } + } catch (e) { + await tx.transactionRollback() + throw e + } + } + + private async applyGiftToMonthlyAbo( + userId: string, + membershipId: string, + gift: Gift, + ): Promise { + const stripeId = await this.getStripeIdForMonthlyAbo(membershipId) + + if (!stripeId) { + throw new Error(`membership ${membershipId} does not exist`) + } + + switch (gift.company) { + case 'REPUBLIK': { + const sub = await this.#stripeAdapters.REPUBLIK.subscriptions.update( + stripeId, + { + coupon: gift.coupon, + }, + ) + return { + id: membershipId, + aboType: 'MONLTY_ABO', + company: 'REPUBLIK', + starting: new Date(secondsToMilliseconds(sub.current_period_end)), + } + } + case 'PROJECT_R': { + const cRepo = new CustomerRepo(this.#pgdb) + const customerId = await this.getCustomerId(cRepo, 'PROJECT_R', userId) + + const shop = new Shop(activeOffers()) + const offer = (await shop.getOfferById(gift.offer))! + + const tx = await this.#pgdb.transactionBegin() + + const updatedMembership = await tx.public.memberships.updateAndGetOne( + { + id: membershipId, + }, + { + renew: false, + updatedAt: new Date(), + }, + ) + + await tx.membershipCancellations.insert({ + membershipId: updatedMembership.id, + reason: 'gift upgrade to yearly_subscription', + category: 'SYSTEM', + suppressConfirmation: true, + suppressWinback: true, + cancelledViaSupport: true, + }) + + await tx.transactionCommit() + + //cancel old monthly subscription on Republik AG stripe + const oldSub = await this.cancelSubscriptionForUpgrade( + this.#stripeAdapters.REPUBLIK, + stripeId, + ) + + // create new subscription starting at the end period of the old one + await this.#stripeAdapters.PROJECT_R.subscriptionSchedules.create({ + customer: customerId, + start_date: oldSub.current_period_end, + phases: [ + { + items: [shop.genLineItem(offer)], + iterations: 1, + collection_method: 'send_invoice', + coupon: gift.coupon, + invoice_settings: { + days_until_due: 14, + }, + metadata: { + [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ + 'confirm:setup': true, + }), + [REPUBLIK_PAYMENTS_SUBSCRIPTION_UPGRADED_FROM]: `monthly_abo:${membershipId}`, + [REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN]: 'GIFT', + }, + }, + ], + end_behavior: 'cancel', + }) + return { + id: membershipId, + aboType: 'YEARLY_SUBSCRIPION', + company: 'PROJECT_R', + starting: new Date(secondsToMilliseconds(oldSub.current_period_end)), + } + } + } + } + private async applyGiftToYearlyAbo( + userId: string, + id: string, + gift: Gift, + ): Promise { + const cRepo = new CustomerRepo(this.#pgdb) + const customerId = await this.getCustomerId(cRepo, gift.company, userId) + const endDate = await this.getMembershipEndDate(this.#pgdb, id) + + const shop = new Shop(activeOffers()) + + const offer = (await shop.getOfferById(gift.offer))! + + await this.#stripeAdapters[gift.company].subscriptionSchedules.create({ + customer: customerId, + start_date: endDate.unix(), + phases: [ + { + items: [shop.genLineItem(offer)], + iterations: 1, + collection_method: 'send_invoice', + coupon: gift.coupon, + invoice_settings: { + days_until_due: 14, + }, + metadata: { + [REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN]: 'GIFT', + }, + }, + ], + }) + + return { + aboType: gift.offer, + company: gift.company, + starting: endDate.toDate(), + } + } + + private async applyGiftToBenefactor( + _userId: string, + _id: string, + _gift: Gift, + ): Promise { + throw new Error('Not implemented') + } + + private async applyGiftToYearlySubscription( + _userId: string, + id: string, + gift: Gift, + ): Promise { + const stripeId = await this.getStripeSubscriptionId(id) + + if (!stripeId) { + throw new Error(`yearly subscription ${id} does not exist`) + } + + switch (gift.company) { + case 'PROJECT_R': { + const sub = await this.#stripeAdapters.PROJECT_R.subscriptions.update( + stripeId, + { + coupon: gift.coupon, + }, + ) + return { + id: id, + aboType: 'YEARLY_SUBSCRIPTION', + company: 'PROJECT_R', + starting: new Date(secondsToMilliseconds(sub.current_period_end)), + } + } + case 'REPUBLIK': { + if (gift.id != 'MONTHLY_SUBSCRPTION_GIFT_3') { + throw Error('Not implemented') + } + + const coupon = getConfig().PROJECT_R_3_MONTH_GIFT_COUPON + + const sub = await this.#stripeAdapters.PROJECT_R.subscriptions.update( + stripeId, + { + coupon: coupon, + }, + ) + + return { + id: id, + aboType: 'YEARLY_SUBSCRIPTION', + company: 'PROJECT_R', + starting: new Date(secondsToMilliseconds(sub.current_period_end)), + } + } + } + } + + private async applyGiftToMonthlySubscription( + userId: string, + subScriptionId: string, + gift: Gift, + ): Promise { + const stripeId = await this.getStripeSubscriptionId(subScriptionId) + + if (!stripeId) { + throw new Error(`monthly subscription ${subScriptionId} does not exist`) + } + + switch (gift.company) { + case 'REPUBLIK': { + await this.#stripeAdapters.REPUBLIK.subscriptions.update(stripeId, { + coupon: gift.coupon, + }) + return { + id: subScriptionId, + aboType: 'MONLTY_SUBSCRIPTION', + company: 'REPUBLIK', + starting: new Date(), + } + } + case 'PROJECT_R': { + const cRepo = new CustomerRepo(this.#pgdb) + + const customerId = await this.getCustomerId(cRepo, 'PROJECT_R', userId) + + const shop = new Shop(activeOffers()) + const offer = (await shop.getOfferById(gift.offer))! + + //cancel old monthly subscription on Republik AG + const oldSub = await this.cancelSubscriptionForUpgrade( + this.#stripeAdapters.REPUBLIK, + stripeId, + ) + + // create new subscription starting at the end period of the old one + await this.#stripeAdapters.PROJECT_R.subscriptionSchedules.create({ + customer: customerId, + start_date: oldSub.current_period_end, + phases: [ + { + items: [shop.genLineItem(offer)], + iterations: 1, + collection_method: 'send_invoice', + coupon: gift.coupon, + invoice_settings: { + days_until_due: 14, + }, + metadata: { + [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ + 'confirm:setup': true, + }), + [REPUBLIK_PAYMENTS_SUBSCRIPTION_UPGRADED_FROM]: `monthly:${subScriptionId}`, + [REPUBLIK_PAYMENTS_SUBSCRIPTION_ORIGIN]: 'GIFT', + }, + }, + ], + }) + return { + company: 'PROJECT_R', + aboType: 'YEARLY_SUBSCRIPION', + starting: new Date(secondsToMilliseconds(oldSub.current_period_end)), + } + } + } + } + + private async getCustomerId( + cRepo: CustomerRepo, + company: Company, + userId: string, + ) { + let customerId = (await cRepo.getCustomerIdForCompany(userId, company)) + ?.customerId + if (!customerId) { + customerId = await Payments.getInstance().createCustomer(company, userId) + } + return customerId + } + + private async getStripeSubscriptionId( + internalId: string, + ): Promise { + const res = await this.#pgdb.queryOne( + `SELECT "externalId" from payments.subscriptions WHERE id = :id`, + { id: internalId }, + ) + + return res.externalId + } + + private async getStripeIdForMonthlyAbo( + internalId: string, + ): Promise { + const res = await this.#pgdb.queryOne( + `SELECT "subscriptionId" from memberships WHERE id = :id`, + { id: internalId }, + ) + + return res.subscriptionId + } + + private async getMembershipEndDate(tx: PgDb, membershipId: string) { + const latestMembershipPeriod = await tx.queryOne( + `SELECT + id, + "endDate" + FROM + public."membershipPeriods" + WHERE + "membershipId" = + ORDER BY + "endDate" DESC NULLS LAST + LIMIT 1;`, + { membershipId: membershipId }, + ) + + const endDate = dayjs(latestMembershipPeriod.endDate) + return endDate + } + + private cancelSubscriptionForUpgrade(stripeClient: Stripe, stripeId: string) { + return stripeClient.subscriptions.update(stripeId, { + cancellation_details: { + comment: + '[System]: cancelation because of upgrade to yearly subscription', + }, + proration_behavior: 'none', + metadata: { + [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ + 'notice:ended': false, + 'confirm:cancel': false, + }), + [REPUBLIK_PAYMENTS_CANCEL_REASON]: 'UPGRADE', + }, + cancel_at_period_end: true, + }) + } +} diff --git a/packages/backend-modules/payments/lib/shop/offers.ts b/packages/backend-modules/payments/lib/shop/offers.ts index 8dd00b7419..7ba3c58c0d 100644 --- a/packages/backend-modules/payments/lib/shop/offers.ts +++ b/packages/backend-modules/payments/lib/shop/offers.ts @@ -1,16 +1,23 @@ import { getConfig } from '../config' import { Company } from '../types' -/** -@var base currency amount to equal 1 Swiss Frank use this value to muliplie -*/ -const CHF = 100 -/** -@var base currency amount to equal 1 Swiss Rappen use this value to muliplie -*/ -const RAPPEN = 1 +export const GIFTS_ENABLED = () => + process.env.PAYMENTS_SHOP_GIFTS_ENABLED === 'true' -export type OfferType = 'SUBSCRIPTION' +const in_chf = (n: number) => n * 100 + +export type OfferType = 'SUBSCRIPTION' | 'ONETIME_PAYMENT' + +export type ComplimentaryItem = { + id: string + maxQuantity: number + lookupKey: string +} + +export type ComplimentaryItemOrder = { + id: string + quantity: number +} export type Offer = { id: string @@ -20,6 +27,8 @@ export type Offer = { productId?: string defaultPriceLookupKey: string taxRateId?: string + requiresLogin: boolean + complimentaryItems?: ComplimentaryItem[] allowPromotions: boolean price?: { id: string @@ -51,12 +60,19 @@ export type Offer = { } } +// const PROMO_ITEM_REPUBLIK_BIBLIOTEK_1 = { +// id: 'REPUBLIK_BILIOTHEK_1', +// maxQuantity: 1, +// lookupKey: 'REPUBLIK_BILIOTHEK_1', +// } + export const Offers: Offer[] = [ { id: 'YEARLY', name: 'Jahresmitgliedschaft', type: 'SUBSCRIPTION', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'ABO', allowPromotions: true, }, @@ -65,12 +81,13 @@ export const Offers: Offer[] = [ name: 'Gönnermitgliedschaft', type: 'SUBSCRIPTION', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'BENEFACTOR_ABO', allowPromotions: false, customPrice: { - min: 1000 * CHF, - max: 4000 * CHF, - step: 100 * RAPPEN, + min: in_chf(1000), + max: in_chf(4000), + step: in_chf(10), recurring: { interval: 'year', interval_count: 1, @@ -82,12 +99,13 @@ export const Offers: Offer[] = [ name: 'Ausbildungs-Mitgliedschaft', type: 'SUBSCRIPTION', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'STUDENT_ABO', allowPromotions: false, customPrice: { - min: 140 * CHF, - max: 239 * CHF, - step: 50 * RAPPEN, + min: in_chf(140), + max: in_chf(239), + step: in_chf(1), recurring: { interval: 'year', interval_count: 1, @@ -99,12 +117,13 @@ export const Offers: Offer[] = [ type: 'SUBSCRIPTION', name: 'Jahresmitgliedschaft', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'ABO', allowPromotions: false, customPrice: { - max: 2000 * CHF, - min: 10 * CHF, - step: 100 * RAPPEN, + max: in_chf(2000), + min: in_chf(10), + step: in_chf(1), recurring: { interval: 'year', interval_count: 1, @@ -116,8 +135,26 @@ export const Offers: Offer[] = [ name: 'Monats-Abo', type: 'SUBSCRIPTION', company: 'REPUBLIK', + requiresLogin: true, allowPromotions: true, defaultPriceLookupKey: 'MONTHLY_ABO', taxRateId: getConfig().REPUBLIK_STRIPE_SUBSCRIPTION_TAX_ID, }, ] + +export const GIFTS_OFFERS: Offer[] = [ + { + id: 'GIFT_YEARLY', + name: 'Jahresmitgliedschafts Geschenk', + type: 'ONETIME_PAYMENT', + company: 'PROJECT_R', + requiresLogin: false, + allowPromotions: false, + complimentaryItems: [], + defaultPriceLookupKey: 'GIFT_YEARLY', + }, +] + +export function activeOffers(): Offer[] { + return [...Offers, ...(GIFTS_ENABLED() ? GIFTS_OFFERS : [])] +} diff --git a/packages/backend-modules/payments/lib/shop/utils.ts b/packages/backend-modules/payments/lib/shop/utils.ts index c04a1bb9eb..1c067df113 100644 --- a/packages/backend-modules/payments/lib/shop/utils.ts +++ b/packages/backend-modules/payments/lib/shop/utils.ts @@ -23,9 +23,9 @@ export async function hasHadMembership(userId: string, pgdb: PgDb) { } export function requiredCustomFields( - user: User, + user?: User, ): Stripe.Checkout.SessionCreateParams.CustomField[] { - if (!user.firstName && !user.lastName) { + if (!user?.firstName && !user?.lastName) { return [ { key: 'firstName', diff --git a/packages/backend-modules/payments/lib/transactionals/sendTransactionalMails.ts b/packages/backend-modules/payments/lib/transactionals/sendTransactionalMails.ts index 17e29f9b2b..c5c252cad2 100644 --- a/packages/backend-modules/payments/lib/transactionals/sendTransactionalMails.ts +++ b/packages/backend-modules/payments/lib/transactionals/sendTransactionalMails.ts @@ -59,7 +59,13 @@ type SendCancelConfirmationMailArgs = { } export async function sendCancelConfirmationMail( - { endDate, cancellationDate, type, userId, email }: SendCancelConfirmationMailArgs, + { + endDate, + cancellationDate, + type, + userId, + email, + }: SendCancelConfirmationMailArgs, pgdb: PgDb, ) { const dateOptions: Intl.DateTimeFormatOptions = { @@ -75,12 +81,12 @@ export async function sendCancelConfirmationMail( }, { name: 'is_yearly', - content: type === 'YEARLY_SUBSCRIPTION' + content: type === 'YEARLY_SUBSCRIPTION', }, { name: 'is_monthly', - content: type === 'MONTHLY_SUBSCRIPTION' - } + content: type === 'MONTHLY_SUBSCRIPTION', + }, ] const templateName = 'subscription_cancel_notice' @@ -115,7 +121,13 @@ type SendRevokeCancellationConfirmationMailArgs = { } export async function sendRevokeCancellationConfirmationMail( - { currentEndDate, revokedCancellationDate, type, userId, email }: SendRevokeCancellationConfirmationMailArgs, + { + currentEndDate, + revokedCancellationDate, + type, + userId, + email, + }: SendRevokeCancellationConfirmationMailArgs, pgdb: PgDb, ) { const dateOptions: Intl.DateTimeFormatOptions = { @@ -131,12 +143,12 @@ export async function sendRevokeCancellationConfirmationMail( }, { name: 'is_yearly', - content: type === 'YEARLY_SUBSCRIPTION' + content: type === 'YEARLY_SUBSCRIPTION', }, { name: 'is_monthly', - content: type === 'MONTHLY_SUBSCRIPTION' - } + content: type === 'MONTHLY_SUBSCRIPTION', + }, ] const templateName = 'subscription_revoke_cancellation_notice' @@ -235,3 +247,49 @@ export async function sendPaymentFailedNoticeMail( return sendMailResult } + +export async function sendGiftPurchaseMail( + { voucherCode, email }: { voucherCode: string; email: string }, + pgdb: PgDb, +) { + const globalMergeVars: MergeVariable[] = [ + { + name: 'voucher_codes', + content: voucherCode, + }, + ] + + const templateName = 'payment_successful_gift_voucher' + const sendMailResult = await sendMailTemplate( + { + to: email, + fromEmail: process.env.DEFAULT_MAIL_FROM_ADDRESS as string, + subject: t(`api/email/${templateName}/subject`), + templateName, + mergeLanguage: 'handlebars', + globalMergeVars, + }, + { pgdb }, + ) + + return sendMailResult +} + +export async function sendSetupGiftMail({ email }: { email: string }, pgdb: PgDb) { + const globalMergeVars: MergeVariable[] = [] + + const templateName = 'subscription_created_gift_subscription' + const sendMailResult = await sendMailTemplate( + { + to: email, + fromEmail: process.env.DEFAULT_MAIL_FROM_ADDRESS as string, + subject: t(`api/email/${templateName}/subject`), + templateName, + mergeLanguage: 'handlebars', + globalMergeVars, + }, + { pgdb }, + ) + + return sendMailResult +} diff --git a/packages/backend-modules/payments/lib/types.ts b/packages/backend-modules/payments/lib/types.ts index a1ed0d9798..11cb988c3e 100644 --- a/packages/backend-modules/payments/lib/types.ts +++ b/packages/backend-modules/payments/lib/types.ts @@ -185,7 +185,7 @@ export type Webhook = { updatedAt: string } -export type PaymentItemLocator = +export type SelectCriteria = | { id: string; externalId?: never } | { externalId: string; id?: never } diff --git a/packages/backend-modules/payments/lib/workers/ConfirmCancelTransactionalWorker.ts b/packages/backend-modules/payments/lib/workers/ConfirmCancelTransactionalWorker.ts index 91d97be82c..8b82d7d0bb 100644 --- a/packages/backend-modules/payments/lib/workers/ConfirmCancelTransactionalWorker.ts +++ b/packages/backend-modules/payments/lib/workers/ConfirmCancelTransactionalWorker.ts @@ -1,7 +1,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' -import { Payments } from '../payments' import Stripe from 'stripe' +import { MailNotificationService } from '../services/MailNotificationService' +import { WebhookService } from '../services/WebhookService' type Args = { $version: 'v1' @@ -21,12 +22,13 @@ export class ConfirmCancelTransactionalWorker extends BaseWorker { throw Error('unable to perform this job version. Expected v1') } - console.log(`[${this.queue}] start`) + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) - const PaymentService = Payments.getInstance() + console.log(`[${this.queue}] start`) const wh = - await PaymentService.findWebhookEventBySourceId( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -44,7 +46,7 @@ export class ConfirmCancelTransactionalWorker extends BaseWorker { try { // send transactional - await PaymentService.sendCancelConfirmationTransactionalMail({ + await mailService.sendCancelConfirmationTransactionalMail({ subscriptionExternalId: event.data.object.id, userId: job.data.userId, }) diff --git a/packages/backend-modules/payments/lib/workers/ConfirmGiftSubscriptionTransactionalWorker.ts b/packages/backend-modules/payments/lib/workers/ConfirmGiftSubscriptionTransactionalWorker.ts new file mode 100644 index 0000000000..413abc933c --- /dev/null +++ b/packages/backend-modules/payments/lib/workers/ConfirmGiftSubscriptionTransactionalWorker.ts @@ -0,0 +1,61 @@ +import { BaseWorker } from '@orbiting/backend-modules-job-queue' +import { Job, SendOptions } from 'pg-boss' +import Stripe from 'stripe' +import { MailNotificationService } from '../services/MailNotificationService' +import { WebhookService } from '../services/WebhookService' + +type Args = { + $version: 'v1' + userId: string + eventSourceId: string +} + +export class ConfirmGiftSubscriptionTransactionalWorker extends BaseWorker { + readonly queue = 'payments:transactional:confirm:gift:subscription' + readonly options: SendOptions = { + retryLimit: 3, + retryDelay: 120, // retry every 2 minutes + } + + async perform([job]: Job[]): Promise { + if (job.data.$version !== 'v1') { + throw Error('unable to perform this job version. Expected v1') + } + + console.log(`[${this.queue}] start`) + + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) + + const wh = + await webhookService.getEvent( + job.data.eventSourceId, + ) + + if (!wh) { + console.error('Webhook dose not exist') + return await this.pgBoss.fail(this.queue, job.id) + } + + if (wh.payload.type !== 'customer.subscription.created') { + console.error('Webhook is not of type customer.subscription.created') + return await this.pgBoss.fail(this.queue, job.id) + } + + const event = wh.payload + + try { + // send transactional + await mailService.sendSetupGiftSubscriptionTransactionalMail({ + subscriptionExternalId: event.data.object.id, + userId: job.data.userId, + }) + } catch (e) { + console.error(`[${this.queue}] error`) + console.error(e) + throw e + } + + console.log(`[${this.queue}] done`) + } +} diff --git a/packages/backend-modules/payments/lib/workers/ConfirmRevokeCancellationTransactionalWorker.ts b/packages/backend-modules/payments/lib/workers/ConfirmRevokeCancellationTransactionalWorker.ts index 123881e785..1b74111a6a 100644 --- a/packages/backend-modules/payments/lib/workers/ConfirmRevokeCancellationTransactionalWorker.ts +++ b/packages/backend-modules/payments/lib/workers/ConfirmRevokeCancellationTransactionalWorker.ts @@ -1,7 +1,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' -import { Payments } from '../payments' import Stripe from 'stripe' +import { MailNotificationService } from '../services/MailNotificationService' +import { WebhookService } from '../services/WebhookService' type Args = { $version: 'v1' @@ -24,10 +25,11 @@ export class ConfirmRevokeCancellationTransactionalWorker extends BaseWorker( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -45,7 +47,7 @@ export class ConfirmRevokeCancellationTransactionalWorker extends BaseWorker { console.log(`[${this.queue}] start`) - const PaymentService = Payments.getInstance() + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) const wh = - await PaymentService.findWebhookEventBySourceId( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -45,7 +47,7 @@ export class ConfirmSetupTransactionalWorker extends BaseWorker { try { // send transactional - await PaymentService.sendSetupSubscriptionTransactionalMail({ + await mailService.sendSetupSubscriptionTransactionalMail({ subscriptionExternalId: event.data.object.subscription as string, userId: job.data.userId, invoiceId: job.data.invoiceId, diff --git a/packages/backend-modules/payments/lib/workers/NoticeEndedTransactionalWorker.ts b/packages/backend-modules/payments/lib/workers/NoticeEndedTransactionalWorker.ts index 88c410fe12..09395b00a3 100644 --- a/packages/backend-modules/payments/lib/workers/NoticeEndedTransactionalWorker.ts +++ b/packages/backend-modules/payments/lib/workers/NoticeEndedTransactionalWorker.ts @@ -1,7 +1,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' -import { Payments } from '../payments' import Stripe from 'stripe' +import { WebhookService } from '../services/WebhookService' +import { MailNotificationService } from '../services/MailNotificationService' type Args = { $version: 'v1' @@ -23,10 +24,11 @@ export class NoticeEndedTransactionalWorker extends BaseWorker { console.log(`[${this.queue}] start`) - const PaymentService = Payments.getInstance() + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) const wh = - await PaymentService.findWebhookEventBySourceId( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -44,10 +46,12 @@ export class NoticeEndedTransactionalWorker extends BaseWorker { try { // send transactional - await PaymentService.sendSubscriptionEndedNoticeTransactionalMail({ + await mailService.sendSubscriptionEndedNoticeTransactionalMail({ subscriptionExternalId: event.data.object.id, userId: job.data.userId, - cancellationReason: event.data.object.cancellation_details?.reason as string | undefined + cancellationReason: event.data.object.cancellation_details?.reason as + | string + | undefined, }) } catch (e) { console.error(`[${this.queue}] error`) diff --git a/packages/backend-modules/payments/lib/workers/NoticePaymentFailedTransactionalWorker.ts b/packages/backend-modules/payments/lib/workers/NoticePaymentFailedTransactionalWorker.ts index 14d6a665bf..3027b34647 100644 --- a/packages/backend-modules/payments/lib/workers/NoticePaymentFailedTransactionalWorker.ts +++ b/packages/backend-modules/payments/lib/workers/NoticePaymentFailedTransactionalWorker.ts @@ -1,7 +1,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' -import { Payments } from '../payments' import Stripe from 'stripe' +import { WebhookService } from '../services/WebhookService' +import { MailNotificationService } from '../services/MailNotificationService' type Args = { $version: 'v1' @@ -24,12 +25,12 @@ export class NoticePaymentFailedTransactionalWorker extends BaseWorker { console.log(`[${this.queue}] start`) - const PaymentService = Payments.getInstance() + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) - const wh = - await PaymentService.findWebhookEventBySourceId( - job.data.eventSourceId, - ) + const wh = await webhookService.getEvent( + job.data.eventSourceId, + ) if (!wh) { console.error('Webhook does not exist') @@ -45,7 +46,7 @@ export class NoticePaymentFailedTransactionalWorker extends BaseWorker { try { // send transactional - await PaymentService.sendNoticePaymentFailedTransactionalMail({ + await mailService.sendNoticePaymentFailedTransactionalMail({ subscriptionExternalId: event.data.object.subscription as string, userId: job.data.userId, invoiceExternalId: job.data.invoiceExternalId, diff --git a/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts b/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts index da9b8ce3a4..6159d95596 100644 --- a/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts +++ b/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts @@ -12,7 +12,8 @@ import { processCheckoutCompleted } from '../handlers/stripe/checkoutCompleted' import { processPaymentFailed } from '../handlers/stripe/paymentFailed' import { isPledgeBased } from '../handlers/stripe/utils' import { processChargeRefunded } from '../handlers/stripe/chargeRefunded' -import { processInvociePaymentSucceded } from '../handlers/stripe/invoicePaymentSucceded' +import { processInvoicePaymentSucceeded } from '../handlers/stripe/invoicePaymentSucceeded' +import { WebhookService } from '../services/WebhookService' type WorkerArgsV1 = { $version: 'v1' @@ -28,9 +29,14 @@ export class StripeWebhookWorker extends BaseWorker { } async perform([job]: Job[]): Promise { + if (typeof this.context === 'undefined') + throw Error('This jobs needs the connection context to run') + + const webhookService = new WebhookService(this.context.pgdb) + const PaymentService = Payments.getInstance() - const wh = await PaymentService.findWebhookEventBySourceId( + const wh = await webhookService.getEvent( job.data.eventSourceId, ) @@ -46,7 +52,7 @@ export class StripeWebhookWorker extends BaseWorker { switch (event.type) { case 'checkout.session.completed': await processCheckoutCompleted( - PaymentService, + { paymentService: PaymentService, ...this.context }, job.data.company, event, ) @@ -97,7 +103,7 @@ export class StripeWebhookWorker extends BaseWorker { await processPaymentFailed(PaymentService, job.data.company, event) break case 'invoice.payment_succeeded': - await processInvociePaymentSucceded( + await processInvoicePaymentSucceeded( PaymentService, job.data.company, event, @@ -124,6 +130,6 @@ export class StripeWebhookWorker extends BaseWorker { event.id, event.type, ) - await PaymentService.markWebhookAsProcessed(event.id) + await webhookService.markEventAsProcessed(event.id) } } diff --git a/packages/backend-modules/payments/lib/workers/SyncAddressDataWorker.ts b/packages/backend-modules/payments/lib/workers/SyncAddressDataWorker.ts index ac62661463..b7d38155e5 100644 --- a/packages/backend-modules/payments/lib/workers/SyncAddressDataWorker.ts +++ b/packages/backend-modules/payments/lib/workers/SyncAddressDataWorker.ts @@ -1,7 +1,7 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Address } from '../types' import { Job } from 'pg-boss' -import { Payments } from '../payments' +import { CustomerInfoService } from '../services/CustomerInfoService' type Args = { $version: 'v1' @@ -13,10 +13,10 @@ export class SyncAddressDataWorker extends BaseWorker { readonly queue = 'payments:stripe:checkout:sync-address' async perform([job]: Job[]): Promise { - const PaymentService = Payments.getInstance() + const customerService = new CustomerInfoService(this.context.pgdb) console.log(`start ${this.queue} worker`) - await PaymentService.updateUserAddress(job.data.userId, job.data.address) + await customerService.updateUserAddress(job.data.userId, job.data.address) console.log(`success ${this.queue} worker`) diff --git a/packages/backend-modules/payments/lib/workers/SyncMailchimpEndedWorker.ts b/packages/backend-modules/payments/lib/workers/SyncMailchimpEndedWorker.ts index ac7a31f476..ddbf05528c 100644 --- a/packages/backend-modules/payments/lib/workers/SyncMailchimpEndedWorker.ts +++ b/packages/backend-modules/payments/lib/workers/SyncMailchimpEndedWorker.ts @@ -2,6 +2,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' import { Payments } from '../payments' import Stripe from 'stripe' +import { WebhookService } from '../services/WebhookService' +import { MailNotificationService } from '../services/MailNotificationService' type Args = { $version: 'v1' @@ -23,10 +25,13 @@ export class SyncMailchimpEndedWorker extends BaseWorker { console.log(`[${this.queue}] start`) + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) + const PaymentService = Payments.getInstance() const wh = - await PaymentService.findWebhookEventBySourceId( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -42,15 +47,23 @@ export class SyncMailchimpEndedWorker extends BaseWorker { const event = wh.payload - const invoice = await PaymentService.getInvoice({ externalId: event.data.object.latest_invoice as string }) - const subscription = await PaymentService.getSubscription({ externalId: event.data.object.id as string }) + const invoice = await PaymentService.getInvoice({ + externalId: event.data.object.latest_invoice as string, + }) + const subscription = await PaymentService.getSubscription({ + externalId: event.data.object.id as string, + }) if (!invoice || !subscription) { - console.error('Latest invoice or subscription could not be found in the database') + console.error( + 'Latest invoice or subscription could not be found in the database', + ) return await this.pgBoss.fail(this.queue, job.id) } - await PaymentService.syncMailchimpUpdateSubscription({ userId: job.data.userId }) + await mailService.syncMailchimpUpdateSubscription({ + userId: job.data.userId, + }) console.log(`[${this.queue}] done`) } diff --git a/packages/backend-modules/payments/lib/workers/SyncMailchimpSetupWorker.ts b/packages/backend-modules/payments/lib/workers/SyncMailchimpSetupWorker.ts index 3646992e16..a3afcdea94 100644 --- a/packages/backend-modules/payments/lib/workers/SyncMailchimpSetupWorker.ts +++ b/packages/backend-modules/payments/lib/workers/SyncMailchimpSetupWorker.ts @@ -2,6 +2,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' import { Payments } from '../payments' import Stripe from 'stripe' +import { WebhookService } from '../services/WebhookService' +import { MailNotificationService } from '../services/MailNotificationService' type Args = { $version: 'v1' @@ -23,10 +25,12 @@ export class SyncMailchimpSetupWorker extends BaseWorker { console.log(`[${this.queue}] start`) + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) const PaymentService = Payments.getInstance() const wh = - await PaymentService.findWebhookEventBySourceId( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -42,15 +46,21 @@ export class SyncMailchimpSetupWorker extends BaseWorker { const event = wh.payload - const invoice = await PaymentService.getInvoice({ externalId: event.data.object.invoice as string }) - const subscription = await PaymentService.getSubscription({ externalId: event.data.object.subscription as string }) + const subscription = await PaymentService.getSubscription({ + externalId: event.data.object.subscription as string, + }) - if (!invoice || !subscription) { - console.error('Invoice or subscription could not be found in the database') + if (!subscription) { + console.error( + 'Subscription could not be found in the database', + ) return await this.pgBoss.fail(this.queue, job.id) } - await PaymentService.syncMailchimpSetupSubscription({ userId: job.data.userId, subscriptionExternalId: event.data.object.subscription as string }) + await mailService.syncMailchimpSetupSubscription({ + userId: job.data.userId, + subscriptionExternalId: event.data.object.subscription as string, + }) console.log(`[${this.queue}] done`) } diff --git a/packages/backend-modules/payments/lib/workers/SyncMailchimpUpdateWorker.ts b/packages/backend-modules/payments/lib/workers/SyncMailchimpUpdateWorker.ts index 03d7fd8de8..ff1c818777 100644 --- a/packages/backend-modules/payments/lib/workers/SyncMailchimpUpdateWorker.ts +++ b/packages/backend-modules/payments/lib/workers/SyncMailchimpUpdateWorker.ts @@ -2,6 +2,8 @@ import { BaseWorker } from '@orbiting/backend-modules-job-queue' import { Job, SendOptions } from 'pg-boss' import { Payments } from '../payments' import Stripe from 'stripe' +import { WebhookService } from '../services/WebhookService' +import { MailNotificationService } from '../services/MailNotificationService' type Args = { $version: 'v1' @@ -23,10 +25,12 @@ export class SyncMailchimpUpdateWorker extends BaseWorker { console.log(`[${this.queue}] start`) + const webhookService = new WebhookService(this.context.pgdb) + const mailService = new MailNotificationService(this.context.pgdb) const PaymentService = Payments.getInstance() const wh = - await PaymentService.findWebhookEventBySourceId( + await webhookService.getEvent( job.data.eventSourceId, ) @@ -42,15 +46,23 @@ export class SyncMailchimpUpdateWorker extends BaseWorker { const event = wh.payload - const invoice = await PaymentService.getInvoice({ externalId: event.data.object.latest_invoice as string }) - const subscription = await PaymentService.getSubscription({ externalId: event.data.object.id as string }) + const invoice = await PaymentService.getInvoice({ + externalId: event.data.object.latest_invoice as string, + }) + const subscription = await PaymentService.getSubscription({ + externalId: event.data.object.id as string, + }) if (!invoice || !subscription) { - console.error('Latest invoice or subscription could not be found in the database') + console.error( + 'Latest invoice or subscription could not be found in the database', + ) return await this.pgBoss.fail(this.queue, job.id) } - await PaymentService.syncMailchimpUpdateSubscription({ userId: job.data.userId }) + await mailService.syncMailchimpUpdateSubscription({ + userId: job.data.userId, + }) console.log(`[${this.queue}] done`) } diff --git a/packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-down.sql b/packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-down.sql new file mode 100644 index 0000000000..dd546fdcff --- /dev/null +++ b/packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-down.sql @@ -0,0 +1 @@ +-- migrate down here: DROP TABLE... \ No newline at end of file diff --git a/packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-up.sql b/packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-up.sql new file mode 100644 index 0000000000..ccfe70b5f3 --- /dev/null +++ b/packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-up.sql @@ -0,0 +1,23 @@ +-- migrate up here: CREATE TABLE... +ALTER TABLE payments.orders ALTER COLUMN "userId" DROP NOT NULL; +ALTER TABLE payments.orders ALTER COLUMN "invoiceId" DROP NOT NULL; +ALTER TABLE payments.orders ADD "metadata" jsonb; +ALTER TABLE payments.orders ADD "customerEmail" text; +ALTER TABLE payments.orders ADD "shippingAddressId" uuid; + +CREATE TABLE IF NOT EXISTS payments."orderLineItems" ( + "id" uuid default uuid_generate_v4() PRIMARY KEY, + "orderId" uuid not null, + "lineItemId" text, + "externalPriceId" text, + "priceLookupKey" text, + "description" text, + "quantity" int not null, + "price" int not null, + "priceSubtotal" int, + "taxAmount" int, + "discountAmount" int, + "createdAt" timestamptz DEFAULT now(), + "updatedAt" timestamptz DEFAULT now(), + CONSTRAINT fk_order_line_item FOREIGN KEY("orderId") REFERENCES payments.orders("id") +); diff --git a/packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-down.sql b/packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-down.sql new file mode 100644 index 0000000000..31d96a2f61 --- /dev/null +++ b/packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-down.sql @@ -0,0 +1,3 @@ +-- migrate down here: DROP TABLE... + +DROP TABLE IF EXISTS payments."giftVouchers"; diff --git a/packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-up.sql b/packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-up.sql new file mode 100644 index 0000000000..0acc7e3a1b --- /dev/null +++ b/packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-up.sql @@ -0,0 +1,12 @@ +-- migrate up here: CREATE TABLE... +CREATE TABLE IF NOT EXISTS payments."giftVouchers" ( + "id" uuid default uuid_generate_v4() PRIMARY KEY, + "code" text, + "giftId" text, + "issuedBy" payments.company NOT NULL, + "redeemedBy" uuid, + "redeemedForCompany" payments.company, + "redeemedAt" timestamptz, + "createdAt" timestamptz DEFAULT now(), + "updatedAt" timestamptz DEFAULT now() +); diff --git a/packages/backend-modules/payments/migrations/sql/20250120094008-gift-voucher-order-id-down.sql b/packages/backend-modules/payments/migrations/sql/20250120094008-gift-voucher-order-id-down.sql new file mode 100644 index 0000000000..f8c08b4332 --- /dev/null +++ b/packages/backend-modules/payments/migrations/sql/20250120094008-gift-voucher-order-id-down.sql @@ -0,0 +1,3 @@ +-- migrate down here: DROP TABLE... +ALTER TABLE payments."giftVouchers" DROP CONSTRAINT fk_gift_voucher_order_id; +ALTER TABLE payments."giftVouchers" DROP COLUMN "orderId"; diff --git a/packages/backend-modules/payments/migrations/sql/20250120094008-gift-voucher-order-id-up.sql b/packages/backend-modules/payments/migrations/sql/20250120094008-gift-voucher-order-id-up.sql new file mode 100644 index 0000000000..6c8021a3d7 --- /dev/null +++ b/packages/backend-modules/payments/migrations/sql/20250120094008-gift-voucher-order-id-up.sql @@ -0,0 +1,3 @@ +-- migrate up here: CREATE TABLE... +ALTER TABLE payments."giftVouchers" ADD "orderId" uuid; +ALTER TABLE payments."giftVouchers" ADD CONSTRAINT fk_gift_voucher_order_id FOREIGN KEY("orderId") REFERENCES payments.orders("id"); diff --git a/packages/backend-modules/payments/package.json b/packages/backend-modules/payments/package.json index b642b53844..e469aa28e0 100644 --- a/packages/backend-modules/payments/package.json +++ b/packages/backend-modules/payments/package.json @@ -5,15 +5,17 @@ "main": "build/index.js", "types": "build/@types", "dependencies": { + "@orbiting/backend-modules-auth": "*", "@orbiting/backend-modules-job-queue": "*", "@orbiting/backend-modules-scalars": "*", "@orbiting/backend-modules-types": "*", - "@orbiting/backend-modules-auth": "*", "@orbiting/backend-modules-utils": "*", "apollo-modules-node": "*", "body-parser": "^1.19.1", - "stripe": "^17.4.0", - "pogi": "^2.11.1" + "crockford-base32": "^2.0.0", + "dayjs": "^1.11.13", + "pogi": "^2.11.1", + "stripe": "^17.4.0" }, "devDependencies": { "@types/express": "4.17.21" diff --git a/packages/backend-modules/payments/tsconfig.json b/packages/backend-modules/payments/tsconfig.json index d5c5c3e3bb..3ba5f48267 100644 --- a/packages/backend-modules/payments/tsconfig.json +++ b/packages/backend-modules/payments/tsconfig.json @@ -8,7 +8,8 @@ "declaration": true, "declarationDir": "build/@types", "declarationMap": true, - "noImplicitAny": true + "noImplicitAny": true, + "strict": true }, "include": ["."], "exclude": ["node_modules", "__test__", "build"] diff --git a/packages/backend-modules/translate/translations.json b/packages/backend-modules/translate/translations.json index dc51da98a9..bea99b50e5 100755 --- a/packages/backend-modules/translate/translations.json +++ b/packages/backend-modules/translate/translations.json @@ -388,6 +388,10 @@ "key": "api/email/payment_successful_abo_give_months/subject", "value": "Vielen Dank! Wir haben Ihre Zahlung erhalten." }, + { + "key": "api/email/payment_successful_gift_voucher/subject", + "value": "Vielen Dank! Wir haben Ihre Zahlung erhalten." + }, { "key": "api/email/payment_successful_benefactor/subject", "value": "Vielen Dank! Wir haben Ihre Zahlung erhalten." diff --git a/yarn.lock b/yarn.lock index 474632400f..5fcee39263 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7397,6 +7397,14 @@ chalk@3.0.0, chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@4.1.2, chalk@^4, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -7417,14 +7425,6 @@ chalk@^2.3.2, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - change-case-all@1.0.15: version "1.0.15" resolved "https://registry.yarnpkg.com/change-case-all/-/change-case-all-1.0.15.tgz#de29393167fc101d646cd76b0ef23e27d09756ad" @@ -9199,6 +9199,11 @@ dayjs@^1.11.10, dayjs@^1.11.7, dayjs@^1.8.29: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== +dayjs@^1.11.13: + version "1.11.13" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== + db-migrate-base@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/db-migrate-base/-/db-migrate-base-2.3.1.tgz#97029e230b344f00cf2e4475e2e6027f7b09994c" @@ -9736,6 +9741,13 @@ dot-prop@^5.1.0, dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dotenv-expand@^11.0.6: + version "11.0.7" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz#af695aea007d6fdc84c86cd8d0ad7beb40a0bd08" + integrity sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA== + dependencies: + dotenv "^16.4.5" + dotenv@^16.0.0, dotenv@^16.0.1, dotenv@^16.4.5: version "16.4.5" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" @@ -11587,6 +11599,17 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +git-diff@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/git-diff/-/git-diff-2.0.6.tgz#4a8ece670d64d1f9f4e68191ad8b1013900f6c1e" + integrity sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA== + dependencies: + chalk "^2.3.2" + diff "^3.5.0" + loglevel "^1.6.1" + shelljs "^0.8.1" + shelljs.exec "^1.1.7" + git-log-parser@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/git-log-parser/-/git-log-parser-1.2.0.tgz#2e6a4c1b13fc00028207ba795a7ac31667b9fd4a" @@ -11676,7 +11699,7 @@ glob@^10.3.7: minipass "^7.0.4" path-scurry "^1.10.2" -glob@^7.0.3, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0, glob@^7.2.3: +glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0, glob@^7.2.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -12901,6 +12924,11 @@ internmap@^1.0.0: resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + intersection-observer@^0.12.2: version "0.12.2" resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.2.tgz#4a45349cc0cd91916682b1f44c28d7ec737dc375" @@ -14551,6 +14579,24 @@ kraut@^0.1.1: resolved "https://registry.yarnpkg.com/kraut/-/kraut-0.1.1.tgz#3c325b15f18e7dd95d8175ac0929725b23aac43a" integrity sha512-Cp0GckQ1+/6t6rM/gcOm4GQZAKC1/MHk8vjK0WORJVJSLYgkEhte7VgUSe5tpR4U0ppNt/H8FISZ68+6ua6K+g== +kysely-codegen@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/kysely-codegen/-/kysely-codegen-0.17.0.tgz#07bb2182ce2f315953c2407a52c99ee1ee942f91" + integrity sha512-C36g6epial8cIOSBEWGI9sRfkKSsEzTcivhjPivtYFQnhMdXnrVFaUe7UMZHeSdXaHiWDqDOkReJgWLD8nPKdg== + dependencies: + chalk "4.1.2" + dotenv "^16.4.5" + dotenv-expand "^11.0.6" + git-diff "^2.0.6" + micromatch "^4.0.8" + minimist "^1.2.8" + pluralize "^8.0.0" + +kysely@^0.27.4: + version "0.27.4" + resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.4.tgz#96a0285467b380948b4de03b20d87e82d797449b" + integrity sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA== + language-subtag-registry@^0.3.20: version "0.3.23" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7" @@ -15116,6 +15162,11 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" +loglevel@^1.6.1: + version "1.9.2" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" + integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== + loglevel@^1.6.8: version "1.9.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.1.tgz#d63976ac9bcd03c7c873116d41c2a85bafff1be7" @@ -16195,7 +16246,7 @@ micromatch@^4.0.2, micromatch@^4.0.5: braces "^3.0.2" picomatch "^2.3.1" -micromatch@^4.0.4: +micromatch@^4.0.4, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -16313,7 +16364,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -17891,6 +17942,11 @@ pg-connection-string@^2.6.4: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.4.tgz#f543862adfa49fa4e14bc8a8892d2a84d754246d" integrity sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA== +pg-connection-string@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" + integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== + pg-cursor@^2.10.3: version "2.10.3" resolved "https://registry.yarnpkg.com/pg-cursor/-/pg-cursor-2.10.3.tgz#4b44fbaede168a4785def56b8ac195e7df354472" @@ -17911,6 +17967,11 @@ pg-pool@^3.6.2: resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.2.tgz#3a592370b8ae3f02a7c8130d245bc02fa2c5f3f2" integrity sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg== +pg-pool@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec" + integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g== + pg-protocol@^1.2.0, pg-protocol@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.0.tgz#4c91613c0315349363af2084608db843502f8833" @@ -17921,6 +17982,11 @@ pg-protocol@^1.6.1: resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3" integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg== +pg-protocol@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93" + integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ== + pg-query-stream@^4: version "4.5.3" resolved "https://registry.yarnpkg.com/pg-query-stream/-/pg-query-stream-4.5.3.tgz#841ce414064d7b14bd2540d2267bdf40779d26f2" @@ -17967,6 +18033,19 @@ pg@^8.12.0: optionalDependencies: pg-cloudflare "^1.1.1" +pg@^8.13.1: + version "8.13.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" + integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== + dependencies: + pg-connection-string "^2.7.0" + pg-pool "^3.7.0" + pg-protocol "^1.7.0" + pg-types "^2.1.0" + pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" + pgpass@1.x: version "1.0.5" resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" @@ -18072,7 +18151,7 @@ plur@^1.0.0: resolved "https://registry.yarnpkg.com/plur/-/plur-1.0.0.tgz#db85c6814f5e5e5a3b49efc28d604fec62975156" integrity sha512-qSnKBSZeDY8ApxwhfVIwKwF36KVJqb1/9nzYYq3j3vdwocULCXT8f8fQGkiw1Nk9BGfxiDagEe/pwakA+bOBqw== -pluralize@8.0.0: +pluralize@8.0.0, pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== @@ -19093,6 +19172,13 @@ real-require@^0.2.0: resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw== + dependencies: + resolve "^1.1.6" + recursive-readdir@^2.2.2: version "2.2.3" resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.3.tgz#e726f328c0d69153bcabd5c322d3195252379372" @@ -20255,6 +20341,20 @@ shell-quote@^1.7.3, shell-quote@^1.8.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== +shelljs.exec@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/shelljs.exec/-/shelljs.exec-1.1.8.tgz#6f3c8dd017cb96d2dea82e712b758eab4fc2f68c" + integrity sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw== + +shelljs@^0.8.1: + version "0.8.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" + integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + shiki@^0.14.3: version "0.14.7" resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.14.7.tgz#c3c9e1853e9737845f1d2ef81b31bcfb07056d4e" @@ -20939,7 +21039,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -20957,15 +21057,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.0.0, string-width@~2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -21082,7 +21173,7 @@ stringify-package@^1.0.1: resolved "https://registry.yarnpkg.com/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21103,13 +21194,6 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23484,7 +23568,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23502,15 +23586,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"