Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 086 / 100

Deposits and partial payments: best practices explained

Split a total over time.

Expona Admin11 min read

Deposits and partial payments: best practices explained

When customers need to split a large purchase across multiple transactions, handling deposits and partial payments becomes a critical workflow in your payment infrastructure. Getting this wrong can lead to lost revenue, compliance headaches, and a frustrating customer experience. This guide walks through the patterns we've used in production to charge a deposit upfront, safely store payment methods, maintain accurate ledgers, and automate reminders for final payments.

Charge a deposit now, balance later

The deposit model is straightforward in concept: collect a percentage or fixed amount immediately, then charge the remainder when a condition is met—typically delivery, service completion, or a specified date. The challenge is ensuring your system cleanly separates these charges and handles failure scenarios without double-charging or leaving your ledger confused.

Start by creating a dedicated data model that tracks the deposit separately from the full order total. When a customer places an order for $1,000 and you require a 30% deposit, you charge $300 now and note that $700 remains outstanding. This separation matters because:

  • You can query all orders with outstanding balances in one query
  • Refunds and disputes are easier to reason about if each charge is tagged
  • Tax and accounting systems can categorize deposits differently than final payments

In practice, create a PaymentIntent for the deposit amount, capture it immediately, and store a reference to the original order with its total and paid state. Here's a simplified approach:

typescript
interface Order {
  id: string;
  customerId: string;
  totalAmount: number;
  depositAmount: number;
  balanceAmount: number;
  depositPaid: boolean;
  depositChargeId: string | null;
  balanceChargeId: string | null;
  depositChargedAt: Date | null;
  balanceChargedAt: Date | null;
  status: 'pending_deposit' | 'deposit_paid' | 'completed' | 'cancelled';
}

async function chargeDeposit(orderId: string): Promise {
  const order = await db.getOrder(orderId);
  
  const paymentIntent = await stripe.paymentIntents.create({
    amount: order.depositAmount,
    currency: 'usd',
    customer: order.customerId,
    description: `Deposit for order ${orderId}`,
    metadata: { orderId, type: 'deposit' },
  });

  await stripe.paymentIntents.confirm(paymentIntent.id, {
    payment_method: order.savedPaymentMethodId,
    off_session: true,
  });

  await db.updateOrder(orderId, {
    depositPaid: true,
    depositChargeId: paymentIntent.id,
    depositChargedAt: new Date(),
    status: 'deposit_paid',
  });

  return paymentIntent.id;
}

By storing both the charge ID and the timestamp, you create an audit trail. If a customer disputes the deposit, you can immediately point to the exact charge and when it occurred. Keep the deposit and balance as separate line items in your database so your accounting team can reconcile without confusion.

You cannot charge the balance later without a stored payment method, so obtaining explicit customer consent upfront is non-negotiable—both legally and operationally. Stripe's off_session parameter tells Stripe that you'll charge this card later without the customer present, which requires prior authorization and affects your compliance obligations.

The consent flow typically looks like this: the customer enters their payment details, you display a clear message like "We'll charge the remaining $700 to this card on [delivery date]," and they explicitly accept before you save the card. Document this consent in your database.

typescript
interface PaymentMethodConsent {
  orderId: string;
  customerId: string;
  paymentMethodId: string;
  consentGivenAt: Date;
  chargeDescription: string;
  remainingBalance: number;
  scheduledChargeDate: Date;
  consentIpAddress: string;
  consentUserAgent: string;
}

async function savePaymentMethodWithConsent(
  orderId: string,
  customerId: string,
  paymentMethodId: string,
  chargeDate: Date
): Promise {
  const order = await db.getOrder(orderId);

  const consent: PaymentMethodConsent = {
    orderId,
    customerId,
    paymentMethodId,
    consentGivenAt: new Date(),
    chargeDescription: `Final payment for order ${orderId}`,
    remainingBalance: order.balanceAmount,
    scheduledChargeDate: chargeDate,
    consentIpAddress: getCurrentRequestIp(),
    consentUserAgent: getCurrentUserAgent(),
  };

  await db.saveConsent(consent);
  
  // Attach to Stripe customer for reference
  await stripe.customers.update(customerId, {
    metadata: {
      lastConsentDate: new Date().toISOString(),
      consentedOrderIds: [...(customer.metadata?.consentedOrderIds || []), orderId],
    },
  });
}

When storing the payment method, you have two choices: attach it to the Stripe customer object (recommended for recurring billing) or store the payment method ID directly against your order. We prefer attaching to the customer because it simplifies managing multiple orders and allows you to enforce rules like "don't allow more than three scheduled charges per customer per week."

