From 3004a75b536fe980d653d5830d625c81f5cd343f Mon Sep 17 00:00:00 2001 From: pillowboy <33105890+poolsar42@users.noreply.github.com> Date: Tue, 4 Jul 2023 11:23:04 -0400 Subject: [PATCH] feat(console): Billing/payment notifications (#2446) --- .../app/routes/__layout/gnillib/webhook.tsx | 51 ++++++- .../routes/apps/$clientId/designer.beta.tsx | 6 +- apps/passport/app/routes/connect/email/otp.ts | 4 +- .../src/jsonrpc/methods/cancelServicePlans.ts | 29 ++++ platform/account/src/jsonrpc/router.ts | 9 ++ .../src/jsonrpc/methods/generateEmailOTP.ts | 6 +- .../methods/sendBillingNotification.ts | 31 +++++ platform/address/src/jsonrpc/router.ts | 9 ++ .../{emailOtpTemplate.ts => emailTemplate.ts} | 80 +++++++---- platform/email/src/emailFunctions.ts | 129 ++++++++++++++++-- .../methods/sendBillingNotification.ts | 46 +++++++ .../email/src/jsonrpc/methods/sendOTPEmail.ts | 69 ++-------- platform/email/src/jsonrpc/router.ts | 19 ++- .../starbase/src/jsonrpc/methods/deleteApp.ts | 1 - .../methods/deleteSubscriptionPlans.ts | 58 ++++++++ platform/starbase/src/jsonrpc/router.ts | 9 ++ platform/starbase/src/nodes/application.ts | 4 + platform/test/src/index.ts | 10 ++ 18 files changed, 454 insertions(+), 116 deletions(-) create mode 100644 platform/account/src/jsonrpc/methods/cancelServicePlans.ts create mode 100644 platform/address/src/jsonrpc/methods/sendBillingNotification.ts rename platform/email/{emailOtpTemplate.ts => emailTemplate.ts} (89%) create mode 100644 platform/email/src/jsonrpc/methods/sendBillingNotification.ts create mode 100644 platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts diff --git a/apps/console/app/routes/__layout/gnillib/webhook.tsx b/apps/console/app/routes/__layout/gnillib/webhook.tsx index 2f88320d56..97570e4bce 100644 --- a/apps/console/app/routes/__layout/gnillib/webhook.tsx +++ b/apps/console/app/routes/__layout/gnillib/webhook.tsx @@ -1,11 +1,13 @@ import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' -import { ActionFunction } from '@remix-run/cloudflare' +import { type ActionFunction } from '@remix-run/cloudflare' import Stripe from 'stripe' import createAccountClient from '@proofzero/platform-clients/account' +import createStarbaseClient from '@proofzero/platform-clients/starbase' +import createAddressClient from '@proofzero/platform-clients/address' import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' -import { AccountURN } from '@proofzero/urns/account' +import { type AccountURN } from '@proofzero/urns/account' import { ServicePlanType } from '@proofzero/types/account' import { updateSubscriptionMetadata } from '~/services/billing/stripe' @@ -18,6 +20,15 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) + const starbaseClient = createStarbaseClient(Starbase, { + ...getAuthzHeaderConditionallyFromToken(undefined), + ...traceHeader, + }) + const addressClient = createAddressClient(Address, { + ...getAuthzHeaderConditionallyFromToken(undefined), + ...traceHeader, + }) + const stripeClient = new Stripe(STRIPE_API_SECRET, { apiVersion: '2022-11-15', }) @@ -120,6 +131,42 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( }) } + break + case 'customer.deleted': + case 'customer.subscription.deleted': + const { + customer: customerDel, + id: subIdDel, + metadata: metaDel, + } = event.data.object as { + customer: string + id: string + metadata: { + accountURN: AccountURN + } + } + const customerDataDel = await stripeClient.customers.retrieve( + customerDel + ) + if (!customerDataDel.deleted && customerDataDel.email) { + const { email, name } = customerDataDel + + await Promise.all([ + addressClient.sendBillingNotification.mutate({ + email, + name: name || 'Client', + }), + accountClient.cancelServicePlans.mutate({ + account: metaDel.accountURN, + subscriptionID: subIdDel, + deletePaymentData: event.type === 'customer.deleted', + }), + starbaseClient.deleteSubscriptionPlans.mutate({ + accountURN: metaDel.accountURN, + }), + ]) + } + break } diff --git a/apps/console/app/routes/apps/$clientId/designer.beta.tsx b/apps/console/app/routes/apps/$clientId/designer.beta.tsx index 262024ac7b..1bf50c8a5b 100644 --- a/apps/console/app/routes/apps/$clientId/designer.beta.tsx +++ b/apps/console/app/routes/apps/$clientId/designer.beta.tsx @@ -65,10 +65,10 @@ import { Helmet } from 'react-helmet' import { notificationHandlerType } from '~/types' import InputTextarea from '@proofzero/design-system/src/atoms/form/InputTextarea' import { - EmailTemplate, + EmailTemplateOTP, darkModeStyles, lightModeStyles, -} from '@proofzero/platform/email/emailOtpTemplate' +} from '@proofzero/platform/email/emailTemplate' import { BadRequestError } from '@proofzero/errors' import { GetEmailOTPThemeResult } from '@proofzero/platform/starbase/src/jsonrpc/methods/getEmailOTPTheme' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' @@ -1178,7 +1178,7 @@ const EmailPanel = ({ ref={iFrameRef} className="w-full border rounded-lg" srcDoc={ - EmailTemplate('XXXXXX', { + EmailTemplateOTP('XXXXXX', { appName: 'Designer', logoURL: logoURL ?? diff --git a/apps/passport/app/routes/connect/email/otp.ts b/apps/passport/app/routes/connect/email/otp.ts index 82f5d8ebeb..7fdc937678 100644 --- a/apps/passport/app/routes/connect/email/otp.ts +++ b/apps/passport/app/routes/connect/email/otp.ts @@ -6,7 +6,7 @@ import { getAddressClient, getStarbaseClient } from '~/platform.server' import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare' import { getAuthzCookieParams } from '~/session.server' -import type { SendOTPEmailThemeProps } from '@proofzero/platform/email/src/jsonrpc/methods/sendOTPEmail' +import type { EmailThemeProps } from '@proofzero/platform/email/src/emailFunctions' import { BadRequestError, InternalServerError } from '@proofzero/errors' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' @@ -62,7 +62,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ]) } - let themeProps: SendOTPEmailThemeProps | undefined + let themeProps: EmailThemeProps | undefined if (appProps) { themeProps = { privacyURL: appProps.privacyURL as string, diff --git a/platform/account/src/jsonrpc/methods/cancelServicePlans.ts b/platform/account/src/jsonrpc/methods/cancelServicePlans.ts new file mode 100644 index 0000000000..81f02cbbbd --- /dev/null +++ b/platform/account/src/jsonrpc/methods/cancelServicePlans.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { Context } from '../../context' +import { inputValidators } from '@proofzero/platform-middleware' +import { initAccountNodeByName } from '../../nodes' +export const CancelServicePlansInput = z.object({ + account: inputValidators.AccountURNInput, + subscriptionID: z.string(), + deletePaymentData: z.boolean().optional(), +}) + +export type CancelServicePlansParams = z.infer + +export const cancelServicePlans = async ({ + input, + ctx, +}: { + input: CancelServicePlansParams + ctx: Context +}) => { + const servicePlansNode = await initAccountNodeByName( + input.account, + ctx.Account + ) + + await servicePlansNode.storage.delete('servicePlans') + if (input.deletePaymentData) { + await servicePlansNode.storage.delete('stripePaymentData') + } +} diff --git a/platform/account/src/jsonrpc/router.ts b/platform/account/src/jsonrpc/router.ts index 610167233a..88b42e5957 100644 --- a/platform/account/src/jsonrpc/router.ts +++ b/platform/account/src/jsonrpc/router.ts @@ -54,6 +54,10 @@ import { getStripePaymentData, setStripePaymentData, } from './methods/stripePaymentData' +import { + CancelServicePlansInput, + cancelServicePlans, +} from './methods/cancelServicePlans' const t = initTRPC.context().create({ errorFormatter }) @@ -171,4 +175,9 @@ export const appRouter = t.router({ .use(Analytics) .input(SetStripePaymentDataInputSchema) .mutation(setStripePaymentData), + cancelServicePlans: t.procedure + .use(LogUsage) + .use(Analytics) + .input(CancelServicePlansInput) + .mutation(cancelServicePlans), }) diff --git a/platform/address/src/jsonrpc/methods/generateEmailOTP.ts b/platform/address/src/jsonrpc/methods/generateEmailOTP.ts index 1e0b9e1b0a..f807e31ebc 100644 --- a/platform/address/src/jsonrpc/methods/generateEmailOTP.ts +++ b/platform/address/src/jsonrpc/methods/generateEmailOTP.ts @@ -6,11 +6,11 @@ import { AddressNode } from '../../nodes' import EmailAddress from '../../nodes/email' import { EMAIL_VERIFICATION_OPTIONS } from '../../constants' -import { SendOTPEmailThemePropsSchema } from '../../../../email/src/jsonrpc/methods/sendOTPEmail' +import { EmailThemePropsSchema } from '../../../../email/src/emailFunctions' export const GenerateEmailOTPInput = z.object({ email: z.string(), - themeProps: SendOTPEmailThemePropsSchema.optional(), + themeProps: EmailThemePropsSchema.optional(), preview: z.boolean().optional(), }) @@ -36,7 +36,7 @@ export const generateEmailOTPMethod = async ({ delayMiliseconds ) - await ctx.emailClient.sendEmailNotification.mutate({ + await ctx.emailClient.sendOTP.mutate({ emailAddress: email, name: email, otpCode: code, diff --git a/platform/address/src/jsonrpc/methods/sendBillingNotification.ts b/platform/address/src/jsonrpc/methods/sendBillingNotification.ts new file mode 100644 index 0000000000..fdeeb0613f --- /dev/null +++ b/platform/address/src/jsonrpc/methods/sendBillingNotification.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' + +import { Context } from '../../context' + +import { EmailThemePropsSchema } from '../../../../email/src/emailFunctions' + +export const SendBillingNotificationInput = z.object({ + email: z.string(), + name: z.string(), + themeProps: EmailThemePropsSchema.optional(), +}) + +type SendBillingNotificationParams = z.infer< + typeof SendBillingNotificationInput +> + +export const sendBillingNotificationMethod = async ({ + input, + ctx, +}: { + input: SendBillingNotificationParams + ctx: Context +}) => { + const { email, name, themeProps } = input + + await ctx.emailClient.sendBillingNotification.mutate({ + emailAddress: email, + name: name, + themeProps, + }) +} diff --git a/platform/address/src/jsonrpc/router.ts b/platform/address/src/jsonrpc/router.ts index 906c6ebbbe..6d05913dfc 100644 --- a/platform/address/src/jsonrpc/router.ts +++ b/platform/address/src/jsonrpc/router.ts @@ -90,6 +90,10 @@ import { revokeWalletSessionKeyMethod, revokeWalletSessionKeyBatchMethod, } from './methods/revokeWalletSessionKey' +import { + SendBillingNotificationInput, + sendBillingNotificationMethod, +} from './methods/sendBillingNotification' const t = initTRPC.context().create({ errorFormatter }) @@ -264,4 +268,9 @@ export const appRouter = t.router({ .use(Analytics) .input(RevokeWalletSessionKeyBatchInput) .mutation(revokeWalletSessionKeyBatchMethod), + sendBillingNotification: t.procedure + .use(LogUsage) + .use(Analytics) + .input(SendBillingNotificationInput) + .mutation(sendBillingNotificationMethod), }) diff --git a/platform/email/emailOtpTemplate.ts b/platform/email/emailTemplate.ts similarity index 89% rename from platform/email/emailOtpTemplate.ts rename to platform/email/emailTemplate.ts index 670dbff9e8..60096ffbb3 100644 --- a/platform/email/emailOtpTemplate.ts +++ b/platform/email/emailTemplate.ts @@ -1,4 +1,4 @@ -import { EmailContent } from './src/types' +import { EmailContent, EmailContentType } from './src/types' export const darkModeStyles = ` body { @@ -55,14 +55,10 @@ export type EmailTemplateParams = { appName: string } -export const EmailTemplate = ( - passcode: string, - params: EmailTemplateParams -): EmailContent => { +const EmailTemplateBase = (params: EmailTemplateParams, content: string) => { const { logoURL, address, contactURL, termsURL, privacyURL, appName } = params - return { - contentType: 'text/html', + contentType: 'text/html' as EmailContentType, subject: `Your ${appName ?? `Rollup ID`} one-time passcode`, body: ` @@ -80,13 +76,13 @@ export const EmailTemplate = ( .content { font-family: "Inter", sans-serif; } - + .container { display: block; width: 98%; text-align: center; } - + .content { display: inline-block; vertical-align: top; @@ -94,12 +90,12 @@ export const EmailTemplate = ( max-width: 375px; border-radius: 8px; } - + .logo { max-width: 375px; margin-bottom: 37px; } - + .heading, .heading-logo { font-size: 36px; @@ -107,14 +103,14 @@ export const EmailTemplate = ( line-height: 44px; margin-bottom: 16px; } - + p { font-size: 16px; font-weight: normal; line-height: 24px; margin-bottom: 16px; } - + #passcode { width: 100%; text-align: center; @@ -125,13 +121,13 @@ export const EmailTemplate = ( margin-bottom: 20px; padding: 15px 0; } - + .divider { border-bottom: 1px solid #e5e7eb; width: 100%; margin-bottom: 10px; } - + .footer-links { font-size: 12px; text-decoration: none; @@ -139,43 +135,36 @@ export const EmailTemplate = ( margin-right: 10px; width: auto; } - + .vl { border: 0.5px solid #6b7280; display: inline; margin-right: 15px; } - + .powered-by { font-size: 12px; text-decoration: none; } - + - +
-
Confirm Your Email Address
-

Please copy the code below into the email verification screen.

-
${passcode}
-

Please note: the code will be valid for the next 10 minutes.

-

- If you didn't request this email, there's nothing to worry - about - you can safely ignore it. -

+ ${content}
- + ${ address && address !== '' ? ` @@ -263,3 +252,36 @@ export const EmailTemplate = ( `, } } + +export const EmailTemplateOTP = ( + passcode: string, + params: EmailTemplateParams +): EmailContent => { + const content = ` +
Confirm Your Email Address
+

Please copy the code below into the email verification screen.

+
${passcode}
+

Please note: the code will be valid for the next 10 minutes.

+

+ If you didn't request this email, there's nothing to worry + about - you can safely ignore it. +

+ ` + return EmailTemplateBase(params, content) +} + +export const EmailTemplateExpiredSubscription = ( + params: EmailTemplateParams +): EmailContent => { + const content = `
RollupId subscription has been cancelled
+

+ Your subscription has been cancelled due to unsuccessful payment + attempts. +

+

+ Please update your payment details to reactivate your subscription. +

+ ` + + return EmailTemplateBase(params, content) +} diff --git a/platform/email/src/emailFunctions.ts b/platform/email/src/emailFunctions.ts index d735e55149..16a85bb1f9 100644 --- a/platform/email/src/emailFunctions.ts +++ b/platform/email/src/emailFunctions.ts @@ -1,6 +1,26 @@ -import { EmailTemplate, EmailTemplateParams } from '../emailOtpTemplate' +import { InternalServerError } from '@proofzero/errors' +import { + EmailTemplateExpiredSubscription, + EmailTemplateOTP, + EmailTemplateParams, +} from '../emailTemplate' import { EmailMessage, EmailNotification } from './types' import { CloudflareEmailMessage, EmailContent, Environment } from './types' +import { Context } from './context' +import email from '@proofzero/platform-clients/email' +import { z } from 'zod' + +export const EmailThemePropsSchema = z.object({ + privacyURL: z.string().url(), + termsURL: z.string().url(), + contactURL: z.string().url().optional(), + address: z.string().optional(), + logoURL: z.string().url().optional(), + appName: z.string(), + hostname: z.string().optional(), +}) + +export type EmailThemeProps = z.infer /** Shape of structure MailChannel API expects */ type MailChannelEmailBody = { @@ -33,13 +53,18 @@ export async function send( if (env.Test) { const otpMatch = message.content.body.match(/id="passcode">(.+)<\/div>/) console.info('Code:', otpMatch?.[1]) - await env.Test.fetch(`http://localhost/otp/${message.recipient.address}`, { - method: 'POST', - headers: { - Authentication: `Bearer ${env.SECRET_TEST_API_TEST_TOKEN}`, - }, - body: message.content.body, - }).then((res) => { + await env.Test.fetch( + `http://localhost/${otpMatch?.[1] ? 'otp' : 'notification'}/${ + message.recipient.address + }`, + { + method: 'POST', + headers: { + Authentication: `Bearer ${env.SECRET_TEST_API_TEST_TOKEN}`, + }, + body: message.content.body, + } + ).then((res) => { console.debug( 'Res: ', res.status, @@ -130,14 +155,10 @@ export async function sendNotification( async function forward(message: CloudflareEmailMessage, env: Environment) { //TODO: Implement for masked email - throw new Error('Not implemented yet') + throw new InternalServerError({ message: 'Not implemented yet' }) } -/** OTP email content template with a `code` parameter */ -export const getOTPEmailContent = ( - passcode: string, - params?: Partial -): EmailContent => { +const adjustEmailParams = (params?: Partial) => { if (!params) { params = { address: '777 Bay Street, Suite C208B Toronto, Ontario M5G 2C8 Canada', @@ -152,7 +173,26 @@ export const getOTPEmailContent = ( 'https://imagedelivery.net/VqQy1abBMHYDZwVsTbsSMw/70676dfd-2899-4556-81ef-e5f48f5eb900/public' } - return EmailTemplate(passcode, params as EmailTemplateParams) + return params +} + +/** OTP email content template with a `code` parameter */ +export const getOTPEmailContent = ( + passcode: string, + params?: Partial +): EmailContent => { + params = adjustEmailParams(params) + + return EmailTemplateOTP(passcode, params as EmailTemplateParams) +} + +/** Subscription Cancellation email content template */ +export const getSubscriptionEmailContent = ( + params?: Partial +): EmailContent => { + params = adjustEmailParams(params) + + return EmailTemplateExpiredSubscription(params as EmailTemplateParams) } /** Magic link email content template */ @@ -165,3 +205,62 @@ export const getMagicLinkEmailContent = ( body: `Your email login link to rollup.id is
. For security reasons, this link is only valid for 1 minute.`, } } + +export const getEmailContent = ({ + ctx, + emailContent, + name, + address, + themeProps, +}: { + ctx: Context + emailContent: EmailContent + name: string + address: string + themeProps?: EmailThemeProps +}) => { + if ( + !( + ctx.NotificationFromUser && + ctx.NotificationFromName && + ctx.INTERNAL_DKIM_DOMAIN && + ctx.KEY_DKIM_PRIVATEKEY && + ctx.INTERNAL_DKIM_SELECTOR + ) + ) + throw new Error( + 'Environment variables not set correctly to be able to send emails.' + ) + + const env: Environment = { + NotificationFromUser: ctx.NotificationFromUser, + NotificationFromName: ctx.NotificationFromName, + INTERNAL_DKIM_DOMAIN: ctx.INTERNAL_DKIM_DOMAIN, + KEY_DKIM_PRIVATEKEY: ctx.KEY_DKIM_PRIVATEKEY, + INTERNAL_DKIM_SELECTOR: ctx.INTERNAL_DKIM_SELECTOR, + SECRET_TEST_API_TEST_TOKEN: ctx.SECRET_TEST_API_TEST_TOKEN, + Test: ctx.Test, + } + + const notification: EmailNotification = { + content: emailContent, + recipient: { + name, + address, + }, + } + let customSender: NotificationSender + if (themeProps?.hostname) { + customSender = { + hostname: themeProps.hostname, + address: `no-reply@${themeProps.hostname}`, + name: themeProps.appName, + } + } + + return { + env, + notification, + customSender, + } +} diff --git a/platform/email/src/jsonrpc/methods/sendBillingNotification.ts b/platform/email/src/jsonrpc/methods/sendBillingNotification.ts new file mode 100644 index 0000000000..c9061d46c5 --- /dev/null +++ b/platform/email/src/jsonrpc/methods/sendBillingNotification.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { Context } from '../../context' +import { + sendNotification, + getSubscriptionEmailContent, + getEmailContent, +} from '../../emailFunctions' +import { EmailThemePropsSchema } from '../../emailFunctions' + +export const sendBillingEmailMethodInput = z.object({ + name: z.string(), + emailAddress: z.string(), + themeProps: EmailThemePropsSchema.optional(), +}) + +export type sendBillingEmailMethodParams = z.infer< + typeof sendBillingEmailMethodInput +> + +export const sendBillingEmailMethodOutput = z.void() + +export type sendBillingEmailMethodOutputParams = z.infer< + typeof sendBillingEmailMethodOutput +> + +export const sendBillingNotificationMethod = async ({ + input, + ctx, +}: { + input: sendBillingEmailMethodParams + ctx: Context +}): Promise => { + const subscriptionEmailTemplate = getSubscriptionEmailContent( + input.themeProps + ) + + const { env, notification, customSender } = getEmailContent({ + ctx, + address: input.emailAddress, + name: input.name, + emailContent: subscriptionEmailTemplate, + themeProps: input.themeProps, + }) + + await sendNotification(notification, env, customSender) +} diff --git a/platform/email/src/jsonrpc/methods/sendOTPEmail.ts b/platform/email/src/jsonrpc/methods/sendOTPEmail.ts index 624f5f6c88..52577b48c4 100644 --- a/platform/email/src/jsonrpc/methods/sendOTPEmail.ts +++ b/platform/email/src/jsonrpc/methods/sendOTPEmail.ts @@ -3,30 +3,16 @@ import { Context } from '../../context' import { sendNotification, getOTPEmailContent, - NotificationSender, + getEmailContent, } from '../../emailFunctions' -import { EmailNotification } from '../../types' -import { Environment } from '../../types' -export const SendOTPEmailThemePropsSchema = z.object({ - privacyURL: z.string().url(), - termsURL: z.string().url(), - contactURL: z.string().url().optional(), - address: z.string().optional(), - logoURL: z.string().url().optional(), - appName: z.string(), - hostname: z.string().optional(), -}) - -export type SendOTPEmailThemeProps = z.infer< - typeof SendOTPEmailThemePropsSchema -> +import { EmailThemePropsSchema } from '../../emailFunctions' export const sendOTPEmailMethodInput = z.object({ name: z.string(), emailAddress: z.string(), otpCode: z.string(), - themeProps: SendOTPEmailThemePropsSchema.optional(), + themeProps: EmailThemePropsSchema.optional(), }) export type sendOTPEmailMethodParams = z.infer @@ -37,53 +23,20 @@ export type sendOTPEmailMethodOutputParams = z.infer< typeof sendOTPEmailMethodOutput > -export const sendEmailNotificationMethod = async ({ +export const sendOTPMethod = async ({ input, ctx, }: { input: sendOTPEmailMethodParams ctx: Context }): Promise => { - if ( - !( - ctx.NotificationFromUser && - ctx.NotificationFromName && - ctx.INTERNAL_DKIM_DOMAIN && - ctx.KEY_DKIM_PRIVATEKEY && - ctx.INTERNAL_DKIM_SELECTOR - ) - ) - throw new Error( - 'Environment variables not set correctly to be able to send emails.' - ) - - const env: Environment = { - NotificationFromUser: ctx.NotificationFromUser, - NotificationFromName: ctx.NotificationFromName, - INTERNAL_DKIM_DOMAIN: ctx.INTERNAL_DKIM_DOMAIN, - KEY_DKIM_PRIVATEKEY: ctx.KEY_DKIM_PRIVATEKEY, - INTERNAL_DKIM_SELECTOR: ctx.INTERNAL_DKIM_SELECTOR, - SECRET_TEST_API_TEST_TOKEN: ctx.SECRET_TEST_API_TEST_TOKEN, - Test: ctx.Test, - } - const otpEmailTemplate = getOTPEmailContent(input.otpCode, input.themeProps) - const notification: EmailNotification = { - content: otpEmailTemplate, - recipient: { - name: input.name, - address: input.emailAddress, - }, - } - - let customSender: NotificationSender - if (input.themeProps?.hostname) { - customSender = { - hostname: input.themeProps.hostname, - address: `no-reply@${input.themeProps.hostname}`, - name: input.themeProps.appName, - } - } - + const { env, notification, customSender } = getEmailContent({ + ctx, + address: input.emailAddress, + name: input.name, + emailContent: otpEmailTemplate, + themeProps: input.themeProps, + }) await sendNotification(notification, env, customSender) } diff --git a/platform/email/src/jsonrpc/router.ts b/platform/email/src/jsonrpc/router.ts index 85574cc5b6..03583bbee1 100644 --- a/platform/email/src/jsonrpc/router.ts +++ b/platform/email/src/jsonrpc/router.ts @@ -6,18 +6,31 @@ import { errorFormatter } from '@proofzero/utils/trpc' import { sendOTPEmailMethodOutput, - sendEmailNotificationMethod, + sendOTPMethod, sendOTPEmailMethodInput, } from './methods/sendOTPEmail' + +import { + sendBillingEmailMethodOutput, + sendBillingEmailMethodInput, + sendBillingNotificationMethod, +} from './methods/sendBillingNotification' + import { Context } from '../context' const t = initTRPC.context().create({ errorFormatter }) export const appRouter = t.router({ - sendEmailNotification: t.procedure + sendOTP: t.procedure .use(LogUsage) .use(Analytics) .input(sendOTPEmailMethodInput) .output(sendOTPEmailMethodOutput) - .mutation(sendEmailNotificationMethod), + .mutation(sendOTPMethod), + sendBillingNotification: t.procedure + .use(LogUsage) + .use(Analytics) + .input(sendBillingEmailMethodInput) + .output(sendBillingEmailMethodOutput) + .mutation(sendBillingNotificationMethod), }) diff --git a/platform/starbase/src/jsonrpc/methods/deleteApp.ts b/platform/starbase/src/jsonrpc/methods/deleteApp.ts index 440aab0dda..dff75c25ac 100644 --- a/platform/starbase/src/jsonrpc/methods/deleteApp.ts +++ b/platform/starbase/src/jsonrpc/methods/deleteApp.ts @@ -3,7 +3,6 @@ import { Context } from '../context' import { getApplicationNodeByClientId } from '../../nodes/application' import { BadRequestError } from '@proofzero/errors' import { ApplicationURNSpace } from '@proofzero/urns/application' -import createEdgesClient from '@proofzero/platform-clients/edges' import { AppClientIdParamSchema } from '../validators/app' import { EDGE_APPLICATION } from '../../types' diff --git a/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts b/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts new file mode 100644 index 0000000000..815393c404 --- /dev/null +++ b/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { Context } from '../context' +import { getApplicationNodeByClientId } from '../../nodes/application' +import { ApplicationURNSpace } from '@proofzero/urns/application' +import { EDGE_PAYS_APP } from '@proofzero/types/graph' +import { AccountURNInput } from '@proofzero/platform-middleware/inputValidators' + +export const DeleteSubscriptionPlansInput = z.object({ + accountURN: AccountURNInput, +}) +type DeleteSubscriptionPlansParams = z.infer< + typeof DeleteSubscriptionPlansInput +> + +export const deleteSubscriptionPlans = async ({ + input, + ctx, +}: { + input: DeleteSubscriptionPlansParams + ctx: Context +}): Promise => { + const { accountURN } = input + + const { edges } = await ctx.edges.getEdges.query({ + query: { + src: { baseUrn: accountURN }, + tag: EDGE_PAYS_APP, + }, + }) + + const appURNs = edges.map((edge) => edge.dst.baseUrn) + const clientIds = appURNs.map((appURN) => ApplicationURNSpace.decode(appURN)) + + if (appURNs.length !== 0) { + await Promise.all([ + // This is a way to delete all edges associated with payments + Promise.all( + appURNs.map((appURN) => + ctx.edges.removeEdge.mutate({ + src: accountURN, + tag: EDGE_PAYS_APP, + dst: appURN, + }) + ) + ), + // This is a way to delete all app plans + Promise.all( + clientIds.map(async (clientId) => { + const appDO = await getApplicationNodeByClientId( + clientId, + ctx.StarbaseApp + ) + appDO.class.deleteAppPlan() + }) + ), + ]) + } +} diff --git a/platform/starbase/src/jsonrpc/router.ts b/platform/starbase/src/jsonrpc/router.ts index 99848a3f7e..ea50444c35 100644 --- a/platform/starbase/src/jsonrpc/router.ts +++ b/platform/starbase/src/jsonrpc/router.ts @@ -116,6 +116,10 @@ import { SetEmailOTPThemeInput, } from './methods/setEmailOTPTheme' import { setAppPlan, SetAppPlanInput } from './methods/setAppPlan' +import { + DeleteSubscriptionPlansInput, + deleteSubscriptionPlans, +} from './methods/deleteSubscriptionPlans' const t = initTRPC.context().create({ errorFormatter }) @@ -330,6 +334,11 @@ export const appRouter = t.router({ .use(OwnAppsMiddleware) .input(SetAppPlanInput) .mutation(setAppPlan), + deleteSubscriptionPlans: t.procedure + .use(LogUsage) + .use(Analytics) + .input(DeleteSubscriptionPlansInput) + .mutation(deleteSubscriptionPlans), }) export type StarbaseRouter = typeof appRouter diff --git a/platform/starbase/src/nodes/application.ts b/platform/starbase/src/nodes/application.ts index f90f02022f..720fb1745e 100644 --- a/platform/starbase/src/nodes/application.ts +++ b/platform/starbase/src/nodes/application.ts @@ -314,6 +314,10 @@ export default class StarbaseApp extends DOProxy { async setAppPlan(planType: ServicePlanType | undefined): Promise { return this.state.storage.put('appPlan', planType) } + + async deleteAppPlan(): Promise { + return this.state.storage.delete('appPlan') + } } export const getApplicationNodeByClientId = async ( diff --git a/platform/test/src/index.ts b/platform/test/src/index.ts index 442b094e3f..2c683da355 100644 --- a/platform/test/src/index.ts +++ b/platform/test/src/index.ts @@ -36,6 +36,16 @@ router.post('/otp/:email', withToken, async (req, env) => { return new Response(otp) }) +router.post('/notification/:email', withToken, async (req, env) => { + const { params } = req + const message = await req.text() + + console.log({ to: params.email }) + console.log({ message }) + + return new Response(message) +}) + // alternative advanced/manual approach for downstream control export default { fetch: (request: RequestLike, env: Environment, context: any) =>