DEV Community

knot crochet
knot crochet

Posted on Originally published at autonnel.com

Stop Hardcoding Payment Providers Into Your Checkout Analytics

Here is a code smell that took me two payment providers to see clearly. My funnel analytics page had a column called stripePayment. It also had paypalPayment, paypalCcPayment, and stripeDeclined. The SQL that filled those columns named the event strings inline. The order detail page had a switch that turned a payment method into a badge color. The settings form that collects API keys had a hand-written <input> per provider per field.

Adding a third provider meant touching five files that have nothing to do with payments. Worse, it meant a new contributor had to know those five files existed. That's not a refactor you postpone. That's a design error you fix before it compounds.

The fix is not interesting in the abstract - it's a registry. What's interesting is what belongs in the registration, because my first attempt put too little in it.

What a provider actually owns

export interface PaymentProviderRegistration {
  paymentMethod: string;
  dbProvider: string;
  eventTypes: PaymentEventTypes;
  display: PaymentDisplayInfo;
  displayName: string;
  formFields: PaymentFormFieldSpec[];
  details: Record<string, PaymentDetailInfo>;
  statsColumns: StatsColumnDef[];
  getPublicConfig?: (credentials: Record<string, any>, settings: Record<string, any>) => Record<string, any> | null;
}
Enter fullscreen mode Exit fullscreen mode

The first version of this had eventTypes and nothing else, and it didn't help much, because the analytics page still had to know which columns to draw. The version that actually removed provider names from unrelated modules is the one where the provider also declares its own analytics columns:

statsColumns: statColumns([
  ['click',   'PAYPAL_BUTTON_CLICK',  'paypalButtonClick',  'PayPal',    'PayPal Button Click'],
  ['click',   'PAYPAL_EXPRESS_CLICK', 'paypalExpressClick', 'PP Express','PayPal Express Button Click'],
  ['click',   'PAYPAL_CC_CLICK',      'paypalCcClick',      'PP CC',     'PayPal Credit Card Click'],
  ['success', 'PAYPAL_PAYMENT',       'paypalPayment',      'PayPal',    'PayPal Payment Success'],
  ['success', 'PAYPAL_CC_PAYMENT',    'paypalCcPayment',    'PP CC',     'PayPal Credit Card Payment Success'],
  ['error',   'PAYPAL_ERROR',         'paypalError',        'PP Error',  'PayPal Payment Error'],
]),
Enter fullscreen mode Exit fullscreen mode

and the analytics page asks the registry rather than a hardcoded list:

export function getAllStatsColumns(): StatsColumnDef[] {
  return flatMapEntries((reg) => reg.statsColumns);
}
Enter fullscreen mode Exit fullscreen mode

Now "add a provider" is one object in one file. The stats table grows a column. The settings screen grows a form. The order badge renders. Nothing in src/lib/stats contains the string "Stripe".

The detail that makes this non-obvious: a provider is not a payment method

This is the part I'd have gotten wrong if I'd designed it up front instead of after shipping.

PayPal is not one thing. A buyer can pay through the PayPal button, through PayPal Express, or by typing a card number into PayPal's embedded card fields. Commercially those are three different things: if your embedded card fields are broken, your PayPal revenue looks fine in aggregate and you lose money for a week without noticing.

So PAYPAL_PAYMENT and PAYPAL_CC_PAYMENT are separate success events, chosen at write time from metadata:

export function getSuccessEventType(paymentMethod: string, metadata?: Record<string, any>): string {
  const provider = getProviderByMethod(paymentMethod);
  if (!provider) return FALLBACK_SUCCESS;
  const { successEvents } = provider.eventTypes;
  const wantsCardEvent = paymentMethod === 'paypal' && Boolean(metadata?.isCardPayment);
  if (wantsCardEvent && successEvents.length > 1) return successEvents[1];
  return successEvents[0] ?? FALLBACK_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

I'm not going to pretend that's clean. paymentMethod === 'paypal' is a provider name inside the registry's generic resolver, which is exactly what the registry was supposed to eliminate. It's the honest state of the code: one provider has a sub-method distinction that the others don't, and I chose a three-line special case over a general "sub-method selector" abstraction that would have one implementation.

If a second provider ever needs it, the special case becomes a selectSuccessEvent(metadata) hook on the registration and this function loses the if. Until then, generalizing would be inventing a requirement.

Fall back, don't throw

Every one of these lookups takes a string that came from a client request or a database row written months ago by an older version of the code:

const FALLBACK_SUCCESS = 'PAYMENT_SUCCESS';
const FALLBACK_ERROR = 'PAYMENT_ERROR';

export function getPaymentDisplay(paymentMethod: string): PaymentDisplayInfo {
  const provider = getProviderByMethod(paymentMethod);
  if (provider) return provider.display;
  return { ...FALLBACK_DISPLAY, label: paymentMethod };
}
Enter fullscreen mode Exit fullscreen mode

An unknown method renders its own raw name in a grey badge. It does not throw. This matters more than it looks: the alternative is that deleting a provider from the registry makes every historical order that used it un-viewable, and you find that out in production, from a customer, during a refund.

Registration is a side effect, and that has a cost

for (const registration of [paypal, stripe]) {
  registerPaymentProvider(registration);
}
Enter fullscreen mode Exit fullscreen mode

A module-level loop mutating a module-level Map. It's the simplest thing that works, and it has a real failure mode: if some module reads getAllStatsColumns() before this file has been imported, it gets an empty array and the stats page silently renders zero columns. Import order becomes load-bearing.

I'd flag this as the weakest part of the design. The fix, if it ever bites, is an explicit initPaymentProviders() called from the composition root instead of an import side effect. I haven't done it because there's exactly one entry point today, and I'd rather have the known-shaped problem than a speculative fix.

When this is over-engineering

If you will only ever integrate one payment provider, skip all of it. The break-even isn't the second provider - it's the second provider whose event shape differs from the first. Two providers that both emit one click and one success event can live behind a plain enum forever.

I hit the break-even the moment PayPal needed three click events and Stripe needed one, because that's the point where every consumer of the data has to stop assuming a one-to-one mapping. That assumption was baked into four files. Registering it in one was cheaper than deleting it from four again later.

Top comments (0)