Merge pull request #85 from artslidd/in-app-purchases

In app purchases
This commit is contained in:
Arthur Belleville 2026-05-03 09:36:49 +02:00 committed by GitHub
commit 8a99a1a792
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 6631 additions and 1638 deletions

View file

@ -14,7 +14,7 @@ steps:
- '--region' - '--region'
- 'europe-west1' - 'europe-west1'
- '--set-env-vars' - '--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: images:
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA' - 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'

View 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"
);
});
});

View 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);
});
});

View file

@ -4,6 +4,7 @@ import {
getOrganizationOwner, getOrganizationOwner,
getTrialWindow, getTrialWindow,
parseTrialRolloutDate, parseTrialRolloutDate,
selectBestBillingCandidate,
} from "../../helpers/billing.js"; } from "../../helpers/billing.js";
describe("billing helpers", () => { describe("billing helpers", () => {
@ -91,4 +92,54 @@ describe("billing helpers", () => {
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z"); expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
expect(result.isTrialExpired).toBe(true); 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 });
});
}); });

View 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" });
});
});

View 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);
});
});

View file

@ -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 // DELETE /me must run last — it hard-deletes the auth user, making ownerUser unusable for subsequent tests
describe("DELETE /me - Delete Account", () => { describe("DELETE /me - Delete Account", () => {
it("should return 401 when unauthenticated", async () => { it("should return 401 when unauthenticated", async () => {

View file

@ -13,6 +13,9 @@ export interface AppConfig {
STRIPE_SOLO_PRICE_ID: string; STRIPE_SOLO_PRICE_ID: string;
STRIPE_TEAM_PRICE_ID: string; STRIPE_TEAM_PRICE_ID: string;
STRIPE_FOUNDER_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_USER: string;
EMAIL_CLIENT_ID: string; EMAIL_CLIENT_ID: string;
EMAIL_CLIENT_SECRET: string; EMAIL_CLIENT_SECRET: string;
@ -133,6 +136,18 @@ export function createConfig(secrets?: Secrets): AppConfig {
"STRIPE_FOUNDER_PRICE_ID", "STRIPE_FOUNDER_PRICE_ID",
process.env.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_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID), EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: isTestMode EMAIL_CLIENT_SECRET: isTestMode

View 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,
};
}

View file

@ -1,4 +1,5 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { toAppleBillingCandidate, type AppleBillingCandidate } from "./appleBilling.js";
export type BillingPlan = "solo" | "team" | "annual"; export type BillingPlan = "solo" | "team" | "annual";
export type RequiredBillingPlan = "solo" | "team"; export type RequiredBillingPlan = "solo" | "team";
@ -41,6 +42,19 @@ type StripeProductRow = {
metadata: Record<string, string | null> | null; 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 = { export type OrganizationBillingState = {
owner_user_id: string; owner_user_id: string;
member_count: number; member_count: number;
@ -56,6 +70,7 @@ export type OrganizationBillingState = {
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"]; const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
const DEFAULT_BILLING_TRIAL_DAYS = 14; const DEFAULT_BILLING_TRIAL_DAYS = 14;
const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z"; 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 parseTrialDays = () => {
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10); 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 === "active") return 3;
if (status === "past_due") return 2; if (status === "past_due") return 2;
if (status === "trialing") return 1; if (status === "trialing") return 1;
if (status === "canceled") return 1;
return 0; return 0;
}; };
@ -147,6 +163,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu
return null; 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 normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
const normalized = (plan ?? "").toLowerCase(); const normalized = (plan ?? "").toLowerCase();
@ -195,157 +225,202 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId:
return { error: null, profiles }; 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 ( const resolveActiveSubscription = async (
supabase: SupabaseClient, supabase: SupabaseClient,
ownerUserId: string, ownerUserId: string,
ownerProfilePlan: string | null ownerProfilePlan: string | null
): Promise<{ plan: BillingPlan | null; quantity: number }> => { ): 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 ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
let stripeCandidates: BillingCandidate[] = [];
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id); try {
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase const { data: customers, error: customersError } = 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") .schema("stripe")
.from("prices") .from("customers")
.select("id, lookup_key, metadata, product") .select("id, metadata")
.in("id", priceIds); .limit(1000);
if (pricesError) { if (customersError) {
throw new Error(pricesError.message); throw new Error(customersError.message);
} }
const normalizedPrices = (prices ?? []) as StripePriceRow[]; const customer = (customers as StripeCustomerRow[] | null)?.find(
pricesById = new Map(normalizedPrices.map((price) => [price.id, price])); (candidate) => candidate.metadata?.user_id === ownerUserId
const productIds = Array.from(
new Set(
normalizedPrices
.map((price) => price.product)
.filter((product): product is string => Boolean(product))
)
); );
if (productIds.length > 0) { if (customer) {
const { data: products, error: productsError } = await supabase const { data: subscriptions, error: subscriptionsError } = await supabase
.schema("stripe") .schema("stripe")
.from("products") .from("subscriptions")
.select("id, name, metadata") .select("id, status, created, current_period_end")
.in("id", productIds); .eq("customer", customer.id)
.in("status", ACTIVE_BILLING_STATUSES);
if (productsError) { if (subscriptionsError) {
throw new Error(productsError.message); throw new Error(subscriptionsError.message);
} }
const normalizedProducts = (products ?? []) as StripeProductRow[]; const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
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 { data: appleSubscriptions, error: appleSubscriptionsError } = await supabase
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id); .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 [ return [
{ {
subscription, currentPeriodEnd: candidate.currentPeriodEnd,
plan: null as BillingPlan | null, plan: candidate.plan,
quantity: 1, 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 selectBestBillingCandidate([...stripeCandidates, ...appleCandidates], ownerFallbackPlan);
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,
};
}; };
export const getOrganizationBillingState = async ( export const getOrganizationBillingState = async (

View 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,
};
};

View file

@ -9,6 +9,7 @@ import { getPublicClientInvitesRouter } from "./clientInvites.js";
import { getClientPortalRouter } from "./clientPortal.js"; import { getClientPortalRouter } from "./clientPortal.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js"; import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
import { getPublicRouter } from "./public.js"; import { getPublicRouter } from "./public.js";
import { getRevenueCatWebhookRouter } from "./revenuecat.js";
import { getStripeWebhookRouter } from "./stripe.js"; import { getStripeWebhookRouter } from "./stripe.js";
import { getTaskRouter } from "./tasks.js"; import { getTaskRouter } from "./tasks.js";
@ -33,6 +34,7 @@ export const getMainRouter = (config: AppConfig) => {
mainRouter.route("/tasks", getTaskRouter()); mainRouter.route("/tasks", getTaskRouter());
// webhooks // webhooks
mainRouter.route("/revenuecat-webhook", getRevenueCatWebhookRouter(config));
mainRouter.route("/stripe-webhook", getStripeWebhookRouter()); mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
// admin routes // admin routes

View 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;
};

View file

@ -4,6 +4,7 @@ import { Hono } from "hono";
import { createFactory } from "hono/factory"; import { createFactory } from "hono/factory";
import { getOrganizationBillingState } from "../helpers/billing.js"; import { getOrganizationBillingState } from "../helpers/billing.js";
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.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 { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js";
import type { AuthEnv } from "../types/app.types.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); return c.json({ error: "Organization not found" }, 404);
} }
const { data: members, error: membersError } = await supabase const { data: members, error: membersError } = await loadOrganizationMembers(supabase, {
.from("profiles") organizationId,
.select("id, email, name, first_name, last_name, avatar_url, created_at, plan") userId: user.id,
.eq("organization_id", organizationId) });
.order("created_at", { ascending: true });
if (membersError) { if (membersError) {
return c.json({ error: "Failed to load organization members" }, 500); return c.json({ error: "Failed to load organization members" }, 500);

View file

@ -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 Apples purchase flow.
- Expos 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

View file

@ -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
);

View 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;

View file

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
View, View,
Text, Text,
@ -9,6 +9,7 @@ import {
Switch, Switch,
Alert, Alert,
Linking, Linking,
ActivityIndicator,
} from "react-native"; } from "react-native";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
@ -38,11 +39,26 @@ import {
} from "@/constants/colors"; } from "@/constants/colors";
import { useThemeColor } from "@/hooks/useThemeColor"; import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme"; 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() { export default function SettingsScreen() {
const signOut = useAuthStore((state) => state.signOut); const signOut = useAuthStore((state) => state.signOut);
const user = useUser(); const user = useUser();
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const organizationBillingQuery = useOrganizationBilling();
// Theme-aware colors // Theme-aware colors
const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background"); const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
@ -61,8 +77,72 @@ export default function SettingsScreen() {
const [pushNotifications, setPushNotifications] = useState(true); const [pushNotifications, setPushNotifications] = useState(true);
const [emailNotifications, setEmailNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true);
const [biometricAuth, setBiometricAuth] = useState(false); 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 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 = () => { const handleSignOut = () => {
Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [ Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [
{ {
@ -197,6 +277,162 @@ export default function SettingsScreen() {
false 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&apos;abonnement
</Text>
</View>
);
}
if (organizationBillingQuery.error || !organizationBilling) {
return (
<View style={styles.billingBox}>
<Text style={styles.billingErrorText}>
Impossible de charger la facturation de l&apos;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 ( return (
<View style={[styles.container, { backgroundColor }]}> <View style={[styles.container, { backgroundColor }]}>
<StatusBar <StatusBar
@ -239,6 +475,8 @@ export default function SettingsScreen() {
</> </>
)} )}
{renderSettingsSection("Abonnement", renderBillingContent())}
{/* {renderSettingsSection( {/* {renderSettingsSection(
"Notifications", "Notifications",
<> <>
@ -531,4 +769,18 @@ const styles = StyleSheet.create({
bottomSpacing: { bottomSpacing: {
height: 100, height: 100,
}, },
billingBox: {
paddingHorizontal: 20,
paddingVertical: 20,
},
billingErrorText: {
color: "#b91c1c",
fontSize: 14,
lineHeight: 20,
},
billingHelperText: {
fontSize: 14,
lineHeight: 20,
marginTop: 10,
},
}); });

View file

@ -1,5 +1,5 @@
import React, { useState } from "react"; 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 { Button, Input } from "@rn-vui/themed";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { Link } from "expo-router"; import { Link } from "expo-router";
@ -30,6 +30,19 @@ export default function SignUp() {
? require("@/assets/images/logo_white.png") ? require("@/assets/images/logo_white.png")
: require("@/assets/images/logo.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 ( return (
<View style={[styles.container, { backgroundColor }]}> <View style={[styles.container, { backgroundColor }]}>
<Image source={logo} style={styles.logo} /> <Image source={logo} style={styles.logo} />
@ -91,7 +104,7 @@ export default function SignUp() {
<Button <Button
title="S'inscrire" title="S'inscrire"
disabled={authLoading} disabled={authLoading}
onPress={() => signUp(email, password, firstName, lastName, companyName)} onPress={handleSignUp}
buttonStyle={styles.button} buttonStyle={styles.button}
titleStyle={styles.buttonTitle} titleStyle={styles.buttonTitle}
/> />

View 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&apos;a pas encore d&apos;accès actif.</Text>
<Text style={styles.body}>
Pour activer l&apos;abonnement sur mobile, demandez au responsable de facturation de
gérer l&apos;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&apos;accès s&apos;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&apos;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",
},
});

View 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");
});
});

View 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);
});
});

View 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");
});
});

View 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,
});
};

View file

@ -7,13 +7,211 @@ export type Json =
| Json[] | Json[]
export type Database = { export type Database = {
// Allows to automatically instantiate createClient with right options graphql_public: {
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY) Tables: {
__InternalSupabase: { [_ in never]: never
PostgrestVersion: "13.0.4" }
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: { public: {
Tables: { 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: { availabilities: {
Row: { Row: {
availability_data: Json 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: { devis: {
Row: { Row: {
client_email: string client_email: string
@ -256,6 +751,58 @@ export type Database = {
} }
Relationships: [] 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: { note_access: {
Row: { Row: {
created_at: string | null created_at: string | null
@ -384,44 +931,140 @@ export type Database = {
} }
Relationships: [] 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: { profiles: {
Row: { Row: {
avatar_url: string | null avatar_url: string | null
client_onboarded_at: string | null
created_at: string | null created_at: string | null
email: string | null email: string | null
first_name: string | null first_name: string | null
id: string id: string
is_client: boolean
last_name: string | null last_name: string | null
last_signed_in: string | null last_signed_in: string | null
name: string | null name: string | null
organization_id: number
plan: Database["public"]["Enums"]["subscription_plan"] | null plan: Database["public"]["Enums"]["subscription_plan"] | null
short_user_id: string short_user_id: string
} }
Insert: { Insert: {
avatar_url?: string | null avatar_url?: string | null
client_onboarded_at?: string | null
created_at?: string | null created_at?: string | null
email?: string | null email?: string | null
first_name?: string | null first_name?: string | null
id: string id: string
is_client?: boolean
last_name?: string | null last_name?: string | null
last_signed_in?: string | null last_signed_in?: string | null
name?: string | null name?: string | null
organization_id: number
plan?: Database["public"]["Enums"]["subscription_plan"] | null plan?: Database["public"]["Enums"]["subscription_plan"] | null
short_user_id: string short_user_id: string
} }
Update: { Update: {
avatar_url?: string | null avatar_url?: string | null
client_onboarded_at?: string | null
created_at?: string | null created_at?: string | null
email?: string | null email?: string | null
first_name?: string | null first_name?: string | null
id?: string id?: string
is_client?: boolean
last_name?: string | null last_name?: string | null
last_signed_in?: string | null last_signed_in?: string | null
name?: string | null name?: string | null
organization_id?: number
plan?: Database["public"]["Enums"]["subscription_plan"] | null plan?: Database["public"]["Enums"]["subscription_plan"] | null
short_user_id?: string short_user_id?: string
} }
Relationships: [] Relationships: [
{
foreignKeyName: "profiles_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
} }
shared_notes: { shared_notes: {
Row: { Row: {
@ -573,7 +1216,9 @@ export type Database = {
deleted_at: string | null deleted_at: string | null
id: string id: string
image: string | null image: string | null
layout_overview_v1: Json | null
name: string name: string
organization_id: number
owner_id: string owner_id: string
position: number position: number
status: string | null status: string | null
@ -585,7 +1230,9 @@ export type Database = {
deleted_at?: string | null deleted_at?: string | null
id?: string id?: string
image?: string | null image?: string | null
layout_overview_v1?: Json | null
name: string name: string
organization_id: number
owner_id: string owner_id: string
position?: number position?: number
status?: string | null status?: string | null
@ -597,19 +1244,31 @@ export type Database = {
deleted_at?: string | null deleted_at?: string | null
id?: string id?: string
image?: string | null image?: string | null
layout_overview_v1?: Json | null
name?: string name?: string
organization_id?: number
owner_id?: string owner_id?: string
position?: number position?: number
status?: string | null status?: string | null
updated_at?: string | null updated_at?: string | null
} }
Relationships: [] Relationships: [
{
foreignKeyName: "tablos_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
} }
tasks: { tasks: {
Row: { Row: {
assignee_id: string | null assignee_id: string | null
created_at: string created_at: string
deleted_at: string | null
description: string | null description: string | null
due_date: string | null
id: string id: string
is_parent: boolean is_parent: boolean
parent_task_id: string | null parent_task_id: string | null
@ -622,7 +1281,9 @@ export type Database = {
Insert: { Insert: {
assignee_id?: string | null assignee_id?: string | null
created_at?: string created_at?: string
deleted_at?: string | null
description?: string | null description?: string | null
due_date?: string | null
id?: string id?: string
is_parent?: boolean is_parent?: boolean
parent_task_id?: string | null parent_task_id?: string | null
@ -635,7 +1296,9 @@ export type Database = {
Update: { Update: {
assignee_id?: string | null assignee_id?: string | null
created_at?: string created_at?: string
deleted_at?: string | null
description?: string | null description?: string | null
due_date?: string | null
id?: string id?: string
is_parent?: boolean is_parent?: boolean
parent_task_id?: string | null parent_task_id?: string | null
@ -728,6 +1391,7 @@ export type Database = {
assignee_name: string | null assignee_name: string | null
created_at: string | null created_at: string | null
description: string | null description: string | null
due_date: string | null
id: string | null id: string | null
is_parent: boolean | null is_parent: boolean | null
parent_task_id: string | null parent_task_id: string | null
@ -805,6 +1469,9 @@ export type Database = {
Args: { tablo_id_param: string } Args: { tablo_id_param: string }
Returns: 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 } generate_random_string: { Args: { length?: number }; Returns: string }
get_my_active_subscription: { get_my_active_subscription: {
Args: never Args: never
@ -895,11 +1562,20 @@ export type Database = {
subscription_id: string subscription_id: string
}[] }[]
} }
is_freemium_available: { Args: never; Returns: boolean }
is_paying_user: { Args: { user_uuid: string }; Returns: boolean } is_paying_user: { Args: { user_uuid: string }; Returns: boolean }
} }
Enums: { Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired" 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" task_status: "todo" | "in_progress" | "in_review" | "done"
} }
CompositeTypes: { CompositeTypes: {
@ -1029,11 +1705,24 @@ export type CompositeTypes<
: never : never
export const Constants = { export const Constants = {
graphql_public: {
Enums: {},
},
public: { public: {
Enums: { Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"], 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"], task_status: ["todo", "in_progress", "in_review", "done"],
}, },
}, },
} as const } as const

View 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;

View file

@ -47,6 +47,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0", "react-native-get-random-values": "~1.11.0",
"react-native-purchases": "^10.0.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
@ -4412,6 +4413,27 @@
"nanoid": "^3.3.11" "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": { "node_modules/@rn-vui/base": {
"version": "5.1.3", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/@rn-vui/base/-/base-5.1.3.tgz", "resolved": "https://registry.npmjs.org/@rn-vui/base/-/base-5.1.3.tgz",
@ -6576,26 +6598,6 @@
"node": ">=12" "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": { "node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "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" "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": { "node_modules/encodeurl": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@ -13239,7 +13247,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -13251,7 +13258,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
@ -13545,6 +13551,31 @@
"react-native": "*" "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": { "node_modules/react-native-reanimated": {
"version": "4.1.7", "version": "4.1.7",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
@ -13632,6 +13663,78 @@
"react-native": "*" "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": { "node_modules/react-native-web": {
"version": "0.21.2", "version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
@ -14936,6 +15039,32 @@
"node": ">=8" "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": { "node_modules/string.prototype.matchall": {
"version": "4.0.12", "version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -15566,7 +15695,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -16317,26 +16446,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "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": { "node_modules/wrap-ansi/node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -16502,38 +16611,6 @@
"node": ">=12" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -54,6 +54,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0", "react-native-get-random-values": "~1.11.0",
"react-native-purchases": "^10.0.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",

View 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."
);
});
});

View file

@ -9,6 +9,8 @@ import { QueryClient } from "@tanstack/react-query";
import { User } from "@/types/user.types"; import { User } from "@/types/user.types";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import * as AppleAuthentication from "expo-apple-authentication"; import * as AppleAuthentication from "expo-apple-authentication";
import { ensurePurchasesConfigured } from "@/lib/purchases";
import { organizationBillingQueryKey } from "@/hooks/organization";
interface AuthState { interface AuthState {
session: Session | null; session: Session | null;
@ -29,7 +31,7 @@ interface AuthState {
signInWithApple: () => Promise<void>; signInWithApple: () => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
createSessionFromUrl: (url: string) => Promise<void>; createSessionFromUrl: (url: string) => Promise<void>;
fetchAndSetUser: (session: Session | null) => Promise<void>; fetchAndSetUser: (session: Session | null, queryClient?: QueryClient) => Promise<void>;
} }
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
@ -41,8 +43,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loading: true, loading: true,
initialized: false, initialized: false,
setSession: (session: Session | null) => set({ session }), setSession: (session: Session | null) => set({ session }),
fetchAndSetUser: async (session: Session | null) => { fetchAndSetUser: async (session: Session | null, queryClient?: QueryClient) => {
if (!session) return; if (!session) {
set({ user: null });
queryClient?.removeQueries({ queryKey: organizationBillingQueryKey });
return;
}
try { try {
const { data } = await api.get<User>("/api/v1/users/me", { const { data } = await api.get<User>("/api/v1/users/me", {
headers: { headers: {
@ -50,6 +57,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}, },
}); });
set({ user: data }); set({ user: data });
await ensurePurchasesConfigured(data.id);
await queryClient?.invalidateQueries({ queryKey: organizationBillingQueryKey });
} catch (error) { } catch (error) {
console.error("Error fetching user:", error); console.error("Error fetching user:", error);
} }
@ -63,12 +72,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ set({
session, session,
}); });
await get().fetchAndSetUser(session, queryClient);
supabase.auth.onAuthStateChange(async (event, session) => { supabase.auth.onAuthStateChange(async (event, session) => {
set({ set({
session, session,
}); });
await get().fetchAndSetUser(session); await get().fetchAndSetUser(session, queryClient);
}); });
const initialUrl = await Linking.getInitialURL(); const initialUrl = await Linking.getInitialURL();
@ -102,18 +112,34 @@ export const useAuthStore = create<AuthState>((set, get) => ({
lastName: string, lastName: string,
companyName: string companyName: string
) => { ) => {
await supabase.auth.signUp({ set({ loading: true });
email,
password, try {
options: { const { data, error } = await supabase.auth.signUp({
data: { email,
firstName, password,
lastName, options: {
companyName, 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) => { performOAuth: async (provider: Provider) => {
const { data, error } = await supabase.auth.signInWithOAuth({ const { data, error } = await supabase.auth.signInWithOAuth({

File diff suppressed because it is too large Load diff

View 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;
};