fix: no billing changes blanks out line items
This commit is contained in:
@@ -16,8 +16,6 @@ export const NEON_TEMPLATE_BRANCH = "dw-template";
|
||||
export const NEON_PARENT_BRANCH = "production";
|
||||
|
||||
export const EMULATE_PID_FILE = join(homedir(), ".autumn-emulate.pid");
|
||||
export const EMULATE_HEALTH_URL =
|
||||
"https://google.emulate.localhost/.well-known/openid-configuration";
|
||||
export const START_EMULATE_SH = join(SCRIPT_DIR, "../setup/start-emulate.sh");
|
||||
|
||||
export const ENV_LOCAL_TARGETS = [
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { sh, log } from "./shell.ts";
|
||||
import {
|
||||
EMULATE_PID_FILE,
|
||||
EMULATE_HEALTH_URL,
|
||||
START_EMULATE_SH,
|
||||
} from "../constants.ts";
|
||||
import { EMULATE_PID_FILE, START_EMULATE_SH } from "../constants.ts";
|
||||
import { portlessHttpsUrl } from "./ports.ts";
|
||||
import { log, sh } from "./shell.ts";
|
||||
|
||||
function emulateReachable(): boolean {
|
||||
const healthUrl = `${portlessHttpsUrl("google.emulate.localhost")}/.well-known/openid-configuration`;
|
||||
const res = sh("curl", [
|
||||
"-sf",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"--max-time",
|
||||
"1",
|
||||
EMULATE_HEALTH_URL,
|
||||
healthUrl,
|
||||
]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
ENV_LOCAL_DISABLED_SUFFIX,
|
||||
ENV_LOCAL_TARGETS,
|
||||
PROJECT_ROOT,
|
||||
} from "../constants.ts";
|
||||
import type { RegistryEntry } from "../types.ts";
|
||||
import {
|
||||
aliasesFor,
|
||||
dragonflyPortFor,
|
||||
elasticMqPortFor,
|
||||
portlessHttpsUrl,
|
||||
} from "./ports.ts";
|
||||
import { log } from "./shell.ts";
|
||||
import { forceSslVerifyFull } from "./url.ts";
|
||||
import { aliasesFor, dragonflyPortFor, elasticMqPortFor } from "./ports.ts";
|
||||
import { PROJECT_ROOT, ENV_LOCAL_TARGETS, ENV_LOCAL_DISABLED_SUFFIX } from "../constants.ts";
|
||||
import type { RegistryEntry } from "../types.ts";
|
||||
|
||||
// Simple KEY=VALUE parse (no quoting/multiline). Sufficient for .env.local
|
||||
// files we own end-to-end; preserves blank lines and comments untouched.
|
||||
export function parseEnvFile(contents: string): { keys: string[]; values: Record<string, string>; raw: string[] } {
|
||||
export function parseEnvFile(contents: string): {
|
||||
keys: string[];
|
||||
values: Record<string, string>;
|
||||
raw: string[];
|
||||
} {
|
||||
const raw = contents.split(/\r?\n/);
|
||||
const values: Record<string, string> = {};
|
||||
const keys: string[] = [];
|
||||
@@ -23,11 +42,14 @@ export function parseEnvFile(contents: string): { keys: string[]; values: Record
|
||||
return { keys, values, raw };
|
||||
}
|
||||
|
||||
export function mergeEnvFile(existing: string | null, managed: Record<string, string>): string {
|
||||
export function mergeEnvFile(
|
||||
existing: string | null,
|
||||
managed: Record<string, string>,
|
||||
): string {
|
||||
if (!existing) {
|
||||
return Object.entries(managed)
|
||||
return `${Object.entries(managed)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n") + "\n";
|
||||
.join("\n")}\n`;
|
||||
}
|
||||
const parsed = parseEnvFile(existing);
|
||||
const managedKeys = new Set(Object.keys(managed));
|
||||
@@ -49,7 +71,7 @@ export function mergeEnvFile(existing: string | null, managed: Record<string, st
|
||||
while (outLines.length > 0 && outLines[outLines.length - 1] === "") {
|
||||
outLines.pop();
|
||||
}
|
||||
return outLines.join("\n") + "\n";
|
||||
return `${outLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function writeEnvLocalFiles(entry: RegistryEntry): void {
|
||||
@@ -68,7 +90,7 @@ export function writeEnvLocalFiles(entry: RegistryEntry): void {
|
||||
DATABASE_CRITICAL_URL: dbUrl,
|
||||
BETTER_AUTH_URL: aliases.apiUrl,
|
||||
CLIENT_URL: aliases.viteUrl,
|
||||
EMULATE_GOOGLE_URL: "https://google.emulate.localhost",
|
||||
EMULATE_GOOGLE_URL: portlessHttpsUrl("google.emulate.localhost"),
|
||||
AUTUMN_TEST_BASE_URL: `http://localhost:${serverPort}`,
|
||||
AUTUMN_TEST_VITE_URL: aliases.viteUrl,
|
||||
STRIPE_WEBHOOK_SKIP_VERIFY: "true",
|
||||
@@ -129,7 +151,11 @@ export function removeEnvLocalFiles(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function disableEnvLocalFiles(): { moved: number; missing: number; alreadyDisabled: number } {
|
||||
export function disableEnvLocalFiles(): {
|
||||
moved: number;
|
||||
missing: number;
|
||||
alreadyDisabled: number;
|
||||
} {
|
||||
let moved = 0;
|
||||
let missing = 0;
|
||||
let alreadyDisabled = 0;
|
||||
@@ -150,7 +176,11 @@ export function disableEnvLocalFiles(): { moved: number; missing: number; alread
|
||||
return { moved, missing, alreadyDisabled };
|
||||
}
|
||||
|
||||
export function enableEnvLocalFiles(): { moved: number; missing: number; alreadyEnabled: number } {
|
||||
export function enableEnvLocalFiles(): {
|
||||
moved: number;
|
||||
missing: number;
|
||||
alreadyEnabled: number;
|
||||
} {
|
||||
let moved = 0;
|
||||
let missing = 0;
|
||||
let alreadyEnabled = 0;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { sh, log } from "./shell.ts";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { WorktreeAliases } from "../types.ts";
|
||||
import { log, sh } from "./shell.ts";
|
||||
|
||||
const PORTLESS_PROXY_PORT_FILE = join(homedir(), ".portless", "proxy.port");
|
||||
|
||||
export function dragonflyPortFor(worktreeNum: number): number {
|
||||
return 6379 + (worktreeNum - 1) * 100;
|
||||
@@ -18,17 +23,38 @@ export function aliasesFor(worktreeNum: number): WorktreeAliases {
|
||||
const viteHost = `wt${worktreeNum}.localhost`;
|
||||
return {
|
||||
apiHost,
|
||||
apiUrl: `https://${apiHost}`,
|
||||
apiUrl: portlessHttpsUrl(apiHost),
|
||||
viteHost,
|
||||
viteUrl: `https://${viteHost}`,
|
||||
viteUrl: portlessHttpsUrl(viteHost),
|
||||
};
|
||||
}
|
||||
|
||||
export function portlessHttpsUrl(host: string): string {
|
||||
const port = currentPortlessProxyPort();
|
||||
const suffix = port && port !== 443 ? `:${port}` : "";
|
||||
return `https://${host}${suffix}`;
|
||||
}
|
||||
|
||||
export function currentPortlessProxyPort(): number | undefined {
|
||||
const envPort = Number(process.env.PORTLESS_PORT);
|
||||
if (Number.isInteger(envPort) && envPort > 0) return envPort;
|
||||
if (!existsSync(PORTLESS_PROXY_PORT_FILE)) return undefined;
|
||||
|
||||
const filePort = Number(
|
||||
readFileSync(PORTLESS_PROXY_PORT_FILE, "utf-8").trim(),
|
||||
);
|
||||
if (Number.isInteger(filePort) && filePort > 0) return filePort;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function killOwnPorts(worktreeNum: number): void {
|
||||
const offset = (worktreeNum - 1) * 100;
|
||||
const ports = [8080 + offset, 3000 + offset, 3001 + offset];
|
||||
if (process.platform === "win32") return;
|
||||
const lsof = sh("lsof", ports.flatMap((p) => ["-ti", `:${p}`]));
|
||||
const lsof = sh(
|
||||
"lsof",
|
||||
ports.flatMap((p) => ["-ti", `:${p}`]),
|
||||
);
|
||||
const pids = lsof.stdout.split("\n").filter(Boolean);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { log, fatal } from "./shell.ts";
|
||||
import { registerPortlessAliases } from "./portless.ts";
|
||||
import { rewriteDbEnv } from "./url.ts";
|
||||
import { aliasesFor, killOwnPorts } from "./ports.ts";
|
||||
import { tmuxSessionName, spawnDevInTmux } from "./tmux.ts";
|
||||
import { join } from "node:path";
|
||||
import { PROJECT_ROOT } from "../constants.ts";
|
||||
import type { RegistryEntry } from "../types.ts";
|
||||
import { registerPortlessAliases } from "./portless.ts";
|
||||
import { portlessHttpsUrl } from "./ports.ts";
|
||||
import { fatal, log } from "./shell.ts";
|
||||
import { spawnDevInTmux, tmuxSessionName } from "./tmux.ts";
|
||||
import { rewriteDbEnv } from "./url.ts";
|
||||
|
||||
export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
||||
env: Record<string, string>;
|
||||
@@ -21,7 +21,7 @@ export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
||||
if (!databaseUrl) fatal("agent worktree missing databaseUrl");
|
||||
env = rewriteDbEnv(env, databaseUrl);
|
||||
if (!env.EMULATE_GOOGLE_URL) {
|
||||
env.EMULATE_GOOGLE_URL = "https://google.emulate.localhost";
|
||||
env.EMULATE_GOOGLE_URL = portlessHttpsUrl("google.emulate.localhost");
|
||||
}
|
||||
const portlessCa = join(homedir(), ".portless", "ca.pem");
|
||||
if (existsSync(portlessCa) && !env.NODE_EXTRA_CA_CERTS) {
|
||||
@@ -44,14 +44,18 @@ export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
||||
return { env, args };
|
||||
}
|
||||
|
||||
export function startDev(entry: RegistryEntry, opts?: { allowTmux?: boolean }): never {
|
||||
export function startDev(
|
||||
entry: RegistryEntry,
|
||||
opts?: { allowTmux?: boolean },
|
||||
): never {
|
||||
const { worktreeNum, branchName } = entry;
|
||||
const { env, args } = buildDevEnvAndArgs(entry);
|
||||
|
||||
// Agent worktrees (N > 1) in a non-TTY invocation: wrap in detached tmux
|
||||
// so the calling agent doesn't block. Canonical (N=1) stays inline always.
|
||||
// Node/Bun sets isTTY to true when stdout is a TTY and undefined otherwise.
|
||||
const useTmux = (opts?.allowTmux ?? true) && worktreeNum > 1 && !process.stdout.isTTY;
|
||||
const useTmux =
|
||||
(opts?.allowTmux ?? true) && worktreeNum > 1 && !process.stdout.isTTY;
|
||||
if (useTmux) {
|
||||
log(
|
||||
`starting dev in tmux (worktree=${worktreeNum}${branchName ? `, branch=${branchName}` : ""}, non-TTY)`,
|
||||
|
||||
@@ -8,13 +8,23 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SEED="$ROOT/emulate.config.yaml"
|
||||
LOG="$HOME/.autumn-emulate.log"
|
||||
PID_FILE="$HOME/.autumn-emulate.pid"
|
||||
PORTLESS_PORT_FILE="$HOME/.portless/proxy.port"
|
||||
EMULATE_URL="https://google.emulate.localhost"
|
||||
|
||||
PORTLESS_PROXY_PORT="${PORTLESS_PORT:-}"
|
||||
if [[ -z "$PORTLESS_PROXY_PORT" && -f "$PORTLESS_PORT_FILE" ]]; then
|
||||
PORTLESS_PROXY_PORT="$(cat "$PORTLESS_PORT_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "$PORTLESS_PROXY_PORT" && "$PORTLESS_PROXY_PORT" != "443" ]]; then
|
||||
EMULATE_URL="${EMULATE_URL}:${PORTLESS_PROXY_PORT}"
|
||||
fi
|
||||
|
||||
reachable() {
|
||||
curl -sf -o /dev/null --max-time 1 "https://google.emulate.localhost/.well-known/openid-configuration"
|
||||
curl -sf -o /dev/null --max-time 1 "${EMULATE_URL}/.well-known/openid-configuration"
|
||||
}
|
||||
|
||||
if reachable; then
|
||||
echo "[emulate] already reachable at https://google.emulate.localhost"
|
||||
echo "[emulate] already reachable at ${EMULATE_URL}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -54,7 +64,7 @@ disown
|
||||
# Block briefly until the emulator is actually serving so callers can race.
|
||||
for _ in $(seq 1 30); do
|
||||
if reachable; then
|
||||
echo "[emulate] ready at https://google.emulate.localhost (pid $(cat "$PID_FILE"))"
|
||||
echo "[emulate] ready at ${EMULATE_URL} (pid $(cat "$PID_FILE"))"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.3
|
||||
|
||||
@@ -31,6 +31,10 @@ export const finalizeLineItems = ({
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
customLineItems?: CustomLineItem[];
|
||||
}): LineItem[] => {
|
||||
if (billingContext.skipBillingChanges) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
billingContext.requestedProrationBehavior === "none" &&
|
||||
!billingContext.anchorResetRefund?.noPartialRefund
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
UpdateSubscriptionPreviewIntent,
|
||||
type UpdateSubscriptionV1ParamsInput,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
expectProductActive,
|
||||
@@ -268,3 +269,45 @@ test.concurrent(`${chalk.yellowBright("update-subscription preview: uncancel wit
|
||||
outgoing: [{ planId: pro.id }],
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: no_billing_changes previews showed immediate charges despite skipping Stripe.
|
||||
// Green: entitlement-only DB updates preview $0 due now and still show the plan change.
|
||||
test.concurrent(`${chalk.yellowBright("update-subscription preview: no_billing_changes custom entitlement has no immediate due")}`, async () => {
|
||||
const customerId = "update-sub-preview-no-billing-custom-ent";
|
||||
const pro = products.base({
|
||||
id: "pro-no-billing-preview",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const preview = await autumnV2_2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>({
|
||||
customer_id: customerId,
|
||||
plan_id: pro.id,
|
||||
no_billing_changes: true,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [itemsV2.monthlyMessages({ included: 250 })],
|
||||
},
|
||||
});
|
||||
|
||||
expect(preview.intent).toBe(UpdateSubscriptionPreviewIntent.UpdatePlan);
|
||||
expect(preview.line_items).toEqual([]);
|
||||
expect(preview.subtotal).toBe(0);
|
||||
expect(preview.total).toBe(0);
|
||||
expectPreviewChanges({
|
||||
preview,
|
||||
incoming: [{ planId: pro.id, effectiveAt: null }],
|
||||
outgoing: [{ planId: pro.id }],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user