Skip to content

Commit b949d2b

Browse files
committed
feat(core): make provider layer pluggable with batch product sync and preflight checks
Replace single syncProduct with batch syncProducts on PaymentProvider interface. Add provider preflight checks (external customers, cross-provider subscriptions) to push and status CLI commands. Lazy provider customer creation — customers are created on the provider only when needed (subscribe, portal, payment method). Remove checkout fallback from upgrade flow since active subscriptions already have payment methods on file. Exclude scheduled subscriptions from duplicate detection warnings.
1 parent d9e83f5 commit b949d2b

16 files changed

Lines changed: 258 additions & 132 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@
33
- do never commit unless you're asked to.
44
- only commit what you're asked to commit, no adding extra changes unless asked
55
- when person asks the question, but not asking you to do the thing, you only answer the question, you don't do the thing you're not asked to do!
6+
- STOP before any file edit. Ask yourself: "Did they explicitly say to make changes?" If no → DO NOT EDIT. Present findings and wait.
7+
- "find", "check", "investigate", "research", "explain", "look into" = RESEARCH ONLY. Never edit code.
8+
- The ONLY trigger for code changes is explicit words like: "do it", "fix it", "implement", "make the change", "go ahead"

e2e/smoke/setup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,11 @@ export async function createTestPayKit(): Promise<TestPayKit> {
150150
// confirmation which isn't possible in automated tests.
151151
(ctx.provider as unknown as Record<string, unknown>).createSubscription = async (data: {
152152
providerCustomerId: string;
153-
providerPriceId: string;
153+
providerProduct: Record<string, string>;
154154
}) => {
155155
const sub = await stripeClient.subscriptions.create({
156156
customer: data.providerCustomerId,
157-
items: [{ price: data.providerPriceId }],
157+
items: [{ price: data.providerProduct.priceId }],
158158
payment_behavior: "allow_incomplete",
159159
expand: ["latest_invoice"],
160160
});

packages/paykit/src/cli/commands/push.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { Command } from "commander";
55
import picocolors from "picocolors";
66

77
import {
8+
checkActiveSubscriptionsOnOtherProvider,
89
checkProvider,
10+
checkProviderCustomers,
911
createPool,
1012
formatProductDiffs,
1113
loadCliDeps,
@@ -60,6 +62,24 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean
6062
const { ctx, diffs } = await loadProductDiffs(config, deps);
6163
const hasChanges = diffs.some((d) => d.action !== "unchanged");
6264

65+
// Preflight checks
66+
s.message("Running preflight checks");
67+
const providerId = config.options.provider.id;
68+
const [subscriptionErrors, customerErrors] = await Promise.all([
69+
checkActiveSubscriptionsOnOtherProvider(ctx, providerId),
70+
checkProviderCustomers(ctx, providerResult.customerSample),
71+
]);
72+
const allErrors = [...providerResult.errors, ...subscriptionErrors, ...customerErrors];
73+
74+
if (allErrors.length > 0) {
75+
s.stop("");
76+
for (const err of allErrors) {
77+
p.log.error(err);
78+
}
79+
p.cancel("Push blocked by preflight checks");
80+
process.exit(1);
81+
}
82+
6383
s.stop("");
6484

6585
// Render all sections

packages/paykit/src/cli/commands/status.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Command } from "commander";
55
import picocolors from "picocolors";
66

77
import {
8+
checkActiveSubscriptionsOnOtherProvider,
89
checkDatabase,
910
checkProvider,
11+
checkProviderCustomers,
1012
createPool,
1113
formatProductDiffs,
1214
loadCliDeps,
@@ -85,6 +87,8 @@ async function statusAction(options: {
8587

8688
const pendingMigrations = dbResult.pendingMigrations;
8789

90+
let preflightErrors: string[] = [...providerResult.errors];
91+
8892
let webhookStatus: string;
8993
if (providerResult.webhookEndpoints === null) {
9094
webhookStatus = `${picocolors.dim("?")} Could not check webhook status`;
@@ -107,6 +111,13 @@ async function statusAction(options: {
107111
} else {
108112
const { ctx, diffs } = await loadProductDiffs(config, deps);
109113

114+
const providerId = config.options.provider.id;
115+
const [subscriptionErrors, customerErrors] = await Promise.all([
116+
checkActiveSubscriptionsOnOtherProvider(ctx, providerId),
117+
checkProviderCustomers(ctx, providerResult.customerSample),
118+
]);
119+
preflightErrors = [...preflightErrors, ...subscriptionErrors, ...customerErrors];
120+
110121
if (diffs.length === 0) {
111122
productsBlock = `Products\n ${picocolors.dim("No products defined")}`;
112123
} else {
@@ -149,8 +160,13 @@ async function statusAction(options: {
149160

150161
p.log.info(productsBlock);
151162

163+
if (preflightErrors.length > 0) {
164+
const errorLines = preflightErrors.map((err) => ` ${picocolors.red("✖")} ${err}`);
165+
p.log.error(`Preflight\n${errorLines.join("\n")}`);
166+
}
167+
152168
const needsMigration = pendingMigrations > 0;
153-
const hasIssues = needsMigration || needsSync;
169+
const hasIssues = needsMigration || needsSync || preflightErrors.length > 0;
154170

155171
if (hasIssues) {
156172
const action =

packages/paykit/src/cli/utils/shared.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export async function checkDatabase(
103103

104104
export interface ProviderCheckResult {
105105
account: { ok: true; displayName: string; mode: string } | { ok: false; message: string };
106+
customerSample: Array<{ providerEmail: string; paykitCustomerId: string | null }>;
107+
errors: string[];
106108
webhookEndpoints: Array<{ url: string; status: string }> | null;
107109
}
108110

@@ -116,30 +118,94 @@ export async function checkProvider(
116118
if (!result) {
117119
return {
118120
account: { ok: true, displayName: providerConfig.name, mode: "unknown" },
121+
customerSample: [],
122+
errors: [],
119123
webhookEndpoints: null,
120124
};
121125
}
122126

123127
if (result.ok) {
124128
return {
125129
account: { ok: true, displayName: result.displayName, mode: result.mode },
130+
customerSample: result.customerSample ?? [],
131+
errors: result.errors ?? [],
126132
webhookEndpoints: result.webhookEndpoints ?? null,
127133
};
128134
}
129135

130136
return {
131137
account: { ok: false, message: result.error ?? "Provider check failed" },
138+
customerSample: [],
139+
errors: result.errors ?? [],
132140
webhookEndpoints: null,
133141
};
134142
} catch (error) {
135143
const message = error instanceof Error ? error.message : "Provider check failed";
136144
return {
137145
account: { ok: false, message },
146+
customerSample: [],
147+
errors: [],
138148
webhookEndpoints: null,
139149
};
140150
}
141151
}
142152

153+
export async function checkProviderCustomers(
154+
ctx: PayKitContext,
155+
customerSample: ProviderCheckResult["customerSample"],
156+
): Promise<string[]> {
157+
if (customerSample.length === 0) return [];
158+
159+
const message = `${ctx.provider.name} account has existing customers that are not synced with PayKit. Use a fresh ${ctx.provider.name} account or remove existing customers from it before proceeding.`;
160+
161+
const hasUnmanaged = customerSample.some((s) => !s.paykitCustomerId);
162+
if (hasUnmanaged) return [message];
163+
164+
const paykitIds = customerSample
165+
.map((s) => s.paykitCustomerId)
166+
.filter((id): id is string => id !== null);
167+
168+
if (paykitIds.length > 0) {
169+
const { customer } = await import("../../database/schema");
170+
const { inArray } = await import("drizzle-orm");
171+
const rows = await ctx.database
172+
.select({ id: customer.id })
173+
.from(customer)
174+
.where(inArray(customer.id, paykitIds));
175+
if (rows.length < paykitIds.length) return [message];
176+
}
177+
178+
return [];
179+
}
180+
181+
export async function checkActiveSubscriptionsOnOtherProvider(
182+
ctx: PayKitContext,
183+
currentProviderId: string,
184+
): Promise<string[]> {
185+
const errors: string[] = [];
186+
const { subscription } = await import("../../database/schema");
187+
const { and, eq, ne, isNotNull, count } = await import("drizzle-orm");
188+
const rows = await ctx.database
189+
.select({ count: count(), providerId: subscription.providerId })
190+
.from(subscription)
191+
.where(
192+
and(
193+
eq(subscription.status, "active"),
194+
isNotNull(subscription.providerId),
195+
ne(subscription.providerId, currentProviderId),
196+
),
197+
)
198+
.groupBy(subscription.providerId);
199+
for (const row of rows) {
200+
if (row.count > 0 && row.providerId) {
201+
errors.push(
202+
`Found ${String(row.count)} active subscription${row.count === 1 ? "" : "s"} linked to "${row.providerId}" but current provider is "${currentProviderId}". Existing subscriptions must be canceled before switching providers.`,
203+
);
204+
}
205+
}
206+
return errors;
207+
}
208+
143209
export async function loadProductDiffs(
144210
config: LoadedConfig,
145211
deps: Pick<CliDeps, "createContext" | "dryRunSyncProducts">,

packages/paykit/src/customer/__tests__/customer.service.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe("customer/service", () => {
7676
listActiveSubscriptions: vi.fn(),
7777
resumeSubscription: vi.fn(),
7878
scheduleSubscriptionChange: vi.fn(),
79-
syncProduct: vi.fn(),
79+
syncProducts: vi.fn(),
8080
updateSubscription: vi.fn(),
8181
createCustomer: vi.fn().mockResolvedValue({
8282
providerCustomer: {
@@ -173,7 +173,7 @@ describe("customer/service", () => {
173173
listActiveSubscriptions: vi.fn(),
174174
resumeSubscription: vi.fn(),
175175
scheduleSubscriptionChange: vi.fn(),
176-
syncProduct: vi.fn(),
176+
syncProducts: vi.fn(),
177177
updateSubscription: vi.fn(),
178178
createCustomer: vi.fn().mockResolvedValue({
179179
providerCustomer: {

packages/paykit/src/customer/customer.api.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { definePayKitMethod, returnUrl } from "../api/define-route";
44
import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors";
55
import {
66
getCustomerWithDetails,
7-
getProviderCustomerIdForCustomer,
87
hardDeleteCustomer,
98
listCustomers,
109
upsertCustomer as upsertCustomerService,
10+
upsertProviderCustomer,
1111
} from "./customer.service";
1212

1313
const upsertCustomerSchema = z.object({
@@ -60,15 +60,10 @@ export const customerPortal = definePayKitMethod(
6060
},
6161
},
6262
async (ctx) => {
63-
const providerCustomerId = await getProviderCustomerIdForCustomer(ctx.paykit.database, {
63+
const { providerCustomerId } = await upsertProviderCustomer(ctx.paykit, {
6464
customerId: ctx.customer.id,
65-
providerId: ctx.paykit.provider.id,
6665
});
6766

68-
if (!providerCustomerId) {
69-
throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.PROVIDER_CUSTOMER_NOT_FOUND);
70-
}
71-
7267
const { url } = await ctx.paykit.provider.createPortalSession({
7368
providerCustomerId,
7469
returnUrl: ctx.input.returnUrl,

packages/paykit/src/customer/customer.service.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -182,17 +182,8 @@ export async function upsertCustomer(
182182
): Promise<Customer> {
183183
const syncedCustomer = await syncCustomer(ctx.database, input);
184184
await ensureDefaultPlansForCustomer(ctx, syncedCustomer.id);
185-
const { providerCustomer } = await upsertProviderCustomer(ctx, {
186-
customerId: syncedCustomer.id,
187-
});
188185

189-
return {
190-
...syncedCustomer,
191-
provider: {
192-
...(syncedCustomer.provider ?? {}),
193-
[ctx.provider.id]: providerCustomer,
194-
},
195-
};
186+
return syncedCustomer;
196187
}
197188

198189
export async function getCustomerById(
@@ -247,6 +238,7 @@ export async function getCustomerWithDetails(
247238

248239
const subscriptionsByGroup = new Map<string, string[]>();
249240
for (const row of subRows) {
241+
if (row.status === "scheduled") continue;
250242
const currentGroup = subscriptionsByGroup.get(row.planGroup) ?? [];
251243
currentGroup.push(row.planId);
252244
subscriptionsByGroup.set(row.planGroup, currentGroup);

packages/paykit/src/database/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const feature = pgTable("feature", {
6464
updatedAt,
6565
});
6666

67-
type ProviderProductMap = Record<string, { productId: string; priceId: string | null }>;
67+
type ProviderProductMap = Record<string, Record<string, string>>;
6868

6969
export const product = pgTable(
7070
"product",

packages/paykit/src/product/product-sync.service.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ export async function syncProducts(ctx: PayKitContext): Promise<SyncProductResul
9595
await upsertFeature(ctx.database, schemaFeature);
9696
}
9797

98+
const paidPlansToSync: Array<{
99+
id: string;
100+
name: string;
101+
priceAmount: number;
102+
priceInterval: string;
103+
existingProviderProduct: Record<string, string> | null;
104+
storedProductInternalId: string;
105+
}> = [];
106+
98107
for (const plan of ctx.plans.plans) {
99108
const existing = await getLatestProductSnapshot(ctx.database, plan.id);
100109
const existingProviderProduct = existing
@@ -151,24 +160,13 @@ export async function syncProducts(ctx: PayKitContext): Promise<SyncProductResul
151160
}
152161

153162
if (storedProduct.priceAmount !== null && storedProduct.priceInterval !== null) {
154-
const shouldReuseExistingPriceId =
155-
action !== "created" && existingProviderProduct?.priceId !== undefined;
156-
const providerResult = await ctx.provider.syncProduct({
157-
existingProviderPriceId: shouldReuseExistingPriceId
158-
? (existingProviderProduct?.priceId ?? null)
159-
: null,
160-
existingProviderProductId: existingProviderProduct?.productId ?? null,
163+
paidPlansToSync.push({
164+
existingProviderProduct: existingProviderProduct ?? null,
161165
id: plan.id,
162166
name: plan.name,
163167
priceAmount: storedProduct.priceAmount,
164168
priceInterval: storedProduct.priceInterval,
165-
});
166-
167-
await upsertProviderProduct(ctx.database, {
168-
productInternalId: storedProduct.internalId,
169-
providerId,
170-
providerProductId: providerResult.providerProductId,
171-
providerPriceId: providerResult.providerPriceId,
169+
storedProductInternalId: storedProduct.internalId,
172170
});
173171
}
174172

@@ -179,5 +177,28 @@ export async function syncProducts(ctx: PayKitContext): Promise<SyncProductResul
179177
});
180178
}
181179

180+
if (paidPlansToSync.length > 0) {
181+
const providerResults = await ctx.provider.syncProducts({
182+
products: paidPlansToSync.map((p) => ({
183+
existingProviderProduct: p.existingProviderProduct,
184+
id: p.id,
185+
name: p.name,
186+
priceAmount: p.priceAmount,
187+
priceInterval: p.priceInterval,
188+
})),
189+
});
190+
191+
for (const providerResult of providerResults.results) {
192+
const plan = paidPlansToSync.find((p) => p.id === providerResult.id);
193+
if (plan) {
194+
await upsertProviderProduct(ctx.database, {
195+
productInternalId: plan.storedProductInternalId,
196+
providerId,
197+
providerProduct: providerResult.providerProduct,
198+
});
199+
}
200+
}
201+
}
202+
182203
return results;
183204
}

0 commit comments

Comments
 (0)