Always request the minimum permissions needed. If you only need to charge the card once, don't request ongoing subscription rights. Be transparent about the charge amount and timing—vague language like "additional charges may apply" will trigger chargebacks and regulatory scrutiny.

Track outstanding balance in a ledger

A dedicated ledger table is your safety net. Rather than relying on calculated fields, maintain an authoritative record of every payment event associated with an order. This ledger becomes invaluable during disputes, reconciliation, and debugging.

Structure your ledger to record every financial event: deposit charged, balance charged, refund issued, adjustment for tax or discount, and failed attempts. Include the Stripe event ID so you can cross-reference with Stripe's logs.

typescript
interface PaymentLedgerEntry {
  id: string;
  orderId: string;
  customerId: string;
  type: 'deposit_charge' | 'balance_charge' | 'refund' | 'adjustment' | 'failed_attempt';
  amount: number;
  currency: string;
  stripeChargeId: string | null;
  stripeEventId: string | null;
  status: 'pending' | 'succeeded' | 'failed' | 'refunded';
  createdAt: Date;
  processedAt: Date | null;
  description: string;
  failureReason: string | null;
}

async function recordPaymentEvent(entry: Omit): Promise {
  const ledgerEntry: PaymentLedgerEntry = {
    ...entry,
    id: generateId(),
    createdAt: new Date(),
  };

  await db.insertLedgerEntry(ledgerEntry);

  // Update order totals
  const successfulEntries = await db.getLedgerEntriesByOrder(entry.orderId, ['succeeded']);
  const totalPaid = successfulEntries.reduce((sum, e) => sum + e.amount, 0);
  const order = await db.getOrder(entry.orderId);

  await db.updateOrder(entry.orderId, {
    totalPaid,
    outstandingBalance: order.totalAmount - totalPaid,
  });
}

When you charge the balance, you'll query this ledger to ensure no double-charges occurred. Before issuing a charge, verify that no successful charge or pending charge for that order exists within the last 24 hours. This guard prevents race conditions if your scheduled job runs twice.

Store the Stripe event ID alongside every ledger entry. When you receive a charge.succeeded or charge.failed webhook, update the corresponding ledger entry. This creates a two-way synchronization: your order state matches Stripe's state.

Remind before charging the remainder

Customers appreciate a heads-up before you charge their card for the balance. Send a reminder email 5–7 days before the scheduled date, and again 24 hours before. Include the exact amount, date, and a link to update their payment method if needed. This reduces failed charges and customer complaints.

Implement a scheduled job that runs daily to identify orders approaching their balance charge date:

typescript
async function sendBalanceReminderEmails(): Promise {
  const now = new Date();
  const fiveDaysFromNow = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000);
  const oneDayFromNow = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000);

  // First reminder: 5 days before
  const ordersForFirstReminder = await db.query(`
    SELECT o.id, o.customerId, o.balanceAmount, o.scheduledBalanceChargeDate
    FROM orders o
    WHERE o.status = 'deposit_paid'
      AND DATE(o.scheduledBalanceChargeDate) = DATE(?)
      AND NOT EXISTS (
        SELECT 1 FROM reminder_log r
        WHERE r.orderId = o.id AND r.reminderType = 'five_days'
          AND DATE(r.sentAt) = DATE(?)
      )
  `, [fiveDaysFromNow, now]);

  for (const order of ordersForFirstReminder) {
    await emailService.send({
      to: order.customer.email,
      template: 'balance_reminder_5days',
      data: {
        amount: order.balanceAmount,
        date: order.scheduledBalanceChargeDate,
        orderUrl: `https://app.example.com/orders/${order.id}`,
      },
    });

    await db.insertReminderLog({
      orderId: order.id,
      reminderType: 'five_days',
      sentAt: now,
    });
  }

  // Second reminder: 1 day before
  const ordersForSecondReminder = await db.query(`
    SELECT o.id, o.customerId, o.balanceAmount, o.scheduledBalanceChargeDate
    FROM orders o
    WHERE o.status = 'deposit_paid'
      AND DATE(o.scheduledBalanceChargeDate) = DATE(?)
      AND NOT EXISTS (
        SELECT 1 FROM reminder_log r
        WHERE r.orderId = o.id AND r.reminderType = 'one_day'
          AND DATE(r.sentAt) = DATE(?)
      )
  `, [oneDayFromNow, now]);

  for (const order of ordersForSecondReminder) {
    await emailService.send({
      to: order.customer.email,
      template: 'balance_reminder_1day',
      data: {
        amount: order.balanceAmount,
        date: order.scheduledBalanceChargeDate,
      },
    });

    await db.insertReminderLog({
      orderId: order.id,
      reminderType: 'one_day',
      sentAt: now,
    });
  }
}

