fix: default payment method

This commit is contained in:
John Yeo
2026-03-05 09:25:02 +00:00
parent 687ea724ad
commit 6cd83f787d
8 changed files with 237 additions and 51 deletions

View File

@@ -14,6 +14,10 @@
"planetscale": {
"type": "remote",
"url": "https://mcp.pscale.dev/mcp/planetscale"
},
"axiom": {
"type": "remote",
"url": "https://mcp.axiom.co/mcp"
}
},

View File

@@ -0,0 +1,89 @@
# ALB Lambda Logging Fix
**Date:** 2026-03-04
**Status:** ✅ Fixed
## Problem
Express logs contain `req.id` (AWS ALB trace IDs) that don't exist in the `alb` Axiom dataset. Investigation showed ~40-60% of ALB logs for high-traffic orgs (e.g., `0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx`) were missing.
## Root Cause
The Lambda functions processing ALB logs (S3 → Lambda → Axiom) were **timing out and running out of memory** on large log files.
### Lambda Config
| Setting | Before | After |
|---------|--------|-------|
| Memory | 256 MB | 1024 MB |
| Timeout | 60 sec | 180 sec |
### Evidence
- CloudWatch showed ~100% error rate (72 invocations, 72 errors per hour)
- Errors: `Status: timeout` and `Runtime.OutOfMemory`
- Small files (164KB) processed successfully (~2000 logs in 3s)
- Large files (6-7MB compressed) failed consistently
- Same files retried multiple times before being abandoned
## Fix Applied
Lambda configuration updated via AWS Console:
- `alb-listener-us-east-2`: Memory 1024MB, Timeout 180s
- `alb-listener-us-west-2`: Check if same fix needed
## Verification (run after 24 hours)
### 1. Check Lambda errors are gone:
```bash
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda \
--metric-name Errors \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 \
--statistics Sum
```
### 2. Compare ALB vs Express trace IDs in Axiom:
```apl
// Sample express trace IDs for an org
['express']
| where ['context.org_id'] == '0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx'
| where isnotnull(['req.id']) and ['req.id'] startswith "Root="
| where ['_time'] > ago(1h)
| summarize count() by ['req.id']
| take 10
// Then verify each exists in ALB
['alb']
| where ['trace_id'] == "<trace_id_from_above>"
```
### 3. Check invocation success rate:
```bash
# Invocations
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda --metric-name Invocations \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 --statistics Sum
# Errors (should be 0 or near 0)
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda --metric-name Errors \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 --statistics Sum
```
## Related Resources
- S3 Buckets: `autumn-alb-us-east-2`, `autumn-alb-us-west-2`
- Lambdas: `alb-listener-us-east-2`, `alb-listener-us-west-2`
- ALBs: `fc-server-oyknwa-9b105z0` (us-east-2), `fc-server-ndcdwy-65bl04in` (us-west-2)
## Notes
- Backfilling missed logs is possible but tedious (manual Lambda re-invocation per S3 file)
- No DLQ was configured, so failed events were discarded after retries
- Consider adding a DLQ in future to catch failed processing attempts

View File

@@ -84,7 +84,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
});
if (lineItems.length > 0) {
const invoiceItems = await createStripeInvoiceItems({
await createStripeInvoiceItems({
ctx,
invoiceItems: lineItemsToCreateInvoiceItemsParams({
stripeCustomerId: eventContext.stripeCustomer.id,

View File

@@ -29,6 +29,17 @@ export const executeStripeSubscriptionOperation = async ({
? "default_incomplete"
: "allow_incomplete";
// If customer's invoice_settings.default_payment_method is null but we
// resolved a payment method from the customer's attached PMs, pass it
// explicitly so Stripe knows which PM to charge.
const customerHasDefaultPm =
billingContext.stripeCustomer.invoice_settings?.default_payment_method;
const fallbackPaymentMethodParams =
paymentMethod && !customerHasDefaultPm
? { default_payment_method: paymentMethod.id }
: {};
switch (subscriptionAction.type) {
case "update": {
let stripeSubscription = billingContext.stripeSubscription;
@@ -45,11 +56,17 @@ export const executeStripeSubscriptionOperation = async ({
);
}
const subscriptionHasDefaultPm =
stripeSubscription?.default_payment_method;
return await stripeClient.subscriptions.update(
subscriptionAction.stripeSubscriptionId,
{
...subscriptionAction.params,
...invoiceModeParams,
...(subscriptionHasDefaultPm
? {}
: fallbackPaymentMethodParams),
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
},
@@ -59,6 +76,7 @@ export const executeStripeSubscriptionOperation = async ({
return await stripeClient.subscriptions.create({
...subscriptionAction.params,
...invoiceModeParams,
...fallbackPaymentMethodParams,
billing_mode: { type: "flexible" },

View File

@@ -94,7 +94,9 @@ export const customerProductToArrearLineItems = ({
direction: "charge",
billingTiming: "in_arrear",
now: billingContext.currentEpochMs,
currency: orgToCurrency({ org: ctx.org }),
currency:
billingContext.stripeCustomer.currency ??
orgToCurrency({ org: ctx.org }),
customerProduct,
customerPrice: cusPrice,
};

View File

@@ -1,10 +1,65 @@
import { test } from "bun:test";
import { createPercentCoupon } from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import { initScenario } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import type { AutumnInt } from "@/external/autumn/autumnCli";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
const testStripeCustomerWithGBP = async ({
ctx,
autumn,
}: {
ctx: AutumnContext;
autumn: AutumnInt;
}) => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
// 1. Create Stripe customer
const stripeCus = await stripeCli.customers.create({
email: "test-gbp@example.com",
name: "GBP Test Customer",
});
const paymentMethod = await stripeCli.paymentMethods.create({
type: "card",
card: {
token: "tok_visa",
},
});
await stripeCli.paymentMethods.attach(paymentMethod.id, {
customer: stripeCus.id,
});
const subscription = await stripeCli.subscriptions.create({
customer: stripeCus.id,
items: [
{
price_data: {
currency: "gbp",
unit_amount: 1000,
recurring: {
interval: "month",
interval_count: 1,
},
product: "prod_U5XwDFBQB4TQJ7",
},
quantity: 1,
},
],
default_payment_method: paymentMethod.id,
});
console.log("Subscription created", subscription);
// 2. Create Autumn customer with stripe_id
const autumnCus = await autumn.customers.create({
id: "gbp-test-customer",
name: "GBP Test Customer",
email: "test-gbp@example.com",
stripe_id: stripeCus.id,
});
console.log("Created:", { stripeCus: stripeCus.id, autumnCus });
};
/**
* Scenario:
@@ -21,54 +76,14 @@ import { createStripeCli } from "@/external/connect/createStripeCli";
test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 3: upgrade carries over discount when coupon is deleted")}`, async () => {
const customerId = "temp";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
// customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
// s.customer({ paymentMethod: "success" }),
// s.products({ list: [pro, premium] }),
],
actions: [],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 1,
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
discounts: [
{
reward_id: coupon.id,
},
],
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
await testStripeCustomerWithGBP({ ctx, autumn: autumnV1 });
});

View File

@@ -0,0 +1,58 @@
/**
* Attach Edge Case - Payment method exists but invoice_settings.default_payment_method is null
*
* Scenario: Customer has a payment method on file (card attached), but the
* Stripe customer's invoice_settings.default_payment_method has been cleared.
* Verifies that billing.attach still succeeds (Stripe can still charge the customer).
*/
import { test } from "bun:test";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { expectCustomerProductCorrect } from "../../utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "../../utils/expectStripeSubCorrect";
test.concurrent(`${chalk.yellowBright("pm-edge-case 1: attach succeeds when invoice_settings.default_payment_method is null")}`, async () => {
const customerId = "attach-pm-edge-null-default";
const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1, ctx, customer } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const stripeCustomerId = customer.processor?.id;
if (!stripeCustomerId) throw new Error("No stripe customer id");
await ctx.stripeCli.customers.update(stripeCustomerId, {
invoice_settings: {
default_payment_method: "" as string,
},
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
});
const customerAfter = await autumnV1.customers.get(customerId);
await expectCustomerProductCorrect({
customerId,
customer: customerAfter,
productId: pro.id,
state: "active",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -10,9 +10,9 @@ import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";