commit
8a99a1a792
33 changed files with 6631 additions and 1638 deletions
|
|
@ -14,7 +14,7 @@ steps:
|
|||
- '--region'
|
||||
- 'europe-west1'
|
||||
- '--set-env-vars'
|
||||
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS'
|
||||
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,REVENUECAT_WEBHOOK_AUTH_HEADER=$_REVENUECAT_WEBHOOK_AUTH_HEADER,REVENUECAT_SOLO_PRODUCT_ID=$_REVENUECAT_SOLO_PRODUCT_ID,REVENUECAT_ANNUAL_PRODUCT_ID=$_REVENUECAT_ANNUAL_PRODUCT_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS'
|
||||
images:
|
||||
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'
|
||||
|
||||
|
|
|
|||
79
apps/api/src/__tests__/config/revenuecat-config.test.ts
Normal file
79
apps/api/src/__tests__/config/revenuecat-config.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createConfig } from "../../config.js";
|
||||
import type { Secrets } from "../../secrets.js";
|
||||
|
||||
const baseSecrets: Secrets = {
|
||||
adminTokenSigningSecret: "admin-token-signing-secret",
|
||||
clientAuthJwtSecret: "client-auth-jwt-secret",
|
||||
supabaseServiceRoleKey: "service-role-from-secret-manager",
|
||||
supabaseConnectionString: "postgres://secret-manager",
|
||||
supabaseCaCert: "ca-cert",
|
||||
emailClientSecret: "email-client-secret",
|
||||
emailRefreshToken: "email-refresh-token",
|
||||
r2AccessKeyId: "r2-access-key-id",
|
||||
r2SecretAccessKey: "r2-secret-access-key",
|
||||
stripeSecretKey: "sk_live_secret_manager",
|
||||
stripeWebhookSecret: "whsec_live_secret_manager",
|
||||
stripeSecretKeyStaging: "sk_live_staging_secret_manager",
|
||||
stripeWebhookSecretStaging: "whsec_live_staging_secret_manager",
|
||||
};
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
const setRequiredBaseEnv = () => {
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.SUPABASE_URL = "https://example.supabase.co";
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key-for-tests";
|
||||
process.env.SUPABASE_CONNECTION_STRING = "postgres://localhost/test";
|
||||
process.env.SUPABASE_CA_CERT = "test-ca-cert";
|
||||
process.env.STRIPE_SECRET_KEY = "sk_test_env_override";
|
||||
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_env_override";
|
||||
process.env.STRIPE_SOLO_PRICE_ID = "price_solo";
|
||||
process.env.STRIPE_TEAM_PRICE_ID = "price_team";
|
||||
process.env.STRIPE_FOUNDER_PRICE_ID = "price_founder";
|
||||
process.env.EMAIL_USER = "test@xtablo.com";
|
||||
process.env.EMAIL_CLIENT_ID = "client-id";
|
||||
process.env.EMAIL_CLIENT_SECRET = "email-client-secret";
|
||||
process.env.EMAIL_REFRESH_TOKEN = "email-refresh-token";
|
||||
process.env.R2_ACCOUNT_ID = "r2-account";
|
||||
process.env.R2_ACCESS_KEY_ID = "r2-access-key";
|
||||
process.env.R2_SECRET_ACCESS_KEY = "r2-secret";
|
||||
process.env.ADMIN_TOKEN_SIGNING_SECRET = "admin-signing-secret";
|
||||
process.env.REVENUECAT_WEBHOOK_AUTH_HEADER = "Bearer revenuecat-secret";
|
||||
process.env.REVENUECAT_SOLO_PRODUCT_ID = "solo_ios_monthly";
|
||||
process.env.REVENUECAT_ANNUAL_PRODUCT_ID = "annual_ios";
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe("createConfig revenuecat env", () => {
|
||||
it("loads revenuecat webhook auth and product ids in test mode", () => {
|
||||
setRequiredBaseEnv();
|
||||
|
||||
const config = createConfig(baseSecrets);
|
||||
|
||||
expect(config.REVENUECAT_WEBHOOK_AUTH_HEADER).toBe("Bearer revenuecat-secret");
|
||||
expect(config.REVENUECAT_SOLO_PRODUCT_ID).toBe("solo_ios_monthly");
|
||||
expect(config.REVENUECAT_ANNUAL_PRODUCT_ID).toBe("annual_ios");
|
||||
});
|
||||
|
||||
it("throws when the revenuecat webhook auth header is missing", () => {
|
||||
setRequiredBaseEnv();
|
||||
process.env.REVENUECAT_WEBHOOK_AUTH_HEADER = "";
|
||||
|
||||
expect(() => createConfig(baseSecrets)).toThrow(
|
||||
"Missing required environment variable: REVENUECAT_WEBHOOK_AUTH_HEADER"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the annual product id is missing", () => {
|
||||
setRequiredBaseEnv();
|
||||
process.env.REVENUECAT_ANNUAL_PRODUCT_ID = "";
|
||||
|
||||
expect(() => createConfig(baseSecrets)).toThrow(
|
||||
"Missing required environment variable: REVENUECAT_ANNUAL_PRODUCT_ID"
|
||||
);
|
||||
});
|
||||
});
|
||||
51
apps/api/src/__tests__/helpers/appleBilling.test.ts
Normal file
51
apps/api/src/__tests__/helpers/appleBilling.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapAppleProductToPlan,
|
||||
normalizeAppleSubscriptionStatus,
|
||||
toAppleBillingCandidate,
|
||||
type AppleBillingCandidate,
|
||||
} from "../../helpers/appleBilling.js";
|
||||
|
||||
describe("apple billing helpers", () => {
|
||||
const productConfig = {
|
||||
annualProductId: "annual_ios",
|
||||
soloProductId: "solo_ios_monthly",
|
||||
};
|
||||
|
||||
it("maps the configured solo product id to the solo billing plan", () => {
|
||||
expect(mapAppleProductToPlan("solo_ios_monthly", productConfig)).toBe("solo");
|
||||
});
|
||||
|
||||
it("maps the configured annual product id to the annual billing plan", () => {
|
||||
expect(mapAppleProductToPlan("annual_ios", productConfig)).toBe("annual");
|
||||
});
|
||||
|
||||
it("returns null for unmapped products", () => {
|
||||
expect(mapAppleProductToPlan("team_ios_monthly", productConfig)).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes expiration-like events to expired", () => {
|
||||
expect(normalizeAppleSubscriptionStatus("EXPIRATION", false)).toBe("expired");
|
||||
expect(normalizeAppleSubscriptionStatus("CANCELLATION", true)).toBe("canceled");
|
||||
});
|
||||
|
||||
it("treats initial purchases as active until further lifecycle changes arrive", () => {
|
||||
expect(normalizeAppleSubscriptionStatus("INITIAL_PURCHASE", false)).toBe("active");
|
||||
expect(normalizeAppleSubscriptionStatus("RENEWAL", false)).toBe("active");
|
||||
});
|
||||
|
||||
it("always shapes Apple billing candidates with quantity one", () => {
|
||||
const candidate = toAppleBillingCandidate({
|
||||
currentPeriodEnd: "2026-06-01T00:00:00.000Z",
|
||||
plan: "annual",
|
||||
status: "active",
|
||||
}) as AppleBillingCandidate;
|
||||
|
||||
expect(candidate).toMatchObject({
|
||||
plan: "annual",
|
||||
quantity: 1,
|
||||
status: "active",
|
||||
});
|
||||
expect(candidate.currentPeriodEnd).toBe(Date.parse("2026-06-01T00:00:00.000Z") / 1000);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getOrganizationOwner,
|
||||
getTrialWindow,
|
||||
parseTrialRolloutDate,
|
||||
selectBestBillingCandidate,
|
||||
} from "../../helpers/billing.js";
|
||||
|
||||
describe("billing helpers", () => {
|
||||
|
|
@ -91,4 +92,54 @@ describe("billing helpers", () => {
|
|||
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
|
||||
expect(result.isTrialExpired).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers annual access over lower-tier plans when selecting the winning entitlement", () => {
|
||||
const winner = selectBestBillingCandidate(
|
||||
[
|
||||
{
|
||||
currentPeriodEnd: 100,
|
||||
plan: "solo",
|
||||
quantity: 1,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
currentPeriodEnd: 50,
|
||||
plan: "annual",
|
||||
quantity: 1,
|
||||
status: "canceled",
|
||||
},
|
||||
],
|
||||
"solo"
|
||||
);
|
||||
|
||||
expect(winner).toEqual({ plan: "annual", quantity: 1 });
|
||||
});
|
||||
|
||||
it("prefers team over solo when both are valid", () => {
|
||||
const winner = selectBestBillingCandidate(
|
||||
[
|
||||
{
|
||||
currentPeriodEnd: 100,
|
||||
plan: "solo",
|
||||
quantity: 1,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
currentPeriodEnd: 80,
|
||||
plan: "team",
|
||||
quantity: 3,
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
"solo"
|
||||
);
|
||||
|
||||
expect(winner).toEqual({ plan: "team", quantity: 3 });
|
||||
});
|
||||
|
||||
it("returns no active subscription when no billing candidates exist", () => {
|
||||
const winner = selectBestBillingCandidate([], "team");
|
||||
|
||||
expect(winner).toEqual({ plan: null, quantity: 0 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
108
apps/api/src/__tests__/helpers/organizationMembers.test.ts
Normal file
108
apps/api/src/__tests__/helpers/organizationMembers.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadOrganizationMembers } from "../../helpers/organizationMembers.js";
|
||||
|
||||
const buildSupabase = ({
|
||||
fallbackResult,
|
||||
primaryResult,
|
||||
}: {
|
||||
primaryResult: { data: unknown; error: { message: string } | null };
|
||||
fallbackResult?: { data: unknown; error: { message: string } | null };
|
||||
}) => {
|
||||
const single = vi.fn().mockResolvedValue(
|
||||
fallbackResult ?? {
|
||||
data: null,
|
||||
error: { message: "fallback failed" },
|
||||
}
|
||||
);
|
||||
const order = vi.fn().mockResolvedValue(primaryResult);
|
||||
const primaryEq = vi.fn(() => ({ order }));
|
||||
const fallbackEq = vi.fn(() => ({ single }));
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => ({ eq: primaryEq }))
|
||||
.mockImplementationOnce(() => ({ eq: fallbackEq }));
|
||||
const from = vi.fn(() => ({ select }));
|
||||
|
||||
return {
|
||||
supabase: {
|
||||
from,
|
||||
},
|
||||
spies: {
|
||||
from,
|
||||
order,
|
||||
primaryEq,
|
||||
fallbackEq,
|
||||
select,
|
||||
single,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("loadOrganizationMembers", () => {
|
||||
it("returns organization members when the primary query succeeds", async () => {
|
||||
const members = [{ email: "owner@example.com", id: "owner-id" }];
|
||||
const { spies, supabase } = buildSupabase({
|
||||
primaryResult: {
|
||||
data: members,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await loadOrganizationMembers(supabase as never, {
|
||||
organizationId: 42,
|
||||
userId: "owner-id",
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(members);
|
||||
expect(result.error).toBeNull();
|
||||
expect(spies.from).toHaveBeenCalledTimes(1);
|
||||
expect(spies.primaryEq).toHaveBeenCalledWith("organization_id", 42);
|
||||
expect(spies.order).toHaveBeenCalledWith("created_at", { ascending: true });
|
||||
});
|
||||
|
||||
it("falls back to the current user profile when the members query fails", async () => {
|
||||
const fallbackMember = { email: "owner@example.com", id: "owner-id" };
|
||||
const { spies, supabase } = buildSupabase({
|
||||
primaryResult: {
|
||||
data: null,
|
||||
error: { message: "members query failed" },
|
||||
},
|
||||
fallbackResult: {
|
||||
data: fallbackMember,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await loadOrganizationMembers(supabase as never, {
|
||||
organizationId: 42,
|
||||
userId: "owner-id",
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([fallbackMember]);
|
||||
expect(result.error).toBeNull();
|
||||
expect(spies.from).toHaveBeenCalledTimes(2);
|
||||
expect(spies.fallbackEq).toHaveBeenCalledWith("id", "owner-id");
|
||||
expect(spies.single).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the fallback error when both queries fail", async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
primaryResult: {
|
||||
data: null,
|
||||
error: { message: "members query failed" },
|
||||
},
|
||||
fallbackResult: {
|
||||
data: null,
|
||||
error: { message: "fallback profile failed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await loadOrganizationMembers(supabase as never, {
|
||||
organizationId: 42,
|
||||
userId: "owner-id",
|
||||
});
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toEqual({ message: "fallback profile failed" });
|
||||
});
|
||||
});
|
||||
145
apps/api/src/__tests__/routes/revenuecat.test.ts
Normal file
145
apps/api/src/__tests__/routes/revenuecat.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { createClient } from "@supabase/supabase-js";
|
||||
import { testClient } from "hono/testing";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createConfig } from "../../config.js";
|
||||
import { MiddlewareManager } from "../../middlewares/middleware.js";
|
||||
import { getMainRouter } from "../../routers/index.js";
|
||||
import { getTestUser } from "../helpers/testUtils.js";
|
||||
|
||||
describe("RevenueCat Webhook Endpoint", () => {
|
||||
const config = createConfig();
|
||||
MiddlewareManager.initialize(config);
|
||||
const app = getMainRouter(config);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
const client = testClient(app) as any;
|
||||
const ownerUser = getTestUser("owner");
|
||||
const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminClient.from("apple_subscription_events").delete().neq("id", 0);
|
||||
await adminClient.from("apple_subscriptions").delete().eq("owner_user_id", ownerUser.userId);
|
||||
await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId);
|
||||
});
|
||||
|
||||
it("rejects webhook calls without the configured authorization header", async () => {
|
||||
const res = await client["revenuecat-webhook"].$post({
|
||||
json: {
|
||||
event: {
|
||||
id: "unauthorized-event",
|
||||
type: "INITIAL_PURCHASE",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("persists a mapped Apple subscription once and treats retries as duplicates", async () => {
|
||||
const eventId = `event-${Date.now()}`;
|
||||
const originalTransactionId = `orig-${Date.now()}`;
|
||||
|
||||
const payload = {
|
||||
event: {
|
||||
aliases: [],
|
||||
app_user_id: ownerUser.userId,
|
||||
environment: "SANDBOX",
|
||||
expiration_at_ms: Date.now() + 86400000,
|
||||
id: eventId,
|
||||
original_app_user_id: ownerUser.userId,
|
||||
original_transaction_id: originalTransactionId,
|
||||
product_id: config.REVENUECAT_SOLO_PRODUCT_ID,
|
||||
purchased_at_ms: Date.now(),
|
||||
store: "APP_STORE",
|
||||
transaction_id: `txn-${Date.now()}`,
|
||||
type: "INITIAL_PURCHASE",
|
||||
},
|
||||
};
|
||||
|
||||
const firstRes = await client["revenuecat-webhook"].$post(
|
||||
{ json: payload },
|
||||
{
|
||||
headers: {
|
||||
authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(firstRes.status).toBe(200);
|
||||
|
||||
const secondRes = await client["revenuecat-webhook"].$post(
|
||||
{ json: payload },
|
||||
{
|
||||
headers: {
|
||||
authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(secondRes.status).toBe(200);
|
||||
expect(await secondRes.json()).toMatchObject({ duplicate: true, received: true });
|
||||
|
||||
const { data: appleSubscriptions } = await adminClient
|
||||
.from("apple_subscriptions")
|
||||
.select("owner_user_id, plan, status, original_transaction_id")
|
||||
.eq("original_transaction_id", originalTransactionId);
|
||||
|
||||
expect(appleSubscriptions).toHaveLength(1);
|
||||
expect(appleSubscriptions?.[0]).toMatchObject({
|
||||
owner_user_id: ownerUser.userId,
|
||||
plan: "solo",
|
||||
status: "active",
|
||||
original_transaction_id: originalTransactionId,
|
||||
});
|
||||
|
||||
const { data: appleEvents } = await adminClient
|
||||
.from("apple_subscription_events")
|
||||
.select("event_id")
|
||||
.eq("event_id", eventId);
|
||||
|
||||
expect(appleEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores unmapped products without creating entitlement rows", async () => {
|
||||
const res = await client["revenuecat-webhook"].$post(
|
||||
{
|
||||
json: {
|
||||
event: {
|
||||
app_user_id: ownerUser.userId,
|
||||
environment: "SANDBOX",
|
||||
expiration_at_ms: Date.now() + 86400000,
|
||||
id: `ignored-${Date.now()}`,
|
||||
original_transaction_id: `orig-ignored-${Date.now()}`,
|
||||
product_id: "team_ios_monthly",
|
||||
purchased_at_ms: Date.now(),
|
||||
store: "APP_STORE",
|
||||
transaction_id: `txn-ignored-${Date.now()}`,
|
||||
type: "INITIAL_PURCHASE",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ ignored: true, reason: "unmapped_product" });
|
||||
|
||||
const { data: rows } = await adminClient
|
||||
.from("apple_subscriptions")
|
||||
.select("id")
|
||||
.eq("owner_user_id", ownerUser.userId);
|
||||
|
||||
expect(rows ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -226,6 +226,84 @@ describe("User Endpoint", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("GET /organization - Billing State", () => {
|
||||
it("returns Apple-backed active access for the authenticated owner", async () => {
|
||||
const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
const organizationName = `Apple Billing Org ${Date.now()}`;
|
||||
const originalTransactionId = `org-apple-${Date.now()}`;
|
||||
|
||||
const { data: organization, error: organizationError } = await adminClient
|
||||
.from("organizations")
|
||||
.insert({
|
||||
name: organizationName,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
expect(organizationError).toBeNull();
|
||||
|
||||
const organizationId = organization?.id as number;
|
||||
const { error: profileUpdateError } = await adminClient
|
||||
.from("profiles")
|
||||
.update({ organization_id: organizationId })
|
||||
.eq("id", ownerUser.userId);
|
||||
expect(profileUpdateError).toBeNull();
|
||||
|
||||
await adminClient.from("apple_subscriptions").delete().eq("owner_user_id", ownerUser.userId);
|
||||
await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId);
|
||||
|
||||
const { error: customerError } = await adminClient.from("apple_customers").insert({
|
||||
revenuecat_app_user_id: ownerUser.userId,
|
||||
user_id: ownerUser.userId,
|
||||
});
|
||||
expect(customerError).toBeNull();
|
||||
|
||||
const { error: subscriptionError } = await adminClient.from("apple_subscriptions").insert({
|
||||
current_period_end: new Date(Date.now() + 86400000).toISOString(),
|
||||
current_period_start: new Date().toISOString(),
|
||||
environment: "SANDBOX",
|
||||
original_transaction_id: originalTransactionId,
|
||||
owner_user_id: ownerUser.userId,
|
||||
plan: "annual",
|
||||
revenuecat_app_user_id: ownerUser.userId,
|
||||
status: "active",
|
||||
store: "APP_STORE",
|
||||
store_product_id: "annual_ios",
|
||||
});
|
||||
expect(subscriptionError).toBeNull();
|
||||
|
||||
const res = await client.users.organization.$get(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ownerUser.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.active_subscription_plan).toBe("annual");
|
||||
expect(data.is_billing_owner).toBe(true);
|
||||
|
||||
await adminClient
|
||||
.from("profiles")
|
||||
.update({ organization_id: null })
|
||||
.eq("id", ownerUser.userId);
|
||||
await adminClient
|
||||
.from("apple_subscriptions")
|
||||
.delete()
|
||||
.eq("original_transaction_id", originalTransactionId);
|
||||
await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId);
|
||||
await adminClient.from("organizations").delete().eq("id", organizationId);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /me must run last — it hard-deletes the auth user, making ownerUser unusable for subsequent tests
|
||||
describe("DELETE /me - Delete Account", () => {
|
||||
it("should return 401 when unauthenticated", async () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface AppConfig {
|
|||
STRIPE_SOLO_PRICE_ID: string;
|
||||
STRIPE_TEAM_PRICE_ID: string;
|
||||
STRIPE_FOUNDER_PRICE_ID: string;
|
||||
REVENUECAT_WEBHOOK_AUTH_HEADER: string;
|
||||
REVENUECAT_SOLO_PRODUCT_ID: string;
|
||||
REVENUECAT_ANNUAL_PRODUCT_ID: string;
|
||||
EMAIL_USER: string;
|
||||
EMAIL_CLIENT_ID: string;
|
||||
EMAIL_CLIENT_SECRET: string;
|
||||
|
|
@ -133,6 +136,18 @@ export function createConfig(secrets?: Secrets): AppConfig {
|
|||
"STRIPE_FOUNDER_PRICE_ID",
|
||||
process.env.STRIPE_FOUNDER_PRICE_ID
|
||||
),
|
||||
REVENUECAT_WEBHOOK_AUTH_HEADER: validateEnvVar(
|
||||
"REVENUECAT_WEBHOOK_AUTH_HEADER",
|
||||
process.env.REVENUECAT_WEBHOOK_AUTH_HEADER
|
||||
),
|
||||
REVENUECAT_SOLO_PRODUCT_ID: validateEnvVar(
|
||||
"REVENUECAT_SOLO_PRODUCT_ID",
|
||||
process.env.REVENUECAT_SOLO_PRODUCT_ID
|
||||
),
|
||||
REVENUECAT_ANNUAL_PRODUCT_ID: validateEnvVar(
|
||||
"REVENUECAT_ANNUAL_PRODUCT_ID",
|
||||
process.env.REVENUECAT_ANNUAL_PRODUCT_ID
|
||||
),
|
||||
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
|
||||
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
|
||||
EMAIL_CLIENT_SECRET: isTestMode
|
||||
|
|
|
|||
105
apps/api/src/helpers/appleBilling.ts
Normal file
105
apps/api/src/helpers/appleBilling.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
export type AppleBillingStatus =
|
||||
| "active"
|
||||
| "expired"
|
||||
| "canceled"
|
||||
| "refunded"
|
||||
| "revoked"
|
||||
| "unknown";
|
||||
|
||||
export type AppleBillingCandidate = {
|
||||
currentPeriodEnd: number;
|
||||
plan: "solo" | "annual";
|
||||
quantity: 1;
|
||||
status: AppleBillingStatus;
|
||||
};
|
||||
|
||||
export const APPLE_ACCESSIBLE_STATUSES: AppleBillingStatus[] = ["active", "canceled"];
|
||||
|
||||
export function mapAppleProductToPlan(
|
||||
productId: string | null | undefined,
|
||||
config: {
|
||||
annualProductId: string;
|
||||
soloProductId: string;
|
||||
}
|
||||
): "solo" | "annual" | null {
|
||||
if (!productId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (productId === config.soloProductId) {
|
||||
return "solo";
|
||||
}
|
||||
|
||||
if (productId === config.annualProductId) {
|
||||
return "annual";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeAppleSubscriptionStatus(
|
||||
eventType: string | null | undefined,
|
||||
cancelAtPeriodEnd: boolean
|
||||
): AppleBillingStatus {
|
||||
const normalizedEventType = (eventType ?? "").trim().toUpperCase();
|
||||
|
||||
if (normalizedEventType === "EXPIRATION") {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
if (normalizedEventType === "CANCELLATION") {
|
||||
return cancelAtPeriodEnd ? "canceled" : "revoked";
|
||||
}
|
||||
|
||||
if (normalizedEventType === "BILLING_ISSUE") {
|
||||
return "canceled";
|
||||
}
|
||||
|
||||
if (normalizedEventType === "SUBSCRIPTION_PAUSED") {
|
||||
return "canceled";
|
||||
}
|
||||
|
||||
if (normalizedEventType === "UNCANCELLATION") {
|
||||
return "active";
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedEventType === "INITIAL_PURCHASE" ||
|
||||
normalizedEventType === "NON_RENEWING_PURCHASE" ||
|
||||
normalizedEventType === "PRODUCT_CHANGE" ||
|
||||
normalizedEventType === "RENEWAL" ||
|
||||
normalizedEventType === "TRANSFER"
|
||||
) {
|
||||
return "active";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function toAppleBillingCandidate(input: {
|
||||
currentPeriodEnd: string | null;
|
||||
now?: Date;
|
||||
plan: "solo" | "annual";
|
||||
status: AppleBillingStatus;
|
||||
}): AppleBillingCandidate | null {
|
||||
if (!APPLE_ACCESSIBLE_STATUSES.includes(input.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPeriodEndMs = input.currentPeriodEnd ? Date.parse(input.currentPeriodEnd) : NaN;
|
||||
if (Number.isNaN(currentPeriodEndMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date();
|
||||
if (currentPeriodEndMs <= now.getTime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentPeriodEnd: Math.floor(currentPeriodEndMs / 1000),
|
||||
plan: input.plan,
|
||||
quantity: 1,
|
||||
status: input.status,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { toAppleBillingCandidate, type AppleBillingCandidate } from "./appleBilling.js";
|
||||
|
||||
export type BillingPlan = "solo" | "team" | "annual";
|
||||
export type RequiredBillingPlan = "solo" | "team";
|
||||
|
|
@ -41,6 +42,19 @@ type StripeProductRow = {
|
|||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
type AppleSubscriptionRow = {
|
||||
plan: string;
|
||||
status: string;
|
||||
current_period_end: string | null;
|
||||
};
|
||||
|
||||
export type BillingCandidate = {
|
||||
currentPeriodEnd: number;
|
||||
plan: BillingPlan;
|
||||
quantity: number;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
export type OrganizationBillingState = {
|
||||
owner_user_id: string;
|
||||
member_count: number;
|
||||
|
|
@ -56,6 +70,7 @@ export type OrganizationBillingState = {
|
|||
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
|
||||
const DEFAULT_BILLING_TRIAL_DAYS = 14;
|
||||
const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z";
|
||||
const RESTRICTED_SCHEMA_ERROR_FRAGMENT = "The schema must be one of the following";
|
||||
|
||||
const parseTrialDays = () => {
|
||||
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10);
|
||||
|
|
@ -114,6 +129,7 @@ const statusWeight = (status: string | null | undefined) => {
|
|||
if (status === "active") return 3;
|
||||
if (status === "past_due") return 2;
|
||||
if (status === "trialing") return 1;
|
||||
if (status === "canceled") return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
|
@ -147,6 +163,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu
|
|||
return null;
|
||||
};
|
||||
|
||||
const normalizeApplePlan = (plan: string | null | undefined): "solo" | "annual" | null => {
|
||||
const normalized = (plan ?? "").toLowerCase();
|
||||
|
||||
if (normalized === "annual") {
|
||||
return "annual";
|
||||
}
|
||||
|
||||
if (normalized === "solo") {
|
||||
return "solo";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
|
||||
const normalized = (plan ?? "").toLowerCase();
|
||||
|
||||
|
|
@ -195,157 +225,202 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId:
|
|||
return { error: null, profiles };
|
||||
};
|
||||
|
||||
const isRestrictedSchemaError = (message: string | null | undefined) =>
|
||||
Boolean(message?.includes(RESTRICTED_SCHEMA_ERROR_FRAGMENT));
|
||||
|
||||
export const selectBestBillingCandidate = (
|
||||
candidates: BillingCandidate[],
|
||||
ownerFallbackPlan: BillingPlan
|
||||
): { plan: BillingPlan | null; quantity: number } => {
|
||||
if (candidates.length === 0) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const sortedCandidates = [...candidates].sort((a, b) => {
|
||||
const byPlanWeight = planWeight(b.plan) - planWeight(a.plan);
|
||||
if (byPlanWeight !== 0) return byPlanWeight;
|
||||
|
||||
const byStatusWeight = statusWeight(b.status) - statusWeight(a.status);
|
||||
if (byStatusWeight !== 0) return byStatusWeight;
|
||||
|
||||
return b.currentPeriodEnd - a.currentPeriodEnd;
|
||||
});
|
||||
|
||||
const winner = sortedCandidates[0];
|
||||
return {
|
||||
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
|
||||
quantity: winner.quantity,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveActiveSubscription = async (
|
||||
supabase: SupabaseClient,
|
||||
ownerUserId: string,
|
||||
ownerProfilePlan: string | null
|
||||
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
|
||||
const { data: customers, error: customersError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000);
|
||||
|
||||
if (customersError) {
|
||||
throw new Error(customersError.message);
|
||||
}
|
||||
|
||||
const customer = (customers as StripeCustomerRow[] | null)?.find(
|
||||
(candidate) => candidate.metadata?.user_id === ownerUserId
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const { data: subscriptions, error: subscriptionsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscriptions")
|
||||
.select("id, status, created, current_period_end")
|
||||
.eq("customer", customer.id)
|
||||
.in("status", ACTIVE_BILLING_STATUSES);
|
||||
|
||||
if (subscriptionsError) {
|
||||
throw new Error(subscriptionsError.message);
|
||||
}
|
||||
|
||||
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
|
||||
if (normalizedSubscriptions.length === 0) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
|
||||
|
||||
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
|
||||
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscription_items")
|
||||
.select("subscription, price, quantity")
|
||||
.in("subscription", subscriptionIds);
|
||||
|
||||
if (subscriptionItemsError) {
|
||||
throw new Error(subscriptionItemsError.message);
|
||||
}
|
||||
|
||||
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
|
||||
const priceIds = Array.from(
|
||||
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
|
||||
);
|
||||
|
||||
let pricesById = new Map<string, StripePriceRow>();
|
||||
let productsById = new Map<string, StripeProductRow>();
|
||||
|
||||
if (priceIds.length > 0) {
|
||||
const { data: prices, error: pricesError } = await supabase
|
||||
let stripeCandidates: BillingCandidate[] = [];
|
||||
try {
|
||||
const { data: customers, error: customersError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("id, lookup_key, metadata, product")
|
||||
.in("id", priceIds);
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000);
|
||||
|
||||
if (pricesError) {
|
||||
throw new Error(pricesError.message);
|
||||
if (customersError) {
|
||||
throw new Error(customersError.message);
|
||||
}
|
||||
|
||||
const normalizedPrices = (prices ?? []) as StripePriceRow[];
|
||||
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
|
||||
|
||||
const productIds = Array.from(
|
||||
new Set(
|
||||
normalizedPrices
|
||||
.map((price) => price.product)
|
||||
.filter((product): product is string => Boolean(product))
|
||||
)
|
||||
const customer = (customers as StripeCustomerRow[] | null)?.find(
|
||||
(candidate) => candidate.metadata?.user_id === ownerUserId
|
||||
);
|
||||
|
||||
if (productIds.length > 0) {
|
||||
const { data: products, error: productsError } = await supabase
|
||||
if (customer) {
|
||||
const { data: subscriptions, error: subscriptionsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("products")
|
||||
.select("id, name, metadata")
|
||||
.in("id", productIds);
|
||||
.from("subscriptions")
|
||||
.select("id, status, created, current_period_end")
|
||||
.eq("customer", customer.id)
|
||||
.in("status", ACTIVE_BILLING_STATUSES);
|
||||
|
||||
if (productsError) {
|
||||
throw new Error(productsError.message);
|
||||
if (subscriptionsError) {
|
||||
throw new Error(subscriptionsError.message);
|
||||
}
|
||||
|
||||
const normalizedProducts = (products ?? []) as StripeProductRow[];
|
||||
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
|
||||
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
|
||||
|
||||
if (normalizedSubscriptions.length > 0) {
|
||||
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
|
||||
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscription_items")
|
||||
.select("subscription, price, quantity")
|
||||
.in("subscription", subscriptionIds);
|
||||
|
||||
if (subscriptionItemsError) {
|
||||
throw new Error(subscriptionItemsError.message);
|
||||
}
|
||||
|
||||
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
|
||||
const priceIds = Array.from(
|
||||
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
|
||||
);
|
||||
|
||||
let pricesById = new Map<string, StripePriceRow>();
|
||||
let productsById = new Map<string, StripeProductRow>();
|
||||
|
||||
if (priceIds.length > 0) {
|
||||
const { data: prices, error: pricesError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("id, lookup_key, metadata, product")
|
||||
.in("id", priceIds);
|
||||
|
||||
if (pricesError) {
|
||||
throw new Error(pricesError.message);
|
||||
}
|
||||
|
||||
const normalizedPrices = (prices ?? []) as StripePriceRow[];
|
||||
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
|
||||
|
||||
const productIds = Array.from(
|
||||
new Set(
|
||||
normalizedPrices
|
||||
.map((price) => price.product)
|
||||
.filter((product): product is string => Boolean(product))
|
||||
)
|
||||
);
|
||||
|
||||
if (productIds.length > 0) {
|
||||
const { data: products, error: productsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("products")
|
||||
.select("id, name, metadata")
|
||||
.in("id", productIds);
|
||||
|
||||
if (productsError) {
|
||||
throw new Error(productsError.message);
|
||||
}
|
||||
|
||||
const normalizedProducts = (products ?? []) as StripeProductRow[];
|
||||
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
|
||||
}
|
||||
}
|
||||
|
||||
stripeCandidates = normalizedSubscriptions.flatMap((subscription) => {
|
||||
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
|
||||
|
||||
if (relatedItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
currentPeriodEnd: subscription.current_period_end ?? subscription.created ?? 0,
|
||||
plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan,
|
||||
quantity: 1,
|
||||
status: subscription.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return relatedItems.map((item) => {
|
||||
const price = item.price ? pricesById.get(item.price) : undefined;
|
||||
const product = price?.product ? productsById.get(price.product) : undefined;
|
||||
const hint = getPlanHint(price, product);
|
||||
|
||||
return {
|
||||
currentPeriodEnd: subscription.current_period_end ?? subscription.created ?? 0,
|
||||
plan:
|
||||
inferBillingPlan(hint) ??
|
||||
(ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
|
||||
quantity: Math.max(1, item.quantity ?? 1),
|
||||
status: subscription.status,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isRestrictedSchemaError(error instanceof Error ? error.message : null)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = normalizedSubscriptions.flatMap((subscription) => {
|
||||
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
|
||||
const { data: appleSubscriptions, error: appleSubscriptionsError } = await supabase
|
||||
.from("apple_subscriptions")
|
||||
.select("plan, status, current_period_end")
|
||||
.eq("owner_user_id", ownerUserId);
|
||||
|
||||
if (appleSubscriptionsError) {
|
||||
throw new Error(appleSubscriptionsError.message);
|
||||
}
|
||||
|
||||
const appleCandidates: BillingCandidate[] = ((appleSubscriptions ?? []) as AppleSubscriptionRow[])
|
||||
.flatMap((subscription) => {
|
||||
const plan = normalizeApplePlan(subscription.plan);
|
||||
if (!plan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidate = toAppleBillingCandidate({
|
||||
currentPeriodEnd: subscription.current_period_end,
|
||||
plan,
|
||||
status: subscription.status as AppleBillingCandidate["status"],
|
||||
});
|
||||
|
||||
if (!candidate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (relatedItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
subscription,
|
||||
plan: null as BillingPlan | null,
|
||||
quantity: 1,
|
||||
currentPeriodEnd: candidate.currentPeriodEnd,
|
||||
plan: candidate.plan,
|
||||
quantity: candidate.quantity,
|
||||
status: candidate.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return relatedItems.map((item) => {
|
||||
const price = item.price ? pricesById.get(item.price) : undefined;
|
||||
const product = price?.product ? productsById.get(price.product) : undefined;
|
||||
const hint = getPlanHint(price, product);
|
||||
|
||||
return {
|
||||
subscription,
|
||||
plan: inferBillingPlan(hint),
|
||||
quantity: Math.max(1, item.quantity ?? 1),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 };
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const aPlan = a.plan ?? "team";
|
||||
const bPlan = b.plan ?? "team";
|
||||
|
||||
const byPlanWeight = planWeight(bPlan) - planWeight(aPlan);
|
||||
if (byPlanWeight !== 0) return byPlanWeight;
|
||||
|
||||
const byStatusWeight =
|
||||
statusWeight(b.subscription.status) - statusWeight(a.subscription.status);
|
||||
if (byStatusWeight !== 0) return byStatusWeight;
|
||||
|
||||
const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0;
|
||||
const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0;
|
||||
return bPeriodEnd - aPeriodEnd;
|
||||
});
|
||||
|
||||
const winner = candidates[0];
|
||||
return {
|
||||
// Keep legacy fallback for unknown paid subscriptions but prefer owner profile plan
|
||||
// so founder/beta organizations do not downgrade to team because of metadata gaps.
|
||||
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
|
||||
quantity: winner.quantity,
|
||||
};
|
||||
return selectBestBillingCandidate([...stripeCandidates, ...appleCandidates], ownerFallbackPlan);
|
||||
};
|
||||
|
||||
export const getOrganizationBillingState = async (
|
||||
|
|
|
|||
56
apps/api/src/helpers/organizationMembers.ts
Normal file
56
apps/api/src/helpers/organizationMembers.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export type OrganizationMember = {
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
email: string | null;
|
||||
first_name: string | null;
|
||||
id: string;
|
||||
last_name: string | null;
|
||||
name: string | null;
|
||||
plan: string | null;
|
||||
};
|
||||
|
||||
type LoadOrganizationMembersOptions = {
|
||||
organizationId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const organizationMemberSelect =
|
||||
"id, email, name, first_name, last_name, avatar_url, created_at, plan";
|
||||
|
||||
export const loadOrganizationMembers = async (
|
||||
supabase: SupabaseClient,
|
||||
{ organizationId, userId }: LoadOrganizationMembersOptions
|
||||
) => {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select(organizationMemberSelect)
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (!error) {
|
||||
return {
|
||||
data: (data as OrganizationMember[] | null) ?? [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = await supabase
|
||||
.from("profiles")
|
||||
.select(organizationMemberSelect)
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (fallback.error || !fallback.data) {
|
||||
return {
|
||||
data: null,
|
||||
error: fallback.error ?? error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [fallback.data as OrganizationMember],
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ import { getPublicClientInvitesRouter } from "./clientInvites.js";
|
|||
import { getClientPortalRouter } from "./clientPortal.js";
|
||||
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
|
||||
import { getPublicRouter } from "./public.js";
|
||||
import { getRevenueCatWebhookRouter } from "./revenuecat.js";
|
||||
import { getStripeWebhookRouter } from "./stripe.js";
|
||||
import { getTaskRouter } from "./tasks.js";
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ export const getMainRouter = (config: AppConfig) => {
|
|||
mainRouter.route("/tasks", getTaskRouter());
|
||||
|
||||
// webhooks
|
||||
mainRouter.route("/revenuecat-webhook", getRevenueCatWebhookRouter(config));
|
||||
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
|
||||
|
||||
// admin routes
|
||||
|
|
|
|||
223
apps/api/src/routers/revenuecat.ts
Normal file
223
apps/api/src/routers/revenuecat.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import {
|
||||
mapAppleProductToPlan,
|
||||
normalizeAppleSubscriptionStatus,
|
||||
} from "../helpers/appleBilling.js";
|
||||
import type { BaseEnv } from "../types/app.types.js";
|
||||
|
||||
type RevenueCatWebhookEvent = {
|
||||
aliases?: string[] | null;
|
||||
app_user_id?: string | null;
|
||||
environment?: string | null;
|
||||
expiration_at_ms?: number | null;
|
||||
id?: string | null;
|
||||
original_app_user_id?: string | null;
|
||||
original_transaction_id?: string | null;
|
||||
product_id?: string | null;
|
||||
purchased_at_ms?: number | null;
|
||||
store?: string | null;
|
||||
transaction_id?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
type RevenueCatWebhookBody = {
|
||||
api_version?: string;
|
||||
event?: RevenueCatWebhookEvent;
|
||||
};
|
||||
|
||||
const factory = createFactory<BaseEnv>();
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const toIsoTimestamp = (value: number | null | undefined) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null;
|
||||
|
||||
const collectCandidateAppUserIds = (event: RevenueCatWebhookEvent) => {
|
||||
const uniqueIds = new Set<string>();
|
||||
|
||||
for (const rawValue of [event.app_user_id, event.original_app_user_id, ...(event.aliases ?? [])]) {
|
||||
const value = rawValue?.trim();
|
||||
if (!value || !UUID_PATTERN.test(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueIds.add(value);
|
||||
}
|
||||
|
||||
return Array.from(uniqueIds);
|
||||
};
|
||||
|
||||
const resolveUserIdFromRevenueCatEvent = async (
|
||||
supabase: BaseEnv["Variables"]["supabase"],
|
||||
event: RevenueCatWebhookEvent
|
||||
) => {
|
||||
const candidateIds = collectCandidateAppUserIds(event);
|
||||
if (candidateIds.length > 0) {
|
||||
const { data: profiles, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.in("id", candidateIds)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const directMatch = (profiles ?? [])[0];
|
||||
if (directMatch?.id) {
|
||||
return directMatch.id as string;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupIds = [event.app_user_id, event.original_app_user_id, ...(event.aliases ?? [])].filter(
|
||||
(value): value is string => Boolean(value?.trim())
|
||||
);
|
||||
if (lookupIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: appleCustomers, error: appleCustomersError } = await supabase
|
||||
.from("apple_customers")
|
||||
.select("user_id, revenuecat_app_user_id")
|
||||
.in("revenuecat_app_user_id", lookupIds)
|
||||
.limit(1);
|
||||
|
||||
if (appleCustomersError) {
|
||||
throw new Error(appleCustomersError.message);
|
||||
}
|
||||
|
||||
return ((appleCustomers ?? [])[0]?.user_id as string | undefined) ?? null;
|
||||
};
|
||||
|
||||
const handleRevenueCatWebhook = (config: AppConfig) =>
|
||||
factory.createHandlers(async (c) => {
|
||||
const authorization = c.req.header("authorization");
|
||||
if (authorization !== config.REVENUECAT_WEBHOOK_AUTH_HEADER) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const body = (await c.req.json()) as RevenueCatWebhookBody;
|
||||
const event = body.event;
|
||||
if (!event?.id || !event.type) {
|
||||
return c.json({ error: "Invalid RevenueCat payload" }, 400);
|
||||
}
|
||||
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
const { data: existingEvent, error: existingEventError } = await supabase
|
||||
.from("apple_subscription_events")
|
||||
.select("id")
|
||||
.eq("event_id", event.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingEventError) {
|
||||
return c.json({ error: existingEventError.message }, 500);
|
||||
}
|
||||
|
||||
if (existingEvent?.id) {
|
||||
return c.json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
const { error: insertEventError } = await supabase.from("apple_subscription_events").insert({
|
||||
environment: event.environment ?? null,
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
payload: body,
|
||||
});
|
||||
|
||||
if (insertEventError) {
|
||||
return c.json({ error: insertEventError.message }, 500);
|
||||
}
|
||||
|
||||
const mappedPlan = mapAppleProductToPlan(event.product_id, {
|
||||
annualProductId: config.REVENUECAT_ANNUAL_PRODUCT_ID,
|
||||
soloProductId: config.REVENUECAT_SOLO_PRODUCT_ID,
|
||||
});
|
||||
|
||||
if (!mappedPlan) {
|
||||
await supabase
|
||||
.from("apple_subscription_events")
|
||||
.update({ processed_at: new Date().toISOString() })
|
||||
.eq("event_id", event.id);
|
||||
|
||||
return c.json({ ignored: true, received: true, reason: "unmapped_product" });
|
||||
}
|
||||
|
||||
const ownerUserId = await resolveUserIdFromRevenueCatEvent(supabase, event);
|
||||
if (!ownerUserId) {
|
||||
await supabase
|
||||
.from("apple_subscription_events")
|
||||
.update({ processed_at: new Date().toISOString() })
|
||||
.eq("event_id", event.id);
|
||||
|
||||
return c.json({ ignored: true, received: true, reason: "unknown_user" });
|
||||
}
|
||||
|
||||
const normalizedStatus = normalizeAppleSubscriptionStatus(event.type, event.type === "CANCELLATION");
|
||||
|
||||
const revenuecatAppUserId = event.app_user_id ?? event.original_app_user_id ?? ownerUserId;
|
||||
const currentPeriodEnd = toIsoTimestamp(event.expiration_at_ms);
|
||||
const currentPeriodStart = toIsoTimestamp(event.purchased_at_ms);
|
||||
|
||||
const { error: appleCustomerError } = await supabase.from("apple_customers").upsert(
|
||||
{
|
||||
last_seen_environment: event.environment ?? null,
|
||||
original_app_user_id: event.original_app_user_id ?? null,
|
||||
revenuecat_app_user_id: revenuecatAppUserId,
|
||||
updated_at: new Date().toISOString(),
|
||||
user_id: ownerUserId,
|
||||
},
|
||||
{ onConflict: "user_id" }
|
||||
);
|
||||
|
||||
if (appleCustomerError) {
|
||||
return c.json({ error: appleCustomerError.message }, 500);
|
||||
}
|
||||
|
||||
if (event.original_transaction_id) {
|
||||
const { error: appleSubscriptionError } = await supabase.from("apple_subscriptions").upsert(
|
||||
{
|
||||
cancel_at_period_end: normalizedStatus === "canceled",
|
||||
current_period_end: currentPeriodEnd,
|
||||
current_period_start: currentPeriodStart,
|
||||
environment: event.environment ?? "PRODUCTION",
|
||||
last_event_type: event.type,
|
||||
original_transaction_id: event.original_transaction_id,
|
||||
owner_user_id: ownerUserId,
|
||||
plan: mappedPlan,
|
||||
raw_customer_id: event.app_user_id ?? null,
|
||||
revenuecat_app_user_id: revenuecatAppUserId,
|
||||
revoked_at:
|
||||
normalizedStatus === "revoked" || normalizedStatus === "refunded"
|
||||
? new Date().toISOString()
|
||||
: null,
|
||||
status: normalizedStatus,
|
||||
store: event.store ?? "APP_STORE",
|
||||
store_product_id: event.product_id ?? "",
|
||||
transaction_id: event.transaction_id ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "original_transaction_id" }
|
||||
);
|
||||
|
||||
if (appleSubscriptionError) {
|
||||
return c.json({ error: appleSubscriptionError.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("apple_subscription_events")
|
||||
.update({ processed_at: new Date().toISOString() })
|
||||
.eq("event_id", event.id);
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
|
||||
export const getRevenueCatWebhookRouter = (config: AppConfig) => {
|
||||
const router = new Hono<BaseEnv>();
|
||||
router.post("/", ...handleRevenueCatWebhook(config));
|
||||
return router;
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import { Hono } from "hono";
|
|||
import { createFactory } from "hono/factory";
|
||||
import { getOrganizationBillingState } from "../helpers/billing.js";
|
||||
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
|
||||
import { loadOrganizationMembers } from "../helpers/organizationMembers.js";
|
||||
import { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js";
|
||||
import type { AuthEnv } from "../types/app.types.js";
|
||||
|
||||
|
|
@ -198,11 +199,10 @@ const getOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "Organization not found" }, 404);
|
||||
}
|
||||
|
||||
const { data: members, error: membersError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, email, name, first_name, last_name, avatar_url, created_at, plan")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
const { data: members, error: membersError } = await loadOrganizationMembers(supabase, {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (membersError) {
|
||||
return c.json({ error: "Failed to load organization members" }, 500);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
# Expo Apple IAP Unified Billing — Design Spec
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Scope:** `xtablo-expo` + `apps/api` + Supabase
|
||||
|
||||
## Summary
|
||||
|
||||
Add iOS in-app subscriptions to `xtablo-expo` using RevenueCat, while keeping Supabase as the billing source of truth and Stripe as the existing web billing rail.
|
||||
|
||||
The mobile app should:
|
||||
- only show a paywall after login/signup
|
||||
- only show purchase actions when Supabase says the authenticated organization has no active access
|
||||
- only allow the organization billing owner to purchase
|
||||
- sell `solo` and `founder/annual` in iOS v1
|
||||
- keep `team` as Stripe/web-only in v1
|
||||
|
||||
The backend should normalize Apple and Stripe into one internal billing state so existing authorization and upgrade logic can continue to rely on `getOrganizationBillingState`.
|
||||
|
||||
## Product Decisions
|
||||
|
||||
- Billing target: mobile-first unified billing
|
||||
- iOS billing rail: Apple in-app purchase via RevenueCat
|
||||
- Web billing rail: existing Stripe flow remains in place
|
||||
- Source of truth for unlocking: Supabase billing state, not client purchase callbacks
|
||||
- Purchase attachment model: purchase attaches to the current authenticated user; organization access is inferred through that user being the billing owner
|
||||
- Existing active subscribers: if Supabase already reports active access, the Expo app shows status only and does not offer Apple purchase
|
||||
- iOS v1 purchasable plans: `solo`, `annual`
|
||||
- iOS v1 excluded plan: `team`
|
||||
|
||||
## External Constraints
|
||||
|
||||
- Apple requires in-app purchase for unlocking digital functionality inside the app. Apps may let users access entitlements bought elsewhere, but direct in-app unlocking of digital features must use Apple’s purchase flow.
|
||||
- Expo’s current in-app purchase guidance recommends native libraries such as `react-native-purchases`, and this requires a development/custom build rather than Expo Go.
|
||||
|
||||
References:
|
||||
- Apple App Review Guidelines: https://developer.apple.com/app-store/review/guidelines/
|
||||
- Expo in-app purchases guide, updated March 9, 2026: https://docs.expo.dev/guides/in-app-purchases/
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-level model
|
||||
|
||||
- `xtablo-expo` handles purchase UI and restore actions through RevenueCat
|
||||
- RevenueCat handles StoreKit integration, renewal lifecycle, and Apple-side state changes
|
||||
- `apps/api` receives RevenueCat webhooks and normalizes Apple subscription state into Supabase
|
||||
- Supabase stores first-party normalized Apple billing records alongside the existing Stripe-backed data inputs
|
||||
- `apps/api/src/helpers/billing.ts` resolves one shared organization billing state from both sources
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No attempt to mirror Apple subscriptions into Stripe
|
||||
- No direct app unlock from local purchase success
|
||||
- No iOS sale of `team`
|
||||
- No Stripe-to-Apple migration flow in v1
|
||||
- No Android billing in v1
|
||||
|
||||
## Supabase Data Model
|
||||
|
||||
Add first-party Apple billing tables instead of reusing the `stripe` schema.
|
||||
|
||||
### `public.apple_customers`
|
||||
|
||||
Purpose:
|
||||
- map xtablo users to RevenueCat customer identity
|
||||
|
||||
Suggested columns:
|
||||
- `id bigint generated ... primary key`
|
||||
- `user_id uuid not null references public.profiles(id)`
|
||||
- `revenuecat_app_user_id text not null`
|
||||
- `original_app_user_id text null`
|
||||
- `last_seen_environment text null`
|
||||
- `created_at timestamptz not null default now()`
|
||||
- `updated_at timestamptz not null default now()`
|
||||
|
||||
Constraints:
|
||||
- unique on `user_id`
|
||||
- unique on `revenuecat_app_user_id`
|
||||
|
||||
### `public.apple_subscriptions`
|
||||
|
||||
Purpose:
|
||||
- hold normalized current and historical Apple subscription state used by billing resolution
|
||||
|
||||
Suggested columns:
|
||||
- `id bigint generated ... primary key`
|
||||
- `owner_user_id uuid not null references public.profiles(id)`
|
||||
- `revenuecat_app_user_id text not null`
|
||||
- `store_product_id text not null`
|
||||
- `plan text not null`
|
||||
- `status text not null`
|
||||
- `environment text not null`
|
||||
- `store text not null default 'app_store'`
|
||||
- `original_transaction_id text not null`
|
||||
- `transaction_id text null`
|
||||
- `current_period_start timestamptz null`
|
||||
- `current_period_end timestamptz null`
|
||||
- `cancel_at_period_end boolean not null default false`
|
||||
- `revoked_at timestamptz null`
|
||||
- `raw_customer_id text null`
|
||||
- `last_event_type text null`
|
||||
- `created_at timestamptz not null default now()`
|
||||
- `updated_at timestamptz not null default now()`
|
||||
|
||||
Constraints and indexes:
|
||||
- unique on `original_transaction_id`
|
||||
- index on `owner_user_id`
|
||||
- index on `status`
|
||||
- index on `current_period_end`
|
||||
|
||||
### `public.apple_subscription_events`
|
||||
|
||||
Purpose:
|
||||
- store raw RevenueCat webhook payloads for audit, replay, and idempotency debugging
|
||||
|
||||
Suggested columns:
|
||||
- `id bigint generated ... primary key`
|
||||
- `event_id text not null unique`
|
||||
- `event_type text not null`
|
||||
- `environment text null`
|
||||
- `payload jsonb not null`
|
||||
- `received_at timestamptz not null default now()`
|
||||
- `processed_at timestamptz null`
|
||||
|
||||
## Plan Mapping
|
||||
|
||||
Use explicit backend configuration for product mapping. Do not infer plan from display name.
|
||||
|
||||
Suggested mapping:
|
||||
- `solo_ios_monthly` -> `solo`
|
||||
- `founder_ios_monthly` or `annual_ios` -> `annual`
|
||||
|
||||
Rules:
|
||||
- no `team` Apple mapping in v1
|
||||
- `active`, `trialing`, grace-period-like states count as valid paid access
|
||||
- `expired`, `revoked`, `refunded`, or equivalent inactive states do not
|
||||
|
||||
## API Design
|
||||
|
||||
### New webhook route
|
||||
|
||||
Add a route such as:
|
||||
- `POST /api/revenuecat/webhook`
|
||||
|
||||
Responsibilities:
|
||||
- validate webhook authenticity
|
||||
- persist raw event to `apple_subscription_events`
|
||||
- resolve xtablo user from RevenueCat app user id
|
||||
- upsert `apple_customers`
|
||||
- upsert normalized `apple_subscriptions`
|
||||
- return success even for duplicate already-processed events
|
||||
|
||||
Implementation notes:
|
||||
- make handler idempotent
|
||||
- treat event logging and normalized state upsert as one logical processing unit
|
||||
- keep product-to-plan mapping in config/env, not inline literals spread across the codebase
|
||||
|
||||
### Existing read surfaces
|
||||
|
||||
Prefer reusing:
|
||||
- `GET /api/v1/users/organization`
|
||||
|
||||
Reason:
|
||||
- the payload already contains `trial_starts_at`, `trial_ends_at`, `required_plan`, `required_team_quantity`, `active_subscription_plan`, `active_subscription_quantity`, and `is_billing_owner`
|
||||
- reusing it keeps mobile aligned with the existing web gate
|
||||
|
||||
Optional follow-up:
|
||||
- add a smaller mobile billing endpoint later if the organization payload proves too heavy
|
||||
|
||||
## Billing Resolution Changes
|
||||
|
||||
Extend `apps/api/src/helpers/billing.ts` so organization billing state resolves from both Stripe and Apple.
|
||||
|
||||
### Existing behavior to preserve
|
||||
|
||||
- billing is organization-scoped
|
||||
- the owner user determines billing ownership
|
||||
- member count and required plan logic stay unchanged
|
||||
- trial window logic stays unchanged
|
||||
- `team` seat logic remains derived from Stripe/web billing only in v1
|
||||
|
||||
### New Apple resolution behavior
|
||||
|
||||
Resolve Apple entitlements for the organization owner from `apple_subscriptions`, then combine them with the current Stripe-derived candidate selection.
|
||||
|
||||
Candidate precedence:
|
||||
1. highest plan weight: `annual` > `team` > `solo`
|
||||
2. healthiest status
|
||||
3. latest `current_period_end`
|
||||
|
||||
Combination rules:
|
||||
- choose one winning entitlement source; do not add Apple and Stripe quantities together
|
||||
- Apple `solo` satisfies `required_plan = solo`
|
||||
- Apple `annual` satisfies any paid requirement in v1
|
||||
- Apple never satisfies `team` seat count semantics beyond `annual`
|
||||
|
||||
Quantity rules:
|
||||
- Apple `solo` -> quantity `1`
|
||||
- Apple `annual` -> quantity `1`, but plan weight makes it satisfy the higher tier
|
||||
|
||||
## Expo App Changes
|
||||
|
||||
### Billing state fetch
|
||||
|
||||
After authentication, the app should load billing-bearing organization state and keep it available to:
|
||||
- settings screen
|
||||
- any post-login paywall gate
|
||||
- purchase success pending-sync state
|
||||
|
||||
This likely means:
|
||||
- extending `xtablo-expo/stores/auth.tsx` bootstrap, or
|
||||
- adding a dedicated organization/billing hook consumed by the authenticated app shell
|
||||
|
||||
### Paywall visibility
|
||||
|
||||
The paywall appears only when all of the following are true:
|
||||
- the user is authenticated
|
||||
- Supabase-backed billing state reports no active access
|
||||
- the authenticated user is the organization billing owner
|
||||
- the platform is iOS
|
||||
|
||||
Do not show Apple purchase CTA when:
|
||||
- the org already has active Stripe or Apple access
|
||||
- the user is not the billing owner
|
||||
- the plan required is `team` and the app only supports `solo` and `annual` purchases in v1
|
||||
|
||||
### UI flow
|
||||
|
||||
Suggested flow:
|
||||
1. user logs in
|
||||
2. app fetches organization billing state
|
||||
3. if access exists, app shows status only
|
||||
4. if no access and the user is the billing owner, app shows paywall
|
||||
5. user can purchase `solo` or `annual`, or restore purchases
|
||||
6. on purchase success callback, app shows “syncing purchase” state and polls billing state
|
||||
7. unlock only after the backend-normalized Supabase state reflects access
|
||||
|
||||
### Placement
|
||||
|
||||
Recommended first placement:
|
||||
- billing entry point in `xtablo-expo/app/(app)/(tabs)/settings.tsx`
|
||||
|
||||
Acceptable alternative:
|
||||
- a dedicated authenticated billing/paywall route if the settings screen becomes too crowded
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Purchase success but backend not updated yet
|
||||
|
||||
- show a pending success state
|
||||
- refetch organization billing state for a short bounded window
|
||||
- if still unpaid, show a “purchase received, still syncing” message and expose restore/retry
|
||||
|
||||
### Restore on different xtablo login
|
||||
|
||||
- do not silently transfer entitlement across users
|
||||
- only associate restored state when RevenueCat identity maps to the authenticated xtablo user
|
||||
- otherwise fail closed and route to support/manual resolution
|
||||
|
||||
### Existing paid web subscriber on iOS
|
||||
|
||||
- do not offer Apple purchase
|
||||
- show current access status only
|
||||
|
||||
### Non-owner unpaid member on iOS
|
||||
|
||||
- do not offer purchase
|
||||
- show explanatory copy that billing is managed by the organization owner
|
||||
|
||||
### Organization requires `team` but iOS v1 cannot sell it
|
||||
|
||||
- do not offer a misleading in-app purchase CTA
|
||||
- show that the organization needs the `team` plan
|
||||
- direct the user to web billing or the billing owner as appropriate
|
||||
|
||||
### Apple cancellation/refund/revoke
|
||||
|
||||
- maintain access until normalized entitlement expiry when appropriate
|
||||
- drop access once the normalized state is no longer valid
|
||||
|
||||
### Offline after purchase
|
||||
|
||||
- never unlock from local callback alone
|
||||
- show syncing/reconnect guidance until the server state can be refreshed
|
||||
|
||||
### RevenueCat or App Store outage
|
||||
|
||||
- preserve existing access from last known Supabase state
|
||||
- fail new purchases and restores closed
|
||||
- show a retry message rather than changing billing state locally
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
|
||||
- unit tests for Apple product-to-plan normalization
|
||||
- unit tests for combined Stripe/Apple winner selection
|
||||
- route tests for RevenueCat webhook idempotency
|
||||
- route tests for malformed or unauthorized webhook rejection
|
||||
|
||||
### Supabase
|
||||
|
||||
- migration tests for table constraints and uniqueness
|
||||
- SQL or integration checks for owner-user lookup and entitlement filtering
|
||||
|
||||
### Expo
|
||||
|
||||
- unpaid owner sees paywall
|
||||
- paid owner does not see paywall
|
||||
- unpaid non-owner does not see purchase CTA
|
||||
- purchase success enters pending-sync state
|
||||
- restore purchase path refreshes billing state correctly
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
- create App Store Connect subscription products for iOS `solo` and `annual`
|
||||
- configure RevenueCat offerings and map them explicitly in backend config
|
||||
- use an Expo development/custom build for local testing because Expo Go is insufficient for native IAP
|
||||
- keep sandbox and production Apple environments distinct in persisted records and operational logging
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- RevenueCat integration in `xtablo-expo`
|
||||
- iOS post-login paywall
|
||||
- owner-only purchase gating
|
||||
- RevenueCat webhook ingestion in `apps/api`
|
||||
- Apple billing tables and migrations in Supabase
|
||||
- unified billing resolution in `getOrganizationBillingState`
|
||||
|
||||
### Out of scope
|
||||
|
||||
- iOS `team` purchase flow
|
||||
- Stripe-to-Apple migration
|
||||
- Android billing
|
||||
- cross-account entitlement transfer tooling
|
||||
- direct client-side unlock without backend normalization
|
||||
|
||||
## Likely File Touch Points
|
||||
|
||||
- `xtablo-expo/app/(app)/(tabs)/settings.tsx`
|
||||
- `xtablo-expo/stores/auth.tsx`
|
||||
- new Expo billing hooks/components
|
||||
- `apps/api/src/helpers/billing.ts`
|
||||
- new `apps/api/src/routers/revenuecat.ts` or `apple-billing.ts`
|
||||
- `apps/api/src/routers/index.ts`
|
||||
- new Supabase migrations for Apple billing tables
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,52 @@
|
|||
create table public.apple_customers (
|
||||
id bigint generated by default as identity primary key,
|
||||
user_id uuid not null references public.profiles (id) on delete cascade,
|
||||
revenuecat_app_user_id text not null,
|
||||
original_app_user_id text,
|
||||
last_seen_environment text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (user_id),
|
||||
unique (revenuecat_app_user_id)
|
||||
);
|
||||
|
||||
create table public.apple_subscriptions (
|
||||
id bigint generated by default as identity primary key,
|
||||
owner_user_id uuid not null references public.profiles (id) on delete cascade,
|
||||
revenuecat_app_user_id text not null,
|
||||
store_product_id text not null,
|
||||
plan text not null,
|
||||
status text not null,
|
||||
environment text not null,
|
||||
store text not null default 'app_store',
|
||||
original_transaction_id text not null,
|
||||
transaction_id text,
|
||||
current_period_start timestamptz,
|
||||
current_period_end timestamptz,
|
||||
cancel_at_period_end boolean not null default false,
|
||||
revoked_at timestamptz,
|
||||
raw_customer_id text,
|
||||
last_event_type text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (original_transaction_id)
|
||||
);
|
||||
|
||||
create index idx_apple_subscriptions_owner_user_id
|
||||
on public.apple_subscriptions (owner_user_id);
|
||||
|
||||
create index idx_apple_subscriptions_status
|
||||
on public.apple_subscriptions (status);
|
||||
|
||||
create index idx_apple_subscriptions_current_period_end
|
||||
on public.apple_subscriptions (current_period_end);
|
||||
|
||||
create table public.apple_subscription_events (
|
||||
id bigint generated by default as identity primary key,
|
||||
event_id text not null unique,
|
||||
event_type text not null,
|
||||
environment text,
|
||||
payload jsonb not null,
|
||||
received_at timestamptz not null default now(),
|
||||
processed_at timestamptz
|
||||
);
|
||||
101
supabase/tests/database/13_apple_billing.test.sql
Normal file
101
supabase/tests/database/13_apple_billing.test.sql
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
begin;
|
||||
select plan(17);
|
||||
|
||||
-- ============================================================================
|
||||
-- Table Existence
|
||||
-- ============================================================================
|
||||
|
||||
SELECT has_table('public', 'apple_customers', 'apple_customers table should exist');
|
||||
SELECT has_table('public', 'apple_subscriptions', 'apple_subscriptions table should exist');
|
||||
SELECT has_table(
|
||||
'public',
|
||||
'apple_subscription_events',
|
||||
'apple_subscription_events table should exist'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Column Coverage
|
||||
-- ============================================================================
|
||||
|
||||
SELECT has_column('public', 'apple_customers', 'user_id', 'apple_customers should have user_id');
|
||||
SELECT has_column(
|
||||
'public',
|
||||
'apple_customers',
|
||||
'revenuecat_app_user_id',
|
||||
'apple_customers should have revenuecat_app_user_id'
|
||||
);
|
||||
SELECT has_column(
|
||||
'public',
|
||||
'apple_subscriptions',
|
||||
'owner_user_id',
|
||||
'apple_subscriptions should have owner_user_id'
|
||||
);
|
||||
SELECT has_column(
|
||||
'public',
|
||||
'apple_subscriptions',
|
||||
'original_transaction_id',
|
||||
'apple_subscriptions should have original_transaction_id'
|
||||
);
|
||||
SELECT has_column(
|
||||
'public',
|
||||
'apple_subscription_events',
|
||||
'event_id',
|
||||
'apple_subscription_events should have event_id'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Primary Keys + Constraints
|
||||
-- ============================================================================
|
||||
|
||||
SELECT has_pk('public', 'apple_customers', 'apple_customers should have primary key');
|
||||
SELECT has_pk('public', 'apple_subscriptions', 'apple_subscriptions should have primary key');
|
||||
SELECT has_pk(
|
||||
'public',
|
||||
'apple_subscription_events',
|
||||
'apple_subscription_events should have primary key'
|
||||
);
|
||||
|
||||
SELECT has_index(
|
||||
'public',
|
||||
'apple_customers',
|
||||
'apple_customers_user_id_key',
|
||||
'apple_customers.user_id unique constraint should exist'
|
||||
);
|
||||
SELECT has_index(
|
||||
'public',
|
||||
'apple_customers',
|
||||
'apple_customers_revenuecat_app_user_id_key',
|
||||
'apple_customers.revenuecat_app_user_id unique constraint should exist'
|
||||
);
|
||||
SELECT has_index(
|
||||
'public',
|
||||
'apple_subscriptions',
|
||||
'apple_subscriptions_original_transaction_id_key',
|
||||
'apple_subscriptions.original_transaction_id unique constraint should exist'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Performance Indexes
|
||||
-- ============================================================================
|
||||
|
||||
SELECT has_index(
|
||||
'public',
|
||||
'apple_subscriptions',
|
||||
'idx_apple_subscriptions_owner_user_id',
|
||||
'apple_subscriptions owner_user_id index should exist'
|
||||
);
|
||||
SELECT has_index(
|
||||
'public',
|
||||
'apple_subscriptions',
|
||||
'idx_apple_subscriptions_status',
|
||||
'apple_subscriptions status index should exist'
|
||||
);
|
||||
SELECT has_index(
|
||||
'public',
|
||||
'apple_subscriptions',
|
||||
'idx_apple_subscriptions_current_period_end',
|
||||
'apple_subscriptions current_period_end index should exist'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Switch,
|
||||
Alert,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
|
@ -38,11 +39,26 @@ import {
|
|||
} from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import {
|
||||
hasOrganizationBillingAccess,
|
||||
shouldShowInAppBillingPaywall,
|
||||
useOrganizationBilling,
|
||||
} from "@/hooks/organization";
|
||||
import { BillingPaywall } from "@/components/BillingPaywall";
|
||||
import {
|
||||
canUseInAppPurchases,
|
||||
getBillingPackageOptions,
|
||||
isPurchaseCancelledError,
|
||||
purchaseBillingPackage,
|
||||
restoreBillingPurchases,
|
||||
type BillingPackageOption,
|
||||
} from "@/lib/purchases";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const signOut = useAuthStore((state) => state.signOut);
|
||||
const user = useUser();
|
||||
const colorScheme = useColorScheme();
|
||||
const organizationBillingQuery = useOrganizationBilling();
|
||||
|
||||
// Theme-aware colors
|
||||
const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
|
||||
|
|
@ -61,8 +77,72 @@ export default function SettingsScreen() {
|
|||
const [pushNotifications, setPushNotifications] = useState(true);
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
const [biometricAuth, setBiometricAuth] = useState(false);
|
||||
const [billingPackages, setBillingPackages] = useState<BillingPackageOption[]>([]);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const [isLoadingPackages, setIsLoadingPackages] = useState(false);
|
||||
const [isPurchasing, setIsPurchasing] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [isSyncingBilling, setIsSyncingBilling] = useState(false);
|
||||
const isDeletingAccount = React.useRef(false);
|
||||
|
||||
const organizationBilling = organizationBillingQuery.data;
|
||||
const hasBillingAccess = organizationBilling
|
||||
? hasOrganizationBillingAccess(organizationBilling)
|
||||
: false;
|
||||
const canShowInAppPaywall = organizationBilling
|
||||
? shouldShowInAppBillingPaywall(organizationBilling)
|
||||
: false;
|
||||
|
||||
const visibleBillingPackages = useMemo(() => {
|
||||
if (!organizationBilling) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return billingPackages.filter((pkg) =>
|
||||
organizationBilling.required_plan === "team" ? pkg.plan === "annual" : true
|
||||
);
|
||||
}, [billingPackages, organizationBilling]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadBillingPackages = async () => {
|
||||
if (!user.id || !canShowInAppPaywall || !canUseInAppPurchases()) {
|
||||
setBillingPackages([]);
|
||||
setBillingError(null);
|
||||
setIsLoadingPackages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setBillingError(null);
|
||||
setIsLoadingPackages(true);
|
||||
|
||||
try {
|
||||
const packages = await getBillingPackageOptions(user.id);
|
||||
if (!cancelled) {
|
||||
setBillingPackages(packages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load RevenueCat packages:", error);
|
||||
if (!cancelled) {
|
||||
setBillingError(
|
||||
"Impossible de charger les offres Apple pour le moment. Réessayez dans quelques instants."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingPackages(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadBillingPackages();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canShowInAppPaywall, user.id]);
|
||||
|
||||
const handleSignOut = () => {
|
||||
Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [
|
||||
{
|
||||
|
|
@ -197,6 +277,162 @@ export default function SettingsScreen() {
|
|||
false
|
||||
);
|
||||
|
||||
const waitForBillingSync = async () => {
|
||||
setIsSyncingBilling(true);
|
||||
setBillingError(null);
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
const result = await organizationBillingQuery.refetch();
|
||||
if (result.data && hasOrganizationBillingAccess(result.data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt < 5) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
setBillingError(
|
||||
"L'achat a bien ete recu, mais la synchronisation serveur prend plus de temps que prevu."
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSyncingBilling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async (pkg: BillingPackageOption) => {
|
||||
if (!user.id || isPurchasing || isSyncingBilling) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBillingError(null);
|
||||
setIsPurchasing(true);
|
||||
|
||||
try {
|
||||
await purchaseBillingPackage(user.id, pkg.package);
|
||||
await waitForBillingSync();
|
||||
} catch (error) {
|
||||
if (!isPurchaseCancelledError(error)) {
|
||||
console.error("Apple purchase failed:", error);
|
||||
setBillingError("Le paiement Apple a echoue. Merci de reessayer.");
|
||||
}
|
||||
} finally {
|
||||
setIsPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestorePurchases = async () => {
|
||||
if (!user.id || isRestoring || isSyncingBilling) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBillingError(null);
|
||||
setIsRestoring(true);
|
||||
|
||||
try {
|
||||
await restoreBillingPurchases(user.id);
|
||||
await waitForBillingSync();
|
||||
} catch (error) {
|
||||
console.error("Apple restore failed:", error);
|
||||
setBillingError("La restauration des achats a echoue. Merci de reessayer.");
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBillingContent = () => {
|
||||
if (organizationBillingQuery.isLoading) {
|
||||
return (
|
||||
<View style={styles.billingBox}>
|
||||
<ActivityIndicator />
|
||||
<Text style={[styles.billingHelperText, { color: subtitleColor }]}>
|
||||
Chargement de votre statut d'abonnement…
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizationBillingQuery.error || !organizationBilling) {
|
||||
return (
|
||||
<View style={styles.billingBox}>
|
||||
<Text style={styles.billingErrorText}>
|
||||
Impossible de charger la facturation de l'organisation.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasBillingAccess && organizationBilling.active_subscription_plan) {
|
||||
const subscriptionLabel =
|
||||
organizationBilling.active_subscription_plan === "annual"
|
||||
? "Annuel"
|
||||
: organizationBilling.active_subscription_plan === "team"
|
||||
? "Equipe"
|
||||
: "Solo";
|
||||
|
||||
return renderSettingsItem(
|
||||
<Shield size={20} color={PRIMARY} />,
|
||||
"Abonnement actif",
|
||||
`${subscriptionLabel} • ${organizationBilling.organization.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (hasBillingAccess && !organizationBilling.is_trial_expired) {
|
||||
const trialEndsAt = new Date(organizationBilling.trial_ends_at).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
return renderSettingsItem(
|
||||
<Shield size={20} color={PRIMARY} />,
|
||||
"Essai actif",
|
||||
`Votre acces d'essai reste actif jusqu'au ${trialEndsAt}.`,
|
||||
undefined,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUseInAppPurchases()) {
|
||||
return (
|
||||
<View style={styles.billingBox}>
|
||||
<Text style={[styles.billingHelperText, { color: subtitleColor }]}>
|
||||
Les achats integres ne sont disponibles que sur iPhone avec une build de developpement
|
||||
native.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.billingBox}>
|
||||
<BillingPaywall
|
||||
canManageBillingInApp={canShowInAppPaywall}
|
||||
errorMessage={
|
||||
visibleBillingPackages.length === 0 &&
|
||||
!isLoadingPackages &&
|
||||
!billingError &&
|
||||
canShowInAppPaywall
|
||||
? "Aucune offre Apple n'est disponible pour ce compte pour le moment."
|
||||
: billingError
|
||||
}
|
||||
isLoadingPackages={isLoadingPackages || isPurchasing}
|
||||
isRestoring={isRestoring}
|
||||
isSyncing={isSyncingBilling}
|
||||
onPurchase={handlePurchase}
|
||||
onRestore={handleRestorePurchases}
|
||||
packages={visibleBillingPackages}
|
||||
requiredPlan={organizationBilling.required_plan}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
<StatusBar
|
||||
|
|
@ -239,6 +475,8 @@ export default function SettingsScreen() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{renderSettingsSection("Abonnement", renderBillingContent())}
|
||||
|
||||
{/* {renderSettingsSection(
|
||||
"Notifications",
|
||||
<>
|
||||
|
|
@ -531,4 +769,18 @@ const styles = StyleSheet.create({
|
|||
bottomSpacing: {
|
||||
height: 100,
|
||||
},
|
||||
billingBox: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
billingErrorText: {
|
||||
color: "#b91c1c",
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
billingHelperText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { StyleSheet, View, Text, Image } from "react-native";
|
||||
import { Alert, StyleSheet, View, Text, Image } from "react-native";
|
||||
import { Button, Input } from "@rn-vui/themed";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Link } from "expo-router";
|
||||
|
|
@ -30,6 +30,19 @@ export default function SignUp() {
|
|||
? require("@/assets/images/logo_white.png")
|
||||
: require("@/assets/images/logo.png");
|
||||
|
||||
const handleSignUp = async () => {
|
||||
try {
|
||||
await signUp(email, password, firstName, lastName, companyName);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Erreur",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Impossible de créer votre compte pour le moment."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
<Image source={logo} style={styles.logo} />
|
||||
|
|
@ -91,7 +104,7 @@ export default function SignUp() {
|
|||
<Button
|
||||
title="S'inscrire"
|
||||
disabled={authLoading}
|
||||
onPress={() => signUp(email, password, firstName, lastName, companyName)}
|
||||
onPress={handleSignUp}
|
||||
buttonStyle={styles.button}
|
||||
titleStyle={styles.buttonTitle}
|
||||
/>
|
||||
|
|
|
|||
218
xtablo-expo/components/BillingPaywall.tsx
Normal file
218
xtablo-expo/components/BillingPaywall.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import React from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import type { RequiredOrganizationPlan } from "@/types/organization.types";
|
||||
import type { BillingPackageOption } from "@/lib/purchases";
|
||||
|
||||
export type BillingPaywallProps = {
|
||||
canManageBillingInApp: boolean;
|
||||
errorMessage: string | null;
|
||||
isLoadingPackages: boolean;
|
||||
isRestoring: boolean;
|
||||
isSyncing: boolean;
|
||||
onPurchase: (pkg: BillingPackageOption) => void;
|
||||
onRestore: () => void;
|
||||
packages: BillingPackageOption[];
|
||||
requiredPlan: RequiredOrganizationPlan;
|
||||
};
|
||||
|
||||
export type { BillingPackageOption } from "@/lib/purchases";
|
||||
|
||||
export function BillingPaywall({
|
||||
canManageBillingInApp,
|
||||
errorMessage,
|
||||
isLoadingPackages,
|
||||
isRestoring,
|
||||
isSyncing,
|
||||
onPurchase,
|
||||
onRestore,
|
||||
packages,
|
||||
requiredPlan,
|
||||
}: BillingPaywallProps) {
|
||||
if (!canManageBillingInApp) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Votre organisation n'a pas encore d'accès actif.</Text>
|
||||
<Text style={styles.body}>
|
||||
Pour activer l'abonnement sur mobile, demandez au responsable de facturation de
|
||||
gérer l'offre depuis son compte.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Débloquer XTablo sur iPhone</Text>
|
||||
<Text style={styles.body}>
|
||||
L'accès s'active quand la souscription est synchronisée dans votre organisation.
|
||||
</Text>
|
||||
|
||||
{requiredPlan === "team" ? (
|
||||
<Text style={styles.notice}>
|
||||
Les forfaits équipe se gèrent sur le web. L'offre annuelle reste disponible ici.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{isLoadingPackages ? (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator />
|
||||
<Text style={styles.loadingText}>Chargement des offres Apple…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isLoadingPackages
|
||||
? packages.map((pkg) => (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
key={pkg.id}
|
||||
onPress={() => onPurchase(pkg)}
|
||||
style={styles.cardShell}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={pkg.plan === "annual" ? ["#0f766e", "#115e59"] : ["#1d4ed8", "#1e40af"]}
|
||||
end={{ x: 1, y: 1 }}
|
||||
start={{ x: 0, y: 0 }}
|
||||
style={styles.card}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>{pkg.title}</Text>
|
||||
<Text style={styles.cardPrice}>{pkg.price}</Text>
|
||||
</View>
|
||||
{pkg.description ? <Text style={styles.cardDescription}>{pkg.description}</Text> : null}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
: null}
|
||||
|
||||
{isSyncing ? (
|
||||
<View style={styles.syncBox}>
|
||||
<Text style={styles.syncTitle}>Achat reçu</Text>
|
||||
<Text style={styles.syncText}>
|
||||
La synchronisation avec votre organisation est en cours. Cela peut prendre quelques
|
||||
secondes.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.75}
|
||||
disabled={isRestoring || isSyncing}
|
||||
onPress={onRestore}
|
||||
style={styles.restoreButton}
|
||||
>
|
||||
<Text style={styles.restoreText}>
|
||||
{isRestoring ? "Restauration en cours…" : "Restaurer mes achats"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
color: "#475569",
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
gap: 10,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 18,
|
||||
},
|
||||
cardDescription: {
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
cardHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
cardPrice: {
|
||||
color: "#ffffff",
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
},
|
||||
cardShell: {
|
||||
marginTop: 12,
|
||||
},
|
||||
cardTitle: {
|
||||
color: "#ffffff",
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
marginRight: 12,
|
||||
},
|
||||
container: {
|
||||
gap: 10,
|
||||
},
|
||||
errorText: {
|
||||
color: "#b91c1c",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
loadingRow: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
loadingText: {
|
||||
color: "#475569",
|
||||
fontSize: 13,
|
||||
},
|
||||
notice: {
|
||||
color: "#92400e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
restoreButton: {
|
||||
alignItems: "center",
|
||||
borderColor: "#cbd5e1",
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
marginTop: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
restoreText: {
|
||||
color: "#0f172a",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
syncBox: {
|
||||
backgroundColor: "#eff6ff",
|
||||
borderColor: "#bfdbfe",
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
syncText: {
|
||||
color: "#1d4ed8",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
syncTitle: {
|
||||
color: "#1e3a8a",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
marginBottom: 4,
|
||||
},
|
||||
title: {
|
||||
color: "#0f172a",
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
88
xtablo-expo/components/__tests__/BillingPaywall.test.tsx
Normal file
88
xtablo-expo/components/__tests__/BillingPaywall.test.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import * as React from "react";
|
||||
import renderer from "react-test-renderer";
|
||||
import {
|
||||
BillingPaywall,
|
||||
type BillingPackageOption,
|
||||
type BillingPaywallProps,
|
||||
} from "../BillingPaywall";
|
||||
|
||||
const basePackages: BillingPackageOption[] = [
|
||||
{
|
||||
description: "Parfait pour une personne",
|
||||
id: "solo_ios_monthly",
|
||||
package: {} as BillingPackageOption["package"],
|
||||
plan: "solo",
|
||||
price: "4,99 €",
|
||||
title: "Solo mensuel",
|
||||
},
|
||||
{
|
||||
description: "Accès annuel complet",
|
||||
id: "annual_ios",
|
||||
package: {} as BillingPackageOption["package"],
|
||||
plan: "annual",
|
||||
price: "49,99 €",
|
||||
title: "Annuel",
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps: BillingPaywallProps = {
|
||||
canManageBillingInApp: true,
|
||||
errorMessage: null,
|
||||
isLoadingPackages: false,
|
||||
isRestoring: false,
|
||||
isSyncing: false,
|
||||
onPurchase: jest.fn(),
|
||||
onRestore: jest.fn(),
|
||||
packages: basePackages,
|
||||
requiredPlan: "solo",
|
||||
};
|
||||
|
||||
const renderToText = async (props?: Partial<BillingPaywallProps>) => {
|
||||
let component: renderer.ReactTestRenderer;
|
||||
|
||||
await renderer.act(async () => {
|
||||
component = renderer.create(<BillingPaywall {...baseProps} {...props} />);
|
||||
});
|
||||
|
||||
return JSON.stringify(component!.toJSON());
|
||||
};
|
||||
|
||||
describe("BillingPaywall", () => {
|
||||
it("renders Apple purchase actions and restore CTA for eligible owners", async () => {
|
||||
const output = await renderToText();
|
||||
|
||||
expect(output).toContain("Débloquer XTablo sur iPhone");
|
||||
expect(output).toContain("Solo mensuel");
|
||||
expect(output).toContain("Restaurer mes achats");
|
||||
});
|
||||
|
||||
it("shows a contact-owner message when the user cannot purchase in app", async () => {
|
||||
const output = await renderToText({
|
||||
canManageBillingInApp: false,
|
||||
});
|
||||
|
||||
expect(output).toContain("Votre organisation n'a pas encore d'accès actif.");
|
||||
expect(output).toContain("demandez au responsable");
|
||||
expect(output).not.toContain("Restaurer mes achats");
|
||||
});
|
||||
|
||||
it("surfaces the pending sync state after a successful store action", async () => {
|
||||
const output = await renderToText({
|
||||
isSyncing: true,
|
||||
});
|
||||
|
||||
expect(output).toContain("Achat reçu");
|
||||
expect(output).toContain("synchronisation");
|
||||
});
|
||||
|
||||
it("keeps team purchases on the web in v1", async () => {
|
||||
const output = await renderToText({
|
||||
packages: basePackages.filter((pkg) => pkg.plan === "annual"),
|
||||
requiredPlan: "team",
|
||||
});
|
||||
|
||||
expect(output).toContain("Les forfaits équipe se gèrent sur le web");
|
||||
expect(output).toContain("Annuel");
|
||||
expect(output).not.toContain("Solo mensuel");
|
||||
});
|
||||
});
|
||||
104
xtablo-expo/components/__tests__/organization-hook.test.ts
Normal file
104
xtablo-expo/components/__tests__/organization-hook.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { api } from "@/lib/api";
|
||||
import {
|
||||
fetchOrganizationBillingState,
|
||||
hasOrganizationBillingAccess,
|
||||
isOrganizationBillingQueryEnabled,
|
||||
shouldShowInAppBillingPaywall,
|
||||
type OrganizationBillingState,
|
||||
} from "@/hooks/organization";
|
||||
|
||||
jest.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/stores/auth", () => ({
|
||||
useAuthStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const baseOrganizationState: OrganizationBillingState = {
|
||||
active_subscription_plan: null,
|
||||
active_subscription_quantity: 0,
|
||||
invites_sent: [],
|
||||
is_billing_owner: true,
|
||||
is_trial_expired: true,
|
||||
members: [],
|
||||
organization: {
|
||||
id: 1,
|
||||
logo_url: null,
|
||||
member_count: 1,
|
||||
name: "XTablo",
|
||||
plan: "solo",
|
||||
tablo_count: 0,
|
||||
},
|
||||
required_plan: "solo",
|
||||
required_team_quantity: 1,
|
||||
trial_ends_at: "2026-05-16T00:00:00.000Z",
|
||||
trial_starts_at: "2026-05-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("organization billing hook helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockReset();
|
||||
});
|
||||
|
||||
it("fetches the organization billing payload with the current bearer token", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: baseOrganizationState });
|
||||
|
||||
const result = await fetchOrganizationBillingState("session-token");
|
||||
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/api/v1/users/organization", {
|
||||
headers: {
|
||||
Authorization: "Bearer session-token",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(baseOrganizationState);
|
||||
});
|
||||
|
||||
it("enables the query only when an access token is present", () => {
|
||||
expect(isOrganizationBillingQueryEnabled(null)).toBe(false);
|
||||
expect(isOrganizationBillingQueryEnabled(undefined)).toBe(false);
|
||||
expect(isOrganizationBillingQueryEnabled("token")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats active subscriptions and active trials as organization access", () => {
|
||||
expect(hasOrganizationBillingAccess(baseOrganizationState)).toBe(false);
|
||||
expect(
|
||||
hasOrganizationBillingAccess({
|
||||
...baseOrganizationState,
|
||||
active_subscription_plan: "annual",
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasOrganizationBillingAccess({
|
||||
...baseOrganizationState,
|
||||
is_trial_expired: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the iOS paywall only to unpaid billing owners after the trial expires", () => {
|
||||
expect(shouldShowInAppBillingPaywall(baseOrganizationState)).toBe(true);
|
||||
expect(
|
||||
shouldShowInAppBillingPaywall({
|
||||
...baseOrganizationState,
|
||||
is_billing_owner: false,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowInAppBillingPaywall({
|
||||
...baseOrganizationState,
|
||||
active_subscription_plan: "solo",
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowInAppBillingPaywall({
|
||||
...baseOrganizationState,
|
||||
is_trial_expired: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
95
xtablo-expo/components/__tests__/signup-screen.test.tsx
Normal file
95
xtablo-expo/components/__tests__/signup-screen.test.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import * as React from "react";
|
||||
import renderer from "react-test-renderer";
|
||||
import { Alert } from "react-native";
|
||||
import SignUp from "@/app/signup";
|
||||
|
||||
const mockSignUp = jest.fn();
|
||||
const InputMock = (props: Record<string, unknown>) => React.createElement("Input", props);
|
||||
const ButtonMock = (props: Record<string, unknown>) => React.createElement("Button", props);
|
||||
|
||||
jest.mock("@/stores/auth", () => ({
|
||||
useAuthStore: jest.fn((selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
loading: false,
|
||||
signUp: mockSignUp,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/useThemeColor", () => ({
|
||||
useThemeColor: ({ light }: { light: string }) => light,
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/useColorScheme", () => ({
|
||||
useColorScheme: () => "light",
|
||||
}));
|
||||
|
||||
jest.mock("@rn-vui/themed", () => {
|
||||
return {
|
||||
Button: ButtonMock,
|
||||
Input: InputMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("expo-router", () => {
|
||||
const React = require("react");
|
||||
|
||||
return {
|
||||
Link: (props: Record<string, unknown> & { children?: React.ReactNode }) =>
|
||||
React.createElement("Link", props, props.children),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("lucide-react-native", () => {
|
||||
const React = require("react");
|
||||
const Icon = () => React.createElement("Icon");
|
||||
|
||||
return {
|
||||
Building2: Icon,
|
||||
Lock: Icon,
|
||||
Mail: Icon,
|
||||
User: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
describe("SignUp screen", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the signup error to the user when registration fails", async () => {
|
||||
mockSignUp.mockRejectedValueOnce(new Error("Adresse déjà utilisée"));
|
||||
const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(jest.fn());
|
||||
|
||||
let component: renderer.ReactTestRenderer;
|
||||
|
||||
await renderer.act(async () => {
|
||||
component = renderer.create(<SignUp />);
|
||||
});
|
||||
|
||||
const inputs = component!.root.findAllByType(InputMock);
|
||||
|
||||
await renderer.act(async () => {
|
||||
inputs[0].props.onChangeText("Ada");
|
||||
inputs[1].props.onChangeText("Lovelace");
|
||||
inputs[2].props.onChangeText("Analytical Engines");
|
||||
inputs[3].props.onChangeText("ada@example.com");
|
||||
inputs[4].props.onChangeText("password123");
|
||||
});
|
||||
|
||||
const button = component!.root.findByType(ButtonMock);
|
||||
|
||||
await renderer.act(async () => {
|
||||
await button.props.onPress();
|
||||
});
|
||||
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
"ada@example.com",
|
||||
"password123",
|
||||
"Ada",
|
||||
"Lovelace",
|
||||
"Analytical Engines"
|
||||
);
|
||||
expect(alertSpy).toHaveBeenCalledWith("Erreur", "Adresse déjà utilisée");
|
||||
});
|
||||
});
|
||||
38
xtablo-expo/hooks/organization.ts
Normal file
38
xtablo-expo/hooks/organization.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import type { OrganizationBillingState } from "@/types/organization.types";
|
||||
|
||||
export type { OrganizationBillingState } from "@/types/organization.types";
|
||||
|
||||
export const organizationBillingQueryKey = ["organization-billing"] as const;
|
||||
|
||||
export const isOrganizationBillingQueryEnabled = (accessToken?: string | null) =>
|
||||
Boolean(accessToken);
|
||||
|
||||
export const fetchOrganizationBillingState = async (accessToken: string) => {
|
||||
const { data } = await api.get<OrganizationBillingState>("/api/v1/users/organization", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const hasOrganizationBillingAccess = (state: OrganizationBillingState) =>
|
||||
Boolean(state.active_subscription_plan) || !state.is_trial_expired;
|
||||
|
||||
export const shouldShowInAppBillingPaywall = (state: OrganizationBillingState) =>
|
||||
state.is_billing_owner && state.is_trial_expired && !state.active_subscription_plan;
|
||||
|
||||
export const useOrganizationBilling = () => {
|
||||
const accessToken = useAuthStore((state) => state.session?.access_token ?? null);
|
||||
|
||||
return useQuery({
|
||||
queryKey: organizationBillingQueryKey,
|
||||
queryFn: () => fetchOrganizationBillingState(accessToken as string),
|
||||
enabled: isOrganizationBillingQueryEnabled(accessToken),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
};
|
||||
|
|
@ -7,13 +7,211 @@ export type Json =
|
|||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
// Allows to automatically instantiate createClient with right options
|
||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: "13.0.4"
|
||||
graphql_public: {
|
||||
Tables: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
graphql: {
|
||||
Args: {
|
||||
extensions?: Json
|
||||
operationName?: string
|
||||
query?: string
|
||||
variables?: Json
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
admin_audit_log: {
|
||||
Row: {
|
||||
action: string
|
||||
after: Json | null
|
||||
before: Json | null
|
||||
created_at: string
|
||||
id: number
|
||||
operator_email: string
|
||||
operator_id: string
|
||||
role: string
|
||||
target_id: string
|
||||
target_type: string
|
||||
}
|
||||
Insert: {
|
||||
action: string
|
||||
after?: Json | null
|
||||
before?: Json | null
|
||||
created_at?: string
|
||||
id?: number
|
||||
operator_email: string
|
||||
operator_id: string
|
||||
role: string
|
||||
target_id: string
|
||||
target_type: string
|
||||
}
|
||||
Update: {
|
||||
action?: string
|
||||
after?: Json | null
|
||||
before?: Json | null
|
||||
created_at?: string
|
||||
id?: number
|
||||
operator_email?: string
|
||||
operator_id?: string
|
||||
role?: string
|
||||
target_id?: string
|
||||
target_type?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
apple_customers: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: number
|
||||
last_seen_environment: string | null
|
||||
original_app_user_id: string | null
|
||||
revenuecat_app_user_id: string
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
last_seen_environment?: string | null
|
||||
original_app_user_id?: string | null
|
||||
revenuecat_app_user_id: string
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
last_seen_environment?: string | null
|
||||
original_app_user_id?: string | null
|
||||
revenuecat_app_user_id?: string
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "apple_customers_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
apple_subscription_events: {
|
||||
Row: {
|
||||
environment: string | null
|
||||
event_id: string
|
||||
event_type: string
|
||||
id: number
|
||||
payload: Json
|
||||
processed_at: string | null
|
||||
received_at: string
|
||||
}
|
||||
Insert: {
|
||||
environment?: string | null
|
||||
event_id: string
|
||||
event_type: string
|
||||
id?: number
|
||||
payload: Json
|
||||
processed_at?: string | null
|
||||
received_at?: string
|
||||
}
|
||||
Update: {
|
||||
environment?: string | null
|
||||
event_id?: string
|
||||
event_type?: string
|
||||
id?: number
|
||||
payload?: Json
|
||||
processed_at?: string | null
|
||||
received_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
apple_subscriptions: {
|
||||
Row: {
|
||||
cancel_at_period_end: boolean
|
||||
created_at: string
|
||||
current_period_end: string | null
|
||||
current_period_start: string | null
|
||||
environment: string
|
||||
id: number
|
||||
last_event_type: string | null
|
||||
original_transaction_id: string
|
||||
owner_user_id: string
|
||||
plan: string
|
||||
raw_customer_id: string | null
|
||||
revenuecat_app_user_id: string
|
||||
revoked_at: string | null
|
||||
status: string
|
||||
store: string
|
||||
store_product_id: string
|
||||
transaction_id: string | null
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
cancel_at_period_end?: boolean
|
||||
created_at?: string
|
||||
current_period_end?: string | null
|
||||
current_period_start?: string | null
|
||||
environment: string
|
||||
id?: number
|
||||
last_event_type?: string | null
|
||||
original_transaction_id: string
|
||||
owner_user_id: string
|
||||
plan: string
|
||||
raw_customer_id?: string | null
|
||||
revenuecat_app_user_id: string
|
||||
revoked_at?: string | null
|
||||
status: string
|
||||
store?: string
|
||||
store_product_id: string
|
||||
transaction_id?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
cancel_at_period_end?: boolean
|
||||
created_at?: string
|
||||
current_period_end?: string | null
|
||||
current_period_start?: string | null
|
||||
environment?: string
|
||||
id?: number
|
||||
last_event_type?: string | null
|
||||
original_transaction_id?: string
|
||||
owner_user_id?: string
|
||||
plan?: string
|
||||
raw_customer_id?: string | null
|
||||
revenuecat_app_user_id?: string
|
||||
revoked_at?: string | null
|
||||
status?: string
|
||||
store?: string
|
||||
store_product_id?: string
|
||||
transaction_id?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "apple_subscriptions_owner_user_id_fkey"
|
||||
columns: ["owner_user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
availabilities: {
|
||||
Row: {
|
||||
availability_data: Json
|
||||
|
|
@ -84,6 +282,303 @@ export type Database = {
|
|||
},
|
||||
]
|
||||
}
|
||||
channel_read_state: {
|
||||
Row: {
|
||||
channel_id: string
|
||||
last_read_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
channel_id: string
|
||||
last_read_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
channel_id?: string
|
||||
last_read_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "channel_read_state_channel_id_fkey"
|
||||
columns: ["channel_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "channel_read_state_channel_id_fkey"
|
||||
columns: ["channel_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "channel_read_state_channel_id_fkey"
|
||||
columns: ["channel_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
client_access: {
|
||||
Row: {
|
||||
client_id: string
|
||||
created_at: string
|
||||
granted_at: string
|
||||
granted_by: string
|
||||
id: number
|
||||
revoked_at: string | null
|
||||
tablo_id: string
|
||||
}
|
||||
Insert: {
|
||||
client_id: string
|
||||
created_at?: string
|
||||
granted_at?: string
|
||||
granted_by: string
|
||||
id?: number
|
||||
revoked_at?: string | null
|
||||
tablo_id: string
|
||||
}
|
||||
Update: {
|
||||
client_id?: string
|
||||
created_at?: string
|
||||
granted_at?: string
|
||||
granted_by?: string
|
||||
id?: number
|
||||
revoked_at?: string | null
|
||||
tablo_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "client_access_client_id_fkey"
|
||||
columns: ["client_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "clients"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_access_granted_by_fkey"
|
||||
columns: ["granted_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_access_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_access_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_access_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
client_invites: {
|
||||
Row: {
|
||||
cancelled_at: string | null
|
||||
created_at: string
|
||||
expires_at: string
|
||||
id: number
|
||||
invite_token: string
|
||||
invite_type: string
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
is_pending: boolean
|
||||
setup_completed_at: string | null
|
||||
tablo_id: string
|
||||
used_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
cancelled_at?: string | null
|
||||
created_at?: string
|
||||
expires_at?: string
|
||||
id?: number
|
||||
invite_token: string
|
||||
invite_type?: string
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
is_pending?: boolean
|
||||
setup_completed_at?: string | null
|
||||
tablo_id: string
|
||||
used_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
cancelled_at?: string | null
|
||||
created_at?: string
|
||||
expires_at?: string
|
||||
id?: number
|
||||
invite_token?: string
|
||||
invite_type?: string
|
||||
invited_by?: string
|
||||
invited_email?: string
|
||||
is_pending?: boolean
|
||||
setup_completed_at?: string | null
|
||||
tablo_id?: string
|
||||
used_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "client_invites_invited_by_fkey"
|
||||
columns: ["invited_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_invites_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_invites_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_invites_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
client_magic_links: {
|
||||
Row: {
|
||||
client_id: string
|
||||
consumed_at: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
email: string
|
||||
expires_at: string
|
||||
id: number
|
||||
jti: string | null
|
||||
purpose: string
|
||||
redirect_to: string | null
|
||||
tablo_id: string | null
|
||||
token_hash: string | null
|
||||
}
|
||||
Insert: {
|
||||
client_id: string
|
||||
consumed_at?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
email: string
|
||||
expires_at: string
|
||||
id?: number
|
||||
jti?: string | null
|
||||
purpose: string
|
||||
redirect_to?: string | null
|
||||
tablo_id?: string | null
|
||||
token_hash?: string | null
|
||||
}
|
||||
Update: {
|
||||
client_id?: string
|
||||
consumed_at?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
email?: string
|
||||
expires_at?: string
|
||||
id?: number
|
||||
jti?: string | null
|
||||
purpose?: string
|
||||
redirect_to?: string | null
|
||||
tablo_id?: string | null
|
||||
token_hash?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "client_magic_links_client_id_fkey"
|
||||
columns: ["client_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "clients"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_magic_links_created_by_fkey"
|
||||
columns: ["created_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_magic_links_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_magic_links_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_magic_links_tablo_id_fkey"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
clients: {
|
||||
Row: {
|
||||
created_at: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
id: string
|
||||
last_login_at: string | null
|
||||
last_name: string | null
|
||||
normalized_email: string
|
||||
phone: string | null
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
email: string
|
||||
first_name?: string | null
|
||||
id?: string
|
||||
last_login_at?: string | null
|
||||
last_name?: string | null
|
||||
normalized_email: string
|
||||
phone?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
email?: string
|
||||
first_name?: string | null
|
||||
id?: string
|
||||
last_login_at?: string | null
|
||||
last_name?: string | null
|
||||
normalized_email?: string
|
||||
phone?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
devis: {
|
||||
Row: {
|
||||
client_email: string
|
||||
|
|
@ -256,6 +751,58 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
messages: {
|
||||
Row: {
|
||||
channel_id: string
|
||||
created_at: string
|
||||
deleted_at: string | null
|
||||
id: string
|
||||
text: string
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
channel_id: string
|
||||
created_at?: string
|
||||
deleted_at?: string | null
|
||||
id?: string
|
||||
text: string
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
channel_id?: string
|
||||
created_at?: string
|
||||
deleted_at?: string | null
|
||||
id?: string
|
||||
text?: string
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "messages_channel_id_fkey"
|
||||
columns: ["channel_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "messages_channel_id_fkey"
|
||||
columns: ["channel_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "messages_channel_id_fkey"
|
||||
columns: ["channel_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
note_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
|
|
@ -384,44 +931,140 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
organization_invites: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: number
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
invited_user_id: string | null
|
||||
organization_id: number
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
invited_user_id?: string | null
|
||||
organization_id: number
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
invited_by?: string
|
||||
invited_email?: string
|
||||
invited_user_id?: string | null
|
||||
organization_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "organization_invites_invited_by_fkey"
|
||||
columns: ["invited_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "organization_invites_invited_user_id_fkey"
|
||||
columns: ["invited_user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "organization_invites_organization_id_fkey"
|
||||
columns: ["organization_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "organizations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
organizations: {
|
||||
Row: {
|
||||
created_at: string
|
||||
deleted_at: string | null
|
||||
id: number
|
||||
internal_uuid: string
|
||||
logo_url: string | null
|
||||
name: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
deleted_at?: string | null
|
||||
id?: number
|
||||
internal_uuid?: string
|
||||
logo_url?: string | null
|
||||
name: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
deleted_at?: string | null
|
||||
id?: number
|
||||
internal_uuid?: string
|
||||
logo_url?: string | null
|
||||
name?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null
|
||||
client_onboarded_at: string | null
|
||||
created_at: string | null
|
||||
email: string | null
|
||||
first_name: string | null
|
||||
id: string
|
||||
is_client: boolean
|
||||
last_name: string | null
|
||||
last_signed_in: string | null
|
||||
name: string | null
|
||||
organization_id: number
|
||||
plan: Database["public"]["Enums"]["subscription_plan"] | null
|
||||
short_user_id: string
|
||||
}
|
||||
Insert: {
|
||||
avatar_url?: string | null
|
||||
client_onboarded_at?: string | null
|
||||
created_at?: string | null
|
||||
email?: string | null
|
||||
first_name?: string | null
|
||||
id: string
|
||||
is_client?: boolean
|
||||
last_name?: string | null
|
||||
last_signed_in?: string | null
|
||||
name?: string | null
|
||||
organization_id: number
|
||||
plan?: Database["public"]["Enums"]["subscription_plan"] | null
|
||||
short_user_id: string
|
||||
}
|
||||
Update: {
|
||||
avatar_url?: string | null
|
||||
client_onboarded_at?: string | null
|
||||
created_at?: string | null
|
||||
email?: string | null
|
||||
first_name?: string | null
|
||||
id?: string
|
||||
is_client?: boolean
|
||||
last_name?: string | null
|
||||
last_signed_in?: string | null
|
||||
name?: string | null
|
||||
organization_id?: number
|
||||
plan?: Database["public"]["Enums"]["subscription_plan"] | null
|
||||
short_user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "profiles_organization_id_fkey"
|
||||
columns: ["organization_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "organizations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
shared_notes: {
|
||||
Row: {
|
||||
|
|
@ -573,7 +1216,9 @@ export type Database = {
|
|||
deleted_at: string | null
|
||||
id: string
|
||||
image: string | null
|
||||
layout_overview_v1: Json | null
|
||||
name: string
|
||||
organization_id: number
|
||||
owner_id: string
|
||||
position: number
|
||||
status: string | null
|
||||
|
|
@ -585,7 +1230,9 @@ export type Database = {
|
|||
deleted_at?: string | null
|
||||
id?: string
|
||||
image?: string | null
|
||||
layout_overview_v1?: Json | null
|
||||
name: string
|
||||
organization_id: number
|
||||
owner_id: string
|
||||
position?: number
|
||||
status?: string | null
|
||||
|
|
@ -597,19 +1244,31 @@ export type Database = {
|
|||
deleted_at?: string | null
|
||||
id?: string
|
||||
image?: string | null
|
||||
layout_overview_v1?: Json | null
|
||||
name?: string
|
||||
organization_id?: number
|
||||
owner_id?: string
|
||||
position?: number
|
||||
status?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "tablos_organization_id_fkey"
|
||||
columns: ["organization_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "organizations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tasks: {
|
||||
Row: {
|
||||
assignee_id: string | null
|
||||
created_at: string
|
||||
deleted_at: string | null
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
is_parent: boolean
|
||||
parent_task_id: string | null
|
||||
|
|
@ -622,7 +1281,9 @@ export type Database = {
|
|||
Insert: {
|
||||
assignee_id?: string | null
|
||||
created_at?: string
|
||||
deleted_at?: string | null
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_parent?: boolean
|
||||
parent_task_id?: string | null
|
||||
|
|
@ -635,7 +1296,9 @@ export type Database = {
|
|||
Update: {
|
||||
assignee_id?: string | null
|
||||
created_at?: string
|
||||
deleted_at?: string | null
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_parent?: boolean
|
||||
parent_task_id?: string | null
|
||||
|
|
@ -728,6 +1391,7 @@ export type Database = {
|
|||
assignee_name: string | null
|
||||
created_at: string | null
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string | null
|
||||
is_parent: boolean | null
|
||||
parent_task_id: string | null
|
||||
|
|
@ -805,6 +1469,9 @@ export type Database = {
|
|||
Args: { tablo_id_param: string }
|
||||
Returns: string
|
||||
}
|
||||
create_personal_organization: { Args: never; Returns: number }
|
||||
current_user_organization_id: { Args: never; Returns: number }
|
||||
generate_cool_organization_name: { Args: never; Returns: string }
|
||||
generate_random_string: { Args: { length?: number }; Returns: string }
|
||||
get_my_active_subscription: {
|
||||
Args: never
|
||||
|
|
@ -895,11 +1562,20 @@ export type Database = {
|
|||
subscription_id: string
|
||||
}[]
|
||||
}
|
||||
is_freemium_available: { Args: never; Returns: boolean }
|
||||
is_paying_user: { Args: { user_uuid: string }; Returns: boolean }
|
||||
}
|
||||
Enums: {
|
||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
|
||||
subscription_plan: "none" | "trial" | "standard" | "beta" | "free"
|
||||
subscription_plan:
|
||||
| "none"
|
||||
| "trial"
|
||||
| "standard"
|
||||
| "beta"
|
||||
| "free"
|
||||
| "solo"
|
||||
| "team"
|
||||
| "annual"
|
||||
task_status: "todo" | "in_progress" | "in_review" | "done"
|
||||
}
|
||||
CompositeTypes: {
|
||||
|
|
@ -1029,11 +1705,24 @@ export type CompositeTypes<
|
|||
: never
|
||||
|
||||
export const Constants = {
|
||||
graphql_public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
subscription_plan: ["none", "trial", "standard", "beta", "free"],
|
||||
subscription_plan: [
|
||||
"none",
|
||||
"trial",
|
||||
"standard",
|
||||
"beta",
|
||||
"free",
|
||||
"solo",
|
||||
"team",
|
||||
"annual",
|
||||
],
|
||||
task_status: ["todo", "in_progress", "in_review", "done"],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
|
|
|
|||
122
xtablo-expo/lib/purchases.ts
Normal file
122
xtablo-expo/lib/purchases.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Platform } from "react-native";
|
||||
import Purchases, {
|
||||
LOG_LEVEL,
|
||||
PURCHASES_ERROR_CODE,
|
||||
type CustomerInfo,
|
||||
type PurchasesError,
|
||||
type PurchasesOffering,
|
||||
type PurchasesPackage,
|
||||
} from "react-native-purchases";
|
||||
|
||||
const revenueCatAppleApiKey = process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY?.trim() ?? "";
|
||||
|
||||
let configuredAppUserId: string | null = null;
|
||||
|
||||
export type BillingPackagePlan = "solo" | "annual";
|
||||
|
||||
export type BillingPackageOption = {
|
||||
description: string | null;
|
||||
id: string;
|
||||
package: PurchasesPackage;
|
||||
plan: BillingPackagePlan;
|
||||
price: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const isIosPurchasePlatform = () => Platform.OS === "ios";
|
||||
|
||||
const inferBillingPackagePlan = (input: string): BillingPackagePlan | null => {
|
||||
const normalized = input.toLowerCase();
|
||||
|
||||
if (normalized.includes("annual") || normalized.includes("founder")) {
|
||||
return "annual";
|
||||
}
|
||||
|
||||
if (normalized.includes("solo")) {
|
||||
return "solo";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const canUseInAppPurchases = () => isIosPurchasePlatform() && revenueCatAppleApiKey.length > 0;
|
||||
|
||||
export const ensurePurchasesConfigured = async (appUserID: string) => {
|
||||
if (!canUseInAppPurchases()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
await Purchases.setLogLevel(LOG_LEVEL.DEBUG);
|
||||
}
|
||||
|
||||
const isConfigured = await Purchases.isConfigured();
|
||||
|
||||
if (!isConfigured) {
|
||||
Purchases.configure({
|
||||
apiKey: revenueCatAppleApiKey,
|
||||
appUserID,
|
||||
});
|
||||
configuredAppUserId = appUserID;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (configuredAppUserId !== appUserID) {
|
||||
await Purchases.logIn(appUserID);
|
||||
configuredAppUserId = appUserID;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const toBillingPackageOption = (pkg: PurchasesPackage): BillingPackageOption | null => {
|
||||
const plan =
|
||||
inferBillingPackagePlan(pkg.product.identifier) ?? inferBillingPackagePlan(pkg.identifier);
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
description: pkg.product.description ?? null,
|
||||
id: pkg.product.identifier,
|
||||
package: pkg,
|
||||
plan,
|
||||
price: pkg.product.priceString,
|
||||
title: pkg.product.title,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCurrentOffering = async (appUserID: string): Promise<PurchasesOffering | null> => {
|
||||
const isConfigured = await ensurePurchasesConfigured(appUserID);
|
||||
if (!isConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offerings = await Purchases.getOfferings();
|
||||
return offerings.current;
|
||||
};
|
||||
|
||||
export const getBillingPackageOptions = async (appUserID: string) => {
|
||||
const offering = await getCurrentOffering(appUserID);
|
||||
if (!offering) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return offering.availablePackages
|
||||
.map(toBillingPackageOption)
|
||||
.filter((pkg): pkg is BillingPackageOption => Boolean(pkg));
|
||||
};
|
||||
|
||||
export const purchaseBillingPackage = async (appUserID: string, pkg: PurchasesPackage) => {
|
||||
await ensurePurchasesConfigured(appUserID);
|
||||
return Purchases.purchasePackage(pkg);
|
||||
};
|
||||
|
||||
export const restoreBillingPurchases = async (appUserID: string): Promise<CustomerInfo> => {
|
||||
await ensurePurchasesConfigured(appUserID);
|
||||
return Purchases.restorePurchases();
|
||||
};
|
||||
|
||||
export const isPurchaseCancelledError = (error: unknown) =>
|
||||
(error as PurchasesError | undefined)?.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR;
|
||||
227
xtablo-expo/package-lock.json
generated
227
xtablo-expo/package-lock.json
generated
|
|
@ -47,6 +47,7 @@
|
|||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-purchases": "^10.0.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
|
|
@ -4412,6 +4413,27 @@
|
|||
"nanoid": "^3.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-js": {
|
||||
"version": "1.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.34.0.tgz",
|
||||
"integrity": "sha512-p3hpHHvyllAckqnjpjaCoj+lVK0gNJwqR1F8EwZPw7eMKFummE0UItbGzBCfncJIPxGA1NJHrgZb1dLflEmjhg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-js-hybrid-mappings": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-18.1.0.tgz",
|
||||
"integrity": "sha512-P2KtqjPUcdEhTx51nzXAWNFoNTRzQm9lR/g2S90jQ/uzwrB4H7OJNRJ9B1NFNa511Uc84LfwmmIh51oeQO9+qQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@revenuecat/purchases-js": "1.34.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-typescript-internal": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-18.1.0.tgz",
|
||||
"integrity": "sha512-aoXmrvDSCVStXAbv+yfUkb1BEIga2hXYiUSLHOoGMX6luXXlSLNMeG7nhvMa7gXTgjnEZXDYOG59en4NNBtJ6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rn-vui/base": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rn-vui/base/-/base-5.1.3.tgz",
|
||||
|
|
@ -6576,26 +6598,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
|
|
@ -7352,6 +7354,12 @@
|
|||
"url": "https://github.com/sindresorhus/emittery?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
|
|
@ -13239,7 +13247,6 @@
|
|||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
|
|
@ -13251,7 +13258,6 @@
|
|||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
|
|
@ -13545,6 +13551,31 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-purchases": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-10.0.1.tgz",
|
||||
"integrity": "sha512-FyJgOLuGo2TqR/sswzgkUebiYu30FPAsB9N2goyv4q2pr5ixPL0v6QrETrYDpnPI8kz/wg+aMv8/HsAU9b6ajw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples/purchaseTesterTypescript",
|
||||
"react-native-purchases-ui",
|
||||
"e2e-tests/MaestroTestApp"
|
||||
],
|
||||
"dependencies": {
|
||||
"@revenuecat/purchases-js-hybrid-mappings": "18.1.0",
|
||||
"@revenuecat/purchases-typescript-internal": "18.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.6.3",
|
||||
"react-native": ">= 0.73.0",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
|
||||
|
|
@ -13632,6 +13663,78 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vector-icons": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",
|
||||
"integrity": "sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==",
|
||||
"deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2",
|
||||
"yargs": "^16.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"fa-upgrade.sh": "bin/fa-upgrade.sh",
|
||||
"fa5-upgrade": "bin/fa5-upgrade.sh",
|
||||
"fa6-upgrade": "bin/fa6-upgrade.sh",
|
||||
"generate-icon": "bin/generate-icon.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vector-icons/node_modules/cliui": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vector-icons/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vector-icons/node_modules/yargs": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
|
||||
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cliui": "^7.0.2",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^20.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vector-icons/node_modules/yargs-parser": {
|
||||
"version": "20.2.9",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
|
||||
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
|
|
@ -14936,6 +15039,32 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.matchall": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
||||
|
|
@ -15566,7 +15695,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -16317,26 +16446,6 @@
|
|||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
|
|
@ -16502,38 +16611,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-purchases": "^10.0.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
|
|
|
|||
124
xtablo-expo/stores/__tests__/auth.test.ts
Normal file
124
xtablo-expo/stores/__tests__/auth.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: jest.fn(),
|
||||
onAuthStateChange: jest.fn(),
|
||||
setSession: jest.fn(),
|
||||
signInWithIdToken: jest.fn(),
|
||||
signInWithOAuth: jest.fn(),
|
||||
signInWithPassword: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
signUp: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/purchases", () => ({
|
||||
ensurePurchasesConfigured: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("expo-web-browser", () => ({
|
||||
maybeCompleteAuthSession: jest.fn(),
|
||||
openAuthSessionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("expo-auth-session", () => ({
|
||||
makeRedirectUri: jest.fn(() => "xtablo://redirect"),
|
||||
}));
|
||||
|
||||
jest.mock("expo-auth-session/build/QueryParams", () => ({
|
||||
getQueryParams: jest.fn(() => ({ params: {}, errorCode: null })),
|
||||
}));
|
||||
|
||||
jest.mock("react-native", () => ({
|
||||
Linking: {
|
||||
addEventListener: jest.fn(),
|
||||
getInitialURL: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("expo-apple-authentication", () => ({
|
||||
AppleAuthenticationScope: {
|
||||
EMAIL: "EMAIL",
|
||||
FULL_NAME: "FULL_NAME",
|
||||
},
|
||||
signInAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
const mockedSupabase = supabase as jest.Mocked<typeof supabase>;
|
||||
const mockedSignUp = mockedSupabase.auth.signUp as jest.Mock;
|
||||
|
||||
describe("auth store signUp", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
session: null,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends snake_case signup metadata and a full name", async () => {
|
||||
mockedSignUp.mockResolvedValue({
|
||||
data: {
|
||||
session: {
|
||||
user: { id: "user-id" },
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
} as never);
|
||||
|
||||
await useAuthStore.getState().signUp(
|
||||
"owner@example.com",
|
||||
"password123",
|
||||
"Ada",
|
||||
"Lovelace",
|
||||
"Analytical Engines"
|
||||
);
|
||||
|
||||
expect(mockedSupabase.auth.signUp).toHaveBeenCalledWith({
|
||||
email: "owner@example.com",
|
||||
password: "password123",
|
||||
options: {
|
||||
data: {
|
||||
company_name: "Analytical Engines",
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
name: "Ada Lovelace",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a helpful error when signup does not create a session", async () => {
|
||||
mockedSignUp.mockResolvedValue({
|
||||
data: {
|
||||
session: null,
|
||||
user: { id: "user-id" },
|
||||
},
|
||||
error: null,
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().signUp(
|
||||
"owner@example.com",
|
||||
"password123",
|
||||
"Ada",
|
||||
"Lovelace",
|
||||
"Analytical Engines"
|
||||
)
|
||||
).rejects.toThrow(
|
||||
"Impossible d'ouvrir votre session après l'inscription. Si cette adresse existe déjà, essayez de vous connecter."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,8 @@ import { QueryClient } from "@tanstack/react-query";
|
|||
import { User } from "@/types/user.types";
|
||||
import { api } from "@/lib/api";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import { ensurePurchasesConfigured } from "@/lib/purchases";
|
||||
import { organizationBillingQueryKey } from "@/hooks/organization";
|
||||
|
||||
interface AuthState {
|
||||
session: Session | null;
|
||||
|
|
@ -29,7 +31,7 @@ interface AuthState {
|
|||
signInWithApple: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
createSessionFromUrl: (url: string) => Promise<void>;
|
||||
fetchAndSetUser: (session: Session | null) => Promise<void>;
|
||||
fetchAndSetUser: (session: Session | null, queryClient?: QueryClient) => Promise<void>;
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
|
@ -41,8 +43,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
loading: true,
|
||||
initialized: false,
|
||||
setSession: (session: Session | null) => set({ session }),
|
||||
fetchAndSetUser: async (session: Session | null) => {
|
||||
if (!session) return;
|
||||
fetchAndSetUser: async (session: Session | null, queryClient?: QueryClient) => {
|
||||
if (!session) {
|
||||
set({ user: null });
|
||||
queryClient?.removeQueries({ queryKey: organizationBillingQueryKey });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await api.get<User>("/api/v1/users/me", {
|
||||
headers: {
|
||||
|
|
@ -50,6 +57,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
},
|
||||
});
|
||||
set({ user: data });
|
||||
await ensurePurchasesConfigured(data.id);
|
||||
await queryClient?.invalidateQueries({ queryKey: organizationBillingQueryKey });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
}
|
||||
|
|
@ -63,12 +72,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
set({
|
||||
session,
|
||||
});
|
||||
await get().fetchAndSetUser(session, queryClient);
|
||||
|
||||
supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
set({
|
||||
session,
|
||||
});
|
||||
await get().fetchAndSetUser(session);
|
||||
await get().fetchAndSetUser(session, queryClient);
|
||||
});
|
||||
|
||||
const initialUrl = await Linking.getInitialURL();
|
||||
|
|
@ -102,18 +112,34 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
lastName: string,
|
||||
companyName: string
|
||||
) => {
|
||||
await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
companyName,
|
||||
set({ loading: true });
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
company_name: companyName,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
name: [firstName, lastName].filter(Boolean).join(" ").trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
set({ loading: false });
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
throw new Error(
|
||||
"Impossible d'ouvrir votre session après l'inscription. Si cette adresse existe déjà, essayez de vous connecter."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
performOAuth: async (provider: Provider) => {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
51
xtablo-expo/types/organization.types.ts
Normal file
51
xtablo-expo/types/organization.types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export type OrganizationPlan = "solo" | "team" | "annual";
|
||||
export type RequiredOrganizationPlan = "solo" | "team";
|
||||
|
||||
export type OrganizationSummary = {
|
||||
id: number;
|
||||
logo_url: string | null;
|
||||
member_count: number;
|
||||
name: string;
|
||||
plan: OrganizationPlan;
|
||||
tablo_count: number;
|
||||
};
|
||||
|
||||
export type OrganizationMember = {
|
||||
avatar_url: string | null;
|
||||
created_at: string | null;
|
||||
email: string | null;
|
||||
first_name: string | null;
|
||||
id: string;
|
||||
last_name: string | null;
|
||||
name: string | null;
|
||||
plan: OrganizationPlan;
|
||||
};
|
||||
|
||||
export type OrganizationInvite = {
|
||||
created_at: string;
|
||||
id: number;
|
||||
invited_email: string;
|
||||
invited_member: {
|
||||
avatar_url: string | null;
|
||||
email: string | null;
|
||||
first_name: string | null;
|
||||
id: string;
|
||||
last_name: string | null;
|
||||
name: string | null;
|
||||
} | null;
|
||||
invited_user_id: string | null;
|
||||
};
|
||||
|
||||
export type OrganizationBillingState = {
|
||||
active_subscription_plan: OrganizationPlan | null;
|
||||
active_subscription_quantity: number;
|
||||
invites_sent: OrganizationInvite[];
|
||||
is_billing_owner: boolean;
|
||||
is_trial_expired: boolean;
|
||||
members: OrganizationMember[];
|
||||
organization: OrganizationSummary;
|
||||
required_plan: RequiredOrganizationPlan;
|
||||
required_team_quantity: number;
|
||||
trial_ends_at: string;
|
||||
trial_starts_at: string;
|
||||
};
|
||||
Loading…
Reference in a new issue