Skip to content

Commit 94b8e74

Browse files
committed
feat: sync Loops audiences reliably through Stripe
1 parent 5419356 commit 94b8e74

35 files changed

Lines changed: 6179 additions & 776 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ jobs:
9393
- name: Check email library
9494
run: |
9595
bun run emails:check
96-
bun test scripts/emails scripts/loops/profile.test.ts
97-
bun run tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --resolveJsonModule --types bun --skipLibCheck scripts/loops/*.ts scripts/emails/*.ts emails/*.ts emails/marketing/*.ts
96+
bun test scripts/emails scripts/loops/profile.test.ts scripts/loops/lifecycle.test.ts
97+
bun run tsc --noEmit --strict --jsx react-jsx --allowImportingTsExtensions --target ESNext --module ESNext --moduleResolution bundler --resolveJsonModule --types bun --skipLibCheck scripts/loops/*.ts scripts/emails/*.ts emails/*.ts emails/marketing/*.ts
9898
9999
- name: Test web React compatibility
100100
run: >-
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { timingSafeEqual } from "node:crypto";
2+
import { runLoopsSync } from "@cap/database/loops/worker";
3+
import {
4+
HttpApi,
5+
HttpApiBuilder,
6+
HttpApiEndpoint,
7+
HttpApiError,
8+
HttpApiGroup,
9+
HttpServerRequest,
10+
} from "@effect/platform";
11+
import { Effect, Layer, Schema } from "effect";
12+
import { apiToHandler } from "@/lib/server";
13+
import { customerCopy } from "../../../../../../emails/customer-copy";
14+
15+
class Api extends HttpApi.make("LoopsSyncApi").add(
16+
HttpApiGroup.make("root").add(
17+
HttpApiEndpoint.get("sync")`/api/cron/sync-loops`
18+
.addSuccess(
19+
Schema.Struct({
20+
enabled: Schema.Boolean,
21+
processed: Schema.Number,
22+
failed: Schema.Number,
23+
}),
24+
)
25+
.addError(HttpApiError.Unauthorized)
26+
.addError(HttpApiError.InternalServerError),
27+
),
28+
) {}
29+
30+
const ApiLive = HttpApiBuilder.api(Api).pipe(
31+
Layer.provide(
32+
HttpApiBuilder.group(Api, "root", (handlers) =>
33+
handlers.handle("sync", () =>
34+
Effect.gen(function* () {
35+
const request = yield* HttpServerRequest.HttpServerRequest;
36+
const secret = process.env.CRON_SECRET;
37+
if (!secret) return yield* new HttpApiError.InternalServerError();
38+
const expected = Buffer.from(`Bearer ${secret}`);
39+
const actual = Buffer.from(request.headers.authorization ?? "");
40+
if (
41+
actual.length !== expected.length ||
42+
!timingSafeEqual(actual, expected)
43+
)
44+
return yield* new HttpApiError.Unauthorized();
45+
const result = yield* Effect.tryPromise({
46+
try: () => runLoopsSync(customerCopy),
47+
catch: () => new HttpApiError.InternalServerError(),
48+
});
49+
if (result.failed)
50+
return yield* new HttpApiError.InternalServerError();
51+
return result;
52+
}),
53+
),
54+
),
55+
),
56+
);
57+
58+
export const GET = apiToHandler(ApiLive);
59+
export const maxDuration = 120;
60+
export const dynamic = "force-dynamic";

apps/web/app/api/invite/accept/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from "@cap/database";
22
import { getCurrentUser } from "@cap/database/auth/session";
33
import { nanoId } from "@cap/database/helpers";
4+
import { enqueueLoopsSync } from "@cap/database/loops/queue";
45
import {
56
organizationInvites,
67
organizationMembers,
@@ -149,6 +150,7 @@ export async function POST(request: NextRequest) {
149150

150151
await tx.update(users).set(userUpdate).where(eq(users.id, user.id));
151152

153+
await enqueueLoopsSync(tx, user.id, true);
152154
await tx
153155
.delete(organizationInvites)
154156
.where(eq(organizationInvites.id, inviteId));

apps/web/app/api/webhooks/stripe/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from "@cap/database";
22
import { sendEmail } from "@cap/database/emails/config";
33
import { PaymentFailed } from "@cap/database/emails/payment-failed";
44
import { nanoId } from "@cap/database/helpers";
5+
import { enqueueLoopsSync } from "@cap/database/loops/queue";
56
import {
67
developerCreditTransactions,
78
signedBaas,
@@ -608,6 +609,7 @@ export const POST = async (req: Request) => {
608609
onboarding_completed_at: isOnBoarding ? new Date() : undefined,
609610
})
610611
.where(eq(users.id, dbUser.id));
612+
await enqueueLoopsSync(db(), dbUser.id);
611613

612614
console.log("Successfully updated user in database");
613615

@@ -767,6 +769,7 @@ export const POST = async (req: Request) => {
767769
inviteQuota: inviteQuota,
768770
})
769771
.where(eq(users.id, dbUser.id));
772+
await enqueueLoopsSync(db(), dbUser.id);
770773

771774
console.log(
772775
"Successfully updated user in database with new invite quota:",
@@ -942,6 +945,7 @@ export const POST = async (req: Request) => {
942945
inviteQuota: 1,
943946
})
944947
.where(eq(users.id, foundUserId));
948+
await enqueueLoopsSync(db(), foundUserId);
945949

946950
console.log("User updated successfully", {
947951
foundUserId,

apps/web/lib/organization-provisioning.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "server-only";
22

33
import { db } from "@cap/database";
44
import { nanoId } from "@cap/database/helpers";
5+
import { enqueueLoopsSync } from "@cap/database/loops/queue";
56
import {
67
organizationInvites,
78
organizationMembers,
@@ -49,7 +50,9 @@ export async function provisionOrganizationInvitee({
4950
const userId = existingUser?.id ?? User.UserId.make(nanoId());
5051

5152
if (existingUser) {
52-
const userUpdate: Partial<typeof users.$inferInsert> = {};
53+
const userUpdate: Partial<typeof users.$inferInsert> = {
54+
marketingOrigin: "teammate",
55+
};
5356

5457
if (!existingUser.name) {
5558
userUpdate.name = getProvisionedUserName(normalizedEmail);
@@ -68,12 +71,14 @@ export async function provisionOrganizationInvitee({
6871
await tx.insert(users).values({
6972
id: userId,
7073
email: normalizedEmail,
74+
marketingOrigin: "teammate",
7175
name: getProvisionedUserName(normalizedEmail),
7276
activeOrganizationId: organizationId,
7377
defaultOrgId: organizationId,
7478
});
7579
}
7680

81+
await enqueueLoopsSync(tx, userId);
7782
const [existingMember] = await tx
7883
.select({
7984
id: organizationMembers.id,

apps/web/vercel.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"$schema": "https://openapi.vercel.sh/vercel.json",
33
"crons": [
4+
{ "path": "/api/cron/sync-loops", "schedule": "* * * * *" },
45
{
56
"path": "/api/cron/cleanup-agent-api",
67
"schedule": "17 3 * * *"

emails/CATALOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ These are the locally configured draft journeys. This document is not a live sta
1313
| [Teammate onboarding](https://app.loops.so/workflows/cmtvpm0ag01it0j37w02o6wav) | teammate | Day 0, Day 3 | 2 |
1414
| [Former customer follow-up](https://app.loops.so/workflows/cmtvpm8zm01ii0j01cz7d15qr) | former | Day 14 | 1 |
1515

16-
All journeys require global subscription, positive Cap consent, the exact audience, lifecycle enabled and onboarding eligible. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility. Customer and teammate flows still require marketing consent.
16+
Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. The integration is not deployed. Historical imports stay held; a new accepted invitation can start teammate help only.
17+
18+
Current draft journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.
1719

1820
Teammate history takes priority over paid/free classification. Ambiguous contacts receive no journey. [Audience classification and consent](../scripts/loops/README.md#audience-rules).
1921

@@ -112,7 +114,7 @@ flowchart TD
112114

113115
## Campaign templates
114116

115-
Campaigns are manually scheduled product updates, with no automatic enrollment. Both require subscription, positive consent and their exact audience. The free template also requires promotional eligibility and excludes teammates.
117+
Campaigns are manually scheduled product updates, with no automatic enrollment. Both require subscription, capConsent=subscribed and their exact audience. The free template also requires promotional eligibility and excludes teammates.
116118

117119
| Campaign | Audience | Subject and source | Loops ID |
118120
| --- | --- | --- | --- |

emails/QA.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ The local suite checks consent precedence, suppression preservation, customer an
4343

4444
## Remaining gate before switching
4545

46-
The deployed Cap signup/purchase/invite/opt-out path through an enrollment dispatcher and into an inbox has not been proven. The reviewed schema is not deployed, new explicit consent capture/enrollment and retry/deduplication dispatch need implementation, and the current sync intentionally keeps contacts held. Freshness expiry and scheduler monitoring are also required: an old `capVerifiedAt` value does not automatically prevent a Loops send.
46+
The new implementation uses native Stripe imports, a held SSO fallback, and `loops_sync_jobs` for durable enrichment. Local routing tests cover missing Stripe imports, incomplete billing bootstrap, teammate persistence, an existing account accepting a new invite, global/list opt-outs, missing list membership, replay, identity conflicts and imported holds. The full email-script, profile and lifecycle suites passed 54 tests; existing SSO and Stripe subscription suites passed another 83. Database and web TypeScript checks passed.
47+
48+
Five integration checks passed on an isolated PlanetScale branch: transactional rollback, overlapping claims, expired lease recovery, newer work surviving an older completion, and an indexed due query. A full `db:push` attempt hit the repository's unrelated storage prefix-index quoting bug; the exact generated Loops migration was then applied and independently checked on the empty test branch. No production schema or data changed. The owned schema-only test branch was deleted after verification.
49+
50+
The worker was exercised from synthetic database records through the real Loops API, including a simulated HTTP 429 followed by a successful retry, free and SSO teammate classification, a free-to-teammate transition, and a later unsubscribe surviving a billing change. Separate live checks cover imported holds and list removals. Loops omits an API-removed list from the returned map; the runtime now treats missing membership as ineligible and never re-adds lists during updates. All owned test contacts were read back held and globally unsubscribed. These checks sent no email and kept production workflows in Draft.
51+
52+
The native Stripe integration is connected to Cap Software, Inc. and enabled for customer creation and updates only. A new owned live Stripe customer appeared in Loops; two later name changes updated that same contact. Name import and Product updates and tips list assignment were verified, and a global unsubscribe survived the final Stripe update. No payments, subscriptions or emails were created. The test contact remains held and globally unsubscribed. Both saved event mappings were checked after reload. A separate test-mode Stripe customer did not import through this live connection, is not counted as a passing native test, and was deleted after verification.
53+
54+
The deployed Cap signup/purchase/invite path has not yet been proven end to end. Production schema/code deployment, recipient Preference Center list-opt-out preservation across native Stripe updates, the final Bento suppression delta, and scheduler monitoring remain cutover gates. The Cap-worker API list-removal check above does not independently prove native Stripe handling of a recipient list opt-out. The worker retries failed updates, but Loops still uses the last successfully synced values during an outage; `capVerifiedAt` is not a native expiry rule.
4755

4856
Follow the [cutover runbook](../scripts/loops/README.md#before-any-activation). Test that deployed path using owned inboxes, including outages and retries, before requesting production activation. Keep imported history held, preserve all opt-outs, reconcile fresh Bento changes, check Resend overlap and disable duplicate Bento automation only during the approved cutover.
4957

packages/database/auth/drizzle-adapter.ts

Lines changed: 16 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { createHash } from "node:crypto";
2-
import { isProSubscription, STRIPE_AVAILABLE, stripe } from "@cap/utils";
2+
import { STRIPE_AVAILABLE } from "@cap/utils";
33
import { type ImageUpload, Organisation, User } from "@cap/web-domain";
44
import { and, eq } from "drizzle-orm";
55
import type { MySql2Database } from "drizzle-orm/mysql2";
66
import type { Adapter } from "next-auth/adapters";
7-
import type Stripe from "stripe";
87
import { nanoId } from "../helpers.ts";
8+
import { enqueueLoopsSync } from "../loops/queue.ts";
99
import {
1010
accounts,
1111
organizationInvites,
@@ -16,6 +16,7 @@ import {
1616
verificationTokens,
1717
} from "../schema.ts";
1818
import type { ValidatedSsoIdentity } from "./sso.ts";
19+
import { provisionStripeCustomer } from "./stripe-customer.ts";
1920

2021
type CreateUserData = Parameters<NonNullable<Adapter["createUser"]>>[0];
2122
type LinkAccountData = Parameters<NonNullable<Adapter["linkAccount"]>>[0];
@@ -118,6 +119,7 @@ export function DrizzleAdapter(
118119
await tx.update(users).set(userUpdate).where(eq(users.id, userId));
119120
}
120121

122+
await enqueueLoopsSync(tx, userId);
121123
return;
122124
}
123125

@@ -157,6 +159,7 @@ export function DrizzleAdapter(
157159
await insertUser;
158160
}
159161

162+
await enqueueLoopsSync(tx, userId);
160163
if (pendingInvite || ssoIdentity) {
161164
return;
162165
}
@@ -194,69 +197,7 @@ export function DrizzleAdapter(
194197
if (!row) throw new Error("User not found");
195198

196199
if (STRIPE_AVAILABLE() && !ssoIdentity) {
197-
const existingCustomers = await stripe().customers.list({
198-
email: normalizedEmail,
199-
limit: 1,
200-
});
201-
202-
let customer: Stripe.Customer;
203-
if (existingCustomers.data.length > 0 && existingCustomers.data[0]) {
204-
customer = existingCustomers.data[0];
205-
206-
customer = await stripe().customers.update(customer.id, {
207-
metadata: {
208-
...customer.metadata,
209-
userId: row.id,
210-
},
211-
});
212-
} else {
213-
customer = await stripe().customers.create({
214-
email: normalizedEmail,
215-
metadata: {
216-
userId: row.id,
217-
},
218-
});
219-
}
220-
221-
const subscriptions = await stripe().subscriptions.list({
222-
customer: customer.id,
223-
status: "active",
224-
limit: 100,
225-
});
226-
227-
const proSubscriptions = subscriptions.data.filter(isProSubscription);
228-
const inviteQuota = proSubscriptions.reduce((total, sub) => {
229-
return (
230-
total +
231-
sub.items.data.reduce(
232-
(subTotal, item) => subTotal + (item.quantity || 1),
233-
0,
234-
)
235-
);
236-
}, 0);
237-
238-
const mostRecentSubscription = proSubscriptions[0];
239-
240-
await db
241-
.update(users)
242-
.set({
243-
stripeCustomerId: customer.id,
244-
...(mostRecentSubscription && {
245-
stripeSubscriptionId: mostRecentSubscription.id,
246-
stripeSubscriptionStatus: mostRecentSubscription.status,
247-
inviteQuota: inviteQuota || 1,
248-
}),
249-
})
250-
.where(eq(users.id, row.id));
251-
252-
const [updatedRow] = await db
253-
.select()
254-
.from(users)
255-
.where(eq(users.id, row.id))
256-
.limit(1);
257-
if (updatedRow) {
258-
row = updatedRow;
259-
}
200+
row = await provisionStripeCustomer(db, row);
260201
}
261202

262203
return row;
@@ -309,13 +250,16 @@ export function DrizzleAdapter(
309250
},
310251
async updateUser({ id, image, ...userData }) {
311252
if (!id) throw new Error("User not found");
312-
await db
313-
.update(users)
314-
.set({
315-
...userData,
316-
image: image as ImageUpload.ImageUrlOrKey | null,
317-
})
318-
.where(eq(users.id, User.UserId.make(id)));
253+
await db.transaction(async (tx) => {
254+
await tx
255+
.update(users)
256+
.set({
257+
...userData,
258+
image: image as ImageUpload.ImageUrlOrKey | null,
259+
})
260+
.where(eq(users.id, User.UserId.make(id)));
261+
await enqueueLoopsSync(tx, User.UserId.make(id));
262+
});
319263
const rows = await db
320264
.select()
321265
.from(users)

packages/database/auth/sso.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { WorkOS } from "@workos-inc/node";
66
import { and, eq, isNull } from "drizzle-orm";
77
import { nanoId } from "../helpers.ts";
88
import { db } from "../index.ts";
9+
import { enqueueLoopsSync } from "../loops/queue.ts";
910
import {
1011
accounts,
1112
organizationInvites,
@@ -273,5 +274,10 @@ export async function provisionSsoMembership(
273274
},
274275
})
275276
.where(eq(users.id, userId));
277+
await enqueueLoopsSync(
278+
tx,
279+
userId,
280+
!member || !user.onboarding_completed_at,
281+
);
276282
});
277283
}

0 commit comments

Comments
 (0)