Sync Autumn billing status to Loops and add billing backfill (#269)
This commit is contained in:
parent
f8d5ffd285
commit
dd429ec4f0
14
drizzle/0023_sad_cobalt_man.sql
Normal file
14
drizzle/0023_sad_cobalt_man.sql
Normal 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`);
|
||||
2618
drizzle/meta/0023_snapshot.json
Normal file
2618
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -162,6 +162,13 @@
|
||||
"when": 1781107930529,
|
||||
"tag": "0022_purple_hitman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1781467831570,
|
||||
"tag": "0023_sad_cobalt_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -8,6 +8,7 @@ export const billingCustomerStatus = sqliteTable("billing_customer_status", {
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
isPaying: integer("is_paying", { mode: "boolean" }).notNull().default(false),
|
||||
paidPlanId: text("paid_plan_id"),
|
||||
paidPlanStatus: text("paid_plan_status"),
|
||||
// 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.
|
||||
customerJson: text("customer_json").notNull(),
|
||||
|
||||
@ -13,6 +13,7 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
|
||||
organizationId: "org_123",
|
||||
isPaying: true,
|
||||
paidPlanId: AUTUMN_PAID_PLAN_ID,
|
||||
paidPlanStatus: "active",
|
||||
});
|
||||
});
|
||||
|
||||
@ -39,6 +40,7 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
|
||||
|
||||
expect(snapshot.isPaying).toBe(false);
|
||||
expect(snapshot.paidPlanId).toBeNull();
|
||||
expect(snapshot.paidPlanStatus).toBeNull();
|
||||
});
|
||||
|
||||
it("records a scheduled (not-yet-active) paid plan as not paying", () => {
|
||||
@ -51,6 +53,7 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
|
||||
organizationId: "org_456",
|
||||
isPaying: false,
|
||||
paidPlanId: AUTUMN_PAID_PLAN_ID,
|
||||
paidPlanStatus: "scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
@ -65,5 +68,6 @@ describe("deriveBillingCustomerStatusSnapshot", () => {
|
||||
|
||||
expect(snapshot.isPaying).toBe(true);
|
||||
expect(snapshot.paidPlanId).toBe(AUTUMN_PAID_PLAN_ID);
|
||||
expect(snapshot.paidPlanStatus).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ export type BillingCustomerStatusSnapshot = {
|
||||
organizationId: string;
|
||||
isPaying: boolean;
|
||||
paidPlanId: string | null;
|
||||
paidPlanStatus: string | null;
|
||||
customerJson: string;
|
||||
syncedAt: string;
|
||||
};
|
||||
@ -36,6 +37,7 @@ export function deriveBillingCustomerStatusSnapshot(
|
||||
organizationId,
|
||||
isPaying: subscription?.status === "active",
|
||||
paidPlanId: subscription?.planId ?? null,
|
||||
paidPlanStatus: subscription?.status ?? null,
|
||||
// Full payload kept verbatim — query rarely-used fields via json_extract.
|
||||
customerJson: JSON.stringify(customer),
|
||||
syncedAt: new Date().toISOString(),
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
deriveBillingCustomerStatusSnapshot,
|
||||
type BillingCustomerStatusSnapshot,
|
||||
} from "./customer-status-model";
|
||||
import { syncBillingStatusToLoops } from "./loops-sync";
|
||||
|
||||
export async function syncAutumnCustomerStatus(customerId: string) {
|
||||
// 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 snapshot = deriveBillingCustomerStatusSnapshot(customer);
|
||||
await upsertBillingCustomerStatus(snapshot);
|
||||
await syncBillingStatusToLoops(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@ -27,6 +29,7 @@ async function upsertBillingCustomerStatus(
|
||||
set: {
|
||||
isPaying: snapshot.isPaying,
|
||||
paidPlanId: snapshot.paidPlanId,
|
||||
paidPlanStatus: snapshot.paidPlanStatus,
|
||||
customerJson: snapshot.customerJson,
|
||||
syncedAt: snapshot.syncedAt,
|
||||
updatedAt: sql`(current_timestamp)`,
|
||||
|
||||
31
src/server/billing/loops-contact-properties.test.ts
Normal file
31
src/server/billing/loops-contact-properties.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/server/billing/loops-contact-properties.ts
Normal file
15
src/server/billing/loops-contact-properties.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
src/server/billing/loops-sync.ts
Normal file
55
src/server/billing/loops-sync.ts
Normal 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));
|
||||
}
|
||||
70
src/server/email/loops-client.ts
Normal file
70
src/server/email/loops-client.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
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_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update";
|
||||
|
||||
function getOptionalEnv(name: string) {
|
||||
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({
|
||||
userId,
|
||||
email,
|
||||
@ -108,34 +95,17 @@ export async function upsertHostedSignupContact({
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(LOOPS_CONTACT_UPDATE_URL, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
await updateLoopsContact({
|
||||
apiKey,
|
||||
payload: {
|
||||
email,
|
||||
userId,
|
||||
source: "openseo-signup",
|
||||
userGroup: "app-user",
|
||||
...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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user