After sending reminders, run your balance charge job. Wrap each charge in a try-catch, record failures in your ledger, and send a notification if the charge fails. Don't retry immediately; instead, schedule a retry for 48 hours later and send a follow-up email asking the customer to update their payment method.

typescript
async function chargeOutstandingBalances(): Promise {
  const now = new Date();

  const orders = await db.query(`
    SELECT * FROM orders
    WHERE status = 'deposit_paid'
      AND scheduledBalanceChargeDate <= ?
      AND NOT EXISTS (
        SELECT 1 FROM payment_ledger
        WHERE orderId = orders.id AND type = 'balance_charge' AND status = 'succeeded'
      )
  `, [now]);

  for (const order of orders) {
    try {
      const paymentIntent = await stripe.paymentIntents.create({
        amount: order.balanceAmount,
        currency: 'usd',
        customer: order.customerId,
        payment_method: order.savedPaymentMethodId,
        off_session: true,
        confirm: true,
        description: `Balance payment for order ${order.id}`,
        metadata: { orderId: order.id, type: 'balance' },
      });

      await recordPaymentEvent({
        orderId: order.id,
        customerId: order.customerId,
        type: 'balance_charge',
        amount: order.balanceAmount,
        currency: 'usd',
        stripeChargeId: paymentIntent.id,
        stripeEventId: null,
        status: 'succeeded',
        processedAt: new Date(),
        description: 'Balance charge succeeded',
        failureReason: null,
      });

      await db.updateOrder(order.id, { status: 'completed' });

      await emailService.send({
        to: order.customer.email,
        template: 'payment_completed',
        data: { amount: order.balanceAmount, orderUrl: `https://app.example.com/orders/${order.id}` },
      });
    } catch (error) {
      await recordPaymentEvent({
        orderId: order.id,
        customerId: order.customerId,
        type: 'failed_attempt',
        amount: order.balanceAmount,
        currency: 'usd',
        stripeChargeId: null,
        stripeEventId: null,
        status: 'failed',
        processedAt: new Date(),
        description: `Balance charge failed: ${error.message}`,
        failureReason: error.code || 'unknown',
      });

      await db.updateOrder(order.id, { status: 'balance_charge_failed' });

      await emailService.send({
        to: order.customer.email,
        template: 'payment_failed',
        data: {
          amount: order.balanceAmount,
          updatePaymentUrl: `https://app.example.com/orders/${order.id}/update-payment`,
        },
      });
    }
  }
}

Track reminder sends in a separate table to avoid spamming customers with duplicate emails. Use the order ID and reminder type as a compound key to ensure idempotency.

Common mistakes

Not storing the original order total separately. If you recalculate the balance as "total minus what we've charged," rounding errors or tax adjustments can lead to incorrect final charges. Always store the original deposit and balance amounts explicitly.

Forgetting to handle failed charges. If the balance charge fails, don't silently mark the order as complete. Notify the customer immediately and retry after 48 hours. Track failures in your ledger so your team can investigate patterns—for example, if 30% of charges fail on day 30, the issue may be card expiration or customer cancellations.

Not syncing with Stripe webhooks. If you charge the balance but never receive the charge.succeeded webhook, your ledger and Stripe's state will diverge. Always listen to and process payment webhooks, updating your ledger accordingly.

Charging without explicit consent documentation. Regulators expect you to prove that customers agreed to be charged off-session. Store the IP address, timestamp, and exact wording of the consent message. If a customer disputes the charge, you'll need this evidence.

Ignoring tax on deposits. In many jurisdictions, you owe sales tax on the deposit amount, not just the final balance. Work with your accounting or tax team to ensure deposits are taxed correctly at the time of charging.

Key takeaways

  • Separate the deposit and balance as distinct fields in your order model so you can query, refund, and report on them independently.
  • Obtain and document explicit customer consent before storing a payment method for off-session charging, including IP address and timestamp.
  • Maintain a dedicated payment ledger that records every financial event—deposits, balances, refunds, and failed attempts—with Stripe event IDs for reconciliation.
  • Send reminder emails 5–7 days and 1 day before you charge the balance, and track reminders in a separate table to prevent duplicates.
  • Wrap balance charges in comprehensive error handling, record failures in the ledger, and notify customers immediately so they can update their payment method.
  • Always sync your ledger state with Stripe webhooks to ensure your authoritative record matches Stripe's truth.
  • Test the full flow—deposit, reminder, balance charge, and failure scenarios—before going live to catch timing or rounding issues early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles