Skip to content

Commit e2ba048

Browse files
authored
feat(email-campaigns): add billing integration for campaign creation (#2685)
* feat(email-campaigns): add billing integration for campaign creation - Add checkCampaignBilling function to validate credits before campaign - Support partialCampaign parameter for retry flow - Use chargedUnits for total_recipients instead of eligibleContacts.length - Forward 402/266 responses for CreditsDialog handling - Block campaign if billing enabled but fails (fail closed) * docs(env): document ENABLE_BILLING configuration - Add ENABLE_BILLING env variable for optional campaign billing - Defaults to false for backward compatibility * feat: add compliance middleware and billing integration - Add compliance-middleware.ts for consent filtering - Filters opt_out contacts from campaign requests - Returns 266 with reason='consent' if consent limits campaign - Use supabaseAdmin.functions.invoke for billing in try/catch - Handle 402/266 responses from billing - Add ComplianceDialog component for consent validation - Update CampaignComposerDialog for unified error handling * refactor: move all compliance and billing logic to middleware - Compliance middleware now handles: - Consent filtering (opt_out contacts) - Billing check via supabaseAdmin.functions.invoke - Returns 266 with reason field for both consent/credits - Modifies request body with filtered emails - Handler simplified: just uses filtered emails - Frontend: add onResponse handler to show ComplianceDialog or CreditsDialog - Add ComplianceDialog component to template * feat: refactor campaign billing flow with proper middleware chain - Split compliance into campaignCheck and campaignBill middleware - Use /quota before creation, /charge after creation - Fix table reference (persons -> refinedpersons) - Add CreditsDialog integration for 402/266 responses - Add plural i18n support in ComplianceDialog - Keep dialog open for partial campaign flows - Add await next() in controller chain - Add unit tests for middleware * chore: remove local plans from repo and add to gitignore * refactor: add campaign billing middleware chain and CreditsDialog integration * fix: resolve deepsource issues in test files and index.ts * fix: add skipcq for unused getCurrentUtcDayStart function * rename to ENABLE_CREDIT for consistency
1 parent 1fac4e9 commit e2ba048

File tree

11 files changed

+1017
-214
lines changed

11 files changed

+1017
-214
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ backend/config/default.yml
2020
supabase/config.toml
2121
frontend-v4/*
2222
.worktrees/
23+
.opencode/plans/

frontend/src/components/campaigns/CampaignComposerDialog.vue

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -334,19 +334,35 @@
334334
:label="t('send_campaign')"
335335
:loading="isSubmitting"
336336
:disabled="isActionDisabled"
337-
@click="submit"
337+
@click="submit(false)"
338338
/>
339339
</div>
340340
</div>
341341
</template>
342342
</Dialog>
343+
344+
<ComplianceDialog ref="complianceDialogRef" @confirm-partial="submit(true)" />
345+
346+
<component
347+
:is="CreditsDialog"
348+
ref="CreditsDialogCampaignRef"
349+
engagement-type="contact"
350+
action-type="campaign"
351+
@secondary-action="submit(true)"
352+
/>
343353
</template>
344354

345355
<script setup lang="ts">
346356
import type { Contact } from '@/types/contact';
347357
import { extractUnavailableSenderEmails } from '@/utils/senderOptions';
348358
import { updateMiningSourcesValidityFromUnavailable } from '@/utils/sources';
349359
import Editor from 'primevue/editor';
360+
import ComplianceDialog from './ComplianceDialog.vue';
361+
import {
362+
CreditsDialog,
363+
CreditsDialogCampaignRef,
364+
openCreditsDialog,
365+
} from '@/utils/credits';
350366
351367
const isVisible = defineModel<boolean>('visible', { required: true });
352368
@@ -402,6 +418,9 @@ type SenderOptionItem = {
402418
const senderOptions = ref<SenderOptionItem[]>([]);
403419
const fallbackSenderEmail = ref('');
404420
const runtimeConfig = useRuntimeConfig();
421+
const complianceDialogRef = ref<InstanceType<typeof ComplianceDialog> | null>(
422+
null,
423+
);
405424
406425
const DEFAULT_PROJECT_URL = 'https://example.com/project';
407426
const DEFAULT_PROJECT_IMAGE_SRC =
@@ -623,7 +642,6 @@ const editorModules = computed(() => ({
623642
}));
624643
625644
type AttributeTarget = 'subject' | 'body';
626-
627645
const activeAttributeTarget = ref<AttributeTarget>('body');
628646
629647
const attributeMenuItems = computed(() =>
@@ -1046,7 +1064,7 @@ async function sendPreview() {
10461064
}
10471065
}
10481066
1049-
async function submit() {
1067+
async function submit(partialCampaign = false) {
10501068
if (!ensureValidForm()) {
10511069
return;
10521070
}
@@ -1056,6 +1074,9 @@ async function submit() {
10561074
}
10571075
10581076
isSubmitting.value = true;
1077+
let shouldCloseDialog = true;
1078+
let showErrorToast = true;
1079+
10591080
try {
10601081
const data = await $saasEdgeFunctions('email-campaigns/campaigns/create', {
10611082
method: 'POST',
@@ -1073,42 +1094,89 @@ async function submit() {
10731094
trackClick: form.trackClick,
10741095
plainTextOnly: form.plainTextOnly,
10751096
onlyValidContacts: form.onlyValidContacts,
1097+
partialCampaign,
1098+
},
1099+
onResponse: ({ response }) => {
1100+
if (response.status === 402) {
1101+
openCreditsDialog(
1102+
CreditsDialogCampaignRef,
1103+
true,
1104+
response._data.total,
1105+
response._data.available,
1106+
response._data.availableAlready,
1107+
);
1108+
shouldCloseDialog = false;
1109+
showErrorToast = false;
1110+
return;
1111+
}
1112+
1113+
if (response.status === 266 && response._data?.reason === 'credits') {
1114+
openCreditsDialog(
1115+
CreditsDialogCampaignRef,
1116+
false,
1117+
response._data.total,
1118+
response._data.available,
1119+
response._data.availableAlready,
1120+
);
1121+
shouldCloseDialog = false;
1122+
showErrorToast = false;
1123+
return;
1124+
}
1125+
1126+
if (response.status === 266 && response._data?.reason === 'consent') {
1127+
complianceDialogRef.value?.openModal(
1128+
response._data.total,
1129+
response._data.available,
1130+
);
1131+
shouldCloseDialog = false;
1132+
showErrorToast = false;
1133+
return;
1134+
}
1135+
1136+
if (response.status === 200) {
1137+
return;
1138+
}
1139+
1140+
throw new Error(response._data?.error || 'Campaign creation failed');
10761141
},
10771142
});
10781143
1079-
$toast.add({
1080-
group: 'has-links',
1081-
severity: 'success',
1082-
summary: t('campaign_started'),
1083-
detail: {
1084-
message: t('campaign_started_detail'),
1085-
button: {
1086-
text: t('see_campaigns'),
1087-
action: () => navigateTo('/campaigns'),
1144+
if (shouldCloseDialog) {
1145+
$toast.add({
1146+
severity: 'success',
1147+
summary: t('campaign_started'),
1148+
detail: {
1149+
message: t('campaign_started_detail'),
1150+
button: {
1151+
text: t('see_campaigns'),
1152+
action: () => navigateTo('/campaigns'),
1153+
},
10881154
},
1089-
},
1090-
life: 6000,
1091-
});
1155+
life: 6000,
1156+
});
10921157
1093-
if (data?.campaignId) {
1094-
startCampaignCompletionWatcher(data.campaignId);
1095-
}
1158+
if (data?.campaignId) {
1159+
startCampaignCompletionWatcher(data.campaignId);
1160+
}
10961161
1097-
isVisible.value = false;
1098-
} catch (error: unknown) {
1099-
const parsedError = error as EdgeResponseError;
1100-
const code = parsedError?.data?.code;
1101-
if (code === 'SENDER_SMTP_FAILED' && fallbackSenderEmail.value) {
1102-
form.senderEmail =
1103-
parsedError?.data?.fallbackSenderEmail || fallbackSenderEmail.value;
1162+
isVisible.value = false;
11041163
}
1164+
} catch (error: unknown) {
1165+
if (showErrorToast) {
1166+
const parsedError = error as EdgeResponseError;
1167+
const code = parsedError?.data?.code;
1168+
if (code === 'SENDER_SMTP_FAILED' && fallbackSenderEmail.value) {
1169+
form.senderEmail =
1170+
parsedError?.data?.fallbackSenderEmail || fallbackSenderEmail.value;
1171+
}
11051172
1106-
$toast.add({
1107-
severity: 'error',
1108-
summary: t('campaign_start_failed'),
1109-
detail: resolveErrorMessage(error, 'error_campaign_start_failed'),
1110-
life: 5000,
1111-
});
1173+
$toast.add({
1174+
severity: 'error',
1175+
summary: t('campaign_start_failed'),
1176+
detail: resolveErrorMessage(error, 'error_campaign_start_failed'),
1177+
life: 5000,
1178+
});
1179+
}
11121180
} finally {
11131181
isSubmitting.value = false;
11141182
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<template>
2+
<Dialog v-model:visible="showModal" modal :header="t('consent_required')">
3+
<p class="m-0">
4+
{{ t('consent_message', { available, total: t('contact', total) }) }}
5+
</p>
6+
<template #footer>
7+
<div class="flex justify-end gap-2">
8+
<Button :label="t('privacy_policy')" link @click="openPrivacyPolicy" />
9+
<Button :label="t('cancel')" severity="secondary" @click="closeModal" />
10+
<Button
11+
:label="
12+
t('continue_with_available', { count: t('contact', available) })
13+
"
14+
severity="contrast"
15+
@click="confirmPartial"
16+
/>
17+
</div>
18+
</template>
19+
</Dialog>
20+
</template>
21+
22+
<script setup lang="ts">
23+
const { t } = useI18n({
24+
useScope: 'local',
25+
});
26+
27+
const emit = defineEmits<{
28+
'confirm-partial': [];
29+
}>();
30+
31+
const showModal = ref(false);
32+
const total = ref(0);
33+
const available = ref(0);
34+
35+
const openModal = (totalCount: number, availableCount: number) => {
36+
total.value = totalCount;
37+
available.value = availableCount;
38+
showModal.value = true;
39+
};
40+
41+
const closeModal = () => {
42+
showModal.value = false;
43+
};
44+
45+
const openPrivacyPolicy = () => {
46+
window.open('/privacy-policy', '_blank');
47+
};
48+
49+
const confirmPartial = () => {
50+
emit('confirm-partial');
51+
closeModal();
52+
};
53+
54+
defineExpose({
55+
openModal,
56+
closeModal,
57+
});
58+
</script>
59+
60+
<i18n lang="json">
61+
{
62+
"en": {
63+
"consent_required": "Consent Required",
64+
"consent_message": "Only {available} of {total} have given consent to be contacted.",
65+
"contact": "contact | contacts",
66+
"privacy_policy": "Privacy Policy",
67+
"cancel": "Cancel",
68+
"continue_with_available": "Continue with {count}"
69+
},
70+
"fr": {
71+
"consent_required": "Consentement Requis",
72+
"consent_message": "Seulement {available} sur {total} ont donné leur consentement.",
73+
"contact": "contact | contacts",
74+
"privacy_policy": "Politique de Confidentialité",
75+
"cancel": "Annuler",
76+
"continue_with_available": "Continuer avec {count}"
77+
}
78+
}
79+
</i18n>

frontend/src/utils/credits.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const CreditsCounter = null;
22
export const CreditsDialog = null;
33
export const CreditsDialogEnrichRef = ref();
44
export const CreditsDialogExportRef = ref();
5+
export const CreditsDialogCampaignRef = ref();
56
// skipcq: JS-0356
67
export const openCreditsDialog = (
78
_ref: Ref<unknown>,

supabase/functions/.env.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ SERVER_ENDPOINT = http://localhost:8081 # ( REQUIRED ) URL of the Backend se
1717
GOOGLE_CLIENT_ID = your_azure_client_id # ( REQUIRED ) Google client ID
1818
GOOGLE_SECRET = your_azure_client_id # ( REQUIRED ) Google secret
1919
AZURE_CLIENT_ID = your_azure_client_id # ( REQUIRED ) Azure client ID
20-
AZURE_SECRET = your_azure_client_secret # ( REQUIRED ) Azure secret
20+
AZURE_SECRET = your_azure_client_secret # ( REQUIRED ) Azure secret
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Context, Next } from "hono";
2+
import { createSupabaseAdmin } from "../_shared/supabase.ts";
3+
import { createLogger } from "../_shared/logger.ts";
4+
5+
const logger = createLogger("email-campaigns:bill");
6+
7+
export async function campaignBillMiddleware(c: Context, next: Next) {
8+
if (!c.req.path.endsWith("/campaigns/create")) {
9+
return next();
10+
}
11+
12+
const billingEnabled = Deno.env.get("ENABLE_CREDIT") === "true";
13+
14+
const campaignData = c.get("campaignCreate");
15+
if (!campaignData) {
16+
logger.error("No campaign data in context");
17+
return c.json(
18+
{ error: "Campaign creation data missing", code: "INTERNAL_ERROR" },
19+
500,
20+
);
21+
}
22+
23+
const { campaignId, createdCount, userId } = campaignData;
24+
25+
if (!billingEnabled) {
26+
return c.json({
27+
msg: "Campaign queued",
28+
campaignId,
29+
queuedCount: createdCount,
30+
});
31+
}
32+
33+
try {
34+
const supabaseAdmin = createSupabaseAdmin();
35+
36+
const { data, error } = await supabaseAdmin.functions.invoke(
37+
"billing/campaign/charge",
38+
{
39+
body: {
40+
userId,
41+
units: createdCount,
42+
partialCampaign: false,
43+
},
44+
},
45+
);
46+
47+
if (error) {
48+
logger.error("Billing charge failed", {
49+
error: error.message,
50+
userId,
51+
campaignId,
52+
createdCount,
53+
});
54+
} else {
55+
logger.info("Billing charge successful", {
56+
userId,
57+
campaignId,
58+
createdCount,
59+
chargedUnits: data?.payload?.chargedUnits,
60+
});
61+
}
62+
63+
return c.json({
64+
msg: "Campaign queued",
65+
campaignId,
66+
queuedCount: createdCount,
67+
});
68+
} catch (error) {
69+
logger.error("Billing charge exception", { error, userId, campaignId });
70+
return c.json({
71+
msg: "Campaign queued",
72+
campaignId,
73+
queuedCount: createdCount,
74+
});
75+
}
76+
}

0 commit comments

Comments
 (0)