diff --git a/supabase/functions/alerts/alert_types/provided_payment_method.ts b/supabase/functions/alerts/alert_types/provided_payment_method.ts new file mode 100644 index 0000000000..0487f39c0c --- /dev/null +++ b/supabase/functions/alerts/alert_types/provided_payment_method.ts @@ -0,0 +1,50 @@ +import { AlertRecord, EmailConfig } from "../index.ts"; +import { commonTemplate } from "../template.ts"; + +interface ProvidedPaymentMethod { + // This feels like it should apply to all alert types, and doesn't belong here.. + recipients: { + email: string; + full_name: string | null; + }[]; + trial_start: string; + trial_end: string; + tenant: string; + in_trial: boolean; + straight_from_free_tier: boolean; +} + +type ProvidedPaymentMethodRecord = AlertRecord<"provided_payment_method", ProvidedPaymentMethod>; + +const ProvidedPaymentMethod = (req: ProvidedPaymentMethodRecord): EmailConfig[] => { + return req.arguments.recipients.map((recipient) => ({ + emails: [recipient.email], + subject: `Estuary Flow: Payment Method Entered 🎉`, + content: commonTemplate(` + Dear ${recipient.full_name}, + Thank you for entering your payment information. ${ + req.arguments.in_trial + ? `After your free trial ends on ${req.arguments.trial_end}, your account will begin accruing usage. Your dataflows will not be interrupted.` + : req.arguments.straight_from_free_tier + ? `Your account will now begin accruing usage. Your dataflows will not be interrupted.` + : `Your account is now active.` + } + 📈 See your bill + + + Frequently Asked Questions + + Where is my data stored? + By default, all collection data is stored in an Estuary-owned cloud storage bucket with a 30 day retention plicy. Now that you have a paid account, you can update this to store data in your own cloud storage bucket. We support GCS, S3, and Azure Blob storage. + `), + })); +}; + +export const ProvidedPaymentMethodEmail = (request: ProvidedPaymentMethodRecord): EmailConfig[] => { + if (request.resolved_at) { + // Do we want to send a "cc confirmed" email when this alert stops firing? + return []; + } else { + return ProvidedPaymentMethod(request); + } +}; diff --git a/supabase/functions/alerts/index.ts b/supabase/functions/alerts/index.ts index 3348812733..0eb6577afb 100644 --- a/supabase/functions/alerts/index.ts +++ b/supabase/functions/alerts/index.ts @@ -6,6 +6,7 @@ import { freeTierExceededEmail } from "./alert_types/free_tier_exceeded.ts"; import { freeTrialEndedEmail } from "./alert_types/free_trial_ended.ts"; import { freeTrialEndingEmail } from "./alert_types/free_trial_ending.ts"; import { freeTrialGracePeriodOverEmail } from "./alert_types/free_trial_grace_period_over.ts"; +import { ProvidedPaymentMethodEmail } from "./alert_types/provided_payment_method.ts"; export interface AlertRecord { alert_type: T; @@ -27,6 +28,7 @@ const emailTemplates = { "free_trial_ended": freeTrialEndedEmail, "free_trial_ending": freeTrialEndingEmail, "free_trial_grace_period_over": freeTrialGracePeriodOverEmail, + "provided_payment_method": ProvidedPaymentMethodEmail, }; // This is a temporary type guard for the POST request that provides shallow validation @@ -159,6 +161,9 @@ serve(async (rawRequest: Request): Promise => { case "free_trial_grace_period_over": pendingEmails = emailTemplates[request.alert_type](request); break; + case "provided_payment_method": + pendingEmails = emailTemplates[request.alert_type](request); + break; default: { // This checks that we have an exhaustive match. If this line has a // type error, make sure you have a case above for every key in `emailTemplates`. diff --git a/supabase/migrations/43_add_free_tier_exceeded_alert.sql b/supabase/migrations/43_payment_emails.sql similarity index 83% rename from supabase/migrations/43_add_free_tier_exceeded_alert.sql rename to supabase/migrations/43_payment_emails.sql index cd9dd48aa9..f995459b9b 100644 --- a/supabase/migrations/43_add_free_tier_exceeded_alert.sql +++ b/supabase/migrations/43_payment_emails.sql @@ -115,6 +115,34 @@ group by customers.name, users.raw_user_meta_data; +-- Alert us internally when they go past 5 days over the trial +create or replace view internal.provided_payment_method_firing as +select + 'provided_payment_method' as alert_type, + tenants.tenant || 'alerts/provided_payment_method' as catalog_name, + alert_subscriptions.email, + auth.users.raw_user_meta_data->>'full_name' as full_name, + tenants.tenant, + tenants.trial_start, + tenants.trial_start + interval '1 month' as trial_end, + -- if tenants.trial_start is null, that means they entered their cc + -- while they're still in the free tier + coalesce((now() - tenants.trial_start) < interval '1 month', false) as in_trial, + tenants.trial_start is null as straight_from_free_tier +from tenants + left join alert_subscriptions on alert_subscriptions.catalog_prefix ^@ tenants.tenant and email is not null + left join stripe.customers on stripe.customers."name" = tenants.tenant + -- Filter out sso users because auth.users is only guarinteed unique when that is false: + -- CREATE UNIQUE INDEX users_email_partial_key ON auth.users(email text_ops) WHERE is_sso_user = false; + left join auth.users on auth.users.email = alert_subscriptions.email and auth.users.is_sso_user is false +where stripe.customers."invoice_settings/default_payment_method" is not null +group by + tenants.tenant, + tenants.trial_start, + alert_subscriptions.email, + customers.name, + users.raw_user_meta_data; + -- Have to update this to join in auth.users for full_name support -- Update to v2 because of the change from `emails` to `recipients` create or replace view internal.alert_data_processing_firing_v2 as @@ -254,12 +282,38 @@ free_trial_grace_period_over as ( trial_start, trial_end, has_credit_card +), +provided_payment_method_firing as ( + select + catalog_name, + alert_type, + json_build_object( + 'tenant', tenant, + 'recipients', array_agg(json_build_object( + 'email', email, + 'full_name', full_name + )), + 'trial_start', trial_start, + 'trial_end', trial_end, + 'in_trial', in_trial, + 'straight_from_free_tier', straight_from_free_tier + ) as arguments + from internal.alert_free_trial_grace_period_over_firing + group by + catalog_name, + tenant, + alert_type, + trial_start, + trial_end, + in_trial + straight_from_free_tier ) select * from data_processing union all select * from free_tier_exceeded union all select * from free_trial_ending union all select * from free_trial_ended union all select * from free_trial_grace_period_over +union all select * from provided_payment_method_firing order by catalog_name asc; create or replace function internal.send_alerts() @@ -277,7 +331,10 @@ if new.alert_type = 'data_not_processed_in_interval' then to_jsonb(new.*), headers:=format('{"Content-Type": "application/json", "Authorization": "Basic %s"}', token)::jsonb ); -else +-- Skip all of the past events that got triggered when we added these new event types +-- NOTE: Change this so that the date is the day (or time) that it's deployed +-- so that only "real" events that happen after deployment get sent +else if new.fired_at > '2024-01-30' perform net.http_post( 'https://eyrcnmuzzyriypdajwdk.supabase.co/functions/v1/alerts',