fix: default payment method
This commit is contained in:
@@ -14,6 +14,10 @@
|
||||
"planetscale": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.pscale.dev/mcp/planetscale"
|
||||
},
|
||||
"axiom": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.axiom.co/mcp"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
89
.opencode/plans/alb-lambda-logging-fix.md
Normal file
89
.opencode/plans/alb-lambda-logging-fix.md
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user