From 29f933476bad1eab28ab5140117a12d3d34acbbe Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Mon, 25 Nov 2024 17:43:23 +0100 Subject: [PATCH 01/30] feat(payments/gifts): start new gift voucher code Start of a new gift voucher system that works with subscriptions and pledges. --- .../resolvers/_mutations/redeemGiftVoucher.ts | 19 ++ .../payments/graphql/schema.ts | 1 + .../backend-modules/payments/lib/payments.ts | 8 +- .../backend-modules/payments/lib/shop/Shop.ts | 6 +- .../payments/lib/shop/gifts.ts | 260 ++++++++++++++++++ .../backend-modules/payments/package.json | 6 +- 6 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts create mode 100644 packages/backend-modules/payments/lib/shop/gifts.ts 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..2f9762b265 --- /dev/null +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts @@ -0,0 +1,19 @@ +import { GraphqlContext } from '@orbiting/backend-modules-types' +import { GiftShop } from '../../../lib/shop/gifts' +// import { Payments } from '../../../lib/payments' +// import { default as Auth } from '@orbiting/backend-modules-auth' + +export = async function redeemGiftVoucher( + _root: never, // eslint-disable-line @typescript-eslint/no-unused-vars + args: { voucherCode: string }, // eslint-disable-line @typescript-eslint/no-unused-vars + ctx: GraphqlContext, // eslint-disable-line @typescript-eslint/no-unused-vars +) { + // Auth.Roles.ensureUserIsMeOrInRoles(user, ctx.user, ['admin', 'supporter']) + // Payments.getInstance().findSubscription(subscriptionId) + + const giftShop = new GiftShop(ctx.pgdb) + + await giftShop.redeemVoucher(args.voucherCode, ctx.user.id) + + return false +} diff --git a/packages/backend-modules/payments/graphql/schema.ts b/packages/backend-modules/payments/graphql/schema.ts index 1223b57755..b04de22b57 100644 --- a/packages/backend-modules/payments/graphql/schema.ts +++ b/packages/backend-modules/payments/graphql/schema.ts @@ -11,6 +11,7 @@ type queries { } type mutations { + redeemGiftVoucher(voucherCode: String): Boolean createCheckoutSession(offerId: ID!, promoCode: String options: CheckoutSessionOptions): CheckoutSession cancelMagazineSubscription(args: CancelSubscription): Boolean createStripeCustomerPortalSession(companyName: CompanyName): CustomerPortalSession diff --git a/packages/backend-modules/payments/lib/payments.ts b/packages/backend-modules/payments/lib/payments.ts index 5156789519..e17c9afbb4 100644 --- a/packages/backend-modules/payments/lib/payments.ts +++ b/packages/backend-modules/payments/lib/payments.ts @@ -678,17 +678,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) diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index 4cff4caa28..5b0b7d4922 100644 --- a/packages/backend-modules/payments/lib/shop/Shop.ts +++ b/packages/backend-modules/payments/lib/shop/Shop.ts @@ -230,11 +230,11 @@ export class Shop { return promition.data[0] } - private genLineItem(offer: Offer, customPrice?: number) { + public genLineItem(offer: Offer, customPrice?: number) { if (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, @@ -245,7 +245,7 @@ export class Shop { } return { - price: offer.price?.id, + price: offer.price!.id, tax_rates: offer.taxRateId ? [offer.taxRateId] : undefined, quantity: 1, } 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..064ce0e045 --- /dev/null +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -0,0 +1,260 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { PgDb } from 'pogi' +import { Company } from '../types' +import Stripe from 'stripe' +import { ProjectRStripe, RepublikAGStripe } from '../providers/stripe' +import { CustomerRepo } from '../database/CutomerRepo' +import { Payments } from '../payments' +import { Offers } from './offers' +import { Shop } from './Shop' + +export type Gift = { + id: string + company: Company + offer: string + coupon: string + valueType: 'FIXED' | 'PERCENTAGE' + value: number + duration: number + durationUnit: 'year' | 'month' +} + +type Voucher = { + id: string + code: string + giftId: string + issuedBy: Company + redeemedBy: string | null + redeemedForCompany: Company | null + state: 'unredeemed' | 'redeemed' +} + +type PLEDGE_ABOS = 'ABO' | 'MONTHLY_ABO' | 'YEARLY_ABO' | 'BENEFACTOR_ABO' +type SUBSCRIPTIONS = 'YEARLY_SUBSCRIPTION' | 'MONTHLY_SUBSCRIPTION' +type PRODUCT_TYPE = PLEDGE_ABOS | SUBSCRIPTIONS + +const GIFTS: Gift[] = [ + { + id: 'YEARLY_SUBSCRPTION_GIFT', + duration: 1, + durationUnit: 'year', + offer: 'YEARLY', + coupon: process.env.PAYMENTS_PROJECT_R_YEARLY_GIFT_COUPON!, + company: 'PROJECT_R', + value: 100, + valueType: 'PERCENTAGE', + }, + { + id: 'MONTHLY_SUBSCRPTION_GIFT_3', + duration: 3, + durationUnit: 'month', + offer: 'MONTHLY', + coupon: process.env.PAYMENTS_REPUBLIK_MONTHLY_GIFT_3_COUPON!, + company: 'REPUBLIK', + value: 100, + valueType: 'PERCENTAGE', + }, +] + +export class GiftRepo { + #store: Voucher[] = [ + { + id: '1', + code: 'AAABBBCCC', + giftId: 'YEARLY_SUBSCRPTION_GIFT', + issuedBy: 'PROJECT_R', + state: 'unredeemed', + redeemedBy: null, + redeemedForCompany: null, + }, + { + id: '1', + code: 'XXXYYYZZZ', + giftId: 'MONTHLY_SUBSCRPTION_GIFT_3', + issuedBy: 'REPUBLIK', + state: 'unredeemed', + redeemedBy: null, + redeemedForCompany: null, + }, + ] + + async getVoucher(code: string) { + return this.#store.find((g) => g.code === code && g.state === 'unredeemed') + } +} + +export class GiftShop { + #pgdb: PgDb + #giftRepo = new GiftRepo() + #stripeAdapters: Record = { + PROJECT_R: ProjectRStripe, + REPUBLIK: RepublikAGStripe, + } + + constructor(pgdb: PgDb) { + this.#pgdb = pgdb + } + + async redeemVoucher(voucherCode: string, userId: string) { + const voucher = await this.#giftRepo.getVoucher(voucherCode) + + if (!voucher) { + throw new Error('voucher is invalid') + } + + if (voucher.state === 'redeemed') { + 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) + console.log(current?.type) + try { + await (async () => { + switch (current?.type) { + case null: + case undefined: + return this.applyGiftToNewSubscription(userId, gift) + case 'ABO': + return this.applyGiftToMembershipAbo(current.id, gift) + case 'MONTHLY_ABO': + return this.applyGiftToMonthlyAbo(current.id, gift) + case 'YEARLY_ABO': + return this.applyGiftToYearlyAbo(current.id, gift) + case 'BENEFACTOR_ABO': + return this.applyGiftToBenefactor(current.id, gift) + case 'YEARLY_SUBSCRIPTION': + return this.applyGiftToYearlySubscription(current.id, gift) + case 'MONTHLY_SUBSCRIPTION': + return this.applyGiftToMonthlySubscription(current.id, gift) + default: + throw Error('Match error') + } + })() + + await this.markGiftAsRedeemed(gift) + } catch (e) { + if (e instanceof Error) { + console.error(e) + } + } + } + + private async getGift(id: string) { + return GIFTS.find((gift) => (gift.id = id)) || null + } + + private async markGiftAsRedeemed(gift: Gift) { + throw new Error('Not implemented') + } + + 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) { + const cRepo = new CustomerRepo(this.#pgdb) + const paymentService = Payments.getInstance() + + let customerId = (await cRepo.getCustomerIdForCompany(userId, gift.company)) + ?.customerId + if (!customerId) { + customerId = await paymentService.createCustomer(gift.company, userId) + } + + const shop = new Shop(Offers) + + const offer = (await shop.getOfferById(gift.offer))! + + const subscription = await this.#stripeAdapters[ + gift.company + ].subscriptions.create({ + customer: customerId, + metadata: { + 'republik.payments.gift': 'true', + }, + items: [shop.genLineItem(offer)], + coupon: gift.coupon, + collection_method: 'send_invoice', + days_until_due: 14, + }) + + if (subscription.latest_invoice) { + await this.#stripeAdapters[gift.company].invoices.finalizeInvoice( + subscription.latest_invoice.toString(), + ) + } + + // const args = mapSubscriptionArgs(gift.company, subscription) + // return (await paymentService.setupSubscription(userId, args)).id + return + } + + private async applyGiftToMembershipAbo(_id: string, _gift: Gift) { + throw new Error('Not implemented') + return + } + private async applyGiftToMonthlyAbo(_id: string, _gift: Gift) { + throw new Error('Not implemented') + return + } + private async applyGiftToYearlyAbo(_id: string, _gift: Gift) { + throw new Error('Not implemented') + return + } + private async applyGiftToBenefactor(_id: string, _gift: Gift) { + throw new Error('Not implemented') + return + } + private async applyGiftToYearlySubscription(id: string, gift: Gift) { + const stripeId = ( + await this.#pgdb.queryOne( + `SELECT "externalId" from payments.subscriptions WHERE id = :id`, + { id: id }, + ) + ).externalId + + if (!stripeId) { + throw new Error(`yearly subscription ${id} does not exist`) + } + + await this.#stripeAdapters.PROJECT_R.subscriptions.update(stripeId, { + coupon: gift.coupon, + }) + return + } + private async applyGiftToMonthlySubscription(_id: string, _gift: Gift) { + throw new Error('Not implemented') + return + } +} diff --git a/packages/backend-modules/payments/package.json b/packages/backend-modules/payments/package.json index b642b53844..22620e8148 100644 --- a/packages/backend-modules/payments/package.json +++ b/packages/backend-modules/payments/package.json @@ -5,15 +5,15 @@ "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" + "pogi": "^2.11.1", + "stripe": "^17.4.0" }, "devDependencies": { "@types/express": "4.17.21" From 906052be1b667b2c72da25e11895d4cbcf37fe45 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Wed, 27 Nov 2024 17:45:54 +0100 Subject: [PATCH 02/30] feat(payments/gifts): allow monthly to yearly upgreade --- .../payments/lib/shop/gifts.ts | 141 +++++++++++++++--- 1 file changed, 118 insertions(+), 23 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 064ce0e045..ada2223623 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -120,17 +120,17 @@ export class GiftShop { case undefined: return this.applyGiftToNewSubscription(userId, gift) case 'ABO': - return this.applyGiftToMembershipAbo(current.id, gift) + return this.applyGiftToMembershipAbo(userId, current.id, gift) case 'MONTHLY_ABO': - return this.applyGiftToMonthlyAbo(current.id, gift) + return this.applyGiftToMonthlyAbo(userId, current.id, gift) case 'YEARLY_ABO': - return this.applyGiftToYearlyAbo(current.id, gift) + return this.applyGiftToYearlyAbo(userId, current.id, gift) case 'BENEFACTOR_ABO': - return this.applyGiftToBenefactor(current.id, gift) + return this.applyGiftToBenefactor(userId, current.id, gift) case 'YEARLY_SUBSCRIPTION': - return this.applyGiftToYearlySubscription(current.id, gift) + return this.applyGiftToYearlySubscription(userId, current.id, gift) case 'MONTHLY_SUBSCRIPTION': - return this.applyGiftToMonthlySubscription(current.id, gift) + return this.applyGiftToMonthlySubscription(userId, current.id, gift) default: throw Error('Match error') } @@ -148,7 +148,7 @@ export class GiftShop { return GIFTS.find((gift) => (gift.id = id)) || null } - private async markGiftAsRedeemed(gift: Gift) { + private async markGiftAsRedeemed(_gift: Gift) { throw new Error('Not implemented') } @@ -201,7 +201,7 @@ export class GiftShop { ].subscriptions.create({ customer: customerId, metadata: { - 'republik.payments.gift': 'true', + 'republik.payments.started-as': 'gift', }, items: [shop.genLineItem(offer)], coupon: gift.coupon, @@ -214,35 +214,47 @@ export class GiftShop { subscription.latest_invoice.toString(), ) } - - // const args = mapSubscriptionArgs(gift.company, subscription) - // return (await paymentService.setupSubscription(userId, args)).id return } - private async applyGiftToMembershipAbo(_id: string, _gift: Gift) { + private async applyGiftToMembershipAbo( + _userId: string, + _membershipId: string, + _gift: Gift, + ) { throw new Error('Not implemented') return } - private async applyGiftToMonthlyAbo(_id: string, _gift: Gift) { + private async applyGiftToMonthlyAbo( + _userId: string, + _membershipId: string, + _gift: Gift, + ) { throw new Error('Not implemented') return } - private async applyGiftToYearlyAbo(_id: string, _gift: Gift) { + private async applyGiftToYearlyAbo( + _userId: string, + _id: string, + _gift: Gift, + ) { throw new Error('Not implemented') return } - private async applyGiftToBenefactor(_id: string, _gift: Gift) { + private async applyGiftToBenefactor( + _userId: string, + _id: string, + _gift: Gift, + ) { throw new Error('Not implemented') return } - private async applyGiftToYearlySubscription(id: string, gift: Gift) { - const stripeId = ( - await this.#pgdb.queryOne( - `SELECT "externalId" from payments.subscriptions WHERE id = :id`, - { id: id }, - ) - ).externalId + private async applyGiftToYearlySubscription( + _userId: string, + id: string, + gift: Gift, + ) { + const stripeId = await this.getStripeSubscriptionId(id) if (!stripeId) { throw new Error(`yearly subscription ${id} does not exist`) @@ -253,8 +265,91 @@ export class GiftShop { }) return } - private async applyGiftToMonthlySubscription(_id: string, _gift: Gift) { + + private async applyGiftToMonthlySubscription( + userId: string, + subScriptionId: string, + gift: Gift, + ) { + 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 + } + case 'PROJECT_R': { + const cRepo = new CustomerRepo(this.#pgdb) + const paymentService = Payments.getInstance() + + let customerId = ( + await cRepo.getCustomerIdForCompany(userId, gift.company) + )?.customerId + if (!customerId) { + customerId = await paymentService.createCustomer(gift.company, userId) + } + + const shop = new Shop(Offers) + + const offer = (await shop.getOfferById(gift.offer))! + + const oldSub = await this.#stripeAdapters.REPUBLIK.subscriptions.update( + stripeId, + { + cancellation_details: { + comment: 'system cancelation because of update', + }, + proration_behavior: 'none', + metadata: { + 'republik.payments.mailing': 'no-cancel', + 'republik.payments.member': 'keep-on-cancel', + }, + cancel_at_period_end: true, + }, + ) + + 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.started-as': 'gift', + }, + }, + ], + }) + return + } + } + throw new Error('Not implemented') return } + + private async getStripeSubscriptionId( + internalId: string, + ): Promise { + const res = await this.#pgdb.queryOne( + `SELECT "externalId" from payments.subscriptions WHERE id = :id`, + { id: internalId }, + ) + + console.log(res) + + return res.externalId + } } From a78f5461c0fa5dc62b7c68eecf442b1cfcf26733 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 28 Nov 2024 14:08:42 +0100 Subject: [PATCH 03/30] feat(payments/gifts): use Crockford Base32 for vouchers --- .../backend-modules/payments/lib/payments.ts | 2 +- .../payments/lib/shop/gifts.ts | 43 ++++++++++++++----- .../backend-modules/payments/package.json | 1 + yarn.lock | 17 +++++++- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/backend-modules/payments/lib/payments.ts b/packages/backend-modules/payments/lib/payments.ts index e17c9afbb4..404d814731 100644 --- a/packages/backend-modules/payments/lib/payments.ts +++ b/packages/backend-modules/payments/lib/payments.ts @@ -46,7 +46,7 @@ import { UserDataRepo } from './database/UserRepo' // eslint-disable-next-line @typescript-eslint/no-require-imports const { UserEvents } = require('@orbiting/backend-modules-auth') -export const Companies: Company[] = ['PROJECT_R', 'REPUBLIK'] as const +export const Companies: readonly Company[] = ['PROJECT_R', 'REPUBLIK'] as const const RegionNames = new Intl.DisplayNames(['de-CH'], { type: 'region' }) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index ada2223623..b50d8dbdb6 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -2,6 +2,7 @@ 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' @@ -33,6 +34,24 @@ type PLEDGE_ABOS = 'ABO' | 'MONTHLY_ABO' | 'YEARLY_ABO' | 'BENEFACTOR_ABO' type SUBSCRIPTIONS = 'YEARLY_SUBSCRIPTION' | 'MONTHLY_SUBSCRIPTION' type PRODUCT_TYPE = PLEDGE_ABOS | SUBSCRIPTIONS +const arr = new Uint8Array(5) +crypto.getRandomValues(arr) +const code1 = CrockfordBase32.encode(Buffer.from(arr)) +crypto.getRandomValues(arr) +const code2 = CrockfordBase32.encode(Buffer.from(arr)) + +console.log('gift code 1 %s', code1) +console.log('gift code 2 %s', code2) + +function normalizeVoucher(voucherCode: string): string | null { + try { + const code = CrockfordBase32.decode(voucherCode) + return CrockfordBase32.encode(code) + } catch { + return null + } +} + const GIFTS: Gift[] = [ { id: 'YEARLY_SUBSCRPTION_GIFT', @@ -60,7 +79,7 @@ export class GiftRepo { #store: Voucher[] = [ { id: '1', - code: 'AAABBBCCC', + code: code1, giftId: 'YEARLY_SUBSCRPTION_GIFT', issuedBy: 'PROJECT_R', state: 'unredeemed', @@ -69,7 +88,7 @@ export class GiftRepo { }, { id: '1', - code: 'XXXYYYZZZ', + code: code1, giftId: 'MONTHLY_SUBSCRPTION_GIFT_3', issuedBy: 'REPUBLIK', state: 'unredeemed', @@ -96,10 +115,14 @@ export class GiftShop { } async redeemVoucher(voucherCode: string, userId: string) { - const voucher = await this.#giftRepo.getVoucher(voucherCode) + const code = normalizeVoucher(voucherCode) + if (!code) { + throw new Error('voucher is invalid') + } + const voucher = await this.#giftRepo.getVoucher(code) if (!voucher) { - throw new Error('voucher is invalid') + throw new Error('Unknown voucher') } if (voucher.state === 'redeemed') { @@ -289,21 +312,21 @@ export class GiftShop { const paymentService = Payments.getInstance() let customerId = ( - await cRepo.getCustomerIdForCompany(userId, gift.company) + await cRepo.getCustomerIdForCompany(userId, 'PROJECT_R') )?.customerId if (!customerId) { - customerId = await paymentService.createCustomer(gift.company, userId) + customerId = await paymentService.createCustomer('PROJECT_R', userId) } const shop = new Shop(Offers) - const offer = (await shop.getOfferById(gift.offer))! + //cancel old monthly subscription on Republik AG const oldSub = await this.#stripeAdapters.REPUBLIK.subscriptions.update( stripeId, { cancellation_details: { - comment: 'system cancelation because of update', + comment: 'system cancelation because of upgrade', }, proration_behavior: 'none', metadata: { @@ -314,6 +337,7 @@ export class GiftShop { }, ) + // 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, @@ -335,9 +359,6 @@ export class GiftShop { return } } - - throw new Error('Not implemented') - return } private async getStripeSubscriptionId( diff --git a/packages/backend-modules/payments/package.json b/packages/backend-modules/payments/package.json index 22620e8148..cdd07c4ff2 100644 --- a/packages/backend-modules/payments/package.json +++ b/packages/backend-modules/payments/package.json @@ -12,6 +12,7 @@ "@orbiting/backend-modules-utils": "*", "apollo-modules-node": "*", "body-parser": "^1.19.1", + "crockford-base32": "^2.0.0", "pogi": "^2.11.1", "stripe": "^17.4.0" }, diff --git a/yarn.lock b/yarn.lock index fe30fe1b98..8d0bb2ba9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22784,11 +22784,26 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript@5.3.3, typescript@^4.4.3, typescript@^4.6.4, typescript@^5.6.2, typescript@~4.5.0: +typescript@5.3.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + +typescript@^4.4.3, typescript@^4.6.4: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== + +typescript@^5.6.2: version "5.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== +typescript@~4.5.0: + version "4.5.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" + integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== + ua-parser-js@^0.7.30: version "0.7.37" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" From 3dcf1f5b9d70cd3917b2464bd305e4bb12aecb9f Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 29 Nov 2024 11:33:13 +0100 Subject: [PATCH 04/30] feat(payments/gifts): apply gift to pledge abo --- .../payments/lib/shop/gifts.ts | 52 ++++++++++++++++--- .../backend-modules/payments/package.json | 1 + yarn.lock | 5 ++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index b50d8dbdb6..3bb79c78e1 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -8,6 +8,7 @@ import { CustomerRepo } from '../database/CutomerRepo' import { Payments } from '../payments' import { Offers } from './offers' import { Shop } from './Shop' +import dayjs from 'dayjs' export type Gift = { id: string @@ -138,10 +139,12 @@ export class GiftShop { console.log(current?.type) try { await (async () => { - switch (current?.type) { - case null: - case undefined: - return this.applyGiftToNewSubscription(userId, gift) + if (!current) { + // create new subscription with the gift if the user has none + return this.applyGiftToNewSubscription(userId, gift) + } + + switch (current.type) { case 'ABO': return this.applyGiftToMembershipAbo(userId, current.id, gift) case 'MONTHLY_ABO': @@ -155,7 +158,7 @@ export class GiftShop { case 'MONTHLY_SUBSCRIPTION': return this.applyGiftToMonthlySubscription(userId, current.id, gift) default: - throw Error('Match error') + throw Error('Gifts not supported for this mabo') } })() @@ -242,10 +245,43 @@ export class GiftShop { private async applyGiftToMembershipAbo( _userId: string, - _membershipId: string, - _gift: Gift, + membershipId: string, + gift: Gift, ) { - throw new Error('Not implemented') + const tx = await this.#pgdb.transactionBegin() + try { + 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) + + const newMembershipPeriod = + await tx.public.membershipPeriods.insertAndGet({ + membershipId: membershipId, + beginDate: endDate, + endDate: endDate.add(gift.duration, gift.durationUnit), + kind: 'GIFT', + }) + + console.log('new membership period created %s', newMembershipPeriod.id) + + await tx.transactionCommit() + } catch (e) { + await tx.transactionRollback() + throw e + } + return } private async applyGiftToMonthlyAbo( diff --git a/packages/backend-modules/payments/package.json b/packages/backend-modules/payments/package.json index cdd07c4ff2..e469aa28e0 100644 --- a/packages/backend-modules/payments/package.json +++ b/packages/backend-modules/payments/package.json @@ -13,6 +13,7 @@ "apollo-modules-node": "*", "body-parser": "^1.19.1", "crockford-base32": "^2.0.0", + "dayjs": "^1.11.13", "pogi": "^2.11.1", "stripe": "^17.4.0" }, diff --git a/yarn.lock b/yarn.lock index 8d0bb2ba9c..f59f916ca0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9492,6 +9492,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" From e74ee7b605a06114fcc1df7e7e10c27735ca4cac Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 29 Nov 2024 17:59:57 +0100 Subject: [PATCH 05/30] feat(payments/gifts): allow gift purchase --- .../_mutations/createCheckoutSession.ts | 40 ++++++---- .../payments/graphql/schema-types.ts | 14 ++++ .../payments/graphql/schema.ts | 2 +- .../backend-modules/payments/lib/shop/Shop.ts | 75 ++++++++++++++++--- .../payments/lib/shop/offers.ts | 40 +++++++++- .../payments/lib/shop/utils.ts | 4 +- 6 files changed, 146 insertions(+), 29 deletions(-) diff --git a/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts b/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts index fd8f7ea482..0dfa16251b 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts @@ -4,10 +4,15 @@ import { Shop, Offers, utils } 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 + promotionItems: { + id: string + quantity: number + }[] options?: { uiMode?: 'HOSTED' | 'CUSTOM' | 'EMBEDDED' promocode?: string @@ -22,10 +27,10 @@ export = async function createCheckoutSession( args: CreateCheckoutSessionArgs, ctx: GraphqlContext, ) { - Auth.ensureUser(ctx.user) - const shop = new Shop(Offers) + const entryOffer = + ctx.user?.id && (await utils.hasHadMembership(ctx.user.id, ctx.pgdb)) === false const offer = await shop.getOfferById(args.offerId, { @@ -37,19 +42,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, @@ -62,6 +57,7 @@ export = async function createCheckoutSession( customFields: requiredCustomFields(ctx.user), metadata: args?.options?.metadata, returnURL: args?.options?.returnURL, + promotionItems: args.promotionItems, }) return { @@ -71,3 +67,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/schema-types.ts b/packages/backend-modules/payments/graphql/schema-types.ts index 5481bda061..fe3eff6478 100644 --- a/packages/backend-modules/payments/graphql/schema-types.ts +++ b/packages/backend-modules/payments/graphql/schema-types.ts @@ -85,6 +85,8 @@ type Offer { price: Price! customPrice: CustomPrice discount: Discount + allowPromotions: Boolean + promotionItems: [PromotionItems!] } type Price { @@ -115,6 +117,18 @@ type Product { defaultPrice: Price } +type PromotionItems { + id: String! + name: String! + description: String! + maxQuantity: Int! +} + +input PromotionItemOrder { + 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 b04de22b57..59f9505152 100644 --- a/packages/backend-modules/payments/graphql/schema.ts +++ b/packages/backend-modules/payments/graphql/schema.ts @@ -12,7 +12,7 @@ type queries { type mutations { redeemGiftVoucher(voucherCode: String): Boolean - createCheckoutSession(offerId: ID!, promoCode: String options: CheckoutSessionOptions): CheckoutSession + createCheckoutSession(offerId: ID!, promoCode: String, promotionItems: [PromotionItemOrder] options: CheckoutSessionOptions): CheckoutSession cancelMagazineSubscription(args: CancelSubscription): Boolean createStripeCustomerPortalSession(companyName: CompanyName): CustomerPortalSession } diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index 5b0b7d4922..03be676c2d 100644 --- a/packages/backend-modules/payments/lib/shop/Shop.ts +++ b/packages/backend-modules/payments/lib/shop/Shop.ts @@ -1,8 +1,11 @@ import Stripe from 'stripe' import { Company } from '../types' -import { Offer } from './offers' +import { Offer, PromotionItems } from './offers' import { ProjectRStripe, RepublikAGStripe } from '../providers/stripe' import { getConfig } from '../config' +import { User } from '@orbiting/backend-modules-types' +import { PgDb } from 'pogi' +import { utils } from '.' const INTRODUCTERY_OFFER_PROMO_CODE = 'EINSTIEG' @@ -27,12 +30,14 @@ export class Shop { metadata, customFields, returnURL, + promotionItems, }: { offer: Offer uiMode: 'HOSTED' | 'CUSTOM' | 'EMBEDDED' - customerId: string + customerId?: string discounts?: string[] customPrice?: number + promotionItems?: PromotionItemOrder[] metadata?: Record returnURL?: string customFields?: Stripe.Checkout.SessionCreateParams.CustomField[] @@ -55,20 +60,35 @@ export class Shop { ...uiConfig, mode: checkoutMode, customer: customerId, - line_items: [lineItem], + line_items: [ + lineItem, + ...(promotionItems?.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: promotionItems?.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', }, @@ -88,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'], }) @@ -231,7 +250,11 @@ export class Shop { } public genLineItem(offer: Offer, customPrice?: number) { - if (offer.customPrice && typeof customPrice !== 'undefined') { + if ( + offer.type === 'SUBSCRIPTION' && + offer.customPrice && + typeof customPrice !== 'undefined' + ) { return { price_data: { product: offer.productId!, @@ -261,6 +284,36 @@ function getPaymentConfigId(company: Company) { } } +export async function checkIntroductoryOfferEligibility( + pgdb: PgDb, + user?: User, +): Promise { + if ( + process.env.PAYMENTS_INTRODUCTORY_OFFER_ELIGIBILITY_FOR_EVERYONE === 'true' + ) { + return true + } + + if (!user) { + // if there is no user we show the entry offers + return true + } + + if ((await utils.hasHadMembership(user?.id, pgdb)) === false) { + return true + } + + return false +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function promoItemToLineItem(_item: PromotionItems) { + return { + price: 'price_1QQUCcFHX910KaTH9SKJhFZI', + quantity: 1, + } +} + function checkoutUIConfig( uiMode: 'HOSTED' | 'CUSTOM' | 'EMBEDDED', offer: Offer, diff --git a/packages/backend-modules/payments/lib/shop/offers.ts b/packages/backend-modules/payments/lib/shop/offers.ts index 37964b3b22..fbe0074190 100644 --- a/packages/backend-modules/payments/lib/shop/offers.ts +++ b/packages/backend-modules/payments/lib/shop/offers.ts @@ -10,7 +10,20 @@ const CHF = 100 */ const RAPPEN = 1 -export type OfferType = 'SUBSCRIPTION' +export type OfferType = 'SUBSCRIPTION' | 'ONETIME_PAYMENT' + +export type PromotionItems = { + id: string + name: string + description: string + maxQuantity: number + lookupKey: string +} + +export type PromotionItemOrder = { + id: string + quantity: number +} export type Offer = { id: string @@ -20,6 +33,8 @@ export type Offer = { productId?: string defaultPriceLookupKey: string taxRateId?: string + requiresLogin: boolean + promotionItems?: PromotionItems[] allowPromotions: boolean price?: { id: string @@ -51,12 +66,21 @@ export type Offer = { } } +const PROMO_ITEM_REPUBLIK_BIBLIOTEK_1 = { + id: 'REPUBLIK_BILIOTHEK_1', + name: 'Hier bitte einen namen einfügen', + description: 'foo bar baz', + 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,6 +89,7 @@ export const Offers: Offer[] = [ name: 'Gönnermitgliedschaft', type: 'SUBSCRIPTION', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'BENEFACTOR_ABO', allowPromotions: false, customPrice: { @@ -82,6 +107,7 @@ export const Offers: Offer[] = [ name: 'Ausbildungs-Mitgliedschaft', type: 'SUBSCRIPTION', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'STUDENT_ABO', allowPromotions: false, customPrice: { @@ -99,6 +125,7 @@ export const Offers: Offer[] = [ type: 'SUBSCRIPTION', name: 'Jahresmitgliedschaft', company: 'PROJECT_R', + requiresLogin: true, defaultPriceLookupKey: 'ABO', allowPromotions: false, customPrice: { @@ -116,8 +143,19 @@ 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, }, + { + id: 'GIFT_YEARLY', + name: 'Jahresmitgliedschafts Geschenk', + type: 'ONETIME_PAYMENT', + company: 'PROJECT_R', + requiresLogin: false, + allowPromotions: false, + promotionItems: [PROMO_ITEM_REPUBLIK_BIBLIOTEK_1], + defaultPriceLookupKey: 'GIFT_YEARLY', + }, ] 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', From b75eb9f25f2ed44984b96f94a5d86966060a1743 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 29 Nov 2024 18:09:21 +0100 Subject: [PATCH 06/30] fix(payments/gifts): add missing PromotionItemOrder import --- packages/backend-modules/payments/lib/shop/Shop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index 03be676c2d..c91c8847e5 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, PromotionItems } from './offers' +import { Offer, PromotionItemOrder, PromotionItems } from './offers' import { ProjectRStripe, RepublikAGStripe } from '../providers/stripe' import { getConfig } from '../config' import { User } from '@orbiting/backend-modules-types' From ddd22c5f547c54b25e1e21c240679ccd2c886dc5 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Mon, 2 Dec 2024 10:41:45 +0100 Subject: [PATCH 07/30] fix(payments/gift): typescript build error --- packages/backend-modules/payments/lib/shop/Shop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index c91c8847e5..26640dcd2c 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, PromotionItemOrder, PromotionItems } from './offers' +import { Offer, PromotionItemOrder } from './offers' import { ProjectRStripe, RepublikAGStripe } from '../providers/stripe' import { getConfig } from '../config' import { User } from '@orbiting/backend-modules-types' @@ -307,7 +307,7 @@ export async function checkIntroductoryOfferEligibility( } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function promoItemToLineItem(_item: PromotionItems) { +function promoItemToLineItem(_item: PromotionItemOrder) { return { price: 'price_1QQUCcFHX910KaTH9SKJhFZI', quantity: 1, From 19c058dfdc7c8e084e50b82f9942072429950e61 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Mon, 2 Dec 2024 11:19:25 +0100 Subject: [PATCH 08/30] fix(payments/gifts): rename PromotionItems -> PromotionItem --- packages/backend-modules/payments/graphql/schema-types.ts | 4 ++-- packages/backend-modules/payments/lib/shop/offers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend-modules/payments/graphql/schema-types.ts b/packages/backend-modules/payments/graphql/schema-types.ts index fe3eff6478..968ae464ea 100644 --- a/packages/backend-modules/payments/graphql/schema-types.ts +++ b/packages/backend-modules/payments/graphql/schema-types.ts @@ -86,7 +86,7 @@ type Offer { customPrice: CustomPrice discount: Discount allowPromotions: Boolean - promotionItems: [PromotionItems!] + promotionItems: [PromotionItem!] } type Price { @@ -117,7 +117,7 @@ type Product { defaultPrice: Price } -type PromotionItems { +type PromotionItem { id: String! name: String! description: String! diff --git a/packages/backend-modules/payments/lib/shop/offers.ts b/packages/backend-modules/payments/lib/shop/offers.ts index fbe0074190..b6e973219e 100644 --- a/packages/backend-modules/payments/lib/shop/offers.ts +++ b/packages/backend-modules/payments/lib/shop/offers.ts @@ -12,7 +12,7 @@ const RAPPEN = 1 export type OfferType = 'SUBSCRIPTION' | 'ONETIME_PAYMENT' -export type PromotionItems = { +export type PromotionItem = { id: string name: string description: string @@ -34,7 +34,7 @@ export type Offer = { defaultPriceLookupKey: string taxRateId?: string requiresLogin: boolean - promotionItems?: PromotionItems[] + promotionItems?: PromotionItem[] allowPromotions: boolean price?: { id: string From d23f935aee5a0a7a825e3d45a1f6f90e621f29b5 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Mon, 2 Dec 2024 11:53:00 +0100 Subject: [PATCH 09/30] fix(payments/shop): only return successfully fetched offers --- .../backend-modules/payments/lib/shop/Shop.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index 26640dcd2c..8dfcaa66c7 100644 --- a/packages/backend-modules/payments/lib/shop/Shop.ts +++ b/packages/backend-modules/payments/lib/shop/Shop.ts @@ -138,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( @@ -161,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, From 2fedcaa6c85f220fadbd21769e34b6b72d53257f Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Mon, 2 Dec 2024 14:55:34 +0100 Subject: [PATCH 10/30] chore(payments/gifts): static base32 codes for testing --- packages/backend-modules/payments/lib/shop/gifts.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 3bb79c78e1..04a43e79d3 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -35,15 +35,6 @@ type PLEDGE_ABOS = 'ABO' | 'MONTHLY_ABO' | 'YEARLY_ABO' | 'BENEFACTOR_ABO' type SUBSCRIPTIONS = 'YEARLY_SUBSCRIPTION' | 'MONTHLY_SUBSCRIPTION' type PRODUCT_TYPE = PLEDGE_ABOS | SUBSCRIPTIONS -const arr = new Uint8Array(5) -crypto.getRandomValues(arr) -const code1 = CrockfordBase32.encode(Buffer.from(arr)) -crypto.getRandomValues(arr) -const code2 = CrockfordBase32.encode(Buffer.from(arr)) - -console.log('gift code 1 %s', code1) -console.log('gift code 2 %s', code2) - function normalizeVoucher(voucherCode: string): string | null { try { const code = CrockfordBase32.decode(voucherCode) @@ -80,7 +71,7 @@ export class GiftRepo { #store: Voucher[] = [ { id: '1', - code: code1, + code: 'V4QPS1W5', giftId: 'YEARLY_SUBSCRPTION_GIFT', issuedBy: 'PROJECT_R', state: 'unredeemed', @@ -89,7 +80,7 @@ export class GiftRepo { }, { id: '1', - code: code1, + code: 'NM13P325', giftId: 'MONTHLY_SUBSCRPTION_GIFT_3', issuedBy: 'REPUBLIK', state: 'unredeemed', From cfff1449c55fd1540eb96aae6be371d7b3e1bb94 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 6 Dec 2024 14:14:54 +0100 Subject: [PATCH 11/30] feat(payments/shop): return offers as interface --- .../payments/graphql/schema-types.ts | 30 +++- .../payments/graphql/schema.ts | 2 +- .../payments/lib/database/BillingRepo.ts | 26 ++-- .../lib/handlers/stripe/checkoutCompleted.ts | 39 ++++- .../lib/handlers/stripe/invoiceCreated.ts | 8 +- .../backend-modules/payments/lib/payments.ts | 29 ++-- .../payments/lib/providers/base.ts | 1 + .../payments/lib/providers/stripe.ts | 8 ++ .../payments/lib/shop/gifts.ts | 29 +++- .../payments/lib/shop/offers.ts | 2 +- .../backend-modules/payments/lib/types.ts | 2 +- .../backend-modules/payments/tsconfig.json | 3 +- yarn.lock | 136 ++++++++++++++---- 13 files changed, 242 insertions(+), 73 deletions(-) diff --git a/packages/backend-modules/payments/graphql/schema-types.ts b/packages/backend-modules/payments/graphql/schema-types.ts index 968ae464ea..85bc263cd4 100644 --- a/packages/backend-modules/payments/graphql/schema-types.ts +++ b/packages/backend-modules/payments/graphql/schema-types.ts @@ -78,7 +78,7 @@ type CheckoutSession { url: String } -type Offer { +interface Offer { id: ID! company: CompanyName! name: String! @@ -86,7 +86,29 @@ type Offer { customPrice: CustomPrice discount: Discount allowPromotions: Boolean - promotionItems: [PromotionItem!] + 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 { @@ -117,14 +139,14 @@ type Product { defaultPrice: Price } -type PromotionItem { +type ComplimentaryItem { id: String! name: String! description: String! maxQuantity: Int! } -input PromotionItemOrder { +input ComplimentaryItemOrder { id: String! quantity: Int! } diff --git a/packages/backend-modules/payments/graphql/schema.ts b/packages/backend-modules/payments/graphql/schema.ts index 59f9505152..b539ee97e9 100644 --- a/packages/backend-modules/payments/graphql/schema.ts +++ b/packages/backend-modules/payments/graphql/schema.ts @@ -12,7 +12,7 @@ type queries { type mutations { redeemGiftVoucher(voucherCode: String): Boolean - createCheckoutSession(offerId: ID!, promoCode: String, promotionItems: [PromotionItemOrder] options: CheckoutSessionOptions): CheckoutSession + 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/database/BillingRepo.ts b/packages/backend-modules/payments/lib/database/BillingRepo.ts index 49f6f69077..d66c52b278 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, @@ -33,7 +33,7 @@ export type OrderRepoArgs = { } export interface PaymentBillingRepo { - getSubscription(by: PaymentItemLocator): Promise + getSubscription(by: SelectCriteria): Promise getUserSubscriptions( userId: string, onlyStatus?: SubscriptionStatus[], @@ -43,18 +43,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 +80,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 +118,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 +135,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 +150,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/handlers/stripe/checkoutCompleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts index 5131497e38..1a91607302 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts @@ -9,14 +9,27 @@ import { mapSubscriptionArgs } from './subscriptionCreated' import { mapInvoiceArgs } from './invoiceCreated' import { SyncAddressDataWorker } from '../../workers/SyncAddressDataWorker' import { mapChargeArgs } from './invoicePaymentSucceded' +// import { GiftShop } from '../../shop/gifts' export async function processCheckoutCompleted( paymentService: PaymentService, company: Company, event: Stripe.CheckoutSessionCompletedEvent, ) { - const customerId = event.data.object.customer as string + switch (event.data.object.mode) { + case 'subscription': + return handleSubscription(paymentService, company, event) + case 'payment': + return handlePayment(paymentService, company, event) + } +} +async function handleSubscription( + paymentService: PaymentService, + company: Company, + event: Stripe.CheckoutSessionCompletedEvent, +) { + const customerId = event.data.object.customer as string if (!customerId) { console.log('No stripe customer provided; skipping') return @@ -130,10 +143,32 @@ export async function processCheckoutCompleted( ) : undefined, ]) - return } +async function handlePayment( + _paymentService: PaymentService, + company: Company, + event: Stripe.CheckoutSessionCompletedEvent, +) { + const sess = await PaymentProvider.forCompany(company).getCheckoutSession( + event.data.object.id, + ) + + const lookupKey = sess?.line_items?.data.map(async (line) => { + const lookupKey = line.price?.lookup_key + + if (lookupKey?.startsWith('GIFT_')) { + // const handler = Payments.getCheckoutHandler('purchesVoucher') + // await handler() + } + }) + + // GiftShop.findGiftByLookupKey() + + console.log(lookupKey) +} + 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..3e499843f4 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/invoiceCreated.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/invoiceCreated.ts @@ -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 [%s]', externalInvoiceId) return } const invoice = await PaymentProvider.forCompany(company).getInvoice( - externalInvocieId, + externalInvoiceId, ) if (!invoice) { throw new Error(`Unknown invoice ${event.data.object.id}`) diff --git a/packages/backend-modules/payments/lib/payments.ts b/packages/backend-modules/payments/lib/payments.ts index 404d814731..a950764d1b 100644 --- a/packages/backend-modules/payments/lib/payments.ts +++ b/packages/backend-modules/payments/lib/payments.ts @@ -7,7 +7,7 @@ import { Order, Subscription, SubscriptionArgs, - PaymentItemLocator, + SelectCriteria, InvoiceUpdateArgs, Webhook, ACTIVE_STATUS_TYPES, @@ -423,7 +423,7 @@ export class Payments implements PaymentService { } } - async getCharge(by: PaymentItemLocator) { + async getCharge(by: SelectCriteria) { return await this.billing.getCharge(by) } @@ -431,16 +431,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 +550,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,7 +579,7 @@ export class Payments implements PaymentService { } async disableSubscription( - locator: PaymentItemLocator, + locator: SelectCriteria, args: any, ): Promise { const tx = await this.pgdb.transactionBegin() @@ -860,10 +860,10 @@ 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 @@ -884,15 +884,12 @@ export interface PaymentService { getOrder(id: string): Promise saveOrder(userId: string, 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 + updateCharge(by: SelectCriteria, args: ChargeUpdate): Promise + updateInvoice(by: SelectCriteria, args: InvoiceUpdateArgs): Promise verifyWebhookForCompany(company: string, req: any): T logWebhookEvent(webhook: WebhookArgs): Promise> findWebhookEventBySourceId(sourceId: string): Promise | null> 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/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 04a43e79d3..df8058e983 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -44,9 +44,15 @@ function normalizeVoucher(voucherCode: string): string | null { } } +function newVoucherCode() { + const bytes = new Uint8Array(6) + crypto.getRandomValues(bytes) + return CrockfordBase32.encode(Buffer.from(bytes)) +} + const GIFTS: Gift[] = [ { - id: 'YEARLY_SUBSCRPTION_GIFT', + id: 'GIFT_YEARLY', duration: 1, durationUnit: 'year', offer: 'YEARLY', @@ -72,7 +78,7 @@ export class GiftRepo { { id: '1', code: 'V4QPS1W5', - giftId: 'YEARLY_SUBSCRPTION_GIFT', + giftId: 'GIFT_YEARLY', issuedBy: 'PROJECT_R', state: 'unredeemed', redeemedBy: null, @@ -92,6 +98,9 @@ export class GiftRepo { async getVoucher(code: string) { return this.#store.find((g) => g.code === code && g.state === 'unredeemed') } + async insertVoucher(voucher: Voucher) { + return this.#store.push(voucher) + } } export class GiftShop { @@ -106,6 +115,22 @@ export class GiftShop { this.#pgdb = pgdb } + async handleVoucherPurches(company: Company, lookupKey: string) { + const voucher: Voucher = { + id: crypto.randomUUID(), + issuedBy: company, + code: newVoucherCode(), + giftId: lookupKey, + state: 'unredeemed', + redeemedBy: null, + redeemedForCompany: null, + } + + this.#giftRepo.insertVoucher(voucher) + + return voucher.code + } + async redeemVoucher(voucherCode: string, userId: string) { const code = normalizeVoucher(voucherCode) if (!code) { diff --git a/packages/backend-modules/payments/lib/shop/offers.ts b/packages/backend-modules/payments/lib/shop/offers.ts index b6e973219e..b6d9b0409d 100644 --- a/packages/backend-modules/payments/lib/shop/offers.ts +++ b/packages/backend-modules/payments/lib/shop/offers.ts @@ -111,7 +111,7 @@ export const Offers: Offer[] = [ defaultPriceLookupKey: 'STUDENT_ABO', allowPromotions: false, customPrice: { - min: 120 * CHF, + min: 140 * CHF, max: 239 * CHF, step: 50 * RAPPEN, recurring: { 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/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/yarn.lock b/yarn.lock index f59f916ca0..ba6a7fa83b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7613,6 +7613,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" @@ -7633,14 +7641,6 @@ chalk@^2.3.2, chalk@^2.4.1, 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" @@ -10044,6 +10044,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" @@ -11920,6 +11927,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" @@ -12009,7 +12027,7 @@ glob@10.3.10, glob@^10.2.2, glob@^10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@7.2.3, 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.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== @@ -13305,6 +13323,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" @@ -14965,6 +14988,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" @@ -15560,6 +15601,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" @@ -16649,7 +16695,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== @@ -16772,7 +16818,7 @@ minimist@1.2.7: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -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== @@ -18355,6 +18401,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" @@ -18375,6 +18426,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" @@ -18385,6 +18441,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" @@ -18431,6 +18492,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" @@ -18541,7 +18615,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== @@ -19562,6 +19636,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" @@ -20751,6 +20832,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" @@ -22789,26 +22884,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript@5.3.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" - integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== - -typescript@^4.4.3, typescript@^4.6.4: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== - -typescript@^5.6.2: +typescript@5.3.3, typescript@^4.4.3, typescript@^4.6.4, typescript@^5.6.2, typescript@~4.5.0: version "5.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== -typescript@~4.5.0: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== - ua-parser-js@^0.7.30: version "0.7.37" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" From 39f07261795e5030d2ec160a43ba8d37ca7ae4d0 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 6 Dec 2024 14:30:33 +0100 Subject: [PATCH 12/30] fix(payments/shop): promotionItems -> complimentaryItems --- .../resolvers/_mutations/createCheckoutSession.ts | 4 ++-- packages/backend-modules/payments/lib/shop/Shop.ts | 12 ++++++------ packages/backend-modules/payments/lib/shop/offers.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts b/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts index 1e98a0b043..7e5eaf5336 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/createCheckoutSession.ts @@ -13,7 +13,7 @@ import { Company } from '../../../lib/types' type CreateCheckoutSessionArgs = { offerId: string promoCode?: string - promotionItems: { + complimentaryItems: { id: string quantity: number }[] @@ -60,7 +60,7 @@ export = async function createCheckoutSession( customFields: requiredCustomFields(ctx.user), metadata: args?.options?.metadata, returnURL: args?.options?.returnURL, - promotionItems: args.promotionItems, + complimentaryItems: args.complimentaryItems, }) return { diff --git a/packages/backend-modules/payments/lib/shop/Shop.ts b/packages/backend-modules/payments/lib/shop/Shop.ts index 8dfcaa66c7..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, PromotionItemOrder } 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,14 +30,14 @@ export class Shop { metadata, customFields, returnURL, - promotionItems, + complimentaryItems, }: { offer: Offer uiMode: 'HOSTED' | 'CUSTOM' | 'EMBEDDED' customerId?: string discounts?: string[] customPrice?: number - promotionItems?: PromotionItemOrder[] + complimentaryItems?: ComplimentaryItemOrder[] metadata?: Record returnURL?: string customFields?: Stripe.Checkout.SessionCreateParams.CustomField[] @@ -62,14 +62,14 @@ export class Shop { customer: customerId, line_items: [ lineItem, - ...(promotionItems?.map(promoItemToLineItem) || []), + ...(complimentaryItems?.map(promoItemToLineItem) || []), ], currency: offer.price?.currency, discounts: discounts?.map((id) => ({ coupon: id })), locale: 'de', billing_address_collection: offer.company === 'PROJECT_R' ? 'required' : 'auto', - shipping_address_collection: promotionItems?.length + shipping_address_collection: complimentaryItems?.length ? { allowed_countries: ['CH'], } @@ -312,7 +312,7 @@ export async function checkIntroductoryOfferEligibility( } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function promoItemToLineItem(_item: PromotionItemOrder) { +function promoItemToLineItem(_item: ComplimentaryItemOrder) { return { price: 'price_1QQUCcFHX910KaTH9SKJhFZI', quantity: 1, diff --git a/packages/backend-modules/payments/lib/shop/offers.ts b/packages/backend-modules/payments/lib/shop/offers.ts index b6d9b0409d..287ca2a886 100644 --- a/packages/backend-modules/payments/lib/shop/offers.ts +++ b/packages/backend-modules/payments/lib/shop/offers.ts @@ -12,7 +12,7 @@ const RAPPEN = 1 export type OfferType = 'SUBSCRIPTION' | 'ONETIME_PAYMENT' -export type PromotionItem = { +export type ComplimentaryItem = { id: string name: string description: string @@ -20,7 +20,7 @@ export type PromotionItem = { lookupKey: string } -export type PromotionItemOrder = { +export type ComplimentaryItemOrder = { id: string quantity: number } @@ -34,7 +34,7 @@ export type Offer = { defaultPriceLookupKey: string taxRateId?: string requiresLogin: boolean - promotionItems?: PromotionItem[] + complimentaryItems?: ComplimentaryItem[] allowPromotions: boolean price?: { id: string @@ -155,7 +155,7 @@ export const Offers: Offer[] = [ company: 'PROJECT_R', requiresLogin: false, allowPromotions: false, - promotionItems: [PROMO_ITEM_REPUBLIK_BIBLIOTEK_1], + complimentaryItems: [PROMO_ITEM_REPUBLIK_BIBLIOTEK_1], defaultPriceLookupKey: 'GIFT_YEARLY', }, ] From 1ae6d495e873b55b313242ee07496b0f591683b3 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 6 Dec 2024 14:54:38 +0100 Subject: [PATCH 13/30] fix(payments/shop): add offer interface type resolver --- .../payments/graphql/resolvers/Offer.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/backend-modules/payments/graphql/resolvers/Offer.ts 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' + }, +} From 595c629b29e6587f315fede18f2c13888e87fdac Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Wed, 11 Dec 2024 18:49:05 +0100 Subject: [PATCH 14/30] refactor: add connection context to all queue workers --- apps/api/server.js | 43 +- .../backend-modules/job-queue/lib/queue.ts | 61 +- .../job-queue/lib/workers/base.ts | 5 +- .../backend-modules/job-queue/package.json | 1 + .../backend-modules/payments/express/index.ts | 15 +- .../payments/express/webhook/stripe.ts | 23 +- .../lib/handlers/stripe/checkoutCompleted.ts | 2 +- ...Succeded.ts => invoicePaymentSucceeded.ts} | 7 +- .../backend-modules/payments/lib/payments.ts | 531 +----------------- .../lib/services/CustomerInfoService.ts | 105 ++++ .../lib/services/MailNotificationService.ts | 318 +++++++++++ .../payments/lib/services/WebhookService.ts | 54 ++ .../ConfirmCancelTransactionalWorker.ts | 12 +- ...rmRevokeCancellationTransactionalWorker.ts | 10 +- .../ConfirmSetupTransactionalWorker.ts | 10 +- .../workers/NoticeEndedTransactionalWorker.ts | 14 +- .../NoticePaymentFailedTransactionalWorker.ts | 15 +- .../lib/workers/StripeWebhookWorker.ts | 14 +- .../lib/workers/SyncAddressDataWorker.ts | 6 +- .../lib/workers/SyncMailchimpEndedWorker.ts | 23 +- .../lib/workers/SyncMailchimpSetupWorker.ts | 23 +- .../lib/workers/SyncMailchimpUpdateWorker.ts | 22 +- 22 files changed, 704 insertions(+), 610 deletions(-) rename packages/backend-modules/payments/lib/handlers/stripe/{invoicePaymentSucceded.ts => invoicePaymentSucceeded.ts} (89%) create mode 100644 packages/backend-modules/payments/lib/services/CustomerInfoService.ts create mode 100644 packages/backend-modules/payments/lib/services/MailNotificationService.ts create mode 100644 packages/backend-modules/payments/lib/services/WebhookService.ts 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/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/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..4d45049385 100644 --- a/packages/backend-modules/payments/express/webhook/stripe.ts +++ b/packages/backend-modules/payments/express/webhook/stripe.ts @@ -1,25 +1,28 @@ +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 @@ -27,7 +30,7 @@ export async function handleStripeWebhook(req: Request, res: Response) { 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/lib/handlers/stripe/checkoutCompleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts index 1a91607302..6527ababb3 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts @@ -8,7 +8,7 @@ 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 { GiftShop } from '../../shop/gifts' export async function processCheckoutCompleted( diff --git a/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceded.ts b/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceeded.ts similarity index 89% rename from packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceded.ts rename to packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceeded.ts index e7f8f6c0e4..db4d74a3a0 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceded.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/invoicePaymentSucceeded.ts @@ -3,7 +3,7 @@ import { PaymentService } from '../../payments' import { Company } from '../../types' import { PaymentProvider } from '../../providers/provider' -export async function processInvociePaymentSucceded( +export async function processInvoicePaymentSucceeded( paymentService: PaymentService, company: Company, event: Stripe.InvoicePaymentSucceededEvent, @@ -19,14 +19,13 @@ export async function processInvociePaymentSucceded( 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) diff --git a/packages/backend-modules/payments/lib/payments.ts b/packages/backend-modules/payments/lib/payments.ts index a950764d1b..0a696387c9 100644 --- a/packages/backend-modules/payments/lib/payments.ts +++ b/packages/backend-modules/payments/lib/payments.ts @@ -9,32 +9,12 @@ import { SubscriptionArgs, 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, @@ -43,17 +23,35 @@ import { 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 +73,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 { @@ -615,35 +322,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) @@ -698,21 +376,6 @@ 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, @@ -747,104 +410,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) - } } /* @@ -877,9 +446,7 @@ 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 @@ -890,65 +457,9 @@ export interface PaymentService { saveCharge(args: ChargeInsert): Promise updateCharge(by: SelectCriteria, args: ChargeUpdate): Promise updateInvoice(by: SelectCriteria, args: InvoiceUpdateArgs): Promise - verifyWebhookForCompany(company: string, req: any): T - logWebhookEvent(webhook: WebhookArgs): Promise> - findWebhookEventBySourceId(sourceId: string): Promise | null> - markWebhookAsProcessed(sourceId: string): 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/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..650cc091ca --- /dev/null +++ b/packages/backend-modules/payments/lib/services/MailNotificationService.ts @@ -0,0 +1,318 @@ +import { PgDb } from 'pogi' +import { NOT_STARTED_STATUS_TYPES, ACTIVE_STATUS_TYPES } from '../types' +import { + sendCancelConfirmationMail, + sendEndedNoticeMail, + sendPaymentFailedNoticeMail, + sendRevokeCancellationConfirmationMail, + 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 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..33b68601ef --- /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) { + 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/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/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..d2bc90e8ab 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, ) @@ -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..862bda767b 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,24 @@ 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 invoice = await PaymentService.getInvoice({ + externalId: event.data.object.invoice 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') + console.error( + 'Invoice or 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`) } From b241baf79f4301b226463f5016a25388e17b55c7 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 12 Dec 2024 13:06:49 +0100 Subject: [PATCH 15/30] test(queue): update queue test to new consturctor interface --- .../job-queue/__test__/queue.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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) From 9d245f954928feacfb8c261b221be564bcddedf9 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 12 Dec 2024 17:28:46 +0100 Subject: [PATCH 16/30] feat: add gift voucher checkout handling and store voucher in db --- ...41212140600-alter-order-table-for-gifts.js | 10 ++ .../20241212144915-gift-code-table.js | 10 ++ .../payments/express/webhook/stripe.ts | 2 +- .../payments/lib/database/BillingRepo.ts | 11 +- .../payments/lib/database/GiftVoucherRepo.ts | 28 +++++ .../lib/handlers/stripe/checkoutCompleted.ts | 106 +++++++++++++++--- .../backend-modules/payments/lib/payments.ts | 15 +-- .../payments/lib/services/WebhookService.ts | 2 +- .../payments/lib/shop/gifts.ts | 85 +++++++------- .../lib/workers/StripeWebhookWorker.ts | 2 +- ...40600-alter-order-table-for-gifts-down.sql | 1 + ...2140600-alter-order-table-for-gifts-up.sql | 23 ++++ .../20241212144915-gift-code-table-down.sql | 3 + .../sql/20241212144915-gift-code-table-up.sql | 12 ++ 14 files changed, 236 insertions(+), 74 deletions(-) create mode 100644 packages/backend-modules/migrations/migrations/20241212140600-alter-order-table-for-gifts.js create mode 100644 packages/backend-modules/migrations/migrations/20241212144915-gift-code-table.js create mode 100644 packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts create mode 100644 packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-down.sql create mode 100644 packages/backend-modules/payments/migrations/sql/20241212140600-alter-order-table-for-gifts-up.sql create mode 100644 packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-down.sql create mode 100644 packages/backend-modules/payments/migrations/sql/20241212144915-gift-code-table-up.sql 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/payments/express/webhook/stripe.ts b/packages/backend-modules/payments/express/webhook/stripe.ts index 4d45049385..f0a8ef7f98 100644 --- a/packages/backend-modules/payments/express/webhook/stripe.ts +++ b/packages/backend-modules/payments/express/webhook/stripe.ts @@ -25,7 +25,7 @@ export async function handleStripeWebhook( 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) } diff --git a/packages/backend-modules/payments/lib/database/BillingRepo.ts b/packages/backend-modules/payments/lib/database/BillingRepo.ts index d66c52b278..f23dca9e74 100644 --- a/packages/backend-modules/payments/lib/database/BillingRepo.ts +++ b/packages/backend-modules/payments/lib/database/BillingRepo.ts @@ -16,20 +16,27 @@ 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 { 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..b8b8a49049 --- /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 + state: 'unredeemed' | 'redeemed' +} + +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/handlers/stripe/checkoutCompleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts index 6527ababb3..ba03413d61 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts @@ -9,26 +9,35 @@ import { mapSubscriptionArgs } from './subscriptionCreated' import { mapInvoiceArgs } from './invoiceCreated' import { SyncAddressDataWorker } from '../../workers/SyncAddressDataWorker' import { mapChargeArgs } from './invoicePaymentSucceeded' -// import { GiftShop } from '../../shop/gifts' +import { ConnectionContext } from '@orbiting/backend-modules-types' +import { GiftShop } from '../../shop/gifts' + +type PaymentWebhookContext = { + paymentService: PaymentService +} & ConnectionContext export async function processCheckoutCompleted( - paymentService: PaymentService, + ctx: PaymentWebhookContext, company: Company, event: Stripe.CheckoutSessionCompletedEvent, ) { switch (event.data.object.mode) { - case 'subscription': - return handleSubscription(paymentService, company, event) - case 'payment': - return handlePayment(paymentService, company, event) + case 'subscription': { + return handleSubscription(ctx, company, event) + } + case 'payment': { + return handlePayment(ctx, company, event) + } } } async function handleSubscription( - paymentService: PaymentService, + 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') @@ -105,7 +114,8 @@ async function handleSubscription( } } - await paymentService.saveOrder(userId, { + await paymentService.saveOrder({ + userId: userId, company: company, externalId: event.data.object.id, invoiceId: invoiceId as string, @@ -147,26 +157,90 @@ async function handleSubscription( } async function handlePayment( - _paymentService: PaymentService, + ctx: PaymentWebhookContext, company: Company, event: Stripe.CheckoutSessionCompletedEvent, ) { + const giftShop = new GiftShop(ctx.pgdb) + const sess = await PaymentProvider.forCompany(company).getCheckoutSession( event.data.object.id, ) - const lookupKey = sess?.line_items?.data.map(async (line) => { - const lookupKey = line.price?.lookup_key + 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' + } + + console.log(sess.line_items.data) + console.log(sess.total_details) - if (lookupKey?.startsWith('GIFT_')) { - // const handler = Payments.getCheckoutHandler('purchesVoucher') - // await handler() + 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, + } + }) + + const giftCodes = [] + for (const item of lineItems) { + if (item.priceLookupKey?.startsWith('GIFT')) { + const code = await giftShop.generateNewVoucher( + company, + item.priceLookupKey, + ) + giftCodes.push(code) } + } + + 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', + } + + const order = await ctx.paymentService.saveOrder(orderDraft) + + const orderLineItems = lineItems.map((i) => { + return { orderId: order.id, ...i } }) - // GiftShop.findGiftByLookupKey() + await ctx.pgdb.payments.orderLineItems.insert(orderLineItems) + // TODO!: Insert shipping address - console.log(lookupKey) + console.log(orderLineItems) + console.log(giftCodes) } async function syncUserNameData( diff --git a/packages/backend-modules/payments/lib/payments.ts b/packages/backend-modules/payments/lib/payments.ts index 0a696387c9..4fbc25a6cd 100644 --- a/packages/backend-modules/payments/lib/payments.ts +++ b/packages/backend-modules/payments/lib/payments.ts @@ -19,7 +19,6 @@ import { CustomerRepo, PaymentCustomerRepo } from './database/CutomerRepo' import { BillingRepo, OrderArgs, - OrderRepoArgs, PaymentBillingRepo, } from './database/BillingRepo' import { UserDataRepo } from './database/UserRepo' @@ -376,16 +375,8 @@ export class Payments implements PaymentService { return customerId } - 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( @@ -449,7 +440,7 @@ export interface PaymentService { createCustomer(company: Company, userId: string): Promise listUserOrders(userId: string): Promise getOrder(id: string): Promise - saveOrder(userId: string, order: OrderArgs): Promise + saveOrder(order: OrderArgs): Promise getSubscriptionInvoices(subscriptionId: string): Promise getInvoice(by: SelectCriteria): Promise saveInvoice(userId: string, args: InvoiceArgs): Promise diff --git a/packages/backend-modules/payments/lib/services/WebhookService.ts b/packages/backend-modules/payments/lib/services/WebhookService.ts index 33b68601ef..087f2ee13a 100644 --- a/packages/backend-modules/payments/lib/services/WebhookService.ts +++ b/packages/backend-modules/payments/lib/services/WebhookService.ts @@ -12,7 +12,7 @@ export class WebhookService { this.#repo = new WebhookRepo(pgdb) } - getEvent(id: string) { + getEvent(id: string): Promise | null> { return this.#repo.findWebhookEventBySourceId(id) } diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index df8058e983..6e28ba074e 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -9,6 +9,7 @@ import { Payments } from '../payments' import { Offers } from './offers' import { Shop } from './Shop' import dayjs from 'dayjs' +import { GiftVoucherRepo } from '../database/GiftVoucherRepo' export type Gift = { id: string @@ -21,14 +22,14 @@ export type Gift = { durationUnit: 'year' | 'month' } -type Voucher = { - id: string +export type Voucher = { + id?: string code: string giftId: string issuedBy: Company redeemedBy: string | null redeemedForCompany: Company | null - state: 'unredeemed' | 'redeemed' + redeemedAt: Date | null } type PLEDGE_ABOS = 'ABO' | 'MONTHLY_ABO' | 'YEARLY_ABO' | 'BENEFACTOR_ABO' @@ -73,39 +74,39 @@ const GIFTS: Gift[] = [ }, ] -export class GiftRepo { - #store: Voucher[] = [ - { - id: '1', - code: 'V4QPS1W5', - giftId: 'GIFT_YEARLY', - issuedBy: 'PROJECT_R', - state: 'unredeemed', - redeemedBy: null, - redeemedForCompany: null, - }, - { - id: '1', - code: 'NM13P325', - giftId: 'MONTHLY_SUBSCRPTION_GIFT_3', - issuedBy: 'REPUBLIK', - state: 'unredeemed', - redeemedBy: null, - redeemedForCompany: null, - }, - ] - - async getVoucher(code: string) { - return this.#store.find((g) => g.code === code && g.state === 'unredeemed') - } - async insertVoucher(voucher: Voucher) { - return this.#store.push(voucher) - } -} +// export class GiftRepo { +// #store: Voucher[] = [ +// { +// id: '1', +// code: 'V4QPS1W5', +// giftId: 'GIFT_YEARLY', +// issuedBy: 'PROJECT_R', +// state: 'unredeemed', +// redeemedBy: null, +// redeemedForCompany: null, +// }, +// { +// id: '1', +// code: 'NM13P325', +// giftId: 'MONTHLY_SUBSCRPTION_GIFT_3', +// issuedBy: 'REPUBLIK', +// state: 'unredeemed', +// redeemedBy: null, +// redeemedForCompany: null, +// }, +// ] + +// async getVoucher(code: string) { +// return this.#store.find((g) => g.code === code && g.state === 'unredeemed') +// } +// async saveVoucher(voucher: Voucher) { +// return this.#store.push(voucher) +// } +// } export class GiftShop { #pgdb: PgDb - #giftRepo = new GiftRepo() + #giftRepo: GiftVoucherRepo #stripeAdapters: Record = { PROJECT_R: ProjectRStripe, REPUBLIK: RepublikAGStripe, @@ -113,22 +114,24 @@ export class GiftShop { constructor(pgdb: PgDb) { this.#pgdb = pgdb + this.#giftRepo = new GiftVoucherRepo(pgdb) } - async handleVoucherPurches(company: Company, lookupKey: string) { + async generateNewVoucher(company: Company, giftId: string) { const voucher: Voucher = { - id: crypto.randomUUID(), issuedBy: company, code: newVoucherCode(), - giftId: lookupKey, - state: 'unredeemed', + giftId: giftId, + redeemedAt: null, redeemedBy: null, redeemedForCompany: null, } - this.#giftRepo.insertVoucher(voucher) + console.log(voucher) + + this.#giftRepo.saveVoucher(voucher) - return voucher.code + return voucher } async redeemVoucher(voucherCode: string, userId: string) { @@ -137,12 +140,12 @@ export class GiftShop { throw new Error('voucher is invalid') } - const voucher = await this.#giftRepo.getVoucher(code) + const voucher = await this.#giftRepo.getVoucherByCode(code) if (!voucher) { throw new Error('Unknown voucher') } - if (voucher.state === 'redeemed') { + if (voucher.redeemedAt !== null) { throw new Error('gift has already been redeemed') } diff --git a/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts b/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts index d2bc90e8ab..6159d95596 100644 --- a/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts +++ b/packages/backend-modules/payments/lib/workers/StripeWebhookWorker.ts @@ -52,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, ) 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() +); From 15a5a6ede41ebd6a8a50ce21fca3d837f23cd6a4 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 27 Dec 2024 16:00:19 +0100 Subject: [PATCH 17/30] chore(shop/gifts): add gift purchase confirmation email --- .../payment_successful_gift_voucher.html | 130 ++++++++++++++++++ .../lib/handlers/stripe/checkoutCompleted.ts | 9 ++ .../payments/lib/shop/gifts.ts | 2 +- .../transactionals/sendTransactionalMails.ts | 55 ++++++-- .../translate/translations.json | 4 + 5 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 packages/backend-modules/mail/templates/payment_successful_gift_voucher.html 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/payments/lib/handlers/stripe/checkoutCompleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts index ba03413d61..72bf08d7ae 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/checkoutCompleted.ts @@ -11,6 +11,7 @@ import { SyncAddressDataWorker } from '../../workers/SyncAddressDataWorker' import { mapChargeArgs } from './invoicePaymentSucceeded' import { ConnectionContext } from '@orbiting/backend-modules-types' import { GiftShop } from '../../shop/gifts' +import { sendGiftPurchaseMail } from '../../transactionals/sendTransactionalMails' type PaymentWebhookContext = { paymentService: PaymentService @@ -241,6 +242,14 @@ async function handlePayment( console.log(orderLineItems) console.log(giftCodes) + + await sendGiftPurchaseMail( + { + email: sess.customer_details!.email!, + voucherCode: giftCodes[0].code.replace(/(\w{4})(\w{4})/, '$1-$2'), + }, + ctx.pgdb, + ) } async function syncUserNameData( diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 6e28ba074e..4b6e15e34e 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -46,7 +46,7 @@ function normalizeVoucher(voucherCode: string): string | null { } function newVoucherCode() { - const bytes = new Uint8Array(6) + const bytes = new Uint8Array(5) crypto.getRandomValues(bytes) return CrockfordBase32.encode(Buffer.from(bytes)) } diff --git a/packages/backend-modules/payments/lib/transactionals/sendTransactionalMails.ts b/packages/backend-modules/payments/lib/transactionals/sendTransactionalMails.ts index 17e29f9b2b..6207abac5e 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,30 @@ 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 +} diff --git a/packages/backend-modules/translate/translations.json b/packages/backend-modules/translate/translations.json index f786b951d8..6f7b7a7a6a 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." From 42273a97e56db2fa02208229491e468ff93c3b90 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 2 Jan 2025 13:31:29 +0100 Subject: [PATCH 18/30] feat(payments/gifts): add gift redemption for YEARLY_ABO --- .../payments/lib/database/GiftVoucherRepo.ts | 2 +- .../payments/lib/shop/gifts.ts | 203 ++++++++++-------- 2 files changed, 119 insertions(+), 86 deletions(-) diff --git a/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts b/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts index b8b8a49049..05a34fedfe 100644 --- a/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts +++ b/packages/backend-modules/payments/lib/database/GiftVoucherRepo.ts @@ -5,7 +5,7 @@ import { Company } from '../types' type UpdateableVoucher = { redeemedBy: string | null redeemedForCompany: Company | null - state: 'unredeemed' | 'redeemed' + redeemedAt: Date | null } export class GiftVoucherRepo { diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 4b6e15e34e..0395a2749d 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -74,36 +74,6 @@ const GIFTS: Gift[] = [ }, ] -// export class GiftRepo { -// #store: Voucher[] = [ -// { -// id: '1', -// code: 'V4QPS1W5', -// giftId: 'GIFT_YEARLY', -// issuedBy: 'PROJECT_R', -// state: 'unredeemed', -// redeemedBy: null, -// redeemedForCompany: null, -// }, -// { -// id: '1', -// code: 'NM13P325', -// giftId: 'MONTHLY_SUBSCRPTION_GIFT_3', -// issuedBy: 'REPUBLIK', -// state: 'unredeemed', -// redeemedBy: null, -// redeemedForCompany: null, -// }, -// ] - -// async getVoucher(code: string) { -// return this.#store.find((g) => g.code === code && g.state === 'unredeemed') -// } -// async saveVoucher(voucher: Voucher) { -// return this.#store.push(voucher) -// } -// } - export class GiftShop { #pgdb: PgDb #giftRepo: GiftVoucherRepo @@ -127,8 +97,6 @@ export class GiftShop { redeemedForCompany: null, } - console.log(voucher) - this.#giftRepo.saveVoucher(voucher) return voucher @@ -157,31 +125,13 @@ export class GiftShop { const current = await this.getCurrentUserAbo(userId) console.log(current?.type) try { - await (async () => { - if (!current) { - // create new subscription with the gift if the user has none - return this.applyGiftToNewSubscription(userId, gift) - } + const abo = await this.applyGift(userId, current, 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') - } - })() - - await this.markGiftAsRedeemed(gift) + await this.markVoucherAsRedeemed({ + voucher, + userId, + company: abo.company, + }) } catch (e) { if (e instanceof Error) { console.error(e) @@ -189,12 +139,48 @@ export class GiftShop { } } + private async applyGift( + userId: string, + current: { type: string; id: string } | null, + gift: Gift, + ) { + if (!current) { + // create new subscription with the gift if the user has none + 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 markGiftAsRedeemed(_gift: Gift) { - throw new Error('Not implemented') + 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( @@ -227,7 +213,10 @@ export class GiftShop { return result[0] } - private async applyGiftToNewSubscription(userId: string, gift: Gift) { + private async applyGiftToNewSubscription( + userId: string, + gift: Gift, + ): Promise<{ company: Company }> { const cRepo = new CustomerRepo(this.#pgdb) const paymentService = Payments.getInstance() @@ -259,14 +248,14 @@ export class GiftShop { subscription.latest_invoice.toString(), ) } - return + return { company: gift.company } } private async applyGiftToMembershipAbo( _userId: string, membershipId: string, gift: Gift, - ) { + ): Promise<{ id: string; company: Company }> { const tx = await this.#pgdb.transactionBegin() try { const latestMembershipPeriod = await tx.queryOne( @@ -296,42 +285,86 @@ export class GiftShop { console.log('new membership period created %s', newMembershipPeriod.id) await tx.transactionCommit() + + return { id: membershipId, company: 'PROJECT_R' } } catch (e) { await tx.transactionRollback() throw e } - - return } private async applyGiftToMonthlyAbo( _userId: string, - _membershipId: string, + membershipId: string, _gift: Gift, - ) { + ): Promise<{ id: string; company: Company }> { throw new Error('Not implemented') - return + return { id: membershipId, company: 'REPUBLIK' } } private async applyGiftToYearlyAbo( - _userId: string, - _id: string, - _gift: Gift, - ) { - throw new Error('Not implemented') - return + userId: string, + id: string, + gift: Gift, + ): Promise<{ company: Company }> { + const cRepo = new CustomerRepo(this.#pgdb) + const paymentService = Payments.getInstance() + + let customerId = (await cRepo.getCustomerIdForCompany(userId, gift.company)) + ?.customerId + if (!customerId) { + customerId = await paymentService.createCustomer(gift.company, userId) + } + + const latestMembershipPeriod = await this.#pgdb.queryOne( + `SELECT + id, + "endDate" + FROM + public."membershipPeriods" + WHERE + "membershipId" = + ORDER BY + "endDate" DESC NULLS LAST + LIMIT 1;`, + { membershipId: id }, + ) + + const endDate = dayjs(latestMembershipPeriod.endDate) + + const shop = new Shop(Offers) + + 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, + }, + }, + ], + }) + + return { company: gift.company } } private async applyGiftToBenefactor( _userId: string, - _id: string, + id: string, _gift: Gift, - ) { + ): Promise<{ id: string; company: Company }> { throw new Error('Not implemented') - return + return { id: id, company: 'PROJECT_R' } } private async applyGiftToYearlySubscription( _userId: string, id: string, gift: Gift, - ) { + ): Promise<{ id: string; company: Company }> { const stripeId = await this.getStripeSubscriptionId(id) if (!stripeId) { @@ -341,14 +374,14 @@ export class GiftShop { await this.#stripeAdapters.PROJECT_R.subscriptions.update(stripeId, { coupon: gift.coupon, }) - return + return { id: id, company: 'REPUBLIK' } } private async applyGiftToMonthlySubscription( userId: string, subScriptionId: string, gift: Gift, - ) { + ): Promise<{ id: string; company: Company } | { company: Company }> { const stripeId = await this.getStripeSubscriptionId(subScriptionId) if (!stripeId) { @@ -360,7 +393,7 @@ export class GiftShop { await this.#stripeAdapters.REPUBLIK.subscriptions.update(stripeId, { coupon: gift.coupon, }) - return + return { id: subScriptionId, company: 'REPUBLIK' } } case 'PROJECT_R': { const cRepo = new CustomerRepo(this.#pgdb) @@ -381,11 +414,12 @@ export class GiftShop { stripeId, { cancellation_details: { - comment: 'system cancelation because of upgrade', + comment: + '[System]: cancelation because of upgrade to yearly subscription', }, proration_behavior: 'none', metadata: { - 'republik.payments.mailing': 'no-cancel', + 'republik.payments.mail.settings': 'no-cancel', 'republik.payments.member': 'keep-on-cancel', }, cancel_at_period_end: true, @@ -406,12 +440,13 @@ export class GiftShop { days_until_due: 14, }, metadata: { - 'republik.payments.started-as': 'gift', + 'republik.payments.mail.settings': 'no-signup', + 'republik.payments.upgrade-from': `monthly:${subScriptionId}`, }, }, ], }) - return + return { company: 'PROJECT_R' } } } } @@ -424,8 +459,6 @@ export class GiftShop { { id: internalId }, ) - console.log(res) - return res.externalId } } From a3b476501ca2b9337b27f0b805e055f931bfff06 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 2 Jan 2025 16:42:18 +0100 Subject: [PATCH 19/30] feat(shop): serialize mail settings into metadata Introduces a mechanism to prevent the sending of emails by checking the metadata of a subscription for the republik.payments.mail.settings key. The value key should be a comma-separated list of mail setting options with a boolean value. For example, to turn off cancellation confirmations, use: confirm:cancel=false If you want to suppress cancellation confirmations and the ending notice email, use: confirm:cancel=false,notice:ended=false Note that the option keys used here might change in the future. --- .../handlers/stripe/subscriptionDeleted.ts | 33 +++++++----- .../payments/lib/mail-settings.ts | 51 +++++++++++++++++++ .../payments/lib/shop/gifts.ts | 17 ++++--- 3 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 packages/backend-modules/payments/lib/mail-settings.ts diff --git a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts index 8015dfe44e..2ca3774df1 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts @@ -4,6 +4,7 @@ import { Company } from '../../types' import { Queue } from '@orbiting/backend-modules-job-queue' import { NoticeEndedTransactionalWorker } from '../../workers/NoticeEndedTransactionalWorker' import { SyncMailchimpEndedWorker } from '../../workers/SyncMailchimpEndedWorker' +import { getMailSettings } from '../../mail-settings' export async function processSubscriptionDeleted( paymentService: PaymentService, @@ -31,23 +32,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'], + ) + + 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/mail-settings.ts b/packages/backend-modules/payments/lib/mail-settings.ts new file mode 100644 index 0000000000..150a9ac9b7 --- /dev/null +++ b/packages/backend-modules/payments/lib/mail-settings.ts @@ -0,0 +1,51 @@ +type MailSettingKey = + | 'confirm:revoke_cancellation' + | 'confirm:setup' + | 'confirm:cancel' + | 'notice:ended' +type MailSettings = Record + +const settings: MailSettings = { + 'notice:ended': true, + 'confirm:revoke_cancellation': true, + 'confirm:cancel': true, + 'confirm:setup': true, +} + +export function getMailSettings(overwriteString?: string) { + 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/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 0395a2749d..7604e60244 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -10,6 +10,10 @@ import { Offers } from './offers' import { Shop } from './Shop' import dayjs from 'dayjs' import { GiftVoucherRepo } from '../database/GiftVoucherRepo' +import createLogger from 'debug' +import { serializeMailSettings } from '../mail-settings' + +const logger = createLogger('payments:gifts') export type Gift = { id: string @@ -234,9 +238,6 @@ export class GiftShop { gift.company ].subscriptions.create({ customer: customerId, - metadata: { - 'republik.payments.started-as': 'gift', - }, items: [shop.genLineItem(offer)], coupon: gift.coupon, collection_method: 'send_invoice', @@ -282,7 +283,7 @@ export class GiftShop { kind: 'GIFT', }) - console.log('new membership period created %s', newMembershipPeriod.id) + logger('new membership period created %s', newMembershipPeriod.id) await tx.transactionCommit() @@ -419,7 +420,9 @@ export class GiftShop { }, proration_behavior: 'none', metadata: { - 'republik.payments.mail.settings': 'no-cancel', + 'republik.payments.mail.settings': serializeMailSettings({ + 'notice:ended': false, + }), 'republik.payments.member': 'keep-on-cancel', }, cancel_at_period_end: true, @@ -440,7 +443,9 @@ export class GiftShop { days_until_due: 14, }, metadata: { - 'republik.payments.mail.settings': 'no-signup', + 'republik.payments.mail.settings': serializeMailSettings({ + 'confirm:setup': true, + }), 'republik.payments.upgrade-from': `monthly:${subScriptionId}`, }, }, From 79f7bf96693b5cb188d45bdb26ad769a54b0bad7 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 3 Jan 2025 11:15:47 +0100 Subject: [PATCH 20/30] feat(shop/gift): handle pledge monthly abo upgrade --- .../payments/lib/shop/gifts.ts | 115 +++++++++++++++++- 1 file changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 7604e60244..0c827ef595 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -160,8 +160,8 @@ export class GiftShop { 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 'BENEFACTOR_ABO': + return this.applyGiftToBenefactor(userId, current.id, gift) case 'YEARLY_SUBSCRIPTION': return this.applyGiftToYearlySubscription(userId, current.id, gift) case 'MONTHLY_SUBSCRIPTION': @@ -294,12 +294,104 @@ export class GiftShop { } } private async applyGiftToMonthlyAbo( - _userId: string, + userId: string, membershipId: string, - _gift: Gift, + gift: Gift, ): Promise<{ id: string; company: Company }> { - throw new Error('Not implemented') - return { id: membershipId, company: 'REPUBLIK' } + const stripeId = await this.getStripeIdForMonthlyAbo(membershipId) + + if (!stripeId) { + throw new Error(`membership ${membershipId} does not exist`) + } + + switch (gift.company) { + case 'REPUBLIK': { + await this.#stripeAdapters.REPUBLIK.subscriptions.update(stripeId, { + coupon: gift.coupon, + }) + return { id: membershipId, company: 'REPUBLIK' } + } + case 'PROJECT_R': { + const cRepo = new CustomerRepo(this.#pgdb) + const paymentService = Payments.getInstance() + + let customerId = ( + await cRepo.getCustomerIdForCompany(userId, 'PROJECT_R') + )?.customerId + if (!customerId) { + customerId = await paymentService.createCustomer('PROJECT_R', userId) + } + + const shop = new Shop(Offers) + 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.#stripeAdapters.REPUBLIK.subscriptions.update( + stripeId, + { + cancellation_details: { + comment: + '[System]: cancelation because of upgrade to yearly subscription', + }, + proration_behavior: 'none', + metadata: { + 'republik.payments.mail.settings': serializeMailSettings({ + 'notice:ended': false, + }), + 'republik.payments.member': 'keep-on-cancel', + }, + cancel_at_period_end: true, + }, + ) + + // 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': serializeMailSettings({ + 'confirm:setup': true, + }), + 'republik.payments.upgrade-from': `monthly_abo:${membershipId}`, + }, + }, + ], + }) + return { id: membershipId, company: 'PROJECT_R' } + } + } } private async applyGiftToYearlyAbo( userId: string, @@ -466,4 +558,15 @@ export class GiftShop { 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 + } } From 895d68a2694fe0e7b8906094bbb0685e9ec615f8 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 3 Jan 2025 13:18:40 +0100 Subject: [PATCH 21/30] chore(payments/gql): cleanup redeemGiftVoucher mutation --- .../resolvers/_mutations/redeemGiftVoucher.ts | 15 +++++++++------ .../backend-modules/payments/lib/shop/gifts.ts | 1 - 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts index 2f9762b265..a11cad0d70 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts @@ -1,19 +1,22 @@ import { GraphqlContext } from '@orbiting/backend-modules-types' import { GiftShop } from '../../../lib/shop/gifts' // import { Payments } from '../../../lib/payments' -// import { default as Auth } from '@orbiting/backend-modules-auth' +import { default as Auth } from '@orbiting/backend-modules-auth' export = async function redeemGiftVoucher( _root: never, // eslint-disable-line @typescript-eslint/no-unused-vars args: { voucherCode: string }, // eslint-disable-line @typescript-eslint/no-unused-vars ctx: GraphqlContext, // eslint-disable-line @typescript-eslint/no-unused-vars ) { - // Auth.Roles.ensureUserIsMeOrInRoles(user, ctx.user, ['admin', 'supporter']) - // Payments.getInstance().findSubscription(subscriptionId) + Auth.ensureUser(ctx.user) const giftShop = new GiftShop(ctx.pgdb) + try { + await giftShop.redeemVoucher(args.voucherCode, ctx.user.id) - await giftShop.redeemVoucher(args.voucherCode, ctx.user.id) - - return false + return true + } catch (e) { + console.error(e) + return false + } } diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 0c827ef595..b63f1788d7 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -127,7 +127,6 @@ export class GiftShop { } const current = await this.getCurrentUserAbo(userId) - console.log(current?.type) try { const abo = await this.applyGift(userId, current, gift) From 4615d42e99afbfa9801c3a7cbb325512cd3ac808 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 3 Jan 2025 17:34:00 +0100 Subject: [PATCH 22/30] refactor(payments/gifts): giftshop class Reduce code duplication for upgrades to the yearly subscription --- .../resolvers/_mutations/redeemGiftVoucher.ts | 5 +- .../payments/lib/shop/gifts.ts | 104 ++++++++---------- 2 files changed, 46 insertions(+), 63 deletions(-) diff --git a/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts index a11cad0d70..c54ab7177c 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_mutations/redeemGiftVoucher.ts @@ -1,12 +1,11 @@ import { GraphqlContext } from '@orbiting/backend-modules-types' import { GiftShop } from '../../../lib/shop/gifts' -// import { Payments } from '../../../lib/payments' import { default as Auth } from '@orbiting/backend-modules-auth' export = async function redeemGiftVoucher( _root: never, // eslint-disable-line @typescript-eslint/no-unused-vars - args: { voucherCode: string }, // eslint-disable-line @typescript-eslint/no-unused-vars - ctx: GraphqlContext, // eslint-disable-line @typescript-eslint/no-unused-vars + args: { voucherCode: string }, + ctx: GraphqlContext, ) { Auth.ensureUser(ctx.user) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index b63f1788d7..6e794932fb 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -127,6 +127,7 @@ export class GiftShop { } const current = await this.getCurrentUserAbo(userId) + console.log(current?.type) try { const abo = await this.applyGift(userId, current, gift) @@ -258,21 +259,7 @@ export class GiftShop { ): Promise<{ id: string; company: Company }> { const tx = await this.#pgdb.transactionBegin() try { - 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) + const endDate = await this.getMembershipEndDate(tx, membershipId) const newMembershipPeriod = await tx.public.membershipPeriods.insertAndGet({ @@ -292,6 +279,7 @@ export class GiftShop { throw e } } + private async applyGiftToMonthlyAbo( userId: string, membershipId: string, @@ -348,22 +336,9 @@ export class GiftShop { await tx.transactionCommit() //cancel old monthly subscription on Republik AG stripe - const oldSub = await this.#stripeAdapters.REPUBLIK.subscriptions.update( + const oldSub = await this.cancelSubscriptionForUpgrade( + this.#stripeAdapters.REPUBLIK, stripeId, - { - cancellation_details: { - comment: - '[System]: cancelation because of upgrade to yearly subscription', - }, - proration_behavior: 'none', - metadata: { - 'republik.payments.mail.settings': serializeMailSettings({ - 'notice:ended': false, - }), - 'republik.payments.member': 'keep-on-cancel', - }, - cancel_at_period_end: true, - }, ) // create new subscription starting at the end period of the old one @@ -406,21 +381,7 @@ export class GiftShop { customerId = await paymentService.createCustomer(gift.company, userId) } - const latestMembershipPeriod = await this.#pgdb.queryOne( - `SELECT - id, - "endDate" - FROM - public."membershipPeriods" - WHERE - "membershipId" = - ORDER BY - "endDate" DESC NULLS LAST - LIMIT 1;`, - { membershipId: id }, - ) - - const endDate = dayjs(latestMembershipPeriod.endDate) + const endDate = await this.getMembershipEndDate(this.#pgdb, id) const shop = new Shop(Offers) @@ -502,22 +463,9 @@ export class GiftShop { const offer = (await shop.getOfferById(gift.offer))! //cancel old monthly subscription on Republik AG - const oldSub = await this.#stripeAdapters.REPUBLIK.subscriptions.update( + const oldSub = await this.cancelSubscriptionForUpgrade( + this.#stripeAdapters.REPUBLIK, stripeId, - { - cancellation_details: { - comment: - '[System]: cancelation because of upgrade to yearly subscription', - }, - proration_behavior: 'none', - metadata: { - 'republik.payments.mail.settings': serializeMailSettings({ - 'notice:ended': false, - }), - 'republik.payments.member': 'keep-on-cancel', - }, - cancel_at_period_end: true, - }, ) // create new subscription starting at the end period of the old one @@ -568,4 +516,40 @@ export class GiftShop { 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': serializeMailSettings({ + 'notice:ended': false, + }), + 'republik.payments.member': 'keep-on-cancel', + }, + cancel_at_period_end: true, + }) + } } From 018be8aba875c43340a8026f959943a9932ac249 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 3 Jan 2025 17:55:31 +0100 Subject: [PATCH 23/30] refactor(payments): disable confrim:cancel mail --- .../lib/handlers/stripe/subscriptionDeleted.ts | 7 +++++-- .../lib/handlers/stripe/subscriptionUpdate.ts | 9 ++++++++- .../backend-modules/payments/lib/mail-settings.ts | 3 +++ packages/backend-modules/payments/lib/shop/gifts.ts | 11 +++++++---- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts index 2ca3774df1..135e6493df 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionDeleted.ts @@ -4,7 +4,10 @@ import { Company } from '../../types' import { Queue } from '@orbiting/backend-modules-job-queue' import { NoticeEndedTransactionalWorker } from '../../workers/NoticeEndedTransactionalWorker' import { SyncMailchimpEndedWorker } from '../../workers/SyncMailchimpEndedWorker' -import { getMailSettings } from '../../mail-settings' +import { + getMailSettings, + REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY, +} from '../../mail-settings' export async function processSubscriptionDeleted( paymentService: PaymentService, @@ -33,7 +36,7 @@ export async function processSubscriptionDeleted( } const mailSettings = getMailSettings( - event.data.object.metadata['republik.payments.mail.settings'], + event.data.object.metadata[REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY], ) if (mailSettings['notice:ended']) { diff --git a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts index 22d0d9f6b8..39654da606 100644 --- a/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts +++ b/packages/backend-modules/payments/lib/handlers/stripe/subscriptionUpdate.ts @@ -5,12 +5,19 @@ 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' 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 @@ -99,7 +106,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/mail-settings.ts b/packages/backend-modules/payments/lib/mail-settings.ts index 150a9ac9b7..b48220f737 100644 --- a/packages/backend-modules/payments/lib/mail-settings.ts +++ b/packages/backend-modules/payments/lib/mail-settings.ts @@ -5,6 +5,9 @@ type MailSettingKey = | 'notice:ended' type MailSettings = Record +export const REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY = + 'republik.payments.mail.settings' + const settings: MailSettings = { 'notice:ended': true, 'confirm:revoke_cancellation': true, diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 6e794932fb..87dfe684b3 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -11,7 +11,10 @@ import { Shop } from './Shop' import dayjs from 'dayjs' import { GiftVoucherRepo } from '../database/GiftVoucherRepo' import createLogger from 'debug' -import { serializeMailSettings } from '../mail-settings' +import { + REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY, + serializeMailSettings, +} from '../mail-settings' const logger = createLogger('payments:gifts') @@ -355,7 +358,7 @@ export class GiftShop { days_until_due: 14, }, metadata: { - 'republik.payments.mail.settings': serializeMailSettings({ + [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ 'confirm:setup': true, }), 'republik.payments.upgrade-from': `monthly_abo:${membershipId}`, @@ -482,7 +485,7 @@ export class GiftShop { days_until_due: 14, }, metadata: { - 'republik.payments.mail.settings': serializeMailSettings({ + [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ 'confirm:setup': true, }), 'republik.payments.upgrade-from': `monthly:${subScriptionId}`, @@ -544,7 +547,7 @@ export class GiftShop { }, proration_behavior: 'none', metadata: { - 'republik.payments.mail.settings': serializeMailSettings({ + [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ 'notice:ended': false, }), 'republik.payments.member': 'keep-on-cancel', From fb82b954e899e0d38c663263679f936af21bead2 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 3 Jan 2025 18:05:10 +0100 Subject: [PATCH 24/30] fix(payments/gifts): add missing mail setting flag The `'confirm:cancel': false` flag was missing for upgrade cancels. --- packages/backend-modules/payments/lib/shop/gifts.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 87dfe684b3..fcf87a3db6 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -549,6 +549,7 @@ export class GiftShop { metadata: { [REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY]: serializeMailSettings({ 'notice:ended': false, + 'confirm:cancel': false, }), 'republik.payments.member': 'keep-on-cancel', }, From 3859a552312a19cfb96bd05306c0b20f0eacf4b7 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 3 Jan 2025 19:39:38 +0100 Subject: [PATCH 25/30] refactor(payments/gifts): cleanup customer creation --- .../payments/lib/shop/gifts.ts | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index fcf87a3db6..11862fbc3f 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -130,7 +130,6 @@ export class GiftShop { } const current = await this.getCurrentUserAbo(userId) - console.log(current?.type) try { const abo = await this.applyGift(userId, current, gift) @@ -225,16 +224,10 @@ export class GiftShop { gift: Gift, ): Promise<{ company: Company }> { const cRepo = new CustomerRepo(this.#pgdb) - const paymentService = Payments.getInstance() - let customerId = (await cRepo.getCustomerIdForCompany(userId, gift.company)) - ?.customerId - if (!customerId) { - customerId = await paymentService.createCustomer(gift.company, userId) - } + const customerId = await this.getCustomerId(cRepo, gift.company, userId) const shop = new Shop(Offers) - const offer = (await shop.getOfferById(gift.offer))! const subscription = await this.#stripeAdapters[ @@ -303,14 +296,7 @@ export class GiftShop { } case 'PROJECT_R': { const cRepo = new CustomerRepo(this.#pgdb) - const paymentService = Payments.getInstance() - - let customerId = ( - await cRepo.getCustomerIdForCompany(userId, 'PROJECT_R') - )?.customerId - if (!customerId) { - customerId = await paymentService.createCustomer('PROJECT_R', userId) - } + const customerId = await this.getCustomerId(cRepo, 'PROJECT_R', userId) const shop = new Shop(Offers) const offer = (await shop.getOfferById(gift.offer))! @@ -376,14 +362,7 @@ export class GiftShop { gift: Gift, ): Promise<{ company: Company }> { const cRepo = new CustomerRepo(this.#pgdb) - const paymentService = Payments.getInstance() - - let customerId = (await cRepo.getCustomerIdForCompany(userId, gift.company)) - ?.customerId - if (!customerId) { - customerId = await paymentService.createCustomer(gift.company, userId) - } - + const customerId = await this.getCustomerId(cRepo, gift.company, userId) const endDate = await this.getMembershipEndDate(this.#pgdb, id) const shop = new Shop(Offers) @@ -453,14 +432,8 @@ export class GiftShop { } case 'PROJECT_R': { const cRepo = new CustomerRepo(this.#pgdb) - const paymentService = Payments.getInstance() - let customerId = ( - await cRepo.getCustomerIdForCompany(userId, 'PROJECT_R') - )?.customerId - if (!customerId) { - customerId = await paymentService.createCustomer('PROJECT_R', userId) - } + const customerId = await this.getCustomerId(cRepo, 'PROJECT_R', userId) const shop = new Shop(Offers) const offer = (await shop.getOfferById(gift.offer))! @@ -498,6 +471,19 @@ export class GiftShop { } } + 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 { From 857d80426bf5c74339c14a5cc9f36c3fd63d64ad Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 9 Jan 2025 14:19:53 +0100 Subject: [PATCH 26/30] feat(payments/gifts): add validateGiftVoucher resolver --- .../resolvers/_queries/validateGiftVoucher.ts | 51 +++++++++++++++++++ .../payments/graphql/schema-types.ts | 5 ++ .../payments/graphql/schema.ts | 1 + .../payments/lib/shop/gifts.ts | 2 +- 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts 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..3f5dbc5f1e --- /dev/null +++ b/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts @@ -0,0 +1,51 @@ +/* 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 = { + valid: boolean + isLegacyVoucher: boolean +} + +export = async function ( + _root: never, + args: { voucher: string }, + ctx: GraphqlContext, +): Promise { + Auth.ensureUser(ctx.user) + + const base32Voucher = normalizeVoucher(args.voucher) + if (base32Voucher) { + const isNewVoucher = await new GiftVoucherRepo(ctx.pgdb).getVoucherByCode( + base32Voucher, + ) + + if (isNewVoucher) { + return { + valid: true, + isLegacyVoucher: false, + } + } + } + + if (await isfLegacyVoucher(ctx.pgdb, args.voucher)) { + return { + 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 85bc263cd4..fae57c54c1 100644 --- a/packages/backend-modules/payments/graphql/schema-types.ts +++ b/packages/backend-modules/payments/graphql/schema-types.ts @@ -146,6 +146,11 @@ type ComplimentaryItem { maxQuantity: Int! } +type GiftVoucherValidationResult { + valid: Boolean! + isLegacyVoucher: Boolean! +} + input ComplimentaryItemOrder { id: String! quantity: Int! diff --git a/packages/backend-modules/payments/graphql/schema.ts b/packages/backend-modules/payments/graphql/schema.ts index b539ee97e9..8ab0fb943a 100644 --- a/packages/backend-modules/payments/graphql/schema.ts +++ b/packages/backend-modules/payments/graphql/schema.ts @@ -8,6 +8,7 @@ schema { type queries { getOffers(promoCode: String): [Offer!]! getOffer(offerId: ID!, promoCode: String): Offer + validateGiftVoucher(voucher: String!): GiftVoucherValidationResult } type mutations { diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index 11862fbc3f..d7d8062264 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -43,7 +43,7 @@ type PLEDGE_ABOS = 'ABO' | 'MONTHLY_ABO' | 'YEARLY_ABO' | 'BENEFACTOR_ABO' type SUBSCRIPTIONS = 'YEARLY_SUBSCRIPTION' | 'MONTHLY_SUBSCRIPTION' type PRODUCT_TYPE = PLEDGE_ABOS | SUBSCRIPTIONS -function normalizeVoucher(voucherCode: string): string | null { +export function normalizeVoucher(voucherCode: string): string | null { try { const code = CrockfordBase32.decode(voucherCode) return CrockfordBase32.encode(code) From b07c84452971c980d5cb9c706b198ccb627c72e2 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Thu, 9 Jan 2025 17:35:54 +0100 Subject: [PATCH 27/30] fix(shop/gifts): make sure new voucehr has not been redeemed --- .../payments/graphql/resolvers/_queries/validateGiftVoucher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts b/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts index 3f5dbc5f1e..a5b620ffe4 100644 --- a/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts +++ b/packages/backend-modules/payments/graphql/resolvers/_queries/validateGiftVoucher.ts @@ -23,7 +23,7 @@ export = async function ( base32Voucher, ) - if (isNewVoucher) { + if (isNewVoucher && isNewVoucher.redeemedAt === null) { return { valid: true, isLegacyVoucher: false, From a03f9464a003ef7e244de8cb72e7f45f6535d669 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 10 Jan 2025 18:47:22 +0100 Subject: [PATCH 28/30] feat(payments/gifts): make 3 months gift work for yearly subs --- .../payments/lib/shop/gifts.ts | 117 +++++++++++++++--- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/packages/backend-modules/payments/lib/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index d7d8062264..a0ff33ba9a 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -130,19 +130,14 @@ export class GiftShop { } const current = await this.getCurrentUserAbo(userId) - try { - const abo = await this.applyGift(userId, current, gift) - await this.markVoucherAsRedeemed({ - voucher, - userId, - company: abo.company, - }) - } catch (e) { - if (e instanceof Error) { - console.error(e) - } - } + const abo = await this.applyGift(userId, current, gift) + + await this.markVoucherAsRedeemed({ + voucher, + userId, + company: abo.company, + }) } private async applyGift( @@ -174,7 +169,7 @@ export class GiftShop { } private async getGift(id: string) { - return GIFTS.find((gift) => (gift.id = id)) || null + return GIFTS.find((gift) => gift.id === id) || null } private async markVoucherAsRedeemed(args: { @@ -237,6 +232,7 @@ export class GiftShop { items: [shop.genLineItem(offer)], coupon: gift.coupon, collection_method: 'send_invoice', + cancel_at_period_end: true, days_until_due: 14, }) @@ -351,6 +347,7 @@ export class GiftShop { }, }, ], + end_behavior: 'cancel', }) return { id: membershipId, company: 'PROJECT_R' } } @@ -402,14 +399,100 @@ export class GiftShop { ): Promise<{ id: string; company: Company }> { const stripeId = await this.getStripeSubscriptionId(id) + console.log('trying to add gift to yearly sub') + if (!stripeId) { throw new Error(`yearly subscription ${id} does not exist`) } - await this.#stripeAdapters.PROJECT_R.subscriptions.update(stripeId, { - coupon: gift.coupon, - }) - return { id: id, company: 'REPUBLIK' } + console.log(gift) + + switch (gift.company) { + case 'PROJECT_R': { + await this.#stripeAdapters.PROJECT_R.subscriptions.update(stripeId, { + coupon: gift.coupon, + }) + return { id: id, company: 'PROJECT_R' } + } + case 'REPUBLIK': { + if (gift.id != 'MONTHLY_SUBSCRPTION_GIFT_3') { + throw Error('Not implemented') + } + + console.log('trying to add three months to yearly subscription') + + const sub = await this.#stripeAdapters.PROJECT_R.subscriptions.retrieve( + stripeId, + { + expand: ['schedule'], + }, + ) + + let schedule: Stripe.SubscriptionSchedule | undefined + if (sub.schedule === null) { + schedule = + await this.#stripeAdapters.PROJECT_R.subscriptionSchedules.create({ + from_subscription: sub.id, + }) + } else { + schedule = sub.schedule as Stripe.SubscriptionSchedule + } + + const nowInSeconds = Math.floor(Date.now() / 1000) + const currentPhase = schedule.phases.find( + (p) => nowInSeconds >= p.start_date && nowInSeconds <= p.end_date, + ) + + if (!currentPhase) + throw Error('unable to get current subscription schedule phase') + + const currentPrice = currentPhase?.items[0].price as string | undefined + + const prices = ( + await this.#stripeAdapters.PROJECT_R.prices.list({ + active: true, + lookup_keys: ['ABO', gift.id], + }) + ).data + + const ABO_PRICE = prices.find((p) => p.lookup_key === 'ABO')! + const GIFT_PRICE = prices.find((p) => p.lookup_key === gift.id)! + + const newSchedule = + await this.#stripeAdapters.PROJECT_R.subscriptionSchedules.update( + schedule.id, + { + phases: [ + { + items: currentPhase.items.map((i) => ({ + price: i.price.toString(), + quantity: i.quantity, + discounts: i.discounts.map((d) => ({ + coupon: d.coupon?.toString(), + })), + })), + start_date: currentPhase.start_date, + end_date: currentPhase.end_date, + }, + { + items: [{ price: GIFT_PRICE.id, quantity: 1 }], + iterations: 1, + }, + { + items: [{ price: currentPrice ?? ABO_PRICE.id, quantity: 1 }], + iterations: 1, + billing_cycle_anchor: 'phase_start', + }, + ], + end_behavior: 'release', + }, + ) + + console.log(newSchedule) + + return { id: id, company: 'PROJECT_R' } + } + } } private async applyGiftToMonthlySubscription( From 41b89bc3cdb977f94676f11b54d8a833350f6aed Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Mon, 13 Jan 2025 16:02:28 +0100 Subject: [PATCH 29/30] fix(payments/mailsettings): copy base mail settings --- packages/backend-modules/payments/lib/mail-settings.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend-modules/payments/lib/mail-settings.ts b/packages/backend-modules/payments/lib/mail-settings.ts index b48220f737..78128db95a 100644 --- a/packages/backend-modules/payments/lib/mail-settings.ts +++ b/packages/backend-modules/payments/lib/mail-settings.ts @@ -8,7 +8,7 @@ type MailSettings = Record export const REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY = 'republik.payments.mail.settings' -const settings: MailSettings = { +const baseSettings: MailSettings = { 'notice:ended': true, 'confirm:revoke_cancellation': true, 'confirm:cancel': true, @@ -16,6 +16,8 @@ const settings: MailSettings = { } export function getMailSettings(overwriteString?: string) { + const settings = { ...baseSettings } + if (!overwriteString) { return settings } From 1b1ab946bbe4b63e283b422b650efd031e9259ec Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Wed, 15 Jan 2025 16:06:00 +0100 Subject: [PATCH 30/30] chore(payments/gifts): change behavior of three months for yearly The 3 months gift for yearly subscriptions now just adds a coupon to the subscription instead of changing the subscription plan --- .../backend-modules/payments/lib/config.ts | 24 ++++++ .../payments/lib/shop/gifts.ts | 80 ++----------------- 2 files changed, 31 insertions(+), 73 deletions(-) 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/shop/gifts.ts b/packages/backend-modules/payments/lib/shop/gifts.ts index a0ff33ba9a..91d6a4efb3 100644 --- a/packages/backend-modules/payments/lib/shop/gifts.ts +++ b/packages/backend-modules/payments/lib/shop/gifts.ts @@ -15,6 +15,7 @@ import { REPUBLIK_PAYMENTS_MAIL_SETTINGS_KEY, serializeMailSettings, } from '../mail-settings' +import { getConfig } from '../config' const logger = createLogger('payments:gifts') @@ -64,7 +65,7 @@ const GIFTS: Gift[] = [ duration: 1, durationUnit: 'year', offer: 'YEARLY', - coupon: process.env.PAYMENTS_PROJECT_R_YEARLY_GIFT_COUPON!, + coupon: getConfig().PROJECT_R_YEARLY_GIFT_COUPON, company: 'PROJECT_R', value: 100, valueType: 'PERCENTAGE', @@ -74,7 +75,7 @@ const GIFTS: Gift[] = [ duration: 3, durationUnit: 'month', offer: 'MONTHLY', - coupon: process.env.PAYMENTS_REPUBLIK_MONTHLY_GIFT_3_COUPON!, + coupon: getConfig().REPUBLIK_3_MONTH_GIFT_COUPON, company: 'REPUBLIK', value: 100, valueType: 'PERCENTAGE', @@ -405,8 +406,6 @@ export class GiftShop { throw new Error(`yearly subscription ${id} does not exist`) } - console.log(gift) - switch (gift.company) { case 'PROJECT_R': { await this.#stripeAdapters.PROJECT_R.subscriptions.update(stripeId, { @@ -419,76 +418,11 @@ export class GiftShop { throw Error('Not implemented') } - console.log('trying to add three months to yearly subscription') - - const sub = await this.#stripeAdapters.PROJECT_R.subscriptions.retrieve( - stripeId, - { - expand: ['schedule'], - }, - ) - - let schedule: Stripe.SubscriptionSchedule | undefined - if (sub.schedule === null) { - schedule = - await this.#stripeAdapters.PROJECT_R.subscriptionSchedules.create({ - from_subscription: sub.id, - }) - } else { - schedule = sub.schedule as Stripe.SubscriptionSchedule - } - - const nowInSeconds = Math.floor(Date.now() / 1000) - const currentPhase = schedule.phases.find( - (p) => nowInSeconds >= p.start_date && nowInSeconds <= p.end_date, - ) + const coupon = getConfig().PROJECT_R_3_MONTH_GIFT_COUPON - if (!currentPhase) - throw Error('unable to get current subscription schedule phase') - - const currentPrice = currentPhase?.items[0].price as string | undefined - - const prices = ( - await this.#stripeAdapters.PROJECT_R.prices.list({ - active: true, - lookup_keys: ['ABO', gift.id], - }) - ).data - - const ABO_PRICE = prices.find((p) => p.lookup_key === 'ABO')! - const GIFT_PRICE = prices.find((p) => p.lookup_key === gift.id)! - - const newSchedule = - await this.#stripeAdapters.PROJECT_R.subscriptionSchedules.update( - schedule.id, - { - phases: [ - { - items: currentPhase.items.map((i) => ({ - price: i.price.toString(), - quantity: i.quantity, - discounts: i.discounts.map((d) => ({ - coupon: d.coupon?.toString(), - })), - })), - start_date: currentPhase.start_date, - end_date: currentPhase.end_date, - }, - { - items: [{ price: GIFT_PRICE.id, quantity: 1 }], - iterations: 1, - }, - { - items: [{ price: currentPrice ?? ABO_PRICE.id, quantity: 1 }], - iterations: 1, - billing_cycle_anchor: 'phase_start', - }, - ], - end_behavior: 'release', - }, - ) - - console.log(newSchedule) + await this.#stripeAdapters.PROJECT_R.subscriptions.update(stripeId, { + coupon: coupon, + }) return { id: id, company: 'PROJECT_R' } }