Menu
Public repository / f7056749a17a422909a828ea639ae776c4e020dc
AkurAI-Platform / payment/service.test.ts
Latest change 391a147709e20a60e52fffcc611f3b082def927d - fix: keep workspace provisioning pending by Ólafur Búi Ólafsson
import { afterEach, describe, expect, test } from "bun:test";
import { createHmac } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createPaymentService, type PaymentProvider, type PaymentService, type RecoveryDelivery } from "./service";
import { nextBillingDate, quoteCheckout, validateCatalog, type Catalog } from "./core";
const catalog: Catalog = validateCatalog({
currency: "EUR",
monthly_unit_cents: 900,
annual_discount_percent: 15,
products: [
{ id: "mail", name: "AkurAI Mail", category: "Communication", purchasable: true, bundle: [] },
{ id: "passvault", name: "AkurAI PassVault", category: "Security", purchasable: true, bundle: [] },
{ id: "crm", name: "AkurAI CRM", category: "Business", purchasable: false, bundle: [], available: false },
],
});
let directory: string | null = null;
let service: PaymentService | null = null;
afterEach(() => {
service?.close();
service = null;
if (directory) rmSync(directory, { recursive: true, force: true });
directory = null;
});
const identitySecret = "test-identity-secret";
const proxySecret = "test-proxy-secret";
function signedIdentityHeaders(userId: string): Record<string, string> {
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", proxySecret).update(`${timestamp}.${userId}`).digest("base64url");
return {
"x-akurai-user-id": userId,
"x-akurai-identity-timestamp": timestamp,
"x-akurai-identity-signature": signature,
};
}
function identityCredential(userId: string): string {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const header = encode({ alg: "HS256", typ: "JWT" });
const payload = encode({ sub: userId, exp: Math.floor(Date.now() / 1000) + 3600 });
const signature = createHmac("sha256", identitySecret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}
function createService(
now: () => Date = () => new Date("2026-07-17T12:00:00Z"),
paymentProvider?: PaymentProvider,
recoveryDelivery?: RecoveryDelivery,
): PaymentService {
directory = mkdtempSync(join(tmpdir(), "akurai-payments-test-"));
service = createPaymentService({
catalog,
dbPath: join(directory, "payments.db"),
publicUrl: "http://payments.test",
env: {
PAYMENTS_PROVIDER: "mock", PAYMENTS_IDENTITY_SECRET: identitySecret, PAYMENTS_TRUSTED_PROXY_SECRET: proxySecret,
...(paymentProvider?.name === "mypos" ? { PAYMENTS_TOKEN_KEY: Buffer.alloc(32, 3).toString("base64") } : {}),
},
paymentProvider,
recoveryDelivery,
now,
});
return service;
}
async function checkout(
app: PaymentService,
interval: "monthly" | "annual" = "monthly",
productIds = ["mail"],
userId?: string,
): Promise<{ orderId: string; manageToken: string; amountCents: number }> {
const response = await app.fetch(new Request("http://payments.test/api/payments/checkout", {
method: "POST",
headers: { "content-type": "application/json", ...(userId ? { authorization: `Bearer ${identityCredential(userId)}` } : {}) },
body: JSON.stringify({
productIds,
interval,
customer: { name: "Mock Customer", email: "mock@example.test" },
}),
}));
expect(response.status).toBe(201);
return response.json();
}
describe("subscription pricing", () => {
test("locks monthly and discounted annual totals on the server", () => {
const monthly = quoteCheckout(catalog, {
productIds: ["mail", "passvault"],
interval: "monthly",
customer: { name: "Customer", email: "customer@example.test" },
});
const annual = quoteCheckout(catalog, {
productIds: ["mail", "passvault"],
interval: "annual",
customer: { name: "Customer", email: "customer@example.test" },
});
expect(monthly.amountCents).toBe(1_800);
expect(annual.amountCents).toBe(18_360);
expect(() => quoteCheckout(catalog, {
productIds: ["crm"],
interval: "monthly",
customer: { name: "Customer", email: "customer@example.test" },
})).toThrow("unavailable");
});
test("preserves end-of-month billing dates", () => {
expect(nextBillingDate(new Date("2026-01-31T12:00:00Z"), "monthly").toISOString()).toBe("2026-02-28T12:00:00.000Z");
expect(nextBillingDate(new Date("2024-02-29T12:00:00Z"), "annual").toISOString()).toBe("2025-02-28T12:00:00.000Z");
});
});
describe("mock checkout", () => {
test("uses the hosted redirect flow, activates, exposes management, and cancels", async () => {
const app = createService();
const checkoutResult = await checkout(app, "annual", ["mail", "passvault"]);
expect(checkoutResult.amountCents).toBe(18_360);
const hosted = await app.fetch(new Request(`http://payments.test/api/payments/mock/checkout/${checkoutResult.orderId}`));
expect(hosted.status).toBe(200);
expect(await hosted.text()).toContain("Approve test payment");
const approval = await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${checkoutResult.orderId}/approve`, { method: "POST" }));
expect(approval.status).toBe(303);
const order = await (await app.fetch(new Request(`http://payments.test/api/payments/orders/${checkoutResult.orderId}`))).json();
expect(order.status).toBe("paid");
expect(order.amountCents).toBe(18_360);
const managed = await (await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${checkoutResult.manageToken}`))).json();
expect(managed.status).toBe("active");
expect(managed.email).toBe("m***@example.test");
expect(managed.products.map((product: { id: string }) => product.id)).toEqual(["mail", "passvault"]);
const canceled = await (await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${checkoutResult.manageToken}?action=cancel`, {
method: "POST",
headers: { accept: "application/json" },
}))).json();
expect(canceled.status).toBe("canceled");
expect(canceled.retentionDeadline).toBeTruthy();
});
test("recovers management access through customer email without persisting a failed rotation", async () => {
const deliveries: string[] = [];
let fail = true;
const app = createService(undefined, undefined, {
async send({ managementUrl }) {
if (fail) throw new Error("mailbox_unavailable");
deliveries.push(managementUrl);
},
});
const checkoutResult = await checkout(app);
await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${checkoutResult.orderId}/approve`, { method: "POST" }));
const recover = () => app.fetch(new Request("http://payments.test/api/payments/recovery", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "mock@example.test" }),
}));
expect((await recover()).status).toBe(202);
expect((await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${checkoutResult.manageToken}`))).status).toBe(200);
fail = false;
expect((await recover()).status).toBe(202);
expect(deliveries).toHaveLength(1);
expect((await app.fetch(new Request(`http://payments.test${new URL(deliveries[0]).pathname}`))).status).toBe(200);
expect((await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${checkoutResult.manageToken}`))).status).toBe(404);
});
test("accepts myPOS notify replay, records payment-method-required, and rolls back", async () => {
let notification: Record<string, unknown> = {};
const provider: PaymentProvider & { validateWebhook(form: FormData): Record<string, unknown> } = {
name: "mypos",
async begin(order) { return { redirectUrl: `http://payments.test/mypos/${order.id}` }; },
async renew() { return { success: true }; },
async recover() { return null; },
validateWebhook() { return notification; },
};
const app = createService(undefined, provider);
const result = await checkout(app);
notification = {
isValid: true, orderId: result.orderId, amount: 9, currency: "EUR",
method: "IPCPurchaseNotify", cardToken: undefined, transactionRef: "notify-1",
};
expect((await app.fetch(new Request("http://payments.test/api/payments/webhook", { method: "POST", body: new FormData() }))).status).toBe(200);
expect((await app.fetch(new Request("http://payments.test/api/payments/webhook", { method: "POST", body: new FormData() }))).status).toBe(200);
expect((await (await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${result.manageToken}`))).json()).status).toBe("requires_payment_method");
notification = { ...notification, method: "IPCPurchaseRollback", transactionRef: "rollback-1" };
expect((await app.fetch(new Request("http://payments.test/api/payments/webhook", { method: "POST", body: new FormData() }))).status).toBe(200);
expect((await (await app.fetch(new Request(`http://payments.test/api/payments/orders/${result.orderId}`))).json()).status).toBe("rolled_back");
});
test("moves failed renewals through past_due, unpaid, and a cancellation race without charging", async () => {
let renewCalls = 0;
const provider: PaymentProvider = {
name: "mock",
async begin(order) { return { redirectUrl: `http://payments.test/api/payments/mock/checkout/${order.id}` }; },
async renew() { renewCalls += 1; return { success: false, errorCode: "declined" }; },
async recover() { return null; },
};
let current = new Date("2026-01-31T12:00:00Z");
const app = createService(() => new Date(current), provider);
const result = await checkout(app);
await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${result.orderId}/approve`, { method: "POST" }));
current = new Date("2026-03-01T12:00:00Z");
await app.runRenewals();
expect((await (await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${result.manageToken}`))).json()).status).toBe("past_due");
current = new Date("2026-03-02T12:00:00Z");
await app.runRenewals();
current = new Date("2026-03-05T12:00:00Z");
await app.runRenewals();
expect((await (await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${result.manageToken}`))).json()).status).toBe("unpaid");
const paid = await checkout(app);
await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${paid.orderId}/approve`, { method: "POST" }));
await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${paid.manageToken}?action=cancel`, { method: "POST" }));
const before = renewCalls;
current = new Date("2026-04-01T12:00:00Z");
await app.runRenewals();
expect(renewCalls).toBe(before);
});
test("renews across token-key overlap and re-encrypts for the new key", async () => {
const oldKey = Buffer.alloc(32, 1).toString("base64");
const newKey = Buffer.alloc(32, 2).toString("base64");
let current = new Date("2026-01-31T12:00:00Z");
directory = mkdtempSync(join(tmpdir(), "akurai-payments-key-rotation-test-"));
service = createPaymentService({
catalog, dbPath: join(directory, "payments.db"), publicUrl: "http://payments.test",
env: { PAYMENTS_PROVIDER: "mock", PAYMENTS_TOKEN_KEY: oldKey, PAYMENTS_TOKEN_KEY_ID: "old" }, now: () => new Date(current),
});
const result = await checkout(service);
await service.fetch(new Request(`http://payments.test/api/payments/mock/orders/${result.orderId}/approve`, { method: "POST" }));
service.close();
service = createPaymentService({
catalog, dbPath: join(directory, "payments.db"), publicUrl: "http://payments.test",
env: {
PAYMENTS_PROVIDER: "mock", PAYMENTS_TOKEN_KEY: newKey, PAYMENTS_TOKEN_KEY_ID: "new",
PAYMENTS_TOKEN_KEY_PREVIOUS: oldKey, PAYMENTS_TOKEN_KEY_PREVIOUS_ID: "old",
}, now: () => new Date(current),
});
current = new Date("2026-03-01T12:00:00Z");
await service.runRenewals();
service.close();
service = createPaymentService({
catalog, dbPath: join(directory, "payments.db"), publicUrl: "http://payments.test",
env: { PAYMENTS_PROVIDER: "mock", PAYMENTS_TOKEN_KEY: newKey, PAYMENTS_TOKEN_KEY_ID: "new" }, now: () => new Date(current),
});
current = new Date("2026-04-01T12:00:00Z");
await service.runRenewals();
expect((await (await service.fetch(new Request(`http://payments.test/api/payments/subscriptions/${result.manageToken}`))).json()).status).toBe("active");
});
test("deduplicates checkout retries and throttles burst creation", async () => {
const app = createService();
const body = JSON.stringify({
productIds: ["mail"], interval: "monthly",
customer: { name: "Mock Customer", email: "mock@example.test" },
});
const request = (key: string) => new Request("http://payments.test/api/payments/checkout", {
method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": key }, body,
});
const first = await app.fetch(request("retry-1"));
const second = await app.fetch(request("retry-1"));
expect(first.status).toBe(201);
expect(second.status).toBe(200);
expect((await first.json()).orderId).toBe((await second.json()).orderId);
await app.fetch(request("retry-2"));
await app.fetch(request("retry-3"));
await app.fetch(request("retry-4"));
expect((await app.fetch(request("retry-5"))).status).toBe(429);
});
test("records a declined mock payment without creating a subscription", async () => {
const app = createService();
const checkoutResult = await checkout(app);
const decline = await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${checkoutResult.orderId}/decline`, { method: "POST" }));
expect(decline.status).toBe(303);
const order = await (await app.fetch(new Request(`http://payments.test/api/payments/orders/${checkoutResult.orderId}`))).json();
expect(order.status).toBe("failed");
const managed = await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${checkoutResult.manageToken}`));
expect(managed.status).toBe(404);
});
test("renews a due subscription automatically at its locked price", async () => {
let current = new Date("2026-01-31T12:00:00Z");
const app = createService(() => new Date(current));
const checkoutResult = await checkout(app);
await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${checkoutResult.orderId}/approve`, { method: "POST" }));
current = new Date("2026-03-01T12:00:00Z");
await app.runRenewals();
const managed = await (await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${checkoutResult.manageToken}`))).json();
expect(managed.status).toBe("active");
expect(managed.amountCents).toBe(900);
expect(managed.nextChargeAt).toBe("2026-03-31T12:00:00.000Z");
});
test("rejects client attempts to select unavailable products", async () => {
const app = createService();
const response = await app.fetch(new Request("http://payments.test/api/payments/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
productIds: ["crm"],
interval: "monthly",
customer: { name: "Mock Customer", email: "mock@example.test" },
}),
}));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "One or more selected apps are unavailable" });
});
test("charges a renewal once when executions overlap", async () => {
let renewCalls = 0;
const provider: PaymentProvider = {
name: "mock",
async begin(order) {
return { redirectUrl: `http://payments.test/api/payments/mock/checkout/${order.id}` };
},
async renew(order) {
renewCalls += 1;
const { promise, resolve } = Promise.withResolvers<void>();
queueMicrotask(resolve);
await promise;
return { success: true, transactionRef: `renewed_${order.id}` };
},
async recover() {
return null;
},
};
let current = new Date("2026-01-31T12:00:00Z");
const app = createService(() => new Date(current), provider);
const checkoutResult = await checkout(app);
await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${checkoutResult.orderId}/approve`, { method: "POST" }));
current = new Date("2026-03-01T12:00:00Z");
const app2 = createPaymentService({
catalog,
dbPath: join(directory!, "payments.db"),
publicUrl: "http://payments.test",
env: { PAYMENTS_PROVIDER: "mock", PAYMENTS_IDENTITY_SECRET: identitySecret, PAYMENTS_TRUSTED_PROXY_SECRET: proxySecret },
paymentProvider: provider,
now: () => new Date(current),
});
try {
await Promise.all([app.runRenewals(), app2.runRenewals()]);
expect(renewCalls).toBe(1);
} finally {
app2.close();
}
});
});
describe("disabled payments", () => {
test("keeps the organization domain available without exposing mock checkout", async () => {
directory = mkdtempSync(join(tmpdir(), "akurai-payments-disabled-test-"));
service = createPaymentService({
catalog,
dbPath: join(directory, "payments.db"),
publicUrl: "https://platform.example.test",
env: { PAYMENTS_PROVIDER: "disabled", PAYMENTS_IDENTITY_SECRET: identitySecret },
});
const health = await (await service.fetch(new Request("https://platform.example.test/api/payments/health"))).json();
expect(health).toMatchObject({ ok: true, provider: "disabled", paymentsEnabled: false });
const created = await service.fetch(new Request("https://platform.example.test/api/organizations", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${identityCredential("admin")}` },
body: JSON.stringify({ name: "Acme" }),
}));
expect(created.status).toBe(201);
const checkoutResponse = await service.fetch(new Request("https://platform.example.test/api/payments/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
productIds: ["mail"],
interval: "monthly",
customer: { name: "Customer", email: "customer@example.test" },
}),
}));
expect(checkoutResponse.status).toBe(503);
expect(await checkoutResponse.json()).toEqual({ error: "Payments are not configured" });
});
});
describe("organization domain", () => {
test("isolates tenants, enforces roles, and keeps idempotent lifecycle state", async () => {
const app = createService();
const request = (path: string, init: RequestInit = {}, user = "admin") => new Request(`http://payments.test${path}`, {
...init,
headers: { "content-type": "application/json", authorization: `Bearer ${identityCredential(user)}`, ...(init.headers ?? {}) },
});
expect((await app.fetch(new Request("http://payments.test/api/organizations"))).status).toBe(401);
expect((await app.fetch(new Request("http://payments.test/api/organizations", {
headers: { "x-akurai-user-id": "admin" },
}))).status).toBe(401);
expect((await app.fetch(new Request("http://payments.test/api/organizations", { headers: signedIdentityHeaders("proxy-user") }))).status).toBe(200);
const created = await (await app.fetch(request("/api/organizations", {
method: "POST",
body: JSON.stringify({ name: "Acme", workspaceName: "Acme Managed" }),
}))).json();
expect(created.organization.organizationId).toMatch(/^org_/);
expect(created.workspace.workspaceId).toMatch(/^ws_/);
expect(created.membership.role).toBe("admin");
const organizationId = created.organization.organizationId;
const workspaceId = created.workspace.workspaceId;
const member = await (await app.fetch(request(`/api/organizations/${organizationId}/members`, {
method: "POST",
body: JSON.stringify({ userId: "member", role: "member" }),
}))).json();
expect(member.membership.role).toBe("member");
const denied = await app.fetch(request(`/api/organizations/${organizationId}/workspaces`, {
method: "POST",
body: JSON.stringify({ name: "Denied" }),
}, "member"));
expect(denied.status).toBe(403);
const memberRead = await app.fetch(request(`/api/organizations/${organizationId}`, {}, "member"));
expect(memberRead.status).toBe(200);
expect((await app.fetch(request(`/api/organizations/${organizationId}/lifecycle`, {}, "member"))).status).toBe(403);
expect((await app.fetch(request("/api/organizations", {
method: "POST",
body: JSON.stringify({ name: "Linked", crmCompanyId: "crm-target" }),
}))).status).toBe(400);
const other = await (await app.fetch(request("/api/organizations", {
method: "POST",
body: JSON.stringify({ name: "Other" }),
}, "other"))).json();
const isolated = await app.fetch(request(`/api/organizations/${other.organization.organizationId}`, {}, "admin"));
expect(isolated.status).toBe(404);
const grant = await (await app.fetch(request(`/api/organizations/${organizationId}/entitlements`, {
method: "POST",
body: JSON.stringify({ workspaceId, appId: "mail", action: "grant" }),
}))).json();
const grantAgain = await (await app.fetch(request(`/api/organizations/${organizationId}/entitlements`, {
method: "POST",
body: JSON.stringify({ workspaceId, appId: "mail", action: "grant" }),
}))).json();
expect(grant.entitlement.entitlementId).toBe(grantAgain.entitlement.entitlementId);
const revoked = await (await app.fetch(request(`/api/organizations/${organizationId}/entitlements`, {
method: "POST",
body: JSON.stringify({ workspaceId, appId: "mail", action: "revoke" }),
}))).json();
expect(revoked.entitlement.status).toBe("revoked");
const revokedAgain = await (await app.fetch(request(`/api/organizations/${organizationId}/entitlements`, {
method: "POST",
body: JSON.stringify({ workspaceId, appId: "mail", action: "revoke" }),
}))).json();
expect(revokedAgain.entitlement.entitlementId).toBe(revoked.entitlement.entitlementId);
const installation = await (await app.fetch(request(`/api/organizations/${organizationId}/installations`, {
method: "POST",
body: JSON.stringify({ workspaceId, appId: "mail" }),
}))).json();
expect(installation.installation.status).toBe("pending");
const domain = await (await app.fetch(request(`/api/organizations/${organizationId}/domains`, {
method: "POST",
body: JSON.stringify({ workspaceId, hostname: "acme.example.test" }),
}))).json();
expect(domain.domain.status).toBe("pending");
const lifecycle = await (await app.fetch(request(`/api/organizations/${organizationId}/lifecycle`))).json();
expect(lifecycle.revisions.length).toBeGreaterThanOrEqual(7);
});
test("binds paid workspaces to the signed-in owner and verifies export, restore, retention, and deletion", async () => {
let current = new Date("2026-01-31T12:00:00Z");
const app = createService(() => new Date(current));
const payment = await checkout(app, "monthly", ["mail"], "owner");
await app.fetch(new Request(`http://payments.test/api/payments/mock/orders/${payment.orderId}/approve`, { method: "POST" }));
const request = (path: string, init: RequestInit = {}) => new Request(`http://payments.test${path}`, {
...init,
headers: { "content-type": "application/json", authorization: `Bearer ${identityCredential("owner")}`, ...(init.headers ?? {}) },
});
const organizations = await (await app.fetch(request("/api/organizations"))).json();
const organizationId = organizations.organizations[0].organizationId;
const backups = await (await app.fetch(request(`/api/organizations/${organizationId}/backups`))).json();
expect(backups.backups).toEqual([]);
const order = await (await app.fetch(new Request(`http://payments.test/api/payments/orders/${payment.orderId}`))).json();
expect(order.provisioning).toEqual({ status: "entitled", errorCode: null });
expect(order.ready).toBe(false);
const exported = await (await app.fetch(request(`/api/organizations/${organizationId}/lifecycle/export`, { method: "POST", body: "{}" }))).json();
expect(exported.export.integrityHash).toMatch(/^[a-f0-9]{64}$/);
await app.fetch(new Request(`http://payments.test/api/payments/subscriptions/${payment.manageToken}?action=cancel`, { method: "POST" }));
expect((await app.fetch(request(`/api/organizations/${organizationId}/lifecycle/restore`, {
method: "POST", body: JSON.stringify({ exportId: exported.export.id }),
}))).status).toBe(200);
current = new Date("2026-05-01T12:00:00Z");
await app.runRenewals();
const workspace = await (await app.fetch(request(`/api/organizations/${organizationId}/workspaces`))).json();
expect(workspace.workspaces[0].status).toBe("deprovisioned");
const deletion = await (await app.fetch(request(`/api/organizations/${organizationId}/lifecycle/delete-request`, { method: "POST", body: "{}" }))).json();
expect((await app.fetch(request(`/api/organizations/${organizationId}/lifecycle/delete-confirm`, {
method: "POST",
body: JSON.stringify({ deletionId: deletion.deletionId, deleteToken: deletion.deleteToken, confirmation: `DELETE ${organizationId}` }),
}))).status).toBe(200);
expect((await app.fetch(request(`/api/organizations/${organizationId}`))).status).toBe(404);
});
test("migration preserves payment rows", async () => {
const app = createService();
const result = await checkout(app);
app.close();
service = createPaymentService({
catalog,
dbPath: join(directory!, "payments.db"),
publicUrl: "http://payments.test",
env: { PAYMENTS_PROVIDER: "mock" },
now: () => new Date("2026-07-17T12:00:00Z"),
});
const order = await (await service.fetch(new Request(`http://payments.test/api/payments/orders/${result.orderId}`))).json();
expect(order.id).toBe(result.orderId);
expect(order.status).toBe("pending");
});
});