Sync Autumn billing status to Loops and add billing backfill (#269)

This commit is contained in:
Ben Senescu 2026-06-14 17:19:26 -04:00 committed by GitHub
parent f8d5ffd285
commit dd429ec4f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2829 additions and 39 deletions

View File

@ -0,0 +1,14 @@
ALTER TABLE `billing_customer_status` ADD `paid_plan_status` text;--> statement-breakpoint
UPDATE `billing_customer_status`
SET `paid_plan_status` = (
SELECT json_extract(subscription.value, '$.status')
FROM json_each(`billing_customer_status`.`customer_json`, '$.subscriptions') AS subscription
WHERE json_extract(subscription.value, '$.planId') = 'base-plan'
ORDER BY CASE
WHEN json_extract(subscription.value, '$.status') = 'active' THEN 0
ELSE 1
END
LIMIT 1
)
WHERE `paid_plan_status` IS NULL
AND json_valid(`customer_json`);

File diff suppressed because it is too large Load Diff

View File

@ -162,6 +162,13 @@
"when": 1781107930529, "when": 1781107930529,
"tag": "0022_purple_hitman", "tag": "0022_purple_hitman",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1781467831570,
"tag": "0023_sad_cobalt_man",
"breakpoints": true
} }
] ]
} }

View File

@ -8,6 +8,7 @@ export const billingCustomerStatus = sqliteTable("billing_customer_status", {
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
isPaying: integer("is_paying", { mode: "boolean" }).notNull().default(false), isPaying: integer("is_paying", { mode: "boolean" }).notNull().default(false),
paidPlanId: text("paid_plan_id"), paidPlanId: text("paid_plan_id"),
paidPlanStatus: text("paid_plan_status"),
// Full Autumn customer payload — escape hatch for any field we don't flatten, // Full Autumn customer payload — escape hatch for any field we don't flatten,
// queryable via json_extract so we never have to widen this table. // queryable via json_extract so we never have to widen this table.
customerJson: text("customer_json").notNull(), customerJson: text("customer_json").notNull(),

View File

@ -13,6 +13,7 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
organizationId: "org_123", organizationId: "org_123",
isPaying: true, isPaying: true,
paidPlanId: AUTUMN_PAID_PLAN_ID, paidPlanId: AUTUMN_PAID_PLAN_ID,
paidPlanStatus: "active",
}); });
}); });
@ -39,6 +40,7 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
expect(snapshot.isPaying).toBe(false); expect(snapshot.isPaying).toBe(false);
expect(snapshot.paidPlanId).toBeNull(); expect(snapshot.paidPlanId).toBeNull();
expect(snapshot.paidPlanStatus).toBeNull();
}); });
it("records a scheduled (not-yet-active) paid plan as not paying", () => { it("records a scheduled (not-yet-active) paid plan as not paying", () => {
@ -51,6 +53,7 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
organizationId: "org_456", organizationId: "org_456",
isPaying: false, isPaying: false,
paidPlanId: AUTUMN_PAID_PLAN_ID, paidPlanId: AUTUMN_PAID_PLAN_ID,
paidPlanStatus: "scheduled",
}); });
}); });
@ -65,5 +68,6 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
expect(snapshot.isPaying).toBe(true); expect(snapshot.isPaying).toBe(true);
expect(snapshot.paidPlanId).toBe(AUTUMN_PAID_PLAN_ID); expect(snapshot.paidPlanId).toBe(AUTUMN_PAID_PLAN_ID);
expect(snapshot.paidPlanStatus).toBe("active");
}); });
}); });

View File

@ -18,6 +18,7 @@ export type BillingCustomerStatusSnapshot = {
organizationId: string; organizationId: string;
isPaying: boolean; isPaying: boolean;
paidPlanId: string | null; paidPlanId: string | null;
paidPlanStatus: string | null;
customerJson: string; customerJson: string;
syncedAt: string; syncedAt: string;
}; };
@ -36,6 +37,7 @@ export function deriveBillingCustomerStatusSnapshot(
organizationId, organizationId,
isPaying: subscription?.status === "active", isPaying: subscription?.status === "active",
paidPlanId: subscription?.planId ?? null, paidPlanId: subscription?.planId ?? null,
paidPlanStatus: subscription?.status ?? null,
// Full payload kept verbatim — query rarely-used fields via json_extract. // Full payload kept verbatim — query rarely-used fields via json_extract.
customerJson: JSON.stringify(customer), customerJson: JSON.stringify(customer),
syncedAt: new Date().toISOString(), syncedAt: new Date().toISOString(),

View File

@ -6,6 +6,7 @@ import {
deriveBillingCustomerStatusSnapshot, deriveBillingCustomerStatusSnapshot,
type BillingCustomerStatusSnapshot, type BillingCustomerStatusSnapshot,
} from "./customer-status-model"; } from "./customer-status-model";
import { syncBillingStatusToLoops } from "./loops-sync";
export async function syncAutumnCustomerStatus(customerId: string) { export async function syncAutumnCustomerStatus(customerId: string) {
// getOrCreate is effectively a "get" here — a billing.updated webhook always // getOrCreate is effectively a "get" here — a billing.updated webhook always
@ -13,6 +14,7 @@ export async function syncAutumnCustomerStatus(customerId: string) {
const customer = await autumn.customers.getOrCreate({ customerId }); const customer = await autumn.customers.getOrCreate({ customerId });
const snapshot = deriveBillingCustomerStatusSnapshot(customer); const snapshot = deriveBillingCustomerStatusSnapshot(customer);
await upsertBillingCustomerStatus(snapshot); await upsertBillingCustomerStatus(snapshot);
await syncBillingStatusToLoops(snapshot);
return snapshot; return snapshot;
} }
@ -27,6 +29,7 @@ async function upsertBillingCustomerStatus(
set: { set: {
isPaying: snapshot.isPaying, isPaying: snapshot.isPaying,
paidPlanId: snapshot.paidPlanId, paidPlanId: snapshot.paidPlanId,
paidPlanStatus: snapshot.paidPlanStatus,
customerJson: snapshot.customerJson, customerJson: snapshot.customerJson,
syncedAt: snapshot.syncedAt, syncedAt: snapshot.syncedAt,
updatedAt: sql`(current_timestamp)`, updatedAt: sql`(current_timestamp)`,

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
getBillingLoopsContactProperties,
LOOPS_BILLING_PLAN_NONE,
} from "./loops-contact-properties";
describe("getBillingLoopsContactProperties", () => {
it("maps Autumn billing fields to Loops custom properties", () => {
expect(
getBillingLoopsContactProperties({
paidPlanId: "base-plan",
paidPlanStatus: "active",
}),
).toEqual({
billingPlanId: "base-plan",
billingPlanStatus: "active",
});
});
it("uses explicit none values when the customer has no paid plan", () => {
expect(
getBillingLoopsContactProperties({
paidPlanId: null,
paidPlanStatus: null,
}),
).toEqual({
billingPlanId: LOOPS_BILLING_PLAN_NONE,
billingPlanStatus: LOOPS_BILLING_PLAN_NONE,
});
});
});

View File

@ -0,0 +1,15 @@
import type { BillingCustomerStatusSnapshot } from "./customer-status-model";
export const LOOPS_BILLING_PLAN_NONE = "none";
export function getBillingLoopsContactProperties(
snapshot: Pick<
BillingCustomerStatusSnapshot,
"paidPlanId" | "paidPlanStatus"
>,
) {
return {
billingPlanId: snapshot.paidPlanId ?? LOOPS_BILLING_PLAN_NONE,
billingPlanStatus: snapshot.paidPlanStatus ?? LOOPS_BILLING_PLAN_NONE,
};
}

View File

@ -0,0 +1,55 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { member, user } from "@/db/schema";
import {
getContactNameParts,
updateLoopsContact,
} from "@/server/email/loops-client";
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
import type { BillingCustomerStatusSnapshot } from "./customer-status-model";
import { getBillingLoopsContactProperties } from "./loops-contact-properties";
export async function syncBillingStatusToLoops(
snapshot: BillingCustomerStatusSnapshot,
) {
const apiKey = await getOptionalEnvValue("LOOPS_API_KEY");
if (!apiKey) {
console.warn(
"Skipping Loops billing contact sync: LOOPS_API_KEY is not set",
);
return;
}
const contacts = await getOrganizationContacts(snapshot.organizationId);
const billingProperties = getBillingLoopsContactProperties(snapshot);
for (const contact of contacts) {
await updateLoopsContact({
apiKey,
payload: {
email: contact.email,
userId: contact.userId,
userGroup: "app-user",
...getContactNameParts(contact.name),
...billingProperties,
},
logContext: {
action: "billing-contact-sync",
organizationId: snapshot.organizationId,
},
});
}
}
async function getOrganizationContacts(organizationId: string) {
return db
.select({
userId: user.id,
email: user.email,
name: user.name,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId));
}

View File

@ -0,0 +1,70 @@
const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update";
type LoopsContactProperty =
| string
| number
| boolean
| null
| undefined
| Record<string, boolean>;
type LoopsContactUpdatePayload = {
email?: string;
userId?: string;
firstName?: string;
lastName?: string;
source?: string;
subscribed?: boolean;
userGroup?: string;
mailingLists?: Record<string, boolean>;
} & Record<string, LoopsContactProperty>;
export async function updateLoopsContact({
apiKey,
payload,
logContext,
}: {
apiKey: string;
payload: LoopsContactUpdatePayload;
logContext?: Record<string, unknown>;
}) {
const response = await fetch(LOOPS_CONTACT_UPDATE_URL, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
if (response.ok) {
return;
}
const errorPayload = await response.json().catch(() => null);
console.error("Loops contact update error:", {
status: response.status,
email: payload.email,
userId: payload.userId,
...logContext,
errorPayload,
});
throw new Error(`Failed to update Loops contact (${response.status})`);
}
export function getContactNameParts(name: string | null | undefined) {
const trimmedName = name?.trim();
if (!trimmedName) {
return {};
}
const [firstName, ...lastNameParts] = trimmedName.split(/\s+/);
const lastName = lastNameParts.join(" ");
return {
firstName,
...(lastName ? { lastName } : {}),
};
}

View File

@ -1,7 +1,10 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import {
getContactNameParts,
updateLoopsContact,
} from "@/server/email/loops-client";
const LOOPS_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional"; const LOOPS_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional";
const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update";
function getOptionalEnv(name: string) { function getOptionalEnv(name: string) {
const value: unknown = Reflect.get(env, name); const value: unknown = Reflect.get(env, name);
@ -74,22 +77,6 @@ async function sendLoopsTransactionalEmail({
); );
} }
function getContactNameParts(name: string | null | undefined) {
const trimmedName = name?.trim();
if (!trimmedName) {
return {};
}
const [firstName, ...lastNameParts] = trimmedName.split(/\s+/);
const lastName = lastNameParts.join(" ");
return {
firstName,
...(lastName ? { lastName } : {}),
};
}
export async function upsertHostedSignupContact({ export async function upsertHostedSignupContact({
userId, userId,
email, email,
@ -108,34 +95,17 @@ export async function upsertHostedSignupContact({
return; return;
} }
const response = await fetch(LOOPS_CONTACT_UPDATE_URL, { await updateLoopsContact({
method: "PUT", apiKey,
headers: { payload: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
email, email,
userId, userId,
source: "openseo-signup", source: "openseo-signup",
userGroup: "app-user", userGroup: "app-user",
...getContactNameParts(name), ...getContactNameParts(name),
}), },
logContext: { action: "signup-contact-sync" },
}); });
if (response.ok) {
return;
}
const errorPayload = await response.json().catch(() => null);
console.error("Loops signup contact sync error:", {
status: response.status,
email,
userId,
errorPayload,
});
throw new Error(`Failed to sync Loops signup contact (${response.status})`);
} }
export async function sendHostedVerificationEmail({ export async function sendHostedVerificationEmail({