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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)`,
|
||||||
|
|||||||
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 { 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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user