Public Billing Service
๐ Overviewโ
Service Path: internal/api/v1/public/services/billing.service.js
The Public Billing service provides public-facing Stripe payment operations for funnel checkouts. Core responsibilities include:
- Product Catalog: Retrieve Stripe products with prices and filtering
- Payment Methods: List and manage customer payment methods
- Payment Method Tokens: Add payment methods via Stripe tokens with Twilio phone validation
- Checkout Processing: Handle one-time and recurring payments with invoice generation
- Contact Auto-Creation: Create CRM contacts from Stripe customers
- Analytics Tracking: Record checkout events for funnel analytics
- Coupon Support: Apply promotional codes with validation
๐๏ธ Collections Usedโ
- crm.contacts (link removed - file does not exist) - Auto-created from Stripe customers
- twilio-numbers (link removed - file does not exist) - Phone number validation and lookup data
- funnel.analytics (link removed - file does not exist) - Checkout analytics and conversion tracking
๐ Data Flowโ
Checkout Flow with Contact Creationโ
sequenceDiagram
participant Customer
participant PublicAPI
participant BillingService
participant Stripe
participant TwilioAPI
participant ContactsDB
participant AnalyticsDB
Customer->>PublicAPI: POST /public/checkout
PublicAPI->>BillingService: addCheckout(checkoutDetails)
alt Preview Mode
BillingService->>Stripe: retrieveUpcoming invoice
Stripe-->>BillingService: Preview data with discounts
BillingService-->>Customer: Preview response
else Actual Checkout
BillingService->>Stripe: Retrieve customer
BillingService->>ContactsDB: Find contact by email/phone
alt Contact Not Found
BillingService->>ContactsDB: Create new contact
else Invalid Stripe Customer
BillingService->>Stripe: Create new customer
BillingService->>ContactsDB: Update contact
end
alt One-Time Payment
BillingService->>Stripe: Create invoice with items
BillingService->>Stripe: Finalize invoice
alt charge_automatically
BillingService->>Stripe: Pay invoice
else send_invoice
BillingService->>Stripe: Send invoice email
end
end
alt Recurring Payment
BillingService->>Stripe: Create subscription
BillingService->>Stripe: Finalize + send if needed
end
BillingService->>Stripe: Check charge status (5 attempts)
BillingService->>AnalyticsDB: Save funnel analytics
BillingService-->>Customer: Checkout data with invoice
end
Payment Method Addition with Phone Validationโ
flowchart TD
A[Add Payment Method] --> B{Phone Number Provided?}
B -->|Yes| C[Remove + from phone]
C --> D{Twilio Lookup Cache Exists?}
D -->|Yes| E[Use Cached Data]
D -->|No| F[Generate JWT Token]
F --> G[Call Twilio Lookup API]
G --> H[Get Validated Phone]
B -->|No| I[Skip Twilio Lookup]
E --> J{Customer Exists in Stripe?}
H --> J
I --> J
J -->|No| K[Create New Customer]
J -->|Yes| L[Get Existing Customer]
L --> M[Update Missing Fields]
K --> N[Retrieve Token Details]
M --> N
N --> O[Get Card Fingerprint]
O --> P{Duplicate Card?}
P -->|Yes| Q[Return Existing Payment Method]
P -->|No| R[Create New Payment Method]
Q --> S[Return Payment Method]
R --> S
style G fill:#fff4e6
style K fill:#e8f5e9
style R fill:#e8f5e9
๐ง Business Logic & Functionsโ
getPublicKey({ stripeFunnel })โ
Purpose: Retrieve Stripe publishable key for client-side tokenization
Parameters:
stripeFunnel(Object) - Funnel's Stripe configurationstripe_publishable_key(String) - Public Stripe key
Returns:
String; // Stripe publishable key
Business Logic Flow:
-
Extract Public Key
const publicKey = stripeFunnel.stripe_publishable_key;
return publicKey;
Key Business Rules:
- Simple getter function for Stripe public key
- Used for client-side Stripe.js initialization
Example Usage:
const publicKey = await getPublicKey({
stripeFunnel: {
stripe_publishable_key: 'pk_test_...',
stripe_user_id: 'acct_...',
},
});
// Returns: 'pk_test_...'
Side Effects:
- None (read-only operation)
getProducts({ status, search, limit, lastProductId, firstProductId, nextPage, type, currency, stripeFunnel })โ
Purpose: Retrieve Stripe products with prices, filtering, and pagination
Parameters:
status(String) - Filter: 'active', 'archived', or undefined (all)search(String, optional) - Search by name or descriptionlimit(Number) - Maximum products to returnlastProductId(String, optional) - For pagination (next page)firstProductId(String, optional) - For pagination (previous page)nextPage(String, optional) - Search pagination tokentype(String, optional) - Price type: 'one-time', 'recurring', or undefined (all)currency(String, optional) - Filter prices by currency (USD, EUR, etc.)stripeFunnel(Object) - Funnel's Stripe configuration
Returns:
{
has_more: Boolean,
first_product_id: String,
last_product_id: String,
next_page: String, // For search pagination
products: [
{
id: String,
name: String,
created: Number, // Unix timestamp
updated: Number,
images: [String],
active: Boolean,
description: String,
default_price: String,
metadata: Object,
statement_descriptor: String,
url: String,
price_count: Number,
unit_amount: String, // Dollars (e.g., "9.99")
interval: String, // For recurring: 'month', 'year', etc.
prices_data: {
unarchive: [Price], // Active prices
archive: [Price] // Archived prices
}
}
]
}
Business Logic Flow:
-
Normalize Type Parameter
type = type === 'one-time' ? 'one_time' : type;- Stripe API uses 'one_time', frontend sends 'one-time'
-
Initialize Stripe Client
const stripe = stripeConfig(stripeFunnel); -
Determine Active Filter
let active;
if (status === 'archived') active = false;
if (status === 'active') active = true;
// undefined = all products -
Fetch Products (Search or List)
if (search) {
product = await stripe.products.search(
{
query: `name~"${search}" OR description~"${search}"`,
limit,
page: nextPage,
},
{ stripeAccount: stripeFunnel.stripe_user_id },
);
} else {
product = await stripe.products.list(
{
active,
limit,
starting_after: lastProductId,
ending_before: firstProductId,
},
{ stripeAccount: stripeFunnel.stripe_user_id },
);
}- search: Uses Stripe search API with query language
- list: Standard pagination with cursors
-
Load Prices for Each Product
const cleanProductData = await Promise.all(
product.data?.map(async productData => {
// Get all prices for this product
const prices = await stripe.prices.list(
{
product: productData.id,
type, // Filter by one_time or recurring
currency, // Filter by currency
limit: 100,
},
{ stripeAccount: stripeFunnel.stripe_user_id },
);
// Normalize price types
const pricesData = await Promise.all(
prices.data.map(async priceInfo => {
if (priceInfo.type === 'one_time') {
priceInfo.type = 'one-time';
}
return priceInfo;
}),
);
// Separate active and archived prices
const separatePriceData = pricesData.reduce(
(acc, data) => {
if (data.active) {
acc.unarchive.push(data);
} else {
acc.archive.push(data);
}
return acc;
},
{ unarchive: [], archive: [] },
);
// Return cleaned product data
return {
id: productData.id,
name: productData.name,
// ... other fields
price_count: prices.data?.length,
unit_amount: (prices.data[0]?.unit_amount / 100).toFixed(2),
interval: prices.data[0]?.recurring?.interval,
prices_data: separatePriceData,
};
}),
); -
Build Pagination Response
productsData.has_more = product.has_more;
productsData.first_product_id = products[0]?.id;
productsData.last_product_id = products[products.length - 1]?.id;
productsData.next_page = product.next_page; // For search
productsData.products = products;
Key Business Rules:
- Type Normalization: Converts 'one-time' to 'one_time' for Stripe API
- Price Loading: Fetches up to 100 prices per product
- Amount Conversion: Stripe stores cents, converts to dollars (divided by 100)
- Price Separation: Active and archived prices separated for UI
- Search vs List: Different pagination mechanisms
- Multi-Account: Uses stripeAccount parameter for connected accounts
Error Handling:
catch (error) {
if (error.type === 'stripeConnectionError') {
return Promise.reject(
custom(
'An error occurred with our connection to Stripe due to timeout',
error.type,
error.statusCode
)
);
}
return Promise.reject(custom(error.message, error.type, error.statusCode));
}
Example Usage:
// Get active one-time products
const result = await getProducts({
status: 'active',
limit: 10,
type: 'one-time',
currency: 'usd',
stripeFunnel: { stripe_user_id: 'acct_...', ... }
});
// Search for products
const searchResult = await getProducts({
search: 'premium plan',
limit: 20,
stripeFunnel: { ... }
});
Side Effects:
- None (read-only Stripe operations)
Performance Considerations:
- N+1 Query: Fetches prices for each product (can be slow for many products)
- Parallel Execution: Uses Promise.all to fetch prices concurrently
- Limit: Default limit should be reasonable (10-50) to avoid timeout
getPaymentMethods({ email, startDate, endDate, firstPaymenthMethodId, lastPaymenthMethodId, limit, stripeFunnel })โ
Purpose: Retrieve payment methods for customer by email with date filtering
Parameters:
email(String) - Customer email to look upstartDate(Date, optional) - Filter payment methods created afterendDate(Date, optional) - Filter payment methods created beforefirstPaymenthMethodId(String, optional) - Pagination cursor (previous page)lastPaymenthMethodId(String, optional) - Pagination cursor (next page)limit(Number) - Maximum payment methods to returnstripeFunnel(Object) - Funnel's Stripe configuration
Returns:
{
has_more: Boolean,
first_paymentMethod_id: String,
last_paymentMethod_id: String,
paymentMethods: [
{
id: String,
billing_details: {
address: Object,
email: String,
name: String,
phone: String
},
card: {
brand: String, // 'visa', 'mastercard', etc.
last4: String,
exp_month: Number,
exp_year: Number
},
created: Number, // Unix timestamp
customer: String,
type: String // 'card'
}
]
}
// Returns undefined if customer not found
Business Logic Flow:
-
Initialize Stripe and Convert Dates
const stripe = stripeConfig(stripeFunnel);
if (startDate && endDate) {
startDate = moment(startDate).unix();
endDate = moment(endDate).unix();
} -
Find Customer by Email
const customer = await stripe.customers.list(
{ email, limit: 1 },
{ stripeAccount: stripeFunnel.stripe_user_id },
);
if (!customer.data.length) {
return; // No customer found
} -
Fetch Payment Methods
let paymentMethod = await stripe.paymentMethods.list(
{
customer: customer.data[0].id,
created: {
gte: startDate,
lte: endDate,
},
limit,
starting_after: lastPaymenthMethodId,
ending_before: firstPaymenthMethodId,
},
{ stripeAccount: stripeFunnel.stripe_user_id },
); -
Clean Payment Method Data
const cleanPaymentMethodData = paymentMethod.data?.map(payData => {
return {
id: payData.id,
billing_details: payData.billing_details,
card: payData.card,
created: payData.created,
customer: payData.customer,
type: payData.type,
};
}); -
Build Pagination Response
paymentMethodData.has_more = paymentMethod.has_more;
paymentMethodData.first_paymentMethod_id = paymentMethods[0]?.id;
paymentMethodData.last_paymentMethod_id = paymentMethod.has_more
? paymentMethods[paymentMethods.length - 1]?.id
: null;
paymentMethodData.paymentMethods = paymentMethods;
Key Business Rules:
- Customer Lookup: Must find customer by email first
- Returns Undefined: If customer not found (not an error)
- Date Filtering: Optional time range for payment methods
- Pagination: Cursor-based with has_more flag
Example Usage:
const methods = await getPaymentMethods({
email: 'customer@example.com',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31'),
limit: 20,
stripeFunnel: { stripe_user_id: 'acct_...', ... }
});
if (methods) {
// Customer found with payment methods
methods.paymentMethods.forEach(pm => {
console.log(`${pm.card.brand} ending in ${pm.card.last4}`);
});
}
Side Effects:
- None (read-only Stripe operations)
addPaymentMethodToken({ paymentMethodDetails, stripeFunnel, accountId })โ
Purpose: Add payment method via Stripe token with Twilio phone validation and duplicate detection
Parameters:
paymentMethodDetails(Object) - Payment method informationemail(String) - Customer emailname(String) - Customer namecompany_name(String, optional) - Company name for metadataphone(String) - Customer phone numberaddress(Object) - Billing addressshipping(Object, optional) - Shipping addresstoken(String) - Stripe token from Stripe.js
stripeFunnel(Object) - Funnel's Stripe configurationaccountId(ObjectId) - Account ID for Twilio lookup token generation
Returns:
{
id: String,
object: 'card',
address_city: String,
address_country: String,
address_line1: String,
address_line2: String,
address_state: String,
address_zip: String,
brand: String, // 'visa', 'mastercard', etc.
country: String,
customer: String,
cvc_check: String,
exp_month: Number,
exp_year: Number,
fingerprint: String,
funding: String,
last4: String,
metadata: Object,
name: String,
wallet: Object
}
Business Logic Flow:
-
Twilio Phone Number Lookup
phone = phone?.replace(/\+/g, ''); // Remove plus sign
// Check cache first
const number = await twilioNumber
.findOne({
phone: phone,
lookup_data: { $exists: true },
})
.lean();
if (number) {
result = number.lookup_data;
} else {
// Generate JWT token for external API call
const external_token = jwt.sign(
{
type: 'access_token',
account_id: accountId.toString(),
parent_account: accountId.toString(),
scope: 'funnels',
},
process.env.APP_SECRET,
{ expiresIn: '1h' },
);
// Find country code
const countryCode = codes.find(c => c.country === address?.country);
// Call Twilio lookup API
const url = `${API_BASE_URL}/v1/e/twilio/pricing/lookup-number?phone=${encodeURIComponent(
phone,
)}&country_code=${countryCode?.isoCode2}`;
result = await axios({
method: 'GET',
url,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${external_token}`,
},
});
result = result.data.data;
}
const phoneNumber = result?.phoneNumber; // Validated phone with country code -
Find or Create Stripe Customer
let customer = await stripe.customers.list(
{ email, limit: 1 },
{ stripeAccount: stripeFunnel.stripe_user_id },
);
if (!customer.data.length) {
// Create new customer
const custObj = {
name,
phone: phoneNumber, // Use validated phone
email,
address,
shipping,
};
if (company_name) {
custObj.metadata = {
company_name,
origin: 'funnels',
};
}
customer = await stripe.customers.create(custObj, {
stripeAccount: stripeFunnel.stripe_user_id,
});
} else {
const customerData = customer.data[0];
customer.id = customerData.id;
// Update missing fields
const dataToUpdate = {};
const fieldsToCheck = ['name', 'phone', 'email', 'address', 'shipping'];
fieldsToCheck.forEach(field => {
if (!customerData[field]) {
if (field === 'phone') {
dataToUpdate[field] = phoneNumber;
} else {
dataToUpdate[field] = paymentMethodDetails[field];
}
}
});
await stripe.customers.update(customer.id, dataToUpdate, {
stripeAccount: stripeFunnel.stripe_user_id,
});
} -
Check for Duplicate Payment Method
// Retrieve token to get card fingerprint
const requestTokenDetails = await stripe.tokens.retrieve(token);
const fingerprint = requestTokenDetails?.card?.fingerprint;
// List existing payment methods
const paymentMethodList = await stripe.paymentMethods.list(
{
customer: customer.id,
type: 'card',
},
{ stripeAccount: stripeFunnel.stripe_user_id },
);
// Check if card already exists
const alreadyExist = paymentMethodList.data.find(c => c?.card?.fingerprint === fingerprint);
if (alreadyExist) {
// Return existing payment method
return {
id: alreadyExist.id,
// ... format existing payment method
};
} -
Create New Payment Method
const newMethod = await stripe.customers.createSource(
customer.id,
{ source: token },
{ stripeAccount: stripeFunnel.stripe_user_id },
);
return newMethod;
Key Business Rules:
- Twilio Validation: Validates and formats phone numbers
- Cache Check: Checks Twilio lookup cache before API call
- JWT Token: Generates temporary token for external API
- Customer Reuse: Finds existing customer by email
- Field Updates: Updates missing customer fields on existing customers
- Duplicate Detection: Uses card fingerprint to detect duplicates
- Company Metadata: Stores company_name in metadata if provided
Error Handling:
// Twilio lookup errors (non-fatal)
try {
// ... Twilio lookup
} catch (err) {
logger.error({
initiator: 'public/billing/addpaymentmethod',
message: `Error occured while twilio lookup`,
error: err,
});
// Continue without validated phone
}
// Customer update errors (non-fatal)
try {
await stripe.customers.update(...);
} catch (err) {
logger.error({
initiator: 'public/billing/addpaymentmethod',
message: `Error occured while updating customer`,
error: err,
});
}
Example Usage:
const paymentMethod = await addPaymentMethodToken({
paymentMethodDetails: {
email: 'customer@example.com',
name: 'John Doe',
company_name: 'Acme Corp',
phone: '+15551234567',
address: {
line1: '123 Main St',
city: 'San Francisco',
state: 'CA',
postal_code: '94102',
country: 'United States'
},
token: 'tok_...' // From Stripe.js
},
stripeFunnel: { stripe_user_id: 'acct_...', ... },
accountId: new mongoose.Types.ObjectId('...')
});
// Returns payment method (new or existing)
console.log(`Card ending in ${paymentMethod.last4}`);
Side Effects:
- Creates/Updates Stripe Customer: May create or update customer in Stripe
- Creates Payment Method: Adds new payment method if not duplicate
- Queries Twilio API: Validates phone number (if not cached)
addCheckout({ preview, checkoutDetails, stripeFunnel, stripeConfigs })โ
Purpose: Process checkout for one-time and/or recurring payments with contact creation and analytics
Parameters:
preview(Boolean) - If true, return preview without chargingcheckoutDetails(Object) - Checkout configurationcustomer(String) - Stripe customer IDdescription(String) - Invoice descriptionmetadata(Object) - Custom metadata (funnel_id, step_id, etc.)collection_method(String) - 'charge_automatically' or 'send_invoice'default_payment_method(String, optional) - Payment method IDreference_id(String, optional) - Unique checkout reference (generates UUID if missing)one_time(Object, optional) - One-time payment detailsamount(Number) - Amount in centscurrency(String) - Currency codedue_date(Number, optional) - Days until due (for send_invoice)products(Array) - Invoice itemsprice(String) - Stripe price IDquantity(Number) - Quantity
recurring(Object, optional) - Recurring payment detailsitems(Array) - Subscription itemsprice(String) - Stripe price IDquantity(Number) - Quantity
days_until_due(Number, optional) - Days until due (for send_invoice)default_source(String, optional) - Payment source ID
coupon(String, optional) - Promotion codeanalytics(Object, optional) - Analytics datautms(Object) - UTM parameters
stripeFunnel(Object) - Funnel's Stripe configurationstripeConfigs(Object) - Account-level Stripe configurationaccount_id(ObjectId) - Account ID
Returns:
// Preview mode
{
account_name: String,
amount_due: Number, // Cents
amount_paid: Number,
amount_remaining: Number,
customer: String,
customer_address: Object,
customer_email: String,
customer_name: String,
customer_phone: String,
products: [
{
id: String,
amount: Number,
amount_excluding_tax: Number,
currency: String,
description: String,
discount_amounts: Array,
price: Object,
quantity: Number
}
],
subtotal: Number,
subtotal_excluding_tax: Number,
total: Number,
total_discount_amounts: Array
}
// Actual checkout
[
{
// Invoice data
id: String,
charge: String,
customer: String,
status: String, // 'paid', 'open', etc.
amount_due: Number, // Dollars
amount_paid: Number,
subtotal: Number,
total: Number,
lines: { data: [...] }
}
]
Business Logic Flow:
-
Validate and Process Coupon
let couponId;
if (coupon) {
const promotionCodes = [];
const filter = { active: true, code: coupon, limit: 1 };
for await (const promo of stripe.promotionCodes.list(filter, {
stripeAccount: stripeFunnel.stripe_user_id,
})) {
promotionCodes.push(promo);
}
if (!promotionCodes.length || promotionCodes?.[0]?.code !== coupon) {
throw notFound('Provided coupon code does not exist.');
}
couponId = promotionCodes?.[0]?.coupon?.id;
} -
Preview Mode (if preview === true)
const { products } = one_time || {};
const { items } = recurring || {};
const options = {
customer,
discounts: [{ coupon: couponId }],
};
if (one_time) {
options.invoice_items = products;
}
if (recurring) {
options.subscription_items = items.map(item => ({
price: item.price,
quantity: item.quantity,
}));
}
const response = await stripe.invoices.retrieveUpcoming(options, {
stripeAccount: stripeFunnel.stripe_user_id,
});
// Clean and return preview data
return {
account_name: response.account_name,
amount_due: response.amount_due,
// ... other preview fields
products: response.lines?.data?.map(lineItem => ({
// ... cleaned line item data
})),
}; -
Create/Update Contact (if not preview)
if (customer) {
// Get product IDs from prices
let price_ids = [];
if (one_time?.products?.length) {
one_time.products.forEach(product => price_ids.push(product?.price));
}
if (recurring?.items?.length) {
recurring.items.forEach(product => price_ids.push(product?.price));
}
// Get Stripe customer data
const stripeCustomerData = await stripe.customers.retrieve(customer, {
stripeAccount: stripeFunnel.stripe_user_id,
});
// Find existing contact
const options = {
type: 'business',
parent_account: stripeConfigs.account_id,
$or: [],
};
const { email, phone, metadata: custMeta } = stripeCustomerData;
if (email) options.$or.push({ email });
if (phone) options.$or.push({ phone });
const contact = await Contact.findOne(options);
if (!contact) {
// Create new contact
const contactData = {
name: stripeCustomerData.name,
email: stripeCustomerData.email,
phone: stripeCustomerData.phone,
stripe_customer_id: stripeCustomerData.id,
type: 'business',
source: 'Funnels',
address: {
street: stripeCustomerData.address?.line1,
unit: stripeCustomerData.address?.line2,
city: stripeCustomerData.address?.city,
postal_code: stripeCustomerData.address?.postal_code,
state_province: stripeCustomerData.address?.state,
country: stripeCustomerData.address?.country,
},
metadata: {
funnels: [
{
funnel_id: new mongoose.Types.ObjectId(metadata?.funnel_id),
step_id: new mongoose.Types.ObjectId(metadata?.step_id),
type: 'PURCHASE',
product_ids: [...new Set(product_ids)],
},
],
},
parent_account: stripeConfigs.account_id,
};
if (custMeta) {
contactData.metadata.company_name = custMeta.company_name;
}
await new Contact(contactData).save();
} else {
// Update existing contact if needed
let isValidCustomer = false;
if (contact?.stripe_customer_id) {
try {
await stripe.customers.retrieve(contact.stripe_customer_id, {
stripeAccount: stripeFunnel.stripe_user_id,
});
isValidCustomer = true;
} catch (error) {
// Invalid Stripe customer
}
}
if (!isValidCustomer) {
// Create new Stripe customer and update contact
const newCustomer = await stripe.customers.create(
{
name: contact.name,
phone: contact.phone,
email: contact.email,
address: contact.address,
},
{
stripeAccount: stripeFunnel.stripe_user_id,
},
);
contact.stripe_customer_id = newCustomer.id;
await contact.save();
}
}
} -
Process One-Time Payment
if (one_time && !recurring) {
let { amount, currency, due_date, products } = one_time;
// Calculate due date
const startDate = moment();
due_date = due_date && moment(startDate).add(due_date, 'days').unix();
// Create invoice
let invoiceData = await stripe.invoices.create(
{
customer,
description,
metadata,
collection_method,
discounts: [{ coupon: couponId }],
due_date,
default_payment_method,
pending_invoice_items_behavior: 'exclude',
},
{ stripeAccount: stripeFunnel.stripe_user_id },
);
// Add invoice items
if (products?.length) {
await Promise.all(
products.map(async product => {
if (product.price) {
await stripe.invoiceItems.create({
invoice: invoiceData.id,
customer,
price: product.price,
metadata,
amount,
currency,
quantity: product.quantity,
});
}
}),
);
// Finalize invoice
invoiceData = await stripe.invoices.finalizeInvoice(invoiceData.id, {
stripeAccount: stripeFunnel.stripe_user_id,
});
// Send or pay invoice
if (collection_method === 'send_invoice') {
await stripe.invoices.sendInvoice(invoiceData.id, {
stripeAccount: stripeFunnel.stripe_user_id,
});
}
if (collection_method === 'charge_automatically') {
invoiceData = await stripe.invoices.pay(invoiceData.id, {
stripeAccount: stripeFunnel.stripe_user_id,
});
}
// Convert cents to dollars
for (let [key, value] of Object.entries(invoiceData)) {
if (AMOUNT_FIELDS.includes(key)) {
invoiceData[key] = value / 100;
}
}
checkoutData.push(invoiceData);
}
} -
Process Recurring Payment
if (recurring) {
const { items, days_until_due, default_source } = recurring;
let { products } = one_time || {};
const subscriptionData = await processRecurring({
customer,
metadata,
description,
items,
collectionMethod: collection_method,
daysUntilDue: days_until_due,
defaultSource: default_source,
defaultPaymentMethod: default_payment_method,
couponId,
products, // One-time items added to first invoice
amountFields: AMOUNT_FIELDS,
stripe,
stripeFunnel,
});
checkoutData.push(subscriptionData.latest_invoice);
} -
Check Charge Status
const chargeData = await checkChargeStatus({
id: checkoutData?.[0]?.charge,
stripeFunnel,
});
// Polls Stripe charge status up to 5 times (5 second intervals) -
Save Analytics
const invoiceData = checkoutData?.[0];
if (invoiceData.livemode) {
// Build items data with discount calculations
const itemsData = [];
let discountAmount = 0;
const discount = !!invoiceData?.discount?.id;
const { percent_off, amount_off } = invoiceData?.discount?.coupon || {};
await Promise.all(
invoiceData?.lines?.data?.map(async data => {
const unitAmount = (data.price?.unit_amount / 100).toFixed(2);
if (percent_off) {
discountAmount = unitAmount - (unitAmount * percent_off) / 100;
}
if (amount_off) {
discountAmount = unitAmount - amount_off;
}
const productData = await stripe.products.retrieve(data.price?.product, {
stripeAccount: stripeFunnel.stripe_user_id,
});
itemsData.push({
price_id: data.price.id,
amount: discount ? discountAmount : unitAmount,
quantity: data.quantity,
product_id: data.price?.product,
product_name: productData?.name,
currency: data.price?.currency,
});
}),
);
const dataToSave = {
account_id: stripeConfigs.account_id,
funnel_id: metadata?.funnel_id,
step_id: metadata?.step_id,
items: itemsData,
customer_id: customer,
page_id: metadata?.step_id,
site_id: metadata?.funnel_id,
invoice_id: invoiceData?.id,
invoice_status: invoiceData?.status,
charge_status: chargeData?.status,
payment_details: [
{
invoice_id: invoiceData?.id,
charge_id: invoiceData?.charge,
subscription_id: invoiceData.subscription,
},
],
timestamp: moment().unix(),
...analytics,
};
await FunnelsAnalytics.updateOne(
{
account_id: stripeConfigs.account_id,
funnel_id: metadata?.funnel_id,
step_id: metadata?.step_id,
invoice_id: invoiceData?.id,
},
dataToSave,
{ upsert: true },
);
} -
Return Checkout Data
return checkoutData;
Key Business Rules:
- Preview Mode: Returns upcoming invoice without charging
- Reference ID: Generates UUID if not provided
- Contact Auto-Creation: Creates business contact from Stripe customer
- Contact Validation: Checks Stripe customer ID validity
- One-Time + Recurring: Can process both in single checkout
- Coupon Validation: Validates promotion code before applying
- Collection Methods:
- charge_automatically: Charges immediately
- send_invoice: Sends email, payment due by date
- Amount Conversion: Stripe cents โ dollars for response
- Analytics: Only saves for live mode (not test mode)
- UTM Tracking: Merges UTM parameters into metadata
- Charge Polling: Waits up to 25 seconds for charge to succeed/fail
Helper Function - processRecurring:
const processRecurring = async ({
customer,
metadata,
description,
items,
collectionMethod,
daysUntilDue,
defaultSource,
defaultPaymentMethod,
products,
couponId,
amountFields,
stripe,
stripeFunnel,
}) => {
let subscriptionData = await stripe.subscriptions.create(
{
customer,
metadata,
description,
items,
collection_method: collectionMethod,
days_until_due: daysUntilDue,
default_source: defaultSource,
default_payment_method: defaultPaymentMethod,
add_invoice_items: products, // One-time items added to first invoice
...(couponId ? { discounts: [{ coupon: couponId }] } : {}),
expand: ['latest_invoice', 'latest_invoice.customer'],
},
{ stripeAccount: stripeFunnel.stripe_user_id },
);
if (collectionMethod === 'send_invoice') {
await Promise.all([
stripe.invoices.finalizeInvoice(subscriptionData.latest_invoice, {
stripeAccount: stripeFunnel.stripe_user_id,
}),
stripe.invoices.sendInvoice(subscriptionData.latest_invoice, {
stripeAccount: stripeFunnel.stripe_user_id,
}),
]);
}
// Convert cents to dollars
for (let [key, value] of Object.entries(subscriptionData.latest_invoice)) {
if (amountFields.includes(key)) {
subscriptionData.latest_invoice[key] = value / 100;
}
}
return subscriptionData;
};
Helper Function - checkChargeStatus:
const checkChargeStatus = async ({ id, stripeFunnel }) => {
let attempts = 0;
const maxAttempts = 5;
const interval = 5000; // 5 seconds
let chargeData;
const stripe = stripeConfig(stripeFunnel);
while (attempts < maxAttempts) {
chargeData = await stripe.charges.retrieve(id, {
stripeAccount: stripeFunnel.stripe_user_id,
});
if (chargeData.status === 'succeeded') {
return chargeData;
}
if (chargeData.status === 'failed') {
throw paymentRequired(
chargeData?.failure_message || 'Payment failed',
chargeData?.failure_code,
);
}
attempts++;
if (attempts < maxAttempts) {
await sleep(interval);
}
}
return chargeData; // Return last status after 5 attempts
};
Error Handling:
// Contact creation errors (non-fatal)
try {
// ... contact creation
} catch (err) {
logger.error({
initiator: 'public/billing/checkout',
message: `Error occured while creating new contact`,
error: err,
});
}
// Analytics errors (non-fatal)
try {
// ... analytics save
} catch (err) {
logger.error({
initiator: 'public/billing/checkout',
message: `Error occured while creating new checkout analytics`,
error: err,
});
}
// Main errors (fatal)
catch (error) {
if (error.type === 'stripeConnectionError') {
return Promise.reject(
custom(
'An error occurred with our connection to Stripe due to timeout',
error.type,
error.statusCode
)
);
}
return Promise.reject(custom(error.message, error.type, error.statusCode));
}
Example Usage:
// Preview checkout
const preview = await addCheckout({
preview: true,
checkoutDetails: {
customer: 'cus_...',
one_time: {
products: [
{ price: 'price_...', quantity: 1 }
]
},
coupon: 'SUMMER25'
},
stripeFunnel: { stripe_user_id: 'acct_...', ... },
stripeConfigs: { account_id: new mongoose.Types.ObjectId('...') }
});
// Actual checkout
const result = await addCheckout({
preview: false,
checkoutDetails: {
customer: 'cus_...',
description: 'Product purchase',
metadata: {
funnel_id: '...',
step_id: '...',
},
collection_method: 'charge_automatically',
default_payment_method: 'pm_...',
one_time: {
amount: 9900,
currency: 'usd',
products: [
{ price: 'price_...', quantity: 1 }
]
},
analytics: {
utms: {
utm_source: 'google',
utm_campaign: 'summer'
}
}
},
stripeFunnel: { stripe_user_id: 'acct_...', ... },
stripeConfigs: { account_id: new mongoose.Types.ObjectId('...') }
});
Side Effects:
- Creates/Updates Contact: Adds business contact to CRM
- Creates Stripe Invoice: Generates invoice in Stripe
- Charges Customer: If collection_method = 'charge_automatically'
- Sends Email: If collection_method = 'send_invoice'
- Creates Subscription: If recurring payment
- Saves Analytics: Records funnel conversion data
Performance Considerations:
- Charge Polling: Can add up to 25 seconds to response time
- Contact Creation: Non-blocking (errors logged but don't fail checkout)
- Analytics: Non-blocking (errors logged but don't fail checkout)
- Multiple API Calls: Sequential Stripe calls can be slow
๐ Integration Pointsโ
External Servicesโ
Stripe API:
- Product and price management
- Customer management
- Payment method management
- Invoice generation
- Subscription creation
- Coupon/promotion code validation
- Charge status tracking
Twilio API (via internal API):
- Phone number lookup and validation
- Country code detection
- Number formatting
Internal Servicesโ
CRM Contacts:
- Auto-creates business contacts from Stripe customers
- Associates funnel purchases with contacts
- Validates Stripe customer IDs
Funnel Analytics:
- Records checkout events
- Tracks product purchases
- Calculates discount amounts
- Links to invoice/charge/subscription IDs
Frontend Integrationโ
Funnel Checkout Page:
// 1. Get Stripe publishable key
const { publicKey } = await fetch('/public/billing/public-key');
// 2. Initialize Stripe.js
const stripe = Stripe(publicKey);
// 3. Collect payment details
const cardElement = stripe.elements().create('card');
// 4. Create payment method token
const { token } = await stripe.createToken(cardElement);
// 5. Add payment method
await fetch('/public/billing/payment-method', {
method: 'POST',
body: JSON.stringify({
email, name, phone, address, token
})
});
// 6. Preview checkout
const preview = await fetch('/public/billing/checkout', {
method: 'POST',
body: JSON.stringify({
preview: true,
customer: customerId,
one_time: { products: [...] },
coupon: 'SUMMER25'
})
});
// 7. Complete checkout
const result = await fetch('/public/billing/checkout', {
method: 'POST',
body: JSON.stringify({
preview: false,
customer: customerId,
metadata: { funnel_id, step_id },
collection_method: 'charge_automatically',
one_time: { products: [...] },
analytics: { utms: {...} }
})
});
๐งช Edge Cases & Special Handlingโ
Duplicate Payment Methodsโ
Card Fingerprint Detection:
const fingerprint = requestTokenDetails?.card?.fingerprint;
const alreadyExist = paymentMethodList.data.find(c => c?.card?.fingerprint === fingerprint);
if (alreadyExist) {
// Return existing payment method instead of creating duplicate
return alreadyExist;
}
Invalid Stripe Customer IDโ
Contact with Bad Customer ID:
try {
await stripe.customers.retrieve(contact.stripe_customer_id);
isValidCustomer = true;
} catch (error) {
// Customer ID invalid, create new Stripe customer
const newCustomer = await stripe.customers.create({...});
contact.stripe_customer_id = newCustomer.id;
await contact.save();
}
Coupon Code Validationโ
Invalid Coupon:
if (!promotionCodes.length || promotionCodes?.[0]?.code !== coupon) {
throw notFound('Provided coupon code does not exist.');
}
Charge Status Pollingโ
Pending Charges:
while (attempts < maxAttempts) {
chargeData = await stripe.charges.retrieve(id);
if (chargeData.status === 'succeeded') return chargeData;
if (chargeData.status === 'failed') throw paymentRequired(...);
// Still pending, wait and retry
await sleep(5000);
attempts++;
}
// Return last status after 5 attempts
return chargeData;
Test vs Live Modeโ
Analytics Only in Live Mode:
if (invoiceData.livemode) {
// Save analytics
await FunnelsAnalytics.updateOne(...);
}
// Skip analytics for test mode
Missing Reference IDโ
Auto-Generate UUID:
if (!reference_id) {
reference_id = uuidv4();
}
metadata.reference_id = reference_id;
One-Time + Recurring Comboโ
Add One-Time Items to Subscription:
if (recurring) {
const subscriptionData = await stripe.subscriptions.create({
// ... subscription config
add_invoice_items: products, // One-time items added to first invoice
});
}
โ ๏ธ Important Notesโ
-
Public API: This service is publicly accessible (no authentication) for funnel checkouts. Security relies on Stripe's token/customer system.
-
Twilio Phone Validation: Phone lookup is non-fatal. If Twilio fails, checkout continues with unvalidated phone number.
-
Contact Auto-Creation: Creates business contacts automatically from Stripe customers. Matches by email OR phone to avoid duplicates.
-
Amount Conversion: Stripe API uses cents, service returns dollars. Always divide by 100 for display.
-
Charge Polling: Waits up to 25 seconds (5 attempts ร 5 seconds) for charge status. Can impact API response time.
-
Preview Mode: Returns upcoming invoice calculation without creating actual invoices or charges. Use for displaying totals before checkout.
-
Analytics Tracking: Only saves analytics for live mode transactions. Test mode checkouts not recorded.
-
Coupon Validation: Validates promotion code exists and is active before applying. Throws 404 if invalid.
-
Stripe Connection Errors: Handles timeout errors with specific error message. Stripe has 80-second timeout on API calls.
-
Non-Blocking Errors: Contact creation, customer updates, and analytics saves are non-blocking. Errors logged but don't fail checkout.
-
Collection Methods:
- charge_automatically: Charges default payment method immediately
- send_invoice: Sends email invoice, customer pays manually by due date
-
Metadata Storage: Stores funnel_id, step_id, and UTM parameters in Stripe metadata and analytics for attribution tracking.
๐ Related Documentationโ
- Funnels Module (link removed - file does not exist) - Funnel management and checkout pages
- CRM Contacts - Auto-created business contacts
- Funnel Analytics Collection (link removed - file does not exist) - Checkout tracking
- Twilio Integration (link removed - file does not exist) - Phone number validation
- Stripe Configuration (link removed - file does not exist) - Stripe client initialization