Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions apps/api/src/lib/workflows/trial-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,11 +297,17 @@ export class TrialEndWorkflow extends WorkflowEntrypoint<
if (!resolvedAuthCode || !resolvedEmail || !amount || amount <= 0) {
await step.do("expire-subscription", async () => {
const now = Date.now();
await this.env.DB.prepare(
"UPDATE subscriptions SET status = 'expired', updated_at = ? WHERE id = ?",
const result = await this.env.DB.prepare(
"UPDATE subscriptions SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'trialing'",
)
.bind(now, subscriptionId)
.run();
if (Number((result as any)?.meta?.changes || 0) === 0) {
console.log(
`[TrialEndWorkflow] Skipped expiry (no card/data): subscription=${subscriptionId} is no longer trialing`,
);
return;
}
await this.deps.invalidateSubscriptionCache(
this.env,
organizationId,
Expand Down Expand Up @@ -341,11 +347,17 @@ export class TrialEndWorkflow extends WorkflowEntrypoint<
// Can't charge — expire the subscription instead
await step.do("expire-no-provider", async () => {
const now = Date.now();
await this.env.DB.prepare(
"UPDATE subscriptions SET status = 'expired', updated_at = ? WHERE id = ?",
const result = await this.env.DB.prepare(
"UPDATE subscriptions SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'trialing'",
)
.bind(now, subscriptionId)
.run();
if (Number((result as any)?.meta?.changes || 0) === 0) {
console.log(
`[TrialEndWorkflow] Skipped expiry (no provider key): subscription=${subscriptionId} is no longer trialing`,
);
return;
}
console.log(
`[TrialEndWorkflow] Expired (no provider key): subscription=${subscriptionId}`,
);
Expand Down
107 changes: 107 additions & 0 deletions apps/api/test/runtime/workflows/trial-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,120 @@ import {
import { createSqliteD1Database } from "../helpers/sqlite-d1";
import {
buildWorkflowEnv,
ImmediateWorkflowStep,
insertSubscription,
runWorkflow,
seedWorkflowBase,
SimulatedProviderAdapter,
} from "../helpers/workflow-runtime";

class ActivateBeforeStep extends ImmediateWorkflowStep {
constructor(
private readonly db: D1Database,
private readonly stepName: string,
private readonly subscriptionId: string,
) {
super();
}

override async do<T>(...args: unknown[]): Promise<T> {
if (args[0] === this.stepName) {
await this.db
.prepare("UPDATE subscriptions SET status = 'active' WHERE id = ?")
.bind(this.subscriptionId)
.run();
}

return super.do<T>(...args);
}
}

describe("TrialEndWorkflow runtime integration", () => {
it("does not expire an already-activated trial from the no-card fallback", async () => {
const db = createSqliteD1Database();

try {
await seedWorkflowBase(db, { paymentMethods: [] });
await insertSubscription(db, {
id: "sub_trial_activated",
status: "trialing",
});

await runWorkflow(
TrialEndWorkflow,
buildWorkflowEnv(db),
{
subscriptionId: "sub_trial_activated",
customerId: "cust_1",
planId: "plan_1",
organizationId: "org_1",
providerId: "paystack",
environment: "test",
trialEndMs: Date.now(),
amount: 3000,
currency: "NGN",
email: "customer@example.com",
},
new ActivateBeforeStep(db, "expire-subscription", "sub_trial_activated"),
);

const subscription = await db
.prepare("SELECT status FROM subscriptions WHERE id = ? LIMIT 1")
.bind("sub_trial_activated")
.first<{ status: string }>();

expect(subscription?.status).toBe("active");
} finally {
db.close();
}
});

it("does not expire an already-activated trial from the no-provider fallback", async () => {
const db = createSqliteD1Database();

try {
await seedWorkflowBase(db, {
providerAccount: { providerId: "stripe" },
paymentMethods: [{ id: "pm_paystack", token: "AUTH_paystack" }],
});
await insertSubscription(db, {
id: "sub_trial_activated_no_provider",
status: "trialing",
});

await runWorkflow(
TrialEndWorkflow,
buildWorkflowEnv(db),
{
subscriptionId: "sub_trial_activated_no_provider",
customerId: "cust_1",
planId: "plan_1",
organizationId: "org_1",
providerId: "paystack",
environment: "test",
trialEndMs: Date.now(),
amount: 3000,
currency: "NGN",
email: "customer@example.com",
},
new ActivateBeforeStep(
db,
"expire-no-provider",
"sub_trial_activated_no_provider",
),
);

const subscription = await db
.prepare("SELECT status FROM subscriptions WHERE id = ? LIMIT 1")
.bind("sub_trial_activated_no_provider")
.first<{ status: string }>();

expect(subscription?.status).toBe("active");
} finally {
db.close();
}
});

it("uses the same-provider saved card even when another provider owns the global default method", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-06T17:00:15.000Z"));
Expand Down
Loading