Skip to main content

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 configuration
    • stripe_publishable_key (String) - Public Stripe key

Returns:

String; // Stripe publishable key

Business Logic Flow:

  1. 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 description
  • limit (Number) - Maximum products to return
  • lastProductId (String, optional) - For pagination (next page)
  • firstProductId (String, optional) - For pagination (previous page)
  • nextPage (String, optional) - Search pagination token
  • type (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:

  1. Normalize Type Parameter

    type = type === 'one-time' ? 'one_time' : type;
    • Stripe API uses 'one_time', frontend sends 'one-time'
  2. Initialize Stripe Client

    const stripe = stripeConfig(stripeFunnel);
  3. Determine Active Filter

    let active;
    if (status === 'archived') active = false;
    if (status === 'active') active = true;
    // undefined = all products
  4. 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
  5. 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,
    };
    }),
    );
  6. 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 up
  • startDate (Date, optional) - Filter payment methods created after
  • endDate (Date, optional) - Filter payment methods created before
  • firstPaymenthMethodId (String, optional) - Pagination cursor (previous page)
  • lastPaymenthMethodId (String, optional) - Pagination cursor (next page)
  • limit (Number) - Maximum payment methods to return
  • stripeFunnel (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:

  1. Initialize Stripe and Convert Dates

    const stripe = stripeConfig(stripeFunnel);

    if (startDate && endDate) {
    startDate = moment(startDate).unix();
    endDate = moment(endDate).unix();
    }
  2. 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
    }
  3. 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 },
    );
  4. 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,
    };
    });
  5. 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 information
    • email (String) - Customer email
    • name (String) - Customer name
    • company_name (String, optional) - Company name for metadata
    • phone (String) - Customer phone number
    • address (Object) - Billing address
    • shipping (Object, optional) - Shipping address
    • token (String) - Stripe token from Stripe.js
  • stripeFunnel (Object) - Funnel's Stripe configuration
  • accountId (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:

  1. 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
  2. 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,
    });
    }
  3. 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
    };
    }
  4. 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 charging
  • checkoutDetails (Object) - Checkout configuration
    • customer (String) - Stripe customer ID
    • description (String) - Invoice description
    • metadata (Object) - Custom metadata (funnel_id, step_id, etc.)
    • collection_method (String) - 'charge_automatically' or 'send_invoice'
    • default_payment_method (String, optional) - Payment method ID
    • reference_id (String, optional) - Unique checkout reference (generates UUID if missing)
    • one_time (Object, optional) - One-time payment details
      • amount (Number) - Amount in cents
      • currency (String) - Currency code
      • due_date (Number, optional) - Days until due (for send_invoice)
      • products (Array) - Invoice items
        • price (String) - Stripe price ID
        • quantity (Number) - Quantity
    • recurring (Object, optional) - Recurring payment details
      • items (Array) - Subscription items
        • price (String) - Stripe price ID
        • quantity (Number) - Quantity
      • days_until_due (Number, optional) - Days until due (for send_invoice)
      • default_source (String, optional) - Payment source ID
    • coupon (String, optional) - Promotion code
    • analytics (Object, optional) - Analytics data
      • utms (Object) - UTM parameters
  • stripeFunnel (Object) - Funnel's Stripe configuration
  • stripeConfigs (Object) - Account-level Stripe configuration
    • account_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:

  1. 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;
    }
  2. 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
    })),
    };
  3. 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();
    }
    }
    }
  4. 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);
    }
    }
  5. 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);
    }
  6. Check Charge Status

    const chargeData = await checkChargeStatus({
    id: checkoutData?.[0]?.charge,
    stripeFunnel,
    });
    // Polls Stripe charge status up to 5 times (5 second intervals)
  7. 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 },
    );
    }
  8. 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โ€‹

  1. Public API: This service is publicly accessible (no authentication) for funnel checkouts. Security relies on Stripe's token/customer system.

  2. Twilio Phone Validation: Phone lookup is non-fatal. If Twilio fails, checkout continues with unvalidated phone number.

  3. Contact Auto-Creation: Creates business contacts automatically from Stripe customers. Matches by email OR phone to avoid duplicates.

  4. Amount Conversion: Stripe API uses cents, service returns dollars. Always divide by 100 for display.

  5. Charge Polling: Waits up to 25 seconds (5 attempts ร— 5 seconds) for charge status. Can impact API response time.

  6. Preview Mode: Returns upcoming invoice calculation without creating actual invoices or charges. Use for displaying totals before checkout.

  7. Analytics Tracking: Only saves analytics for live mode transactions. Test mode checkouts not recorded.

  8. Coupon Validation: Validates promotion code exists and is active before applying. Throws 404 if invalid.

  9. Stripe Connection Errors: Handles timeout errors with specific error message. Stripe has 80-second timeout on API calls.

  10. Non-Blocking Errors: Contact creation, customer updates, and analytics saves are non-blocking. Errors logged but don't fail checkout.

  11. Collection Methods:

    • charge_automatically: Charges default payment method immediately
    • send_invoice: Sends email invoice, customer pays manually by due date
  12. Metadata Storage: Stores funnel_id, step_id, and UTM parameters in Stripe metadata and analytics for attribution tracking.

  • 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
๐Ÿ’ฌ

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:31 AM