From f7b27a6018258b7722a9542e4d6e312fcaa2c232 Mon Sep 17 00:00:00 2001 From: SourceSensei Date: Wed, 2 Sep 2026 12:14:13 +0100 Subject: [PATCH 1/3] feat: restrict listing approval to sole administrator --- frontend/messages/en.json | 3 +- frontend/messages/es.json | 3 +- frontend/messages/pt-PT.json | 3 +- .../src/app/[locale]/account/page.test.tsx | 11 +++ frontend/src/app/[locale]/account/page.tsx | 12 ++- .../src/app/[locale]/admin/listings/page.tsx | 5 ++ .../moderation/listings/page.test.tsx | 46 +++++++++++ .../app/[locale]/moderation/listings/page.tsx | 4 +- .../api/v1/moderation/listings/route.test.ts | 82 ++++++++++++++++++- .../app/api/v1/moderation/listings/route.ts | 37 +++++---- .../src/features/auth/sole-administrator.ts | 79 ++++++++++++++++++ 11 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 frontend/src/app/[locale]/admin/listings/page.tsx create mode 100644 frontend/src/app/[locale]/moderation/listings/page.test.tsx create mode 100644 frontend/src/features/auth/sole-administrator.ts diff --git a/frontend/messages/en.json b/frontend/messages/en.json index df48886..2c1e0fd 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -100,7 +100,8 @@ "manageQuotations": "Requests and proposals", "manageBookings": "Manage bookings", "manageReviews": "Reviews", - "manageEntitlements": "Plans and promotions" + "manageEntitlements": "Plans and promotions", + "manageModeration": "Approve listings" } }, "ProviderProfile": { diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f025786..c23546a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -100,7 +100,8 @@ "manageQuotations": "Solicitudes y propuestas", "manageBookings": "Gestionar reservas", "manageReviews": "Reseñas", - "manageEntitlements": "Planes y promociones" + "manageEntitlements": "Planes y promociones", + "manageModeration": "Aprobar anuncios" } }, "ProviderProfile": { diff --git a/frontend/messages/pt-PT.json b/frontend/messages/pt-PT.json index 415ed2e..2330493 100644 --- a/frontend/messages/pt-PT.json +++ b/frontend/messages/pt-PT.json @@ -100,7 +100,8 @@ "manageQuotations": "Pedidos e propostas", "manageBookings": "Gerir reservas", "manageReviews": "Avaliações", - "manageEntitlements": "Planos e promoções" + "manageEntitlements": "Planos e promoções", + "manageModeration": "Aprovar anúncios" } }, "ProviderProfile": { diff --git a/frontend/src/app/[locale]/account/page.test.tsx b/frontend/src/app/[locale]/account/page.test.tsx index 01aff03..2fcbf74 100644 --- a/frontend/src/app/[locale]/account/page.test.tsx +++ b/frontend/src/app/[locale]/account/page.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + currentUserIsSoleAdministrator: vi.fn(), getTranslations: vi.fn(), requireAuthenticatedUser: vi.fn(), })); @@ -12,6 +13,9 @@ vi.mock("next-intl/server", () => ({ vi.mock("@/features/auth/require-session", () => ({ requireAuthenticatedUser: mocks.requireAuthenticatedUser, })); +vi.mock("@/features/auth/sole-administrator", () => ({ + currentUserIsSoleAdministrator: mocks.currentUserIsSoleAdministrator, +})); vi.mock("@/features/account/account-capabilities-card", () => ({ AccountCapabilitiesCard: ({ copy }: { copy: { providerLabel: string } }) => (
{copy.providerLabel}
@@ -22,6 +26,7 @@ import AccountPage, { dynamic } from "./page"; afterEach(() => { mocks.getTranslations.mockReset(); + mocks.currentUserIsSoleAdministrator.mockReset(); mocks.requireAuthenticatedUser.mockReset(); }); @@ -32,6 +37,7 @@ describe("AccountPage", () => { it("requires a verified session before rendering the localized account confirmation", async () => { mocks.requireAuthenticatedUser.mockResolvedValue("user_verified_subject"); + mocks.currentUserIsSoleAdministrator.mockResolvedValue(true); mocks.getTranslations.mockResolvedValue( (key: string) => ({ @@ -46,6 +52,7 @@ describe("AccountPage", () => { "Não foi possível carregar as capacidades da conta.", "capabilities.loading": "A carregar as capacidades da conta…", "capabilities.manageProvider": "Gerir perfil de prestador", + "capabilities.manageModeration": "Aprovar anúncios", "capabilities.providerDescription": "Ative esta opção para preparar o seu perfil de prestador.", "capabilities.providerLabel": "Disponibilizar serviços", @@ -71,5 +78,9 @@ describe("AccountPage", () => { expect(screen.getByTestId("account-capabilities-card")).toHaveTextContent( "Disponibilizar serviços", ); + expect(screen.getByRole("link", { name: "Aprovar anúncios" })).toHaveAttribute( + "href", + "/pt-PT/admin/listings", + ); }); }); diff --git a/frontend/src/app/[locale]/account/page.tsx b/frontend/src/app/[locale]/account/page.tsx index 85a4056..17079cd 100644 --- a/frontend/src/app/[locale]/account/page.tsx +++ b/frontend/src/app/[locale]/account/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { AccountCapabilitiesCard } from "@/features/account/account-capabilities-card"; import { requireAuthenticatedUser } from "@/features/auth/require-session"; +import { currentUserIsSoleAdministrator } from "@/features/auth/sole-administrator"; import { routing } from "@/i18n/routing"; export const dynamic = "force-dynamic"; @@ -19,7 +20,8 @@ export default async function AccountPage({ params }: AccountPageProps) { notFound(); } - await requireAuthenticatedUser(locale); + const userId = await requireAuthenticatedUser(locale); + const soleAdministrator = await currentUserIsSoleAdministrator(userId); const t = await getTranslations("Account"); const capabilityCopy = { title: t("capabilities.title"), @@ -96,6 +98,14 @@ export default async function AccountPage({ params }: AccountPageProps) { > {t("capabilities.manageEntitlements")} + {soleAdministrator ? ( + + {t("capabilities.manageModeration")} + + ) : null} diff --git a/frontend/src/app/[locale]/admin/listings/page.tsx b/frontend/src/app/[locale]/admin/listings/page.tsx new file mode 100644 index 0000000..b5f6339 --- /dev/null +++ b/frontend/src/app/[locale]/admin/listings/page.tsx @@ -0,0 +1,5 @@ +import ModerationListingsPage from "../../moderation/listings/page"; + +export const dynamic = "force-dynamic"; + +export default ModerationListingsPage; diff --git a/frontend/src/app/[locale]/moderation/listings/page.test.tsx b/frontend/src/app/[locale]/moderation/listings/page.test.tsx new file mode 100644 index 0000000..81b67df --- /dev/null +++ b/frontend/src/app/[locale]/moderation/listings/page.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getTranslations: vi.fn(), + requireSoleAdministrator: vi.fn(), +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mocks.getTranslations, +})); +vi.mock("@/features/auth/sole-administrator", () => ({ + requireSoleAdministrator: mocks.requireSoleAdministrator, +})); +vi.mock("@/features/auth/require-session", () => ({ + requireAuthenticatedUser: vi.fn(), +})); +vi.mock("@/features/listings/moderation-queue", () => ({ + ModerationQueue: ({ copy }: { copy: { title: string } }) => ( +
{copy.title}
+ ), +})); + +import ModerationListingsPage, { dynamic } from "./page"; + +afterEach(() => { + mocks.getTranslations.mockReset(); + mocks.requireSoleAdministrator.mockReset(); +}); + +describe("ModerationListingsPage", () => { + it("is dynamic and requires the sole administrator before rendering", async () => { + mocks.requireSoleAdministrator.mockResolvedValue("user_admin"); + mocks.getTranslations.mockResolvedValue((key: string) => key); + + render( + await ModerationListingsPage({ + params: Promise.resolve({ locale: "pt-PT" }), + }), + ); + + expect(dynamic).toBe("force-dynamic"); + expect(mocks.requireSoleAdministrator).toHaveBeenCalledWith("pt-PT"); + expect(screen.getByTestId("moderation-queue")).toHaveTextContent("title"); + }); +}); diff --git a/frontend/src/app/[locale]/moderation/listings/page.tsx b/frontend/src/app/[locale]/moderation/listings/page.tsx index a97e203..51a32ff 100644 --- a/frontend/src/app/[locale]/moderation/listings/page.tsx +++ b/frontend/src/app/[locale]/moderation/listings/page.tsx @@ -1,7 +1,7 @@ import { hasLocale } from "next-intl"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -import { requireAuthenticatedUser } from "@/features/auth/require-session"; +import { requireSoleAdministrator } from "@/features/auth/sole-administrator"; import { ModerationQueue } from "@/features/listings/moderation-queue"; import { routing } from "@/i18n/routing"; export const dynamic = "force-dynamic"; @@ -12,7 +12,7 @@ export default async function ModerationListingsPage({ }) { const { locale } = await params; if (!hasLocale(routing.locales, locale)) notFound(); - await requireAuthenticatedUser(locale); + await requireSoleAdministrator(locale); const t = await getTranslations("Moderation"); const keys = [ "title", diff --git a/frontend/src/app/api/v1/moderation/listings/route.test.ts b/frontend/src/app/api/v1/moderation/listings/route.test.ts index 10abb9d..c9c09ed 100644 --- a/frontend/src/app/api/v1/moderation/listings/route.test.ts +++ b/frontend/src/app/api/v1/moderation/listings/route.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ auth: vi.fn() })); -vi.mock("@clerk/nextjs/server", () => ({ auth: mocks.auth })); +const mocks = vi.hoisted(() => ({ auth: vi.fn(), currentUser: vi.fn() })); +vi.mock("@clerk/nextjs/server", () => ({ + auth: mocks.auth, + currentUser: mocks.currentUser, +})); import { GET, POST } from "./route"; const listing = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", @@ -23,14 +26,25 @@ describe("moderation listings BFF", () => { beforeEach(() => vi.stubEnv("JUNTLY_API_ORIGIN", "http://go-api:8080")); afterEach(() => { mocks.auth.mockReset(); + mocks.currentUser.mockReset(); vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it("uses server token and preserves forbidden", async () => { mocks.auth.mockResolvedValue({ isAuthenticated: true, + userId: "user_admin", getToken: vi.fn().mockResolvedValue("server-token"), }); + mocks.currentUser.mockResolvedValue({ + id: "user_admin", + emailAddresses: [ + { + emailAddress: "source.sensei1205@gmail.com", + verification: { status: "verified" }, + }, + ], + }); vi.stubGlobal( "fetch", vi.fn(async (req: Request) => { @@ -74,7 +88,11 @@ describe("moderation listings BFF", () => { expect(approve.status).toBe(403); }); it("does not call upstream signed out", async () => { - mocks.auth.mockResolvedValue({ isAuthenticated: false, getToken: vi.fn() }); + mocks.auth.mockResolvedValue({ + isAuthenticated: false, + userId: null, + getToken: vi.fn(), + }); const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); const r = await GET( @@ -85,4 +103,62 @@ describe("moderation listings BFF", () => { expect(r.status).toBe(401); expect(fetch).not.toHaveBeenCalled(); }); + + it("forbids every other authenticated account before the upstream API", async () => { + const getToken = vi.fn().mockResolvedValue("must-not-be-used"); + mocks.auth.mockResolvedValue({ + isAuthenticated: true, + userId: "user_other", + getToken, + }); + mocks.currentUser.mockResolvedValue({ + id: "user_other", + emailAddresses: [ + { + emailAddress: "someone@example.com", + verification: { status: "verified" }, + }, + ], + }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + const response = await GET( + new Request("http://localhost/api/v1/moderation/listings", { + headers: { "X-Request-ID": "req_moderation_other" }, + }), + ); + + expect(response.status).toBe(403); + expect(getToken).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("forbids an unverified copy of the administrator email", async () => { + mocks.auth.mockResolvedValue({ + isAuthenticated: true, + userId: "user_unverified", + getToken: vi.fn(), + }); + mocks.currentUser.mockResolvedValue({ + id: "user_unverified", + emailAddresses: [ + { + emailAddress: "source.sensei1205@gmail.com", + verification: { status: "unverified" }, + }, + ], + }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + const response = await GET( + new Request("http://localhost/api/v1/moderation/listings", { + headers: { "X-Request-ID": "req_moderation_unverified" }, + }), + ); + + expect(response.status).toBe(403); + expect(fetch).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/api/v1/moderation/listings/route.ts b/frontend/src/app/api/v1/moderation/listings/route.ts index cbc49ff..ea3bbe0 100644 --- a/frontend/src/app/api/v1/moderation/listings/route.ts +++ b/frontend/src/app/api/v1/moderation/listings/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@clerk/nextjs/server"; +import { resolveSoleAdministratorSession } from "@/features/auth/sole-administrator"; import { approveListing, listPendingModerationListings, @@ -15,50 +15,50 @@ const header = "X-Request-ID"; export const runtime = "nodejs"; export async function GET(request: Request): Promise { const id = requestID(request.headers), - token = await tokenForSession(); - if (!token) return error("UNAUTHORIZED", "Unauthorized", 401, id); + session = await resolveSoleAdministratorSession(); + if (session.status !== "authorized") return accessError(session.status, id); return upstream( id, - token, + session.token, () => listPendingModerationListings({ baseUrl: origin(), - headers: headers(token, id), + headers: headers(session.token, id), }), validListings, ); } export async function POST(request: Request): Promise { const id = requestID(request.headers), - token = await tokenForSession(), + session = await resolveSoleAdministratorSession(), listingID = idFrom(request); - if (!token) return error("UNAUTHORIZED", "Unauthorized", 401, id); + if (session.status !== "authorized") return accessError(session.status, id); if (!listingID) return error("INVALID_REQUEST", "Invalid request", 400, id); const action = new URL(request.url).pathname.split("/").pop(), body = await json(request); if (action === "approve" && revision(body)) return upstream( id, - token, + session.token, () => approveListing({ baseUrl: origin(), path: { listingId: listingID }, body: body as RevisionRequest, - headers: headers(token, id), + headers: headers(session.token, id), }), validListing, ); if (action === "reject" && reject(body)) return upstream( id, - token, + session.token, () => rejectListing({ baseUrl: origin(), path: { listingId: listingID }, body: body as RejectListingRequest, - headers: headers(token, id), + headers: headers(session.token, id), }), validListing, ); @@ -103,13 +103,14 @@ async function upstream( return unavailable(id); } } -async function tokenForSession() { - try { - const s = await auth(); - return s.isAuthenticated ? await s.getToken() : null; - } catch { - return null; - } +function accessError( + status: "unauthenticated" | "forbidden" | "unavailable", + id: string, +) { + if (status === "forbidden") + return error("FORBIDDEN", "Forbidden", 403, id); + if (status === "unavailable") return unavailable(id); + return error("UNAUTHORIZED", "Unauthorized", 401, id); } async function json(r: Request) { try { diff --git a/frontend/src/features/auth/sole-administrator.ts b/frontend/src/features/auth/sole-administrator.ts new file mode 100644 index 0000000..3ca4984 --- /dev/null +++ b/frontend/src/features/auth/sole-administrator.ts @@ -0,0 +1,79 @@ +import { auth, currentUser } from "@clerk/nextjs/server"; +import { notFound, redirect } from "next/navigation"; + +import type { AppLocale } from "@/i18n/routing"; + +export const SOLE_ADMIN_EMAIL = "source.sensei1205@gmail.com"; + +type EmailIdentity = { + emailAddress: string; + verification?: { status?: string | null } | null; +}; + +type UserIdentity = { + id: string; + emailAddresses: EmailIdentity[]; +}; + +type SoleAdministratorSession = + | { status: "unauthenticated" } + | { status: "forbidden" } + | { status: "unavailable" } + | { status: "authorized"; token: string; userId: string }; + +export function isSoleAdministrator( + sessionUserId: string, + user: UserIdentity | null, +): boolean { + if (!user || user.id !== sessionUserId) return false; + + return user.emailAddresses.some( + (identity) => + identity.verification?.status === "verified" && + identity.emailAddress.trim().toLowerCase() === SOLE_ADMIN_EMAIL, + ); +} + +export async function resolveSoleAdministratorSession(): Promise { + try { + const session = await auth(); + if (!session.isAuthenticated || !session.userId) { + return { status: "unauthenticated" }; + } + + const user = await currentUser(); + if (!isSoleAdministrator(session.userId, user)) { + return { status: "forbidden" }; + } + + const token = await session.getToken(); + if (!token) return { status: "unauthenticated" }; + + return { status: "authorized", token, userId: session.userId }; + } catch { + return { status: "unavailable" }; + } +} + +export async function requireSoleAdministrator( + locale: AppLocale, +): Promise { + const session = await resolveSoleAdministratorSession(); + if (session.status === "unauthenticated") { + redirect(`/${locale}/sign-in`); + } + if (session.status !== "authorized") { + notFound(); + } + return session.userId; +} + +export async function currentUserIsSoleAdministrator( + sessionUserId: string, +): Promise { + try { + return isSoleAdministrator(sessionUserId, await currentUser()); + } catch { + return false; + } +} From 7c77b971c65b1356c4c4b31b327e860979c7bfc8 Mon Sep 17 00:00:00 2001 From: SourceSensei Date: Thu, 3 Sep 2026 10:25:31 +0100 Subject: [PATCH 2/3] feat: add protected marketplace payments --- backend/.env.example | 9 + backend/cmd/api/config.go | 35 +- backend/cmd/api/config_test.go | 36 ++ backend/cmd/api/main.go | 4 +- backend/internal/httpapi/health_handler.go | 24 +- backend/internal/httpapi/payment_handler.go | 192 +++++++++ .../internal/httpapi/payment_handler_test.go | 86 ++++ backend/internal/httpapi/router_test.go | 2 +- .../payments/migration_contract_test.go | 57 +++ backend/internal/payments/model.go | 118 ++++++ backend/internal/payments/service.go | 248 +++++++++++ backend/internal/payments/service_test.go | 160 +++++++ backend/internal/payments/sql_store.go | 401 ++++++++++++++++++ .../payments/sql_store_integration_test.go | 98 +++++ backend/internal/payments/stripe_gateway.go | 317 ++++++++++++++ .../internal/payments/stripe_gateway_test.go | 94 ++++ compose.production.yaml | 4 + compose.yaml | 5 + context/decisions.md | 14 + context/product.md | 2 +- docs/operations/deployment-recovery.md | 26 +- frontend/messages/en.json | 42 +- frontend/messages/es.json | 42 +- frontend/messages/pt-PT.json | 44 +- .../app/[locale]/account/bookings/page.tsx | 7 + .../src/app/[locale]/account/page.test.tsx | 12 +- frontend/src/app/[locale]/account/page.tsx | 26 +- .../src/app/[locale]/account/payouts/page.tsx | 41 ++ .../src/app/[locale]/admin/payments/page.tsx | 45 ++ .../app/[locale]/legal/[document]/page.tsx | 330 ++++++++++++++ .../admin/payments/[orderId]/refund/route.ts | 50 +++ .../app/api/v1/admin/payments/route.test.ts | 100 +++++ .../src/app/api/v1/admin/payments/route.ts | 26 ++ .../me/bookings/[bookingId]/checkout/route.ts | 50 +++ frontend/src/app/api/v1/me/payments/route.ts | 24 ++ .../src/app/api/v1/me/payout-account/route.ts | 55 +++ .../app/api/v1/moderation/listings/route.ts | 3 +- .../api/v1/payments/webhooks/stripe/route.ts | 44 ++ .../features/bookings/booking-dashboard.tsx | 176 ++++++-- .../discovery/public-discovery.test.tsx | 5 +- .../features/discovery/public-discovery.tsx | 5 +- .../landing/components/landing-shell.test.tsx | 2 +- .../payments/payment-administration.test.tsx | 65 +++ .../payments/payment-administration.tsx | 183 ++++++++ .../src/features/payments/payment-bff.test.ts | 72 ++++ frontend/src/features/payments/payment-bff.ts | 169 ++++++++ .../src/features/payments/payment-proxy.ts | 60 +++ .../features/payments/payout-dashboard.tsx | 141 ++++++ frontend/src/shared/api/generated/index.ts | 4 +- frontend/src/shared/api/generated/sdk.gen.ts | 80 +++- .../src/shared/api/generated/types.gen.ts | 324 ++++++++++++++ openapi/juntly-api.v1.yaml | 150 +++++++ .../20260902123000_create_payments.sql | 89 ++++ 53 files changed, 4329 insertions(+), 69 deletions(-) create mode 100644 backend/internal/httpapi/payment_handler.go create mode 100644 backend/internal/httpapi/payment_handler_test.go create mode 100644 backend/internal/payments/migration_contract_test.go create mode 100644 backend/internal/payments/model.go create mode 100644 backend/internal/payments/service.go create mode 100644 backend/internal/payments/service_test.go create mode 100644 backend/internal/payments/sql_store.go create mode 100644 backend/internal/payments/sql_store_integration_test.go create mode 100644 backend/internal/payments/stripe_gateway.go create mode 100644 backend/internal/payments/stripe_gateway_test.go create mode 100644 frontend/src/app/[locale]/account/payouts/page.tsx create mode 100644 frontend/src/app/[locale]/admin/payments/page.tsx create mode 100644 frontend/src/app/[locale]/legal/[document]/page.tsx create mode 100644 frontend/src/app/api/v1/admin/payments/[orderId]/refund/route.ts create mode 100644 frontend/src/app/api/v1/admin/payments/route.test.ts create mode 100644 frontend/src/app/api/v1/admin/payments/route.ts create mode 100644 frontend/src/app/api/v1/me/bookings/[bookingId]/checkout/route.ts create mode 100644 frontend/src/app/api/v1/me/payments/route.ts create mode 100644 frontend/src/app/api/v1/me/payout-account/route.ts create mode 100644 frontend/src/app/api/v1/payments/webhooks/stripe/route.ts create mode 100644 frontend/src/features/payments/payment-administration.test.tsx create mode 100644 frontend/src/features/payments/payment-administration.tsx create mode 100644 frontend/src/features/payments/payment-bff.test.ts create mode 100644 frontend/src/features/payments/payment-bff.ts create mode 100644 frontend/src/features/payments/payment-proxy.ts create mode 100644 frontend/src/features/payments/payout-dashboard.tsx create mode 100644 supabase/migrations/20260902123000_create_payments.sql diff --git a/backend/.env.example b/backend/.env.example index f210baf..54659fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,3 +9,12 @@ CLERK_JWT_KEY= CLERK_AUTHORIZED_PARTIES=http://localhost:4200 # Optional Go duration, capped at 30s. Leave empty unless a local clock cannot synchronize. CLERK_CLOCK_SKEW= +# Stripe payments remain disabled when all fields below are empty. If any field is set, +# all required fields must be present or API startup fails closed. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +# Optional test-only API override. Production uses https://api.stripe.com. +STRIPE_API_BASE= +JUNTLY_PUBLIC_ORIGIN=https://somosvila.com +# Reviewed commission in basis points (1000 = 10%). Do not derive this in the browser. +JUNTLY_PLATFORM_FEE_BPS= diff --git a/backend/cmd/api/config.go b/backend/cmd/api/config.go index 5ca91c9..7343081 100644 --- a/backend/cmd/api/config.go +++ b/backend/cmd/api/config.go @@ -2,19 +2,23 @@ package main import ( "errors" + "strconv" "strings" "time" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/contactreveal" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments" ) var ErrInvalidRuntimeConfig = errors.New("invalid API runtime configuration") type runtimeConfig struct { - databaseURL string - verifier authn.Verifier - contactCipher contactreveal.Cipher + databaseURL string + verifier authn.Verifier + contactCipher contactreveal.Cipher + paymentGateway payments.Gateway + platformFeeBPS int } func loadRuntimeConfig(lookup func(string) string) (runtimeConfig, error) { @@ -43,8 +47,31 @@ func loadRuntimeConfig(lookup func(string) string) (runtimeConfig, error) { return runtimeConfig{}, ErrInvalidRuntimeConfig } } + var paymentGateway payments.Gateway + platformFeeBPS := 0 + stripeSecret := strings.TrimSpace(lookup("STRIPE_SECRET_KEY")) + stripeWebhook := strings.TrimSpace(lookup("STRIPE_WEBHOOK_SECRET")) + publicOrigin := strings.TrimSpace(lookup("JUNTLY_PUBLIC_ORIGIN")) + feeValue := strings.TrimSpace(lookup("JUNTLY_PLATFORM_FEE_BPS")) + if stripeSecret != "" || stripeWebhook != "" || publicOrigin != "" || feeValue != "" { + if stripeSecret == "" || stripeWebhook == "" || publicOrigin == "" || feeValue == "" { + return runtimeConfig{}, ErrInvalidRuntimeConfig + } + platformFeeBPS, err = strconv.Atoi(feeValue) + if err != nil || platformFeeBPS < 0 || platformFeeBPS >= 10_000 { + return runtimeConfig{}, ErrInvalidRuntimeConfig + } + apiBase := strings.TrimSpace(lookup("STRIPE_API_BASE")) + if apiBase == "" { + apiBase = "https://api.stripe.com" + } + paymentGateway, err = payments.NewStripeGateway(payments.StripeConfig{SecretKey: stripeSecret, WebhookSecret: stripeWebhook, APIBase: apiBase, PublicOrigin: publicOrigin, Now: time.Now}) + if err != nil { + return runtimeConfig{}, ErrInvalidRuntimeConfig + } + } - return runtimeConfig{databaseURL: databaseURL, verifier: verifier, contactCipher: contactCipher}, nil + return runtimeConfig{databaseURL: databaseURL, verifier: verifier, contactCipher: contactCipher, paymentGateway: paymentGateway, platformFeeBPS: platformFeeBPS}, nil } func parseOptionalDuration(value string) (time.Duration, error) { diff --git a/backend/cmd/api/config_test.go b/backend/cmd/api/config_test.go index d2861b2..0fc3948 100644 --- a/backend/cmd/api/config_test.go +++ b/backend/cmd/api/config_test.go @@ -114,3 +114,39 @@ func TestLoadRuntimeConfigRejectsInvalidClerkClockSkew(t *testing.T) { }) } } + +func TestLoadRuntimeConfigEnablesStripeOnlyWithCompleteServerConfiguration(t *testing.T) { + t.Parallel() + config, err := loadRuntimeConfig(func(key string) string { + return map[string]string{ + "DATABASE_URL": "postgresql://synthetic", + "CLERK_SECRET_KEY": "synthetic-secret", + "CLERK_AUTHORIZED_PARTIES": "http://localhost:4200", + "STRIPE_SECRET_KEY": "sk_test_synthetic", + "STRIPE_WEBHOOK_SECRET": "whsec_synthetic", + "JUNTLY_PUBLIC_ORIGIN": "https://vila.example", + "JUNTLY_PLATFORM_FEE_BPS": "1000", + }[key] + }) + if err != nil { + t.Fatalf("load runtime config: %v", err) + } + if config.paymentGateway == nil || config.platformFeeBPS != 1000 { + t.Fatalf("payment config = %#v/%d", config.paymentGateway, config.platformFeeBPS) + } +} + +func TestLoadRuntimeConfigRejectsPartialStripeConfiguration(t *testing.T) { + t.Parallel() + _, err := loadRuntimeConfig(func(key string) string { + return map[string]string{ + "DATABASE_URL": "postgresql://synthetic", + "CLERK_SECRET_KEY": "synthetic-secret", + "CLERK_AUTHORIZED_PARTIES": "http://localhost:4200", + "STRIPE_SECRET_KEY": "sk_test_synthetic", + }[key] + }) + if err == nil { + t.Fatal("partial Stripe configuration accepted") + } +} diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 38b9741..d1c13d6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -27,6 +27,7 @@ import ( "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listings" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/messaging" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/moderation" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/providers" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/quotations" @@ -134,10 +135,11 @@ func newAPIHandler(config runtimeConfig) (http.Handler, io.Closer, error) { listingRepository := listings.NewEntRepository(client) listingDrafts := listings.NewService(providerAuthorizer, listingRepository) moderatorAuthorizer := moderation.NewService(userService, moderation.NewEntRepository(client)) + paymentService := payments.NewService(userService, moderatorAuthorizer, payments.NewSQLStore(database), config.paymentGateway, config.platformFeeBPS) listingLifecycle := listings.NewLifecycleService(providerAuthorizer, moderatorAuthorizer, listingRepository) listingMedia := listingmedia.NewService(providerAuthorizer, listingmedia.NewEntRepository(client), listingmedia.NewUnavailableStorage()) ownerListings := listings.NewOwnerService(listingDrafts, listingLifecycle, listingMedia) moderationQueue := moderation.NewQueueService(moderatorAuthorizer, listingRepository) moderationReview := moderation.NewReviewService(moderationQueue, listingLifecycle) - return httpapi.NewRouter(healthService, readinessService, config.verifier, userService, accountService, referenceService, providerService, ownerListings, moderationReview, publicDiscovery, contactChannels, contactReveal, messagingService, quotationService, bookingService, reviewService, entitlementService, administrationService), client, nil + return httpapi.NewRouter(healthService, readinessService, config.verifier, userService, accountService, referenceService, providerService, ownerListings, moderationReview, publicDiscovery, contactChannels, contactReveal, messagingService, quotationService, bookingService, reviewService, entitlementService, administrationService, paymentService), client, nil } diff --git a/backend/internal/httpapi/health_handler.go b/backend/internal/httpapi/health_handler.go index 249bcd4..e4d9924 100644 --- a/backend/internal/httpapi/health_handler.go +++ b/backend/internal/httpapi/health_handler.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "net/http" + "strings" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/health" @@ -35,7 +36,7 @@ func (h HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(h.service.Check(requestID)) } -func NewRouter(service health.Service, readinessService ReadinessService, verifier authn.Verifier, reconcileService ReconcileService, accountService AccountService, referenceService ReferenceService, providerProfileService ProviderProfileService, listingService ListingService, moderationListingService ModerationListingService, publicDiscoveryService PublicDiscoveryService, contactChannelService ContactChannelService, contactRevealService ContactRevealService, messagingService MessagingService, quotationService QuotationService, bookingService BookingService, reviewService ReviewService, entitlementService EntitlementService, administrationService AdministrationService) http.Handler { +func NewRouter(service health.Service, readinessService ReadinessService, verifier authn.Verifier, reconcileService ReconcileService, accountService AccountService, referenceService ReferenceService, providerProfileService ProviderProfileService, listingService ListingService, moderationListingService ModerationListingService, publicDiscoveryService PublicDiscoveryService, contactChannelService ContactChannelService, contactRevealService ContactRevealService, messagingService MessagingService, quotationService QuotationService, bookingService BookingService, reviewService ReviewService, entitlementService EntitlementService, administrationService AdministrationService, paymentService PaymentService) http.Handler { mux := http.NewServeMux() mux.Handle("/api/v1/health", NewHealthHandler(service)) mux.Handle("/api/v1/ready", NewReadinessHandler(readinessService)) @@ -60,8 +61,10 @@ func NewRouter(service health.Service, readinessService ReadinessService, verifi mux.Handle("/api/v1/me/quotation-requests", authn.RequireVerifiedIdentity(verifier, NewQuotationHandler(quotationService))) mux.Handle("/api/v1/me/quotation-requests/", authn.RequireVerifiedIdentity(verifier, NewQuotationHandler(quotationService))) mux.Handle("/api/v1/me/quotation-opportunities", authn.RequireVerifiedIdentity(verifier, NewQuotationHandler(quotationService))) - mux.Handle("/api/v1/me/bookings", authn.RequireVerifiedIdentity(verifier, NewBookingHandler(bookingService))) - mux.Handle("/api/v1/me/bookings/", authn.RequireVerifiedIdentity(verifier, NewBookingHandler(bookingService))) + bookingHandler := NewBookingHandler(bookingService) + paymentHandler := NewPaymentHandler(paymentService) + mux.Handle("/api/v1/me/bookings", authn.RequireVerifiedIdentity(verifier, bookingHandler)) + mux.Handle("/api/v1/me/bookings/", authn.RequireVerifiedIdentity(verifier, dispatchCheckout(paymentHandler, bookingHandler))) mux.Handle("/api/v1/me/reviews", authn.RequireVerifiedIdentity(verifier, NewReviewHandler(reviewService))) mux.Handle("/api/v1/me/reviews/", authn.RequireVerifiedIdentity(verifier, NewReviewHandler(reviewService))) mux.Handle("/api/v1/public/providers/", NewReviewHandler(reviewService)) @@ -71,9 +74,24 @@ func NewRouter(service health.Service, readinessService ReadinessService, verifi mux.Handle("/api/v1/me/promotions", authn.RequireVerifiedIdentity(verifier, NewEntitlementHandler(entitlementService))) mux.Handle("/api/v1/admin/dashboard", authn.RequireVerifiedIdentity(verifier, NewAdministrationHandler(administrationService))) mux.Handle("/api/v1/admin/moderation", authn.RequireVerifiedIdentity(verifier, NewAdministrationHandler(administrationService))) + mux.Handle("/api/v1/me/payments", authn.RequireVerifiedIdentity(verifier, paymentHandler)) + mux.Handle("/api/v1/me/payout-account", authn.RequireVerifiedIdentity(verifier, paymentHandler)) + mux.Handle("/api/v1/admin/payments/", authn.RequireVerifiedIdentity(verifier, paymentHandler)) + mux.Handle("/api/v1/admin/payments", authn.RequireVerifiedIdentity(verifier, paymentHandler)) + mux.Handle("/api/v1/payments/webhooks/stripe", NewStripeWebhookHandler(paymentService)) return mux } +func dispatchCheckout(payment, booking http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(strings.TrimSuffix(r.URL.Path, "/"), "/checkout") { + payment.ServeHTTP(w, r) + return + } + booking.ServeHTTP(w, r) + }) +} + func requestIDFromHeader(value string) string { if validRequestID(value) { return value diff --git a/backend/internal/httpapi/payment_handler.go b/backend/internal/httpapi/payment_handler.go new file mode 100644 index 0000000..84daa88 --- /dev/null +++ b/backend/internal/httpapi/payment_handler.go @@ -0,0 +1,192 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +const maxPaymentRequestBytes = 256 * 1024 + +type PaymentService interface { + BeginCheckout(context.Context, users.VerifiedIdentity, uuid.UUID, string, string) (payments.CheckoutResult, error) + ListOrders(context.Context, users.VerifiedIdentity) ([]payments.Order, error) + ListAdminOrders(context.Context, users.VerifiedIdentity) ([]payments.Order, error) + BeginPayoutOnboarding(context.Context, users.VerifiedIdentity, string) (payments.PayoutOnboardingResult, error) + PayoutStatus(context.Context, users.VerifiedIdentity) (payments.ProviderAccount, error) + HandleWebhook(context.Context, []byte, string) error + Refund(context.Context, users.VerifiedIdentity, uuid.UUID, string) (payments.Order, error) +} + +type paymentHandler struct{ service PaymentService } + +type stripeWebhookHandler struct{ service PaymentService } + +func NewPaymentHandler(service PaymentService) http.Handler { return paymentHandler{service: service} } +func NewStripeWebhookHandler(service PaymentService) http.Handler { + return stripeWebhookHandler{service: service} +} + +func (h paymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + requestID := requestIDFromHeader(r.Header.Get(RequestIDHeader)) + identity, ok := authn.IdentityFromContext(r.Context()) + if !ok { + writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", requestID) + return + } + if h.service == nil { + writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", requestID) + return + } + path := strings.TrimSuffix(r.URL.Path, "/") + switch { + case path == "/api/v1/me/payments" && r.Method == http.MethodGet: + orders, err := h.service.ListOrders(r.Context(), identity) + if err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 200, map[string]any{"orders": orders}, requestID) + case path == "/api/v1/admin/payments" && r.Method == http.MethodGet: + orders, err := h.service.ListAdminOrders(r.Context(), identity) + if err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 200, map[string]any{"orders": orders}, requestID) + case path == "/api/v1/me/payout-account" && r.Method == http.MethodGet: + account, err := h.service.PayoutStatus(r.Context(), identity) + if err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 200, account, requestID) + case path == "/api/v1/me/payout-account" && r.Method == http.MethodPost: + var body struct { + Locale *string `json:"locale"` + } + if !decodePayment(r.Body, &body) || body.Locale == nil { + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + return + } + result, err := h.service.BeginPayoutOnboarding(r.Context(), identity, *body.Locale) + if err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 200, result, requestID) + case strings.HasPrefix(path, "/api/v1/me/bookings/") && strings.HasSuffix(path, "/checkout") && r.Method == http.MethodPost: + id, ok := paymentPathID(path, "/api/v1/me/bookings/", "/checkout") + if !ok { + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + return + } + var body struct { + IdempotencyKey *string `json:"idempotencyKey"` + Locale *string `json:"locale"` + } + if !decodePayment(r.Body, &body) || body.IdempotencyKey == nil || body.Locale == nil { + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + return + } + result, err := h.service.BeginCheckout(r.Context(), identity, id, *body.IdempotencyKey, *body.Locale) + if err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 201, result, requestID) + case strings.HasPrefix(path, "/api/v1/admin/payments/") && strings.HasSuffix(path, "/refund") && r.Method == http.MethodPost: + id, ok := paymentPathID(path, "/api/v1/admin/payments/", "/refund") + if !ok { + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + return + } + var body struct { + IdempotencyKey *string `json:"idempotencyKey"` + } + if !decodePayment(r.Body, &body) || body.IdempotencyKey == nil { + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + return + } + order, err := h.service.Refund(r.Context(), identity, id, *body.IdempotencyKey) + if err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 200, order, requestID) + default: + writeAPIError(w, 404, "NOT_FOUND", "Not found", requestID) + } +} + +func (h stripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + requestID := requestIDFromHeader(r.Header.Get(RequestIDHeader)) + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, http.StatusText(405), 405) + return + } + if h.service == nil { + writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", requestID) + return + } + signature := strings.TrimSpace(r.Header.Get("Stripe-Signature")) + if signature == "" { + writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", requestID) + return + } + payload, err := io.ReadAll(io.LimitReader(r.Body, maxPaymentRequestBytes+1)) + if err != nil || len(payload) == 0 || len(payload) > maxPaymentRequestBytes { + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + return + } + if err := h.service.HandleWebhook(r.Context(), payload, signature); err != nil { + writePaymentError(w, err, requestID) + return + } + writeJSON(w, 200, map[string]bool{"received": true}, requestID) +} + +func paymentPathID(path, prefix, suffix string) (uuid.UUID, bool) { + raw := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix) + if strings.Contains(raw, "/") { + return uuid.Nil, false + } + id, err := uuid.Parse(raw) + return id, err == nil +} + +func decodePayment(body io.Reader, target any) bool { + decoder := json.NewDecoder(io.LimitReader(body, 8*1024+1)) + decoder.DisallowUnknownFields() + if decoder.Decode(target) != nil { + return false + } + var extra any + return errors.Is(decoder.Decode(&extra), io.EOF) +} + +func writePaymentError(w http.ResponseWriter, err error, requestID string) { + switch { + case errors.Is(err, payments.ErrInvalid): + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID) + case errors.Is(err, payments.ErrUnauthorized): + writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", requestID) + case errors.Is(err, payments.ErrForbidden): + writeAPIError(w, 403, "FORBIDDEN", "Forbidden", requestID) + case errors.Is(err, payments.ErrNotFound): + writeAPIError(w, 404, "NOT_FOUND", "Not found", requestID) + case errors.Is(err, payments.ErrConflict): + writeAPIError(w, 409, "CONFLICT", "Conflict", requestID) + default: + writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", requestID) + } +} diff --git a/backend/internal/httpapi/payment_handler_test.go b/backend/internal/httpapi/payment_handler_test.go new file mode 100644 index 0000000..03b5ca9 --- /dev/null +++ b/backend/internal/httpapi/payment_handler_test.go @@ -0,0 +1,86 @@ +package httpapi_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/httpapi" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +func TestPaymentHandlerRoutesCheckoutWithoutAcceptingMoneyFromBrowser(t *testing.T) { + service := &recordingPaymentService{checkout: payments.CheckoutResult{Order: payments.Order{ID: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", GrossMinor: 12500, PlatformFeeMinor: 1250, ProviderNetMinor: 11250, Currency: "EUR"}, URL: "https://checkout.stripe.test/session"}} + handler := authn.RequireVerifiedIdentity(staticVerifier{identity: users.VerifiedIdentity{Subject: "customer"}}, httpapi.NewPaymentHandler(service)) + request := httptest.NewRequest(http.MethodPost, "/api/v1/me/bookings/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/checkout", strings.NewReader(`{"idempotencyKey":"checkout-key-123","locale":"pt-PT"}`)) + request.Header.Set("Authorization", "Bearer synthetic-token") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated || service.bookingID.String() != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || service.idempotencyKey != "checkout-key-123" || strings.Contains(response.Body.String(), "stripeAccount") { + t.Fatalf("response=%d/%s service=%#v", response.Code, response.Body.String(), service) + } +} + +func TestStripeWebhookHandlerPassesExactRawBodyAndSignature(t *testing.T) { + service := &recordingPaymentService{} + handler := httpapi.NewStripeWebhookHandler(service) + request := httptest.NewRequest(http.MethodPost, "/api/v1/payments/webhooks/stripe", strings.NewReader(`{"id":"evt_test"}`)) + request.Header.Set("Stripe-Signature", "t=1,v1=synthetic") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || string(service.payload) != `{"id":"evt_test"}` || service.signature != "t=1,v1=synthetic" { + t.Fatalf("response=%d body=%q signature=%q", response.Code, service.payload, service.signature) + } +} + +func TestPaymentHandlerListsAdministratorPaymentOrders(t *testing.T) { + service := &recordingPaymentService{orders: []payments.Order{{ID: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", State: payments.StateDisputed}}} + handler := authn.RequireVerifiedIdentity(staticVerifier{identity: users.VerifiedIdentity{Subject: "moderator"}}, httpapi.NewPaymentHandler(service)) + request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/payments", nil) + request.Header.Set("Authorization", "Bearer synthetic-token") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "disputed") || service.adminListCalls != 1 { + t.Fatalf("response=%d/%s calls=%d", response.Code, response.Body.String(), service.adminListCalls) + } +} + +type recordingPaymentService struct { + checkout payments.CheckoutResult + bookingID uuid.UUID + idempotencyKey string + payload []byte + signature string + orders []payments.Order + adminListCalls int +} + +func (s *recordingPaymentService) BeginCheckout(_ context.Context, _ users.VerifiedIdentity, bookingID uuid.UUID, key, _ string) (payments.CheckoutResult, error) { + s.bookingID, s.idempotencyKey = bookingID, key + return s.checkout, nil +} +func (s *recordingPaymentService) ListOrders(context.Context, users.VerifiedIdentity) ([]payments.Order, error) { + return nil, nil +} +func (s *recordingPaymentService) ListAdminOrders(context.Context, users.VerifiedIdentity) ([]payments.Order, error) { + s.adminListCalls++ + return s.orders, nil +} +func (s *recordingPaymentService) BeginPayoutOnboarding(context.Context, users.VerifiedIdentity, string) (payments.PayoutOnboardingResult, error) { + return payments.PayoutOnboardingResult{}, nil +} +func (s *recordingPaymentService) PayoutStatus(context.Context, users.VerifiedIdentity) (payments.ProviderAccount, error) { + return payments.ProviderAccount{}, nil +} +func (s *recordingPaymentService) HandleWebhook(_ context.Context, payload []byte, signature string) error { + s.payload, s.signature = append([]byte(nil), payload...), signature + return nil +} +func (s *recordingPaymentService) Refund(context.Context, users.VerifiedIdentity, uuid.UUID, string) (payments.Order, error) { + return payments.Order{}, nil +} diff --git a/backend/internal/httpapi/router_test.go b/backend/internal/httpapi/router_test.go index 4b5cc04..54b7593 100644 --- a/backend/internal/httpapi/router_test.go +++ b/backend/internal/httpapi/router_test.go @@ -37,7 +37,7 @@ func TestRouterLeavesHealthPublicAndProtectsReconciliation(t *testing.T) { discoveryService := &recordingPublicDiscoveryService{} contactChannelService := &recordingRouterContactChannelService{} contactRevealService := &recordingRouterContactRevealService{} - router := httpapi.NewRouter(healthService, nil, verifier, reconcileService, accountService, referenceService, providerProfileService, &recordingListingService{created: sampleListing()}, &recordingModerationReview{listing: sampleListing()}, discoveryService, contactChannelService, contactRevealService, nil, nil, nil, nil, nil, nil) + router := httpapi.NewRouter(healthService, nil, verifier, reconcileService, accountService, referenceService, providerProfileService, &recordingListingService{created: sampleListing()}, &recordingModerationReview{listing: sampleListing()}, discoveryService, contactChannelService, contactRevealService, nil, nil, nil, nil, nil, nil, nil) healthResponse := httptest.NewRecorder() router.ServeHTTP(healthResponse, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)) diff --git a/backend/internal/payments/migration_contract_test.go b/backend/internal/payments/migration_contract_test.go new file mode 100644 index 0000000..644fea9 --- /dev/null +++ b/backend/internal/payments/migration_contract_test.go @@ -0,0 +1,57 @@ +package payments + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPaymentMigrationDefinesDurableMoneyAndWebhookInvariants(t *testing.T) { + migration := paymentMigration(t) + for _, requirement := range []string{ + "create table public.provider_payment_accounts", + "stripe_account_id text not null unique", + "create table public.payment_orders", + "gross_minor integer not null", + "platform_fee_minor integer not null", + "provider_net_minor integer not null", + "unique(booking_id)", + "stripe_checkout_session_id text unique", + "stripe_payment_intent_id text unique", + "stripe_invoice_id text", + "create table public.payment_events", + "create table public.stripe_webhook_receipts", + "stripe_event_id text primary key", + "create table public.payment_disputes", + } { + if !strings.Contains(migration, requirement) { + t.Errorf("payment migration does not contain %q", requirement) + } + } + for _, prohibited := range []string{"card_number", "bank_account", "raw_payload", "webhook_signature"} { + if strings.Contains(migration, prohibited) { + t.Errorf("payment migration must not persist %q", prohibited) + } + } +} + +func paymentMigration(t *testing.T) string { + t.Helper() + directory := filepath.Join("..", "..", "..", "supabase", "migrations") + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatalf("read migrations: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), "_create_payments.sql") { + contents, err := os.ReadFile(filepath.Join(directory, entry.Name())) + if err != nil { + t.Fatalf("read payment migration: %v", err) + } + return strings.ToLower(string(contents)) + } + } + t.Fatal("payment migration not found") + return "" +} diff --git a/backend/internal/payments/model.go b/backend/internal/payments/model.go new file mode 100644 index 0000000..64b756f --- /dev/null +++ b/backend/internal/payments/model.go @@ -0,0 +1,118 @@ +package payments + +import ( + "errors" + "time" +) + +var ( + ErrInvalid = errors.New("payment invalid request") + ErrUnauthorized = errors.New("payment unauthorized") + ErrForbidden = errors.New("payment forbidden") + ErrNotFound = errors.New("payment not found") + ErrConflict = errors.New("payment conflict") + ErrUnavailable = errors.New("payment unavailable") +) + +type State string + +const ( + StatePendingCheckout State = "pending_checkout" + StateCheckoutCreated State = "checkout_created" + StateProcessing State = "processing" + StatePaid State = "paid" + StateFailed State = "failed" + StateRefundPending State = "refund_pending" + StateRefunded State = "refunded" + StateDisputed State = "disputed" + StateDisputeWon State = "dispute_won" + StateDisputeLost State = "dispute_lost" + StateCancelled State = "cancelled" +) + +type EventKind string + +const ( + EventProcessing EventKind = "processing" + EventPaid EventKind = "paid" + EventFailed EventKind = "failed" + EventRefunded EventKind = "refunded" + EventDisputeOpened EventKind = "dispute_opened" + EventDisputeWon EventKind = "dispute_won" + EventDisputeLost EventKind = "dispute_lost" + EventAccountUpdate EventKind = "account_updated" +) + +type Order struct { + ID string `json:"id"` + BookingID string `json:"bookingId"` + CustomerID string `json:"customerId"` + ProviderID string `json:"providerId"` + State State `json:"state"` + GrossMinor int64 `json:"grossMinor"` + PlatformFeeMinor int64 `json:"platformFeeMinor"` + ProviderNetMinor int64 `json:"providerNetMinor"` + Currency string `json:"currency"` + CheckoutSessionID string `json:"-"` + PaymentIntentID string `json:"-"` + InvoiceID string `json:"-"` + RefundID string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ProviderAccount struct { + InternalUserID string `json:"-"` + StripeAccountID string `json:"-"` + DetailsSubmitted bool `json:"detailsSubmitted"` + ChargesEnabled bool `json:"chargesEnabled"` + PayoutsEnabled bool `json:"payoutsEnabled"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CheckoutRequest struct { + OrderID string + BookingID string + ConnectedAccountID string + GrossMinor int64 + FeeMinor int64 + Locale string + IdempotencyKey string +} + +type CheckoutSession struct { + ID string + URL string +} + +type ConnectedAccount struct { + ID string + DetailsSubmitted bool + ChargesEnabled bool + PayoutsEnabled bool +} + +type AccountLink struct{ URL string } + +type RefundResult struct{ ID string } + +type ProviderEvent struct { + ID string + Kind EventKind + ProviderObjectID string + OrderID string + PaymentIntentID string + InvoiceID string + RefundID string + AccountID string + ChargeID string + DisputeID string + DisputeState string + DisputeReason string + AmountMinor int64 + Currency string + OccurredAt time.Time + DetailsSubmitted bool + ChargesEnabled bool + PayoutsEnabled bool +} diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go new file mode 100644 index 0000000..aff657f --- /dev/null +++ b/backend/internal/payments/service.go @@ -0,0 +1,248 @@ +package payments + +import ( + "context" + "errors" + "regexp" + "strings" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +var paymentIdempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]{8,128}$`) + +type IdentityReconciler interface { + Reconcile(context.Context, users.VerifiedIdentity) (users.InternalUser, bool, error) +} + +type ModeratorAuthorizer interface { + RequireModerator(context.Context, users.VerifiedIdentity) (users.InternalUser, error) +} + +type Store interface { + PrepareCheckout(context.Context, uuid.UUID, uuid.UUID, string, int) (Order, ProviderAccount, error) + AttachCheckout(context.Context, uuid.UUID, uuid.UUID, CheckoutSession) (Order, error) + ListOrders(context.Context, uuid.UUID) ([]Order, error) + ListAdminOrders(context.Context, uuid.UUID) ([]Order, error) + GetProviderAccount(context.Context, uuid.UUID) (ProviderAccount, error) + SaveProviderAccount(context.Context, uuid.UUID, ConnectedAccount) (ProviderAccount, error) + ApplyProviderEvent(context.Context, ProviderEvent) error + PrepareRefund(context.Context, uuid.UUID, uuid.UUID) (Order, error) + AttachRefund(context.Context, uuid.UUID, uuid.UUID, RefundResult) (Order, error) +} + +type Gateway interface { + CreateCheckout(context.Context, CheckoutRequest) (CheckoutSession, error) + CreateConnectedAccount(context.Context, string) (ConnectedAccount, error) + CreateAccountLink(context.Context, string, string) (AccountLink, error) + GetConnectedAccount(context.Context, string) (ConnectedAccount, error) + CreateRefund(context.Context, string, string) (RefundResult, error) + VerifyWebhook([]byte, string) (ProviderEvent, error) +} + +type CheckoutResult struct { + Order Order `json:"order"` + URL string `json:"url"` +} + +type PayoutOnboardingResult struct { + Account ProviderAccount `json:"account"` + URL string `json:"url"` +} + +type Service interface { + BeginCheckout(context.Context, users.VerifiedIdentity, uuid.UUID, string, string) (CheckoutResult, error) + ListOrders(context.Context, users.VerifiedIdentity) ([]Order, error) + ListAdminOrders(context.Context, users.VerifiedIdentity) ([]Order, error) + BeginPayoutOnboarding(context.Context, users.VerifiedIdentity, string) (PayoutOnboardingResult, error) + PayoutStatus(context.Context, users.VerifiedIdentity) (ProviderAccount, error) + HandleWebhook(context.Context, []byte, string) error + Refund(context.Context, users.VerifiedIdentity, uuid.UUID, string) (Order, error) +} + +type service struct { + identities IdentityReconciler + moderators ModeratorAuthorizer + store Store + gateway Gateway + feeBPS int +} + +func NewService(identities IdentityReconciler, moderators ModeratorAuthorizer, store Store, gateway Gateway, feeBPS int) Service { + return service{identities: identities, moderators: moderators, store: store, gateway: gateway, feeBPS: feeBPS} +} + +func (s service) BeginCheckout(ctx context.Context, identity users.VerifiedIdentity, bookingID uuid.UUID, idempotencyKey, locale string) (CheckoutResult, error) { + idempotencyKey = strings.TrimSpace(idempotencyKey) + if bookingID == uuid.Nil || !paymentIdempotencyPattern.MatchString(idempotencyKey) || s.feeBPS < 0 || s.feeBPS >= 10_000 { + return CheckoutResult{}, ErrInvalid + } + actor, err := s.actor(ctx, identity) + if err != nil { + return CheckoutResult{}, err + } + if s.gateway == nil { + return CheckoutResult{}, ErrUnavailable + } + order, account, err := s.store.PrepareCheckout(ctx, actor.ID, bookingID, idempotencyKey, s.feeBPS) + if err != nil { + return CheckoutResult{}, normalize(err) + } + if !account.DetailsSubmitted || !account.ChargesEnabled || !account.PayoutsEnabled { + return CheckoutResult{}, ErrForbidden + } + session, err := s.gateway.CreateCheckout(ctx, CheckoutRequest{OrderID: order.ID, BookingID: order.BookingID, ConnectedAccountID: account.StripeAccountID, GrossMinor: order.GrossMinor, FeeMinor: order.PlatformFeeMinor, Locale: locale, IdempotencyKey: "checkout-" + order.ID}) + if err != nil { + return CheckoutResult{}, normalize(err) + } + orderID, err := uuid.Parse(order.ID) + if err != nil { + return CheckoutResult{}, ErrUnavailable + } + attached, err := s.store.AttachCheckout(ctx, actor.ID, orderID, session) + if err != nil { + return CheckoutResult{}, normalize(err) + } + return CheckoutResult{Order: attached, URL: session.URL}, nil +} + +func (s service) ListOrders(ctx context.Context, identity users.VerifiedIdentity) ([]Order, error) { + actor, err := s.actor(ctx, identity) + if err != nil { + return nil, err + } + orders, err := s.store.ListOrders(ctx, actor.ID) + return orders, normalize(err) +} + +func (s service) ListAdminOrders(ctx context.Context, identity users.VerifiedIdentity) ([]Order, error) { + if s.moderators == nil || s.store == nil { + return nil, ErrUnavailable + } + moderator, err := s.moderators.RequireModerator(ctx, identity) + if err != nil { + return nil, normalize(err) + } + orders, err := s.store.ListAdminOrders(ctx, moderator.ID) + return orders, normalize(err) +} + +func (s service) BeginPayoutOnboarding(ctx context.Context, identity users.VerifiedIdentity, locale string) (PayoutOnboardingResult, error) { + actor, err := s.actor(ctx, identity) + if err != nil { + return PayoutOnboardingResult{}, err + } + if s.gateway == nil { + return PayoutOnboardingResult{}, ErrUnavailable + } + account, err := s.store.GetProviderAccount(ctx, actor.ID) + if errors.Is(err, ErrNotFound) { + created, createErr := s.gateway.CreateConnectedAccount(ctx, "connect-"+actor.ID.String()) + if createErr != nil { + return PayoutOnboardingResult{}, normalize(createErr) + } + account, err = s.store.SaveProviderAccount(ctx, actor.ID, created) + } else if err == nil { + refreshed, refreshErr := s.gateway.GetConnectedAccount(ctx, account.StripeAccountID) + if refreshErr != nil { + return PayoutOnboardingResult{}, normalize(refreshErr) + } + account, err = s.store.SaveProviderAccount(ctx, actor.ID, refreshed) + } + if err != nil { + return PayoutOnboardingResult{}, normalize(err) + } + link, err := s.gateway.CreateAccountLink(ctx, account.StripeAccountID, supportedLocale(locale)) + if err != nil { + return PayoutOnboardingResult{}, normalize(err) + } + return PayoutOnboardingResult{Account: account, URL: link.URL}, nil +} + +func (s service) PayoutStatus(ctx context.Context, identity users.VerifiedIdentity) (ProviderAccount, error) { + actor, err := s.actor(ctx, identity) + if err != nil { + return ProviderAccount{}, err + } + account, err := s.store.GetProviderAccount(ctx, actor.ID) + if err != nil { + return ProviderAccount{}, normalize(err) + } + if s.gateway == nil { + return ProviderAccount{}, ErrUnavailable + } + refreshed, err := s.gateway.GetConnectedAccount(ctx, account.StripeAccountID) + if err != nil { + return ProviderAccount{}, normalize(err) + } + account, err = s.store.SaveProviderAccount(ctx, actor.ID, refreshed) + return account, normalize(err) +} + +func (s service) HandleWebhook(ctx context.Context, payload []byte, signature string) error { + if s.gateway == nil || s.store == nil { + return ErrUnavailable + } + event, err := s.gateway.VerifyWebhook(payload, signature) + if err != nil { + return normalize(err) + } + return normalize(s.store.ApplyProviderEvent(ctx, event)) +} + +func (s service) Refund(ctx context.Context, identity users.VerifiedIdentity, orderID uuid.UUID, idempotencyKey string) (Order, error) { + if orderID == uuid.Nil || !paymentIdempotencyPattern.MatchString(idempotencyKey) || s.moderators == nil || s.gateway == nil || s.store == nil { + return Order{}, ErrInvalid + } + moderator, err := s.moderators.RequireModerator(ctx, identity) + if err != nil { + return Order{}, normalize(err) + } + order, err := s.store.PrepareRefund(ctx, moderator.ID, orderID) + if err != nil { + return Order{}, normalize(err) + } + if order.State == StateRefundPending { + return order, nil + } + refund, err := s.gateway.CreateRefund(ctx, order.PaymentIntentID, "refund-"+order.ID) + if err != nil { + return Order{}, normalize(err) + } + order, err = s.store.AttachRefund(ctx, moderator.ID, orderID, refund) + return order, normalize(err) +} + +func (s service) actor(ctx context.Context, identity users.VerifiedIdentity) (users.InternalUser, error) { + if s.identities == nil || s.store == nil { + return users.InternalUser{}, ErrUnavailable + } + actor, _, err := s.identities.Reconcile(ctx, identity) + if err != nil || actor.ID == uuid.Nil { + if errors.Is(err, users.ErrInvalidIdentity) { + return users.InternalUser{}, ErrUnauthorized + } + return users.InternalUser{}, ErrUnavailable + } + return actor, nil +} + +func supportedLocale(locale string) string { + if locale == "en" || locale == "es" || locale == "pt-PT" { + return locale + } + return "pt-PT" +} + +func normalize(err error) error { + if err == nil { + return nil + } + for _, known := range []error{ErrInvalid, ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict} { + if errors.Is(err, known) { + return known + } + } + return ErrUnavailable +} diff --git a/backend/internal/payments/service_test.go b/backend/internal/payments/service_test.go new file mode 100644 index 0000000..a9f7315 --- /dev/null +++ b/backend/internal/payments/service_test.go @@ -0,0 +1,160 @@ +package payments + +import ( + "context" + "testing" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +func TestServiceBeginsCheckoutFromDurableServerAmounts(t *testing.T) { + actor := users.InternalUser{ID: uuid.MustParse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")} + store := &recordingStore{ + order: Order{ID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", BookingID: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", CustomerID: actor.ID.String(), ProviderID: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", State: StatePendingCheckout, GrossMinor: 12500, PlatformFeeMinor: 1250, ProviderNetMinor: 11250, Currency: "EUR"}, + account: ProviderAccount{StripeAccountID: "acct_provider", DetailsSubmitted: true, ChargesEnabled: true, PayoutsEnabled: true}, + } + gateway := &recordingGateway{checkout: CheckoutSession{ID: "cs_test_checkout", URL: "https://checkout.stripe.test/session"}} + service := NewService(staticIdentity{user: actor}, nil, store, gateway, 1000) + + result, err := service.BeginCheckout(context.Background(), users.VerifiedIdentity{Subject: "customer"}, uuid.MustParse(store.order.BookingID), "checkout-key-123", "pt-PT") + if err != nil { + t.Fatalf("begin checkout: %v", err) + } + if result.URL != gateway.checkout.URL || gateway.checkoutInput.GrossMinor != 12500 || gateway.checkoutInput.FeeMinor != 1250 || gateway.checkoutInput.ConnectedAccountID != "acct_provider" { + t.Fatalf("checkout result=%#v input=%#v", result, gateway.checkoutInput) + } + if store.attachedSession.ID != "cs_test_checkout" { + t.Fatalf("session not attached: %#v", store.attachedSession) + } +} + +func TestServiceVerifiesWebhookBeforeStoreMutation(t *testing.T) { + store := &recordingStore{} + gateway := &recordingGateway{event: ProviderEvent{ID: "evt_paid", Kind: EventPaid, OrderID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", ProviderObjectID: "cs_paid"}} + service := NewService(staticIdentity{}, nil, store, gateway, 1000) + if err := service.HandleWebhook(context.Background(), []byte("signed"), "t=1,v1=synthetic"); err != nil { + t.Fatalf("webhook: %v", err) + } + if store.event.ID != "evt_paid" || string(gateway.payload) != "signed" { + t.Fatalf("event=%#v payload=%q", store.event, gateway.payload) + } +} + +func TestServiceListsAllOrdersOnlyThroughModeratorBoundary(t *testing.T) { + moderator := users.InternalUser{ID: uuid.MustParse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")} + store := &recordingStore{order: Order{ID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", State: StateDisputed}} + service := NewService(staticIdentity{}, staticModerator{user: moderator}, store, nil, 1000) + orders, err := service.ListAdminOrders(context.Background(), users.VerifiedIdentity{Subject: "moderator"}) + if err != nil || len(orders) != 1 || store.adminActor != moderator.ID { + t.Fatalf("orders=%#v actor=%s err=%v", orders, store.adminActor, err) + } +} + +func TestServiceUsesOneOrderOwnedRefundKeyAndSkipsPendingReplay(t *testing.T) { + moderator := users.InternalUser{ID: uuid.MustParse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")} + orderID := "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + store := &recordingStore{order: Order{ID: orderID, State: StatePaid, PaymentIntentID: "pi_refund"}} + gateway := &recordingGateway{} + service := NewService(staticIdentity{}, staticModerator{user: moderator}, store, gateway, 1000) + if _, err := service.Refund(context.Background(), users.VerifiedIdentity{Subject: "moderator"}, uuid.MustParse(orderID), "browser-key-one"); err != nil { + t.Fatalf("refund: %v", err) + } + if gateway.refundKey != "refund-"+orderID || gateway.refundCalls != 1 { + t.Fatalf("refund key=%q calls=%d", gateway.refundKey, gateway.refundCalls) + } + if _, err := service.Refund(context.Background(), users.VerifiedIdentity{Subject: "moderator"}, uuid.MustParse(orderID), "browser-key-two"); err != nil { + t.Fatalf("refund replay: %v", err) + } + if gateway.refundCalls != 1 { + t.Fatalf("pending refund called provider again: %d", gateway.refundCalls) + } +} + +type staticIdentity struct{ user users.InternalUser } + +func (s staticIdentity) Reconcile(context.Context, users.VerifiedIdentity) (users.InternalUser, bool, error) { + return s.user, false, nil +} + +type staticModerator struct{ user users.InternalUser } + +func (s staticModerator) RequireModerator(context.Context, users.VerifiedIdentity) (users.InternalUser, error) { + return s.user, nil +} + +type recordingStore struct { + order Order + account ProviderAccount + attachedSession CheckoutSession + event ProviderEvent + adminActor uuid.UUID +} + +func (s *recordingStore) PrepareCheckout(context.Context, uuid.UUID, uuid.UUID, string, int) (Order, ProviderAccount, error) { + return s.order, s.account, nil +} +func (s *recordingStore) AttachCheckout(_ context.Context, _ uuid.UUID, _ uuid.UUID, session CheckoutSession) (Order, error) { + s.attachedSession = session + s.order.CheckoutSessionID = session.ID + s.order.State = StateCheckoutCreated + return s.order, nil +} +func (s *recordingStore) ListOrders(context.Context, uuid.UUID) ([]Order, error) { + return []Order{s.order}, nil +} +func (s *recordingStore) ListAdminOrders(_ context.Context, actor uuid.UUID) ([]Order, error) { + s.adminActor = actor + return []Order{s.order}, nil +} +func (s *recordingStore) GetProviderAccount(context.Context, uuid.UUID) (ProviderAccount, error) { + return s.account, nil +} +func (s *recordingStore) SaveProviderAccount(_ context.Context, _ uuid.UUID, account ConnectedAccount) (ProviderAccount, error) { + s.account = ProviderAccount{StripeAccountID: account.ID, DetailsSubmitted: account.DetailsSubmitted, ChargesEnabled: account.ChargesEnabled, PayoutsEnabled: account.PayoutsEnabled} + return s.account, nil +} +func (s *recordingStore) ApplyProviderEvent(_ context.Context, event ProviderEvent) error { + s.event = event + return nil +} +func (s *recordingStore) PrepareRefund(context.Context, uuid.UUID, uuid.UUID) (Order, error) { + return s.order, nil +} +func (s *recordingStore) AttachRefund(_ context.Context, _ uuid.UUID, _ uuid.UUID, refund RefundResult) (Order, error) { + s.order.RefundID = refund.ID + s.order.State = StateRefundPending + return s.order, nil +} + +type recordingGateway struct { + checkout CheckoutSession + checkoutInput CheckoutRequest + event ProviderEvent + payload []byte + refundKey string + refundCalls int +} + +func (g *recordingGateway) CreateCheckout(_ context.Context, input CheckoutRequest) (CheckoutSession, error) { + g.checkoutInput = input + return g.checkout, nil +} +func (g *recordingGateway) CreateConnectedAccount(context.Context, string) (ConnectedAccount, error) { + return ConnectedAccount{ID: "acct_provider"}, nil +} +func (g *recordingGateway) CreateAccountLink(context.Context, string, string) (AccountLink, error) { + return AccountLink{URL: "https://connect.stripe.test/onboarding"}, nil +} +func (g *recordingGateway) GetConnectedAccount(context.Context, string) (ConnectedAccount, error) { + return ConnectedAccount{ID: "acct_provider"}, nil +} +func (g *recordingGateway) CreateRefund(_ context.Context, _ string, key string) (RefundResult, error) { + g.refundKey = key + g.refundCalls++ + return RefundResult{ID: "re_test"}, nil +} +func (g *recordingGateway) VerifyWebhook(payload []byte, _ string) (ProviderEvent, error) { + g.payload = append([]byte(nil), payload...) + return g.event, nil +} diff --git a/backend/internal/payments/sql_store.go b/backend/internal/payments/sql_store.go new file mode 100644 index 0000000..a5fde40 --- /dev/null +++ b/backend/internal/payments/sql_store.go @@ -0,0 +1,401 @@ +package payments + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +type sqlStore struct{ database *sql.DB } + +func NewSQLStore(database *sql.DB) Store { return sqlStore{database: database} } + +func (s sqlStore) PrepareCheckout(ctx context.Context, actor, bookingID uuid.UUID, key string, feeBPS int) (Order, ProviderAccount, error) { + if s.database == nil { + return Order{}, ProviderAccount{}, ErrUnavailable + } + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return Order{}, ProviderAccount{}, err + } + defer func() { _ = tx.Rollback() }() + var customer, provider uuid.UUID + var bookingState string + var gross int64 + var currency string + var account ProviderAccount + err = tx.QueryRowContext(ctx, `select b.customer_internal_user_id,b.provider_internal_user_id,b.state,b.agreed_price_minor,b.currency,coalesce(a.stripe_account_id,''),coalesce(a.details_submitted,false),coalesce(a.charges_enabled,false),coalesce(a.payouts_enabled,false),coalesce(a.updated_at,timezone('utc',now())) from public.bookings b left join public.provider_payment_accounts a on a.internal_user_id=b.provider_internal_user_id where b.id=$1 for update of b`, bookingID).Scan(&customer, &provider, &bookingState, &gross, ¤cy, &account.StripeAccountID, &account.DetailsSubmitted, &account.ChargesEnabled, &account.PayoutsEnabled, &account.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Order{}, ProviderAccount{}, ErrNotFound + } + if err != nil { + return Order{}, ProviderAccount{}, err + } + if customer != actor { + return Order{}, ProviderAccount{}, ErrForbidden + } + if bookingState != "confirmed" && bookingState != "scheduled" { + return Order{}, ProviderAccount{}, ErrConflict + } + account.InternalUserID = provider.String() + if account.StripeAccountID == "" { + return Order{}, ProviderAccount{}, ErrForbidden + } + if existing, found, loadErr := loadOrderByBooking(ctx, tx, bookingID); loadErr != nil { + return Order{}, ProviderAccount{}, loadErr + } else if found { + var existingKey string + if err := tx.QueryRowContext(ctx, `select idempotency_key from public.payment_orders where id=$1`, existing.ID).Scan(&existingKey); err != nil { + return Order{}, ProviderAccount{}, err + } + if existingKey != key { + return Order{}, ProviderAccount{}, ErrConflict + } + if err := tx.Commit(); err != nil { + return Order{}, ProviderAccount{}, err + } + return existing, account, nil + } + fee := gross * int64(feeBPS) / 10_000 + if fee < 0 || fee >= gross || currency != "EUR" { + return Order{}, ProviderAccount{}, ErrInvalid + } + id := uuid.New() + var order Order + err = tx.QueryRowContext(ctx, `insert into public.payment_orders(id,booking_id,customer_internal_user_id,provider_internal_user_id,idempotency_key,gross_minor,platform_fee_minor,provider_net_minor,currency) values($1,$2,$3,$4,$5,$6,$7,$8,$9) returning id,booking_id,customer_internal_user_id,provider_internal_user_id,state,gross_minor,platform_fee_minor,provider_net_minor,currency,coalesce(stripe_checkout_session_id,''),coalesce(stripe_payment_intent_id,''),coalesce(stripe_invoice_id,''),coalesce(stripe_refund_id,''),created_at,updated_at`, id, bookingID, customer, provider, key, gross, fee, gross-fee, currency).Scan(orderScan(&order)...) + if err != nil { + return Order{}, ProviderAccount{}, err + } + if err := tx.Commit(); err != nil { + return Order{}, ProviderAccount{}, err + } + return order, account, nil +} + +func (s sqlStore) AttachCheckout(ctx context.Context, actor, orderID uuid.UUID, session CheckoutSession) (Order, error) { + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return Order{}, err + } + defer func() { _ = tx.Rollback() }() + order, found, err := loadOrderByID(ctx, tx, orderID, true) + if err != nil || !found { + if !found && err == nil { + err = ErrNotFound + } + return Order{}, err + } + if order.CustomerID != actor.String() { + return Order{}, ErrForbidden + } + if order.State == StateCheckoutCreated && order.CheckoutSessionID == session.ID { + if err := tx.Commit(); err != nil { + return Order{}, err + } + return order, nil + } + if order.State != StatePendingCheckout { + return Order{}, ErrConflict + } + result, err := tx.ExecContext(ctx, `update public.payment_orders set state='checkout_created',stripe_checkout_session_id=$1,updated_at=timezone('utc',now()) where id=$2 and state='pending_checkout'`, session.ID, orderID) + if err != nil { + return Order{}, err + } + if rows, _ := result.RowsAffected(); rows != 1 { + return Order{}, ErrConflict + } + if _, err := tx.ExecContext(ctx, `insert into public.payment_events(payment_order_id,event_type,from_state,to_state,provider_object_id) values($1,'checkout_created','pending_checkout','checkout_created',$2)`, orderID, session.ID); err != nil { + return Order{}, err + } + order, _, err = loadOrderByID(ctx, tx, orderID, false) + if err != nil { + return Order{}, err + } + if err := tx.Commit(); err != nil { + return Order{}, err + } + return order, nil +} + +func (s sqlStore) ListOrders(ctx context.Context, actor uuid.UUID) ([]Order, error) { + rows, err := s.database.QueryContext(ctx, `select id,booking_id,customer_internal_user_id,provider_internal_user_id,state,gross_minor,platform_fee_minor,provider_net_minor,currency,coalesce(stripe_checkout_session_id,''),coalesce(stripe_payment_intent_id,''),coalesce(stripe_invoice_id,''),coalesce(stripe_refund_id,''),created_at,updated_at from public.payment_orders where customer_internal_user_id=$1 or provider_internal_user_id=$1 order by updated_at desc,id`, actor) + if err != nil { + return nil, err + } + defer rows.Close() + orders := []Order{} + for rows.Next() { + var order Order + if err := rows.Scan(orderScan(&order)...); err != nil { + return nil, err + } + orders = append(orders, order) + } + return orders, rows.Err() +} + +func (s sqlStore) ListAdminOrders(ctx context.Context, actor uuid.UUID) ([]Order, error) { + var authorized bool + if err := s.database.QueryRowContext(ctx, `select exists(select 1 from public.platform_roles where internal_user_id=$1 and role in('moderator','administrator'))`, actor).Scan(&authorized); err != nil { + return nil, err + } + if !authorized { + return nil, ErrForbidden + } + rows, err := s.database.QueryContext(ctx, orderSelect+` order by updated_at desc,id limit 100`) + if err != nil { + return nil, err + } + defer rows.Close() + orders := []Order{} + for rows.Next() { + var order Order + if err := rows.Scan(orderScan(&order)...); err != nil { + return nil, err + } + orders = append(orders, order) + } + return orders, rows.Err() +} + +func (s sqlStore) GetProviderAccount(ctx context.Context, actor uuid.UUID) (ProviderAccount, error) { + var account ProviderAccount + err := s.database.QueryRowContext(ctx, `select internal_user_id,stripe_account_id,details_submitted,charges_enabled,payouts_enabled,updated_at from public.provider_payment_accounts where internal_user_id=$1`, actor).Scan(&account.InternalUserID, &account.StripeAccountID, &account.DetailsSubmitted, &account.ChargesEnabled, &account.PayoutsEnabled, &account.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ProviderAccount{}, ErrNotFound + } + return account, err +} + +func (s sqlStore) SaveProviderAccount(ctx context.Context, actor uuid.UUID, value ConnectedAccount) (ProviderAccount, error) { + var providerExists bool + if err := s.database.QueryRowContext(ctx, `select exists(select 1 from public.provider_profiles where internal_user_id=$1)`, actor).Scan(&providerExists); err != nil { + return ProviderAccount{}, err + } + if !providerExists { + return ProviderAccount{}, ErrForbidden + } + var account ProviderAccount + err := s.database.QueryRowContext(ctx, `insert into public.provider_payment_accounts(internal_user_id,stripe_account_id,details_submitted,charges_enabled,payouts_enabled) values($1,$2,$3,$4,$5) on conflict(internal_user_id) do update set details_submitted=excluded.details_submitted,charges_enabled=excluded.charges_enabled,payouts_enabled=excluded.payouts_enabled,updated_at=timezone('utc',now()) where provider_payment_accounts.stripe_account_id=excluded.stripe_account_id returning internal_user_id,stripe_account_id,details_submitted,charges_enabled,payouts_enabled,updated_at`, actor, value.ID, value.DetailsSubmitted, value.ChargesEnabled, value.PayoutsEnabled).Scan(&account.InternalUserID, &account.StripeAccountID, &account.DetailsSubmitted, &account.ChargesEnabled, &account.PayoutsEnabled, &account.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ProviderAccount{}, ErrConflict + } + return account, err +} + +func (s sqlStore) ApplyProviderEvent(ctx context.Context, event ProviderEvent) error { + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `insert into public.stripe_webhook_receipts(stripe_event_id,event_type,provider_object_id,outcome) values($1,$2,$3,'processing') on conflict do nothing`, event.ID, event.Kind, event.ProviderObjectID) + if err != nil { + return err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return tx.Commit() + } + if event.Kind == EventAccountUpdate { + result, err = tx.ExecContext(ctx, `update public.provider_payment_accounts set details_submitted=$1,charges_enabled=$2,payouts_enabled=$3,updated_at=timezone('utc',now()) where stripe_account_id=$4`, event.DetailsSubmitted, event.ChargesEnabled, event.PayoutsEnabled, event.AccountID) + if err != nil { + return err + } + if rows, _ := result.RowsAffected(); rows != 1 { + return ErrNotFound + } + _, err = tx.ExecContext(ctx, `update public.stripe_webhook_receipts set outcome='account_updated',processed_at=timezone('utc',now()) where stripe_event_id=$1`, event.ID) + if err != nil { + return err + } + return tx.Commit() + } + order, err := loadOrderForEvent(ctx, tx, event) + if err != nil { + return err + } + from := order.State + to, eventType, err := eventTransition(from, event.Kind) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `update public.payment_orders set state=$1,stripe_checkout_session_id=coalesce(nullif($2,''),stripe_checkout_session_id),stripe_payment_intent_id=coalesce(nullif($3,''),stripe_payment_intent_id),stripe_invoice_id=coalesce(nullif($4,''),stripe_invoice_id),paid_at=case when $1='paid' then coalesce(paid_at,timezone('utc',now())) else paid_at end,refunded_at=case when $1='refunded' then coalesce(refunded_at,timezone('utc',now())) else refunded_at end,updated_at=timezone('utc',now()) where id=$5`, to, checkoutID(event), event.PaymentIntentID, event.InvoiceID, order.ID) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `insert into public.payment_events(payment_order_id,event_type,from_state,to_state,provider_object_id) values($1,$2,$3,$4,$5)`, order.ID, eventType, from, to, event.ProviderObjectID); err != nil { + return err + } + if event.Kind == EventDisputeOpened || event.Kind == EventDisputeWon || event.Kind == EventDisputeLost { + closed := any(nil) + if event.Kind == EventDisputeWon || event.Kind == EventDisputeLost { + closed = time.Now().UTC() + } + _, err = tx.ExecContext(ctx, `insert into public.payment_disputes(stripe_dispute_id,payment_order_id,stripe_charge_id,amount_minor,currency,state,reason,opened_at,closed_at) values($1,$2,$3,$4,$5,$6,$7,$8,$9) on conflict(stripe_dispute_id) do update set state=excluded.state,reason=excluded.reason,closed_at=excluded.closed_at,updated_at=timezone('utc',now())`, event.DisputeID, order.ID, event.ChargeID, event.AmountMinor, event.Currency, event.DisputeState, event.DisputeReason, event.OccurredAt, closed) + if err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `update public.stripe_webhook_receipts set outcome=$1,processed_at=timezone('utc',now()) where stripe_event_id=$2`, eventType, event.ID); err != nil { + return err + } + return tx.Commit() +} + +func (s sqlStore) PrepareRefund(ctx context.Context, actor, orderID uuid.UUID) (Order, error) { + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return Order{}, err + } + defer func() { _ = tx.Rollback() }() + var moderator bool + if err := tx.QueryRowContext(ctx, `select exists(select 1 from public.platform_roles where internal_user_id=$1 and role in('moderator','administrator'))`, actor).Scan(&moderator); err != nil { + return Order{}, err + } + if !moderator { + return Order{}, ErrForbidden + } + order, found, err := loadOrderByID(ctx, tx, orderID, true) + if err != nil || !found { + if !found && err == nil { + err = ErrNotFound + } + return Order{}, err + } + if order.State == StateRefundPending { + if err := tx.Commit(); err != nil { + return Order{}, err + } + return order, nil + } + if order.State != StatePaid && order.State != StateDisputeWon || order.PaymentIntentID == "" { + return Order{}, ErrConflict + } + return order, tx.Commit() +} + +func (s sqlStore) AttachRefund(ctx context.Context, actor, orderID uuid.UUID, refund RefundResult) (Order, error) { + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return Order{}, err + } + defer func() { _ = tx.Rollback() }() + var from State + if err := tx.QueryRowContext(ctx, `select state from public.payment_orders where id=$1 for update`, orderID).Scan(&from); errors.Is(err, sql.ErrNoRows) { + return Order{}, ErrNotFound + } else if err != nil { + return Order{}, err + } + result, err := tx.ExecContext(ctx, `update public.payment_orders set state='refund_pending',stripe_refund_id=$1,updated_at=timezone('utc',now()) where id=$2 and state=$3`, refund.ID, orderID, from) + if err != nil { + return Order{}, err + } + if rows, _ := result.RowsAffected(); rows != 1 { + return Order{}, ErrConflict + } + if _, err := tx.ExecContext(ctx, `insert into public.payment_events(payment_order_id,event_type,from_state,to_state,provider_object_id) values($1,'refund_requested',$2,'refund_pending',$3)`, orderID, from, refund.ID); err != nil { + return Order{}, err + } + order, _, err := loadOrderByID(ctx, tx, orderID, false) + if err != nil { + return Order{}, err + } + if err := tx.Commit(); err != nil { + return Order{}, err + } + return order, nil +} + +type queryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func loadOrderByBooking(ctx context.Context, q queryer, bookingID uuid.UUID) (Order, bool, error) { + var order Order + err := q.QueryRowContext(ctx, orderSelect+` where booking_id=$1`, bookingID).Scan(orderScan(&order)...) + if errors.Is(err, sql.ErrNoRows) { + return Order{}, false, nil + } + return order, err == nil, err +} +func loadOrderByID(ctx context.Context, q queryer, orderID uuid.UUID, lock bool) (Order, bool, error) { + suffix := ` where id=$1` + if lock { + suffix += ` for update` + } + var order Order + err := q.QueryRowContext(ctx, orderSelect+suffix, orderID).Scan(orderScan(&order)...) + if errors.Is(err, sql.ErrNoRows) { + return Order{}, false, nil + } + return order, err == nil, err +} + +const orderSelect = `select id,booking_id,customer_internal_user_id,provider_internal_user_id,state,gross_minor,platform_fee_minor,provider_net_minor,currency,coalesce(stripe_checkout_session_id,''),coalesce(stripe_payment_intent_id,''),coalesce(stripe_invoice_id,''),coalesce(stripe_refund_id,''),created_at,updated_at from public.payment_orders` + +func orderScan(order *Order) []any { + return []any{&order.ID, &order.BookingID, &order.CustomerID, &order.ProviderID, &order.State, &order.GrossMinor, &order.PlatformFeeMinor, &order.ProviderNetMinor, &order.Currency, &order.CheckoutSessionID, &order.PaymentIntentID, &order.InvoiceID, &order.RefundID, &order.CreatedAt, &order.UpdatedAt} +} +func loadOrderForEvent(ctx context.Context, tx *sql.Tx, event ProviderEvent) (Order, error) { + var order Order + var row *sql.Row + if event.OrderID != "" { + row = tx.QueryRowContext(ctx, orderSelect+` where id=$1 for update`, event.OrderID) + } else if event.PaymentIntentID != "" { + row = tx.QueryRowContext(ctx, orderSelect+` where stripe_payment_intent_id=$1 for update`, event.PaymentIntentID) + } else { + return Order{}, ErrInvalid + } + if err := row.Scan(orderScan(&order)...); errors.Is(err, sql.ErrNoRows) { + return Order{}, ErrNotFound + } else if err != nil { + return Order{}, err + } + return order, nil +} +func eventTransition(from State, kind EventKind) (State, string, error) { + switch kind { + case EventProcessing: + if from == StateCheckoutCreated { + return StateProcessing, "processing", nil + } + case EventPaid: + if from == StateCheckoutCreated || from == StateProcessing { + return StatePaid, "paid", nil + } + if from == StatePaid { + return from, "paid", nil + } + case EventFailed: + if from == StateCheckoutCreated || from == StateProcessing { + return StateFailed, "failed", nil + } + case EventRefunded: + if from == StateRefundPending || from == StatePaid || from == StateDisputed || from == StateDisputeLost { + return StateRefunded, "refunded", nil + } + case EventDisputeOpened: + if from == StatePaid { + return StateDisputed, "dispute_opened", nil + } + case EventDisputeWon: + if from == StateDisputed { + return StateDisputeWon, "dispute_won", nil + } + case EventDisputeLost: + if from == StateDisputed { + return StateDisputeLost, "dispute_lost", nil + } + } + return "", "", fmt.Errorf("%w: transition %s from %s", ErrConflict, kind, from) +} +func checkoutID(event ProviderEvent) string { + if len(event.ProviderObjectID) > 3 && event.ProviderObjectID[:3] == "cs_" { + return event.ProviderObjectID + } + return "" +} diff --git a/backend/internal/payments/sql_store_integration_test.go b/backend/internal/payments/sql_store_integration_test.go new file mode 100644 index 0000000..5c1d6b0 --- /dev/null +++ b/backend/internal/payments/sql_store_integration_test.go @@ -0,0 +1,98 @@ +package payments + +import ( + "context" + "database/sql" + "os" + "testing" + "time" + + "github.com/google/uuid" + _ "github.com/jackc/pgx/v5/stdlib" +) + +func TestSQLStorePersistsCheckoutAndIdempotentPaidWebhook(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL is required") + } + database, err := sql.Open("pgx", url) + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + customer, provider, booking := uuid.New(), uuid.New(), uuid.New() + cleanup := func() { + _, _ = database.ExecContext(ctx, `delete from public.payment_events where payment_order_id in(select id from public.payment_orders where booking_id=$1)`, booking) + _, _ = database.ExecContext(ctx, `delete from public.stripe_webhook_receipts where provider_object_id='cs_testpayment'`) + _, _ = database.ExecContext(ctx, `delete from public.payment_orders where booking_id=$1`, booking) + _, _ = database.ExecContext(ctx, `delete from public.bookings where id=$1`, booking) + _, _ = database.ExecContext(ctx, `delete from public.provider_payment_accounts where internal_user_id=$1`, provider) + _, _ = database.ExecContext(ctx, `delete from public.provider_profiles where internal_user_id=$1`, provider) + _, _ = database.ExecContext(ctx, `delete from public.platform_roles where internal_user_id=$1`, provider) + _, _ = database.ExecContext(ctx, `delete from public.user_accounts where internal_user_id in($1,$2)`, customer, provider) + _, _ = database.ExecContext(ctx, `delete from public.internal_users where id in($1,$2)`, customer, provider) + } + cleanup() + defer cleanup() + for id, subject := range map[uuid.UUID]string{customer: "payment_customer_" + customer.String(), provider: "payment_provider_" + provider.String()} { + if _, err := database.ExecContext(ctx, `insert into public.internal_users(id,clerk_subject) values($1,$2)`, id, subject); err != nil { + t.Fatal(err) + } + if _, err := database.ExecContext(ctx, `insert into public.user_accounts(internal_user_id,provider_enabled) values($1,$2)`, id, id == provider); err != nil { + t.Fatal(err) + } + } + var locality uuid.UUID + if err := database.QueryRowContext(ctx, `select id from public.localities order by id limit 1`).Scan(&locality); err != nil { + t.Fatal(err) + } + if _, err := database.ExecContext(ctx, `insert into public.provider_profiles(internal_user_id,display_name,provider_type,bio,primary_locality_id,max_travel_distance_km,remote_services) values($1,'Payment test provider','professional','Synthetic transactional payment test provider.',$2,0,true)`, provider, locality); err != nil { + t.Fatal(err) + } + if _, err := database.ExecContext(ctx, `insert into public.bookings(id,customer_internal_user_id,provider_internal_user_id,source_type,idempotency_key,state,scheduled_at,private_location,agreed_price_minor) values($1,$2,$3,'direct','payment-booking-test','confirmed',$4,'Synthetic private test location',12500)`, booking, customer, provider, time.Now().UTC().Add(24*time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := database.ExecContext(ctx, `insert into public.provider_payment_accounts(internal_user_id,stripe_account_id,details_submitted,charges_enabled,payouts_enabled) values($1,'acct_syntheticpayment',true,true,true)`, provider); err != nil { + t.Fatal(err) + } + if _, err := database.ExecContext(ctx, `insert into public.platform_roles(id,internal_user_id,role) values($1,$2,'moderator')`, uuid.New(), provider); err != nil { + t.Fatal(err) + } + store := NewSQLStore(database) + order, account, err := store.PrepareCheckout(ctx, customer, booking, "payment-order-test", 1000) + if err != nil || order.GrossMinor != 12500 || order.PlatformFeeMinor != 1250 || order.ProviderNetMinor != 11250 || !account.PayoutsEnabled { + t.Fatalf("prepare order=%#v account=%#v err=%v", order, account, err) + } + orderID := uuid.MustParse(order.ID) + order, err = store.AttachCheckout(ctx, customer, orderID, CheckoutSession{ID: "cs_testpayment", URL: "https://checkout.stripe.test/session"}) + if err != nil || order.State != StateCheckoutCreated { + t.Fatalf("attach order=%#v err=%v", order, err) + } + event := ProviderEvent{ID: "evt_123synthetic", Kind: EventPaid, ProviderObjectID: "cs_testpayment", OrderID: order.ID, PaymentIntentID: "pi_syntheticpayment", InvoiceID: "in_syntheticpayment", OccurredAt: time.Now().UTC()} + if err := store.ApplyProviderEvent(ctx, event); err != nil { + t.Fatalf("apply event: %v", err) + } + if err := store.ApplyProviderEvent(ctx, event); err != nil { + t.Fatalf("replay event: %v", err) + } + var state string + var paidEvents, receipts int + if err := database.QueryRowContext(ctx, `select state from public.payment_orders where id=$1`, orderID).Scan(&state); err != nil { + t.Fatal(err) + } + if err := database.QueryRowContext(ctx, `select count(*) from public.payment_events where payment_order_id=$1 and event_type='paid'`, orderID).Scan(&paidEvents); err != nil { + t.Fatal(err) + } + if err := database.QueryRowContext(ctx, `select count(*) from public.stripe_webhook_receipts where stripe_event_id=$1`, event.ID).Scan(&receipts); err != nil { + t.Fatal(err) + } + if state != "paid" || paidEvents != 1 || receipts != 1 { + t.Fatalf("state=%s paid_events=%d receipts=%d", state, paidEvents, receipts) + } + adminOrders, err := store.ListAdminOrders(ctx, provider) + if err != nil || len(adminOrders) != 1 || adminOrders[0].ID != order.ID { + t.Fatalf("admin orders=%#v err=%v", adminOrders, err) + } +} diff --git a/backend/internal/payments/stripe_gateway.go b/backend/internal/payments/stripe_gateway.go new file mode 100644 index 0000000..60e03e1 --- /dev/null +++ b/backend/internal/payments/stripe_gateway.go @@ -0,0 +1,317 @@ +package payments + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + stripeBodyLimit = 256 * 1024 + stripeTolerance = 5 * time.Minute +) + +type StripeConfig struct { + SecretKey string + WebhookSecret string + APIBase string + PublicOrigin string + HTTPClient *http.Client + Now func() time.Time +} + +type StripeGateway struct { + secretKey string + webhookSecret string + apiBase string + publicOrigin string + client *http.Client + now func() time.Time +} + +func NewStripeGateway(config StripeConfig) (*StripeGateway, error) { + config.SecretKey = strings.TrimSpace(config.SecretKey) + config.WebhookSecret = strings.TrimSpace(config.WebhookSecret) + config.APIBase = strings.TrimRight(strings.TrimSpace(config.APIBase), "/") + config.PublicOrigin = strings.TrimRight(strings.TrimSpace(config.PublicOrigin), "/") + api, apiErr := url.Parse(config.APIBase) + public, publicErr := url.Parse(config.PublicOrigin) + if !strings.HasPrefix(config.SecretKey, "sk_") || !strings.HasPrefix(config.WebhookSecret, "whsec_") || apiErr != nil || api.Host == "" || (api.Scheme != "https" && api.Hostname() != "127.0.0.1" && api.Hostname() != "localhost") || publicErr != nil || public.Scheme != "https" || public.Host == "" || public.Path != "" { + return nil, ErrUnavailable + } + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 15 * time.Second} + } + if config.Now == nil { + config.Now = time.Now + } + return &StripeGateway{secretKey: config.SecretKey, webhookSecret: config.WebhookSecret, apiBase: config.APIBase, publicOrigin: config.PublicOrigin, client: config.HTTPClient, now: config.Now}, nil +} + +func (g *StripeGateway) CreateCheckout(ctx context.Context, input CheckoutRequest) (CheckoutSession, error) { + if input.OrderID == "" || input.BookingID == "" || !strings.HasPrefix(input.ConnectedAccountID, "acct_") || input.GrossMinor < 50 || input.FeeMinor < 0 || input.FeeMinor >= input.GrossMinor || len(input.IdempotencyKey) < 8 { + return CheckoutSession{}, ErrInvalid + } + locale := input.Locale + if locale != "pt-PT" && locale != "en" && locale != "es" { + locale = "pt-PT" + } + values := url.Values{ + "mode": {"payment"}, + "success_url": {g.publicOrigin + "/" + locale + "/account/bookings?payment=returned"}, + "cancel_url": {g.publicOrigin + "/" + locale + "/account/bookings?payment=cancelled"}, + "line_items[0][price_data][currency]": {"eur"}, + "line_items[0][price_data][unit_amount]": {strconv.FormatInt(input.GrossMinor, 10)}, + "line_items[0][price_data][product_data][name]": {"Vila service booking"}, + "line_items[0][quantity]": {"1"}, + "payment_intent_data[application_fee_amount]": {strconv.FormatInt(input.FeeMinor, 10)}, + "payment_intent_data[transfer_data][destination]": {input.ConnectedAccountID}, + "metadata[order_id]": {input.OrderID}, + "metadata[booking_id]": {input.BookingID}, + "automatic_tax[enabled]": {"true"}, + "tax_id_collection[enabled]": {"true"}, + "invoice_creation[enabled]": {"true"}, + "billing_address_collection": {"required"}, + "customer_creation": {"always"}, + "payment_method_types[0]": {"card"}, + "payment_method_types[1]": {"mb_way"}, + "consent_collection[terms_of_service]": {"required"}, + "custom_text[terms_of_service_acceptance][message]": {"I agree to Vila's terms, payment policy, and refund policy."}, + } + var response struct { + ID string `json:"id"` + URL string `json:"url"` + } + if err := g.form(ctx, http.MethodPost, "/v1/checkout/sessions", values, input.IdempotencyKey, &response); err != nil || !strings.HasPrefix(response.ID, "cs_") || !validHTTPS(response.URL) { + return CheckoutSession{}, ErrUnavailable + } + return CheckoutSession{ID: response.ID, URL: response.URL}, nil +} + +func (g *StripeGateway) CreateConnectedAccount(ctx context.Context, idempotencyKey string) (ConnectedAccount, error) { + values := url.Values{"type": {"express"}, "country": {"PT"}, "capabilities[card_payments][requested]": {"true"}, "capabilities[transfers][requested]": {"true"}, "business_type": {"individual"}} + var response stripeAccount + if err := g.form(ctx, http.MethodPost, "/v1/accounts", values, idempotencyKey, &response); err != nil || !strings.HasPrefix(response.ID, "acct_") { + return ConnectedAccount{}, ErrUnavailable + } + return response.account(), nil +} + +func (g *StripeGateway) CreateAccountLink(ctx context.Context, accountID, locale string) (AccountLink, error) { + if !strings.HasPrefix(accountID, "acct_") { + return AccountLink{}, ErrInvalid + } + values := url.Values{"account": {accountID}, "type": {"account_onboarding"}, "refresh_url": {g.publicOrigin + "/" + locale + "/account/payouts?onboarding=refresh"}, "return_url": {g.publicOrigin + "/" + locale + "/account/payouts?onboarding=returned"}} + var response struct { + URL string `json:"url"` + } + if err := g.form(ctx, http.MethodPost, "/v1/account_links", values, "", &response); err != nil || !validHTTPS(response.URL) { + return AccountLink{}, ErrUnavailable + } + return AccountLink{URL: response.URL}, nil +} + +func (g *StripeGateway) GetConnectedAccount(ctx context.Context, accountID string) (ConnectedAccount, error) { + if !strings.HasPrefix(accountID, "acct_") { + return ConnectedAccount{}, ErrInvalid + } + var response stripeAccount + if err := g.form(ctx, http.MethodGet, "/v1/accounts/"+url.PathEscape(accountID), nil, "", &response); err != nil { + return ConnectedAccount{}, ErrUnavailable + } + return response.account(), nil +} + +func (g *StripeGateway) CreateRefund(ctx context.Context, paymentIntentID, idempotencyKey string) (RefundResult, error) { + if !strings.HasPrefix(paymentIntentID, "pi_") || len(idempotencyKey) < 8 { + return RefundResult{}, ErrInvalid + } + var response struct { + ID string `json:"id"` + } + if err := g.form(ctx, http.MethodPost, "/v1/refunds", url.Values{"payment_intent": {paymentIntentID}, "reverse_transfer": {"true"}, "refund_application_fee": {"true"}}, idempotencyKey, &response); err != nil || !strings.HasPrefix(response.ID, "re_") { + return RefundResult{}, ErrUnavailable + } + return RefundResult{ID: response.ID}, nil +} + +func (g *StripeGateway) VerifyWebhook(payload []byte, signature string) (ProviderEvent, error) { + if len(payload) == 0 || len(payload) > stripeBodyLimit { + return ProviderEvent{}, ErrInvalid + } + timestamp, signatures, ok := parseStripeSignature(signature) + if !ok || g.now().Sub(time.Unix(timestamp, 0)).Abs() > stripeTolerance { + return ProviderEvent{}, ErrUnauthorized + } + mac := hmac.New(sha256.New, []byte(g.webhookSecret)) + _, _ = mac.Write([]byte(strconv.FormatInt(timestamp, 10))) + _, _ = mac.Write([]byte(".")) + _, _ = mac.Write(payload) + expected := mac.Sum(nil) + verified := false + for _, candidate := range signatures { + decoded, err := hex.DecodeString(candidate) + if err == nil && hmac.Equal(decoded, expected) { + verified = true + break + } + } + if !verified { + return ProviderEvent{}, ErrUnauthorized + } + return projectStripeEvent(payload) +} + +type stripeAccount struct { + ID string `json:"id"` + DetailsSubmitted bool `json:"details_submitted"` + ChargesEnabled bool `json:"charges_enabled"` + PayoutsEnabled bool `json:"payouts_enabled"` +} + +func (a stripeAccount) account() ConnectedAccount { + return ConnectedAccount{ID: a.ID, DetailsSubmitted: a.DetailsSubmitted, ChargesEnabled: a.ChargesEnabled, PayoutsEnabled: a.PayoutsEnabled} +} + +func (g *StripeGateway) form(ctx context.Context, method, path string, values url.Values, idempotencyKey string, target any) error { + var body io.Reader + if values != nil { + body = strings.NewReader(values.Encode()) + } + req, err := http.NewRequestWithContext(ctx, method, g.apiBase+path, body) + if err != nil { + return err + } + req.SetBasicAuth(g.secretKey, "") + if values != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } + response, err := g.client.Do(req) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, stripeBodyLimit)) + return fmt.Errorf("stripe status %d", response.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(response.Body, stripeBodyLimit)) + return decoder.Decode(target) +} + +func parseStripeSignature(value string) (int64, []string, bool) { + var timestamp int64 + var signatures []string + for _, part := range strings.Split(value, ",") { + key, raw, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok { + continue + } + switch key { + case "t": + timestamp, _ = strconv.ParseInt(raw, 10, 64) + case "v1": + signatures = append(signatures, raw) + } + } + return timestamp, signatures, timestamp > 0 && len(signatures) > 0 +} + +func projectStripeEvent(payload []byte) (ProviderEvent, error) { + var envelope struct { + ID string `json:"id"` + Type string `json:"type"` + Created int64 `json:"created"` + Data struct { + Object json.RawMessage `json:"object"` + } `json:"data"` + } + if err := json.Unmarshal(payload, &envelope); err != nil || !strings.HasPrefix(envelope.ID, "evt_") { + return ProviderEvent{}, ErrInvalid + } + event := ProviderEvent{ID: envelope.ID, OccurredAt: time.Unix(envelope.Created, 0).UTC()} + switch envelope.Type { + case "checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed": + var object struct { + ID string `json:"id"` + PaymentStatus string `json:"payment_status"` + PaymentIntent string `json:"payment_intent"` + Invoice string `json:"invoice"` + Metadata map[string]string `json:"metadata"` + } + if json.Unmarshal(envelope.Data.Object, &object) != nil || !strings.HasPrefix(object.ID, "cs_") || object.Metadata["order_id"] == "" { + return ProviderEvent{}, ErrInvalid + } + event.ProviderObjectID, event.OrderID, event.PaymentIntentID, event.InvoiceID = object.ID, object.Metadata["order_id"], object.PaymentIntent, object.Invoice + if envelope.Type == "checkout.session.async_payment_failed" { + event.Kind = EventFailed + } else if object.PaymentStatus == "paid" || envelope.Type == "checkout.session.async_payment_succeeded" { + event.Kind = EventPaid + } else { + event.Kind = EventProcessing + } + case "charge.refunded": + var object struct { + ID string `json:"id"` + PaymentIntent string `json:"payment_intent"` + Currency string `json:"currency"` + AmountRefunded int64 `json:"amount_refunded"` + } + if json.Unmarshal(envelope.Data.Object, &object) != nil { + return ProviderEvent{}, ErrInvalid + } + event.Kind, event.ProviderObjectID, event.ChargeID, event.PaymentIntentID, event.AmountMinor, event.Currency = EventRefunded, object.ID, object.ID, object.PaymentIntent, object.AmountRefunded, strings.ToUpper(object.Currency) + case "charge.dispute.created", "charge.dispute.closed": + var object struct { + ID string `json:"id"` + Charge string `json:"charge"` + PaymentIntent string `json:"payment_intent"` + Status string `json:"status"` + Reason string `json:"reason"` + Currency string `json:"currency"` + Amount int64 `json:"amount"` + } + if json.Unmarshal(envelope.Data.Object, &object) != nil { + return ProviderEvent{}, ErrInvalid + } + event.ProviderObjectID, event.DisputeID, event.ChargeID, event.PaymentIntentID, event.DisputeState, event.DisputeReason, event.AmountMinor, event.Currency = object.ID, object.ID, object.Charge, object.PaymentIntent, object.Status, object.Reason, object.Amount, strings.ToUpper(object.Currency) + switch object.Status { + case "won": + event.Kind = EventDisputeWon + case "lost", "warning_closed": + event.Kind = EventDisputeLost + default: + event.Kind = EventDisputeOpened + } + case "account.updated": + var object stripeAccount + if json.Unmarshal(envelope.Data.Object, &object) != nil || !strings.HasPrefix(object.ID, "acct_") { + return ProviderEvent{}, ErrInvalid + } + event.Kind, event.ProviderObjectID, event.AccountID, event.DetailsSubmitted, event.ChargesEnabled, event.PayoutsEnabled = EventAccountUpdate, object.ID, object.ID, object.DetailsSubmitted, object.ChargesEnabled, object.PayoutsEnabled + default: + return ProviderEvent{}, ErrInvalid + } + if event.OccurredAt.IsZero() { + event.OccurredAt = time.Unix(0, 0).UTC() + } + return event, nil +} + +func validHTTPS(raw string) bool { + parsed, err := url.Parse(raw) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" +} diff --git a/backend/internal/payments/stripe_gateway_test.go b/backend/internal/payments/stripe_gateway_test.go new file mode 100644 index 0000000..23b0000 --- /dev/null +++ b/backend/internal/payments/stripe_gateway_test.go @@ -0,0 +1,94 @@ +package payments + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestStripeGatewayCreatesServerPricedDestinationCheckout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/checkout/sessions" || r.Method != http.MethodPost { + t.Fatalf("unexpected Stripe request %s %s", r.Method, r.URL.Path) + } + if user, _, ok := r.BasicAuth(); !ok || user != "sk_test_synthetic" { + t.Fatal("missing Stripe server authentication") + } + body, _ := io.ReadAll(r.Body) + values, _ := url.ParseQuery(string(body)) + for key, expected := range map[string]string{ + "mode": "payment", + "line_items[0][price_data][currency]": "eur", + "line_items[0][price_data][unit_amount]": "12500", + "line_items[0][quantity]": "1", + "payment_intent_data[application_fee_amount]": "1250", + "payment_intent_data[transfer_data][destination]": "acct_provider", + "metadata[order_id]": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "automatic_tax[enabled]": "true", + "tax_id_collection[enabled]": "true", + "invoice_creation[enabled]": "true", + } { + if values.Get(key) != expected { + t.Errorf("%s = %q, want %q", key, values.Get(key), expected) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cs_test_checkout","url":"https://checkout.stripe.test/session"}`)) + })) + defer server.Close() + + gateway, err := NewStripeGateway(StripeConfig{SecretKey: "sk_test_synthetic", WebhookSecret: "whsec_synthetic", APIBase: server.URL, PublicOrigin: "https://vila.example", Now: time.Now}) + if err != nil { + t.Fatalf("gateway: %v", err) + } + session, err := gateway.CreateCheckout(context.Background(), CheckoutRequest{OrderID: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", BookingID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", ConnectedAccountID: "acct_provider", GrossMinor: 12500, FeeMinor: 1250, Locale: "pt-PT", IdempotencyKey: "checkout-key-123"}) + if err != nil { + t.Fatalf("checkout: %v", err) + } + if session.ID != "cs_test_checkout" || session.URL != "https://checkout.stripe.test/session" { + t.Fatalf("session = %#v", session) + } +} + +func TestStripeGatewayVerifiesRawWebhookAndRejectsTampering(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + gateway, err := NewStripeGateway(StripeConfig{SecretKey: "sk_test_synthetic", WebhookSecret: "whsec_synthetic", APIBase: "https://api.stripe.test", PublicOrigin: "https://vila.example", Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + payload := []byte(`{"id":"evt_checkout","type":"checkout.session.completed","data":{"object":{"id":"cs_live","payment_status":"paid","payment_intent":"pi_live","invoice":"in_live","metadata":{"order_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}}}}`) + signed := []byte("1800000000." + string(payload)) + mac := hmac.New(sha256.New, []byte("whsec_synthetic")) + _, _ = mac.Write(signed) + signature := "t=1800000000,v1=" + hex.EncodeToString(mac.Sum(nil)) + + event, err := gateway.VerifyWebhook(payload, signature) + if err != nil { + t.Fatalf("verify: %v", err) + } + if event.ID != "evt_checkout" || event.Kind != EventPaid || event.OrderID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" || event.PaymentIntentID != "pi_live" || event.InvoiceID != "in_live" { + t.Fatalf("event = %#v", event) + } + if _, err := gateway.VerifyWebhook([]byte(strings.ReplaceAll(string(payload), "paid", "unpaid")), signature); err == nil { + t.Fatal("tampered payload accepted") + } +} + +func TestProjectStripeRefundAndDisputeFields(t *testing.T) { + refund, err := projectStripeEvent([]byte(`{"id":"evt_refund","type":"charge.refunded","created":1800000000,"data":{"object":{"id":"ch_refund","payment_intent":"pi_refund","amount_refunded":12500,"currency":"eur"}}}`)) + if err != nil || refund.Kind != EventRefunded || refund.PaymentIntentID != "pi_refund" || refund.AmountMinor != 12500 || refund.Currency != "EUR" { + t.Fatalf("refund = %#v, err = %v", refund, err) + } + dispute, err := projectStripeEvent([]byte(`{"id":"evt_dispute","type":"charge.dispute.created","created":1800000000,"data":{"object":{"id":"dp_test","charge":"ch_test","payment_intent":"pi_test","status":"needs_response","reason":"fraudulent","amount":12500,"currency":"eur"}}}`)) + if err != nil || dispute.Kind != EventDisputeOpened || dispute.PaymentIntentID != "pi_test" || dispute.DisputeID != "dp_test" || dispute.AmountMinor != 12500 { + t.Fatalf("dispute = %#v, err = %v", dispute, err) + } +} diff --git a/compose.production.yaml b/compose.production.yaml index 21996f5..1a8d714 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -15,6 +15,10 @@ services: CLERK_AUTHORIZED_PARTIES: ${CLERK_AUTHORIZED_PARTIES:?Set CLERK_AUTHORIZED_PARTIES} CLERK_CLOCK_SKEW: ${CLERK_CLOCK_SKEW:-} JUNTLY_CONTACT_ENCRYPTION_KEY: ${JUNTLY_CONTACT_ENCRYPTION_KEY:?Set JUNTLY_CONTACT_ENCRYPTION_KEY} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:?Set a rotated STRIPE_SECRET_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:?Set STRIPE_WEBHOOK_SECRET} + JUNTLY_PUBLIC_ORIGIN: ${JUNTLY_PUBLIC_ORIGIN:?Set the canonical HTTPS frontend origin} + JUNTLY_PLATFORM_FEE_BPS: ${JUNTLY_PLATFORM_FEE_BPS:?Set the reviewed platform fee in basis points} healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/api/v1/ready || exit 1"] interval: 15s diff --git a/compose.yaml b/compose.yaml index 03b2337..cf1a2b9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,11 @@ services: CLERK_AUTHORIZED_PARTIES: ${CLERK_AUTHORIZED_PARTIES:-http://localhost:4200} CLERK_CLOCK_SKEW: ${CLERK_CLOCK_SKEW:-} JUNTLY_CONTACT_ENCRYPTION_KEY: ${JUNTLY_CONTACT_ENCRYPTION_KEY:-} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_API_BASE: ${STRIPE_API_BASE:-} + JUNTLY_PUBLIC_ORIGIN: ${JUNTLY_PUBLIC_ORIGIN:-} + JUNTLY_PLATFORM_FEE_BPS: ${JUNTLY_PLATFORM_FEE_BPS:-} healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/api/v1/ready || exit 1"] interval: 5s diff --git a/context/decisions.md b/context/decisions.md index 06e3821..6f83dab 100644 --- a/context/decisions.md +++ b/context/decisions.md @@ -73,3 +73,17 @@ - Decision: The first repository delivery contains durable context and a verified localized Next.js shell only. - Alternatives: complete Go/OpenAPI/Docker/Supabase tracer in the initial commit. - Consequences: matches the requested starting scope. Documentation and reports must not call the full-stack foundation complete until the later vertical tracer exists. + +## ADR-011: Vila user-facing brand with stable technical identifiers + +- Status: accepted +- Date: 2026-09-02 +- Decision: Present the marketplace publicly as Vila and use `https://somosvila.com` as its canonical future public origin. Preserve Juntly repository, API, environment-variable, database, migration, and package identifiers unless a separate infrastructure migration is approved. +- Consequences: customer-facing copy, metadata, and legal surfaces use Vila while deployed technical contracts remain backwards-compatible. + +## ADR-012: Advance protected Stripe marketplace payments + +- Status: accepted +- Date: 2026-09-02 +- Decision: Implement optional protected payments now through Stripe-hosted Checkout and Connect destination charges, with server-owned EUR amounts and commission, durable payment/refund/dispute records, signed idempotent webhooks, and hosted provider onboarding. Cards and MB WAY are requested when eligible; external arrangements remain allowed and commission-free. +- Consequences: payment routes fail closed without complete server configuration and a payout-ready provider. Production activation remains blocked on rotated secrets, production Clerk, Connect and payment-method enablement, legal operator details, test-mode transaction/refund/dispute evidence, monitoring, and explicit deployment approval. No custom escrow or browser-owned financial authority is introduced. diff --git a/context/product.md b/context/product.md index ff7fd55..068b33b 100644 --- a/context/product.md +++ b/context/product.md @@ -36,7 +36,7 @@ Initial focus: Zebreira, Idanha-a-Nova, Penha Garcia, Monsanto, Castelo Branco, ## Explicitly deferred until after initial validation -Full protected payments/payouts, MB WAY-compatible payments, advanced verification and analytics, saved searches, web push, dispute automation, institutional dashboards, native mobile apps, and AI-assisted matching. Payment/provider abstractions may be designed earlier, but secondary features must not delay discovery, direct contact, chat, and quotations. +Advanced verification and analytics, saved searches, web push, institutional dashboards, native mobile apps, and AI-assisted matching remain deferred. SourceSensei explicitly advanced protected Stripe Checkout/Connect payments, MB WAY-compatible checkout, durable refunds, and dispute synchronization into the current implementation scope. These capabilities remain disabled until production identity, legal, Stripe, webhook, monitoring, and live test-mode activation gates are satisfied. ## Non-functional requirements diff --git a/docs/operations/deployment-recovery.md b/docs/operations/deployment-recovery.md index 2bc6c63..17a6644 100644 --- a/docs/operations/deployment-recovery.md +++ b/docs/operations/deployment-recovery.md @@ -4,7 +4,11 @@ Deploy immutable digest-pinned API and frontend images through `compose.production.yaml`. Supply runtime values through the deployment platform's secret store. Never commit an environment file. -Required values are `JUNTLY_API_IMAGE`, `JUNTLY_FRONTEND_IMAGE`, `DATABASE_URL`, Clerk keys and authorized parties, `JUNTLY_CONTACT_ENCRYPTION_KEY`, and the public frontend port. +Required values are `JUNTLY_API_IMAGE`, `JUNTLY_FRONTEND_IMAGE`, `DATABASE_URL`, production Clerk keys and authorized parties, `JUNTLY_CONTACT_ENCRYPTION_KEY`, a rotated `STRIPE_SECRET_KEY`, the Dashboard-created `STRIPE_WEBHOOK_SECRET`, canonical HTTPS `JUNTLY_PUBLIC_ORIGIN`, reviewed `JUNTLY_PLATFORM_FEE_BPS`, and the public frontend port. Never reuse a credential pasted into chat, an issue, CI logs, or source control. + +The canonical future public origin is `https://somosvila.com`. Name the Stripe event destination `Vila Production Payments` and configure its endpoint as `https://somosvila.com/api/v1/payments/webhooks/stripe`. Subscribe only to the allowlisted payment and connected-account events documented below; do not subscribe to SetupIntent lifecycle events. + +Production Clerk must authorize `https://somosvila.com`; local development must authorize `http://localhost:4200`. Google OAuth is configured in Clerk for both origins. Keep the OAuth client secret and Clerk secret only in their provider/deployment secret stores. Use the manually dispatched `Publish immutable images` GitHub workflow to build a reviewed ref. It publishes `linux/amd64` API and frontend images to GHCR with the resolved full commit SHA as the only tag, disables mutable provenance/SBOM side artifacts, and records each registry digest in the workflow summary. Supply the resulting `image@sha256:...` references to `compose.production.yaml`; do not deploy a branch tag. @@ -15,7 +19,11 @@ Use the manually dispatched `Publish immutable images` GitHub workflow to build 3. Require the API `/api/v1/ready` check and frontend health check to pass. 4. Run `JUNTLY_BASE_URL=https://staging.example scripts/smoke.sh` against the API origin. 5. Exercise one localized signed-out discovery journey and one Clerk-authenticated account journey. -6. Record image digests and migration head. Promote those exact digests to production. +6. Complete Stripe Connect onboarding with a test provider. Require `details_submitted`, `charges_enabled`, and `payouts_enabled` before checkout. +7. In Stripe test mode, exercise one card payment and one eligible MB WAY payment. Verify gross, fee, provider net, invoice reference, and one idempotent signed webhook receipt in PostgreSQL. +8. Exercise one refund and one synthetic dispute event. Confirm duplicate webhook delivery creates no duplicate payment event. +9. Confirm Stripe Tax registrations/settings, invoice branding, Connect platform profile, MB WAY enablement, legal operator identity, VAT/tax details, terms, privacy, and cancellation/refund wording with the responsible business/legal owner. +10. Record image digests and migration head. Promote those exact digests to production only after explicit approval. ## Backup and restore @@ -33,4 +41,16 @@ Application rollback uses the previous immutable image digests. Database migrati ## Monitoring -Alert on sustained `/api/v1/ready` failures, elevated 5xx rate, latency, database connection exhaustion, email-outbox failures, and container restarts. Correlate requests by `X-Request-ID`; do not put message bodies, contact data, tokens, or database URLs in logs. +Alert on sustained `/api/v1/ready` failures, elevated 5xx rate, latency, database connection exhaustion, email-outbox failures, container restarts, Stripe webhook failures/retries, payment orders stuck in `processing` or `refund_pending`, open disputes, Connect accounts losing charge/payout capability, and payout failures visible in Stripe. Correlate requests by `X-Request-ID`; do not put message bodies, contact data, Stripe payloads/signatures, tokens, or database URLs in logs. + +## Payment activation gate + +Keep production checkout unavailable until every item below is true: + +- the previously exposed live secret is revoked and a replacement exists only in the deployment secret store; +- Stripe Connect is activated for the platform and each provider completes Stripe-hosted onboarding; +- the public webhook points to `https://somosvila.com/api/v1/payments/webhooks/stripe` and subscribes to `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `charge.refunded`, `charge.dispute.created`, `charge.dispute.closed`, and `account.updated`; +- cards and MB WAY are enabled for EUR where the Stripe account and transaction are eligible; +- automatic tax, tax-ID collection, invoices, platform fee, refund policy, dispute ownership, payout schedule, and reserve exposure are reviewed; +- production Clerk, database backups, legal operator details, privacy contacts, and monitoring are configured; +- test-mode payment, payout, refund, dispute, replay, and recovery evidence has been recorded. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2c1e0fd..1a4c74e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -101,7 +101,9 @@ "manageBookings": "Manage bookings", "manageReviews": "Reviews", "manageEntitlements": "Plans and promotions", - "manageModeration": "Approve listings" + "manageModeration": "Approve listings", + "managePayouts": "Payments and payouts", + "managePaymentsAdmin": "Manage payments and disputes" } }, "ProviderProfile": { @@ -312,7 +314,43 @@ "complete": "Complete", "cancel": "Cancel", "dispute": "Open dispute", - "refund": "Mark refunded" + "refund": "Mark refunded", + "preparePayment": "Prepare payment", + "continuePayment": "Continue to secure payment", + "paymentStatus": "Payment status", + "platformFee": "Platform fee", + "providerNet": "Expected provider payout", + "paymentUnavailable": "Secure payment is currently unavailable.", + "paymentTerms": "Review payment, cancellation, and refund terms" + }, + "Payouts": { + "title": "Provider payouts", + "description": "Complete Stripe-hosted verification before Vila can route customer payments to you. Vila never collects your bank details.", + "loading": "Loading payout readiness…", + "unavailable": "Payout onboarding is currently unavailable.", + "notStarted": "Payout onboarding has not started.", + "ready": "Your account is ready to accept payments and receive payouts.", + "incomplete": "Stripe still needs information before payments can be enabled.", + "charges": "Accept payments", + "payouts": "Receive payouts", + "details": "Identity and business details", + "start": "Start secure Stripe onboarding", + "continue": "Continue Stripe onboarding" + }, + "PaymentAdministration": { + "title": "Payments and disputes", + "description": "Monitor protected payments, disputes, and full refunds.", + "loading": "Loading payments…", + "error": "Payments could not be loaded or updated.", + "empty": "There are no protected payments.", + "booking": "Booking", + "gross": "Total", + "fee": "Vila fee", + "providerNet": "Provider amount", + "refund": "Refund", + "refundConfirm": "Confirm the full refund to the original payment method?", + "refundPending": "The refund was submitted to Stripe.", + "state": "State" }, "Reviews": { "title": "Reviews", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c23546a..50d4cdd 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -101,7 +101,9 @@ "manageBookings": "Gestionar reservas", "manageReviews": "Reseñas", "manageEntitlements": "Planes y promociones", - "manageModeration": "Aprobar anuncios" + "manageModeration": "Aprobar anuncios", + "managePayouts": "Pagos y cobros", + "managePaymentsAdmin": "Gestionar pagos y disputas" } }, "ProviderProfile": { @@ -312,7 +314,43 @@ "complete": "Completar", "cancel": "Cancelar", "dispute": "Abrir disputa", - "refund": "Marcar reembolso" + "refund": "Marcar reembolso", + "preparePayment": "Preparar pago", + "continuePayment": "Continuar al pago seguro", + "paymentStatus": "Estado del pago", + "platformFee": "Comisión de la plataforma", + "providerNet": "Importe previsto para el profesional", + "paymentUnavailable": "El pago seguro no está disponible en este momento.", + "paymentTerms": "Consultar pagos, cancelaciones y reembolsos" + }, + "Payouts": { + "title": "Cobros del profesional", + "description": "Completa la verificación alojada por Stripe antes de que Vila pueda enviarte pagos. Vila nunca recopila tus datos bancarios.", + "loading": "Cargando el estado de cobros…", + "unavailable": "La configuración de cobros no está disponible en este momento.", + "notStarted": "La configuración de cobros aún no ha comenzado.", + "ready": "Tu cuenta está lista para aceptar pagos y recibir cobros.", + "incomplete": "Stripe todavía necesita información antes de activar los pagos.", + "charges": "Aceptar pagos", + "payouts": "Recibir cobros", + "details": "Identidad y datos profesionales", + "start": "Iniciar configuración segura con Stripe", + "continue": "Continuar configuración con Stripe" + }, + "PaymentAdministration": { + "title": "Pagos y disputas", + "description": "Supervisa pagos protegidos, disputas y reembolsos íntegros.", + "loading": "Cargando pagos…", + "error": "No se pudieron cargar o actualizar los pagos.", + "empty": "No hay pagos protegidos.", + "booking": "Reserva", + "gross": "Total", + "fee": "Comisión de Vila", + "providerNet": "Importe del profesional", + "refund": "Reembolsar", + "refundConfirm": "¿Confirmar el reembolso íntegro al método de pago original?", + "refundPending": "El reembolso se ha enviado a Stripe.", + "state": "Estado" }, "Reviews": { "title": "Reseñas", diff --git a/frontend/messages/pt-PT.json b/frontend/messages/pt-PT.json index 2330493..e39b92e 100644 --- a/frontend/messages/pt-PT.json +++ b/frontend/messages/pt-PT.json @@ -12,7 +12,7 @@ "discoverLinkLabel": "Explorar serviços", "visionTitle": "Criada para ligações locais reais", "visionDescription": "Descoberta, contacto e confiança sem retirar a escolha às pessoas. A Vila liga quem precisa a quem sabe fazer.", - "showcaseTitle": "Fazer local, mais simples.", + "showcaseTitle": "Comércio local, mais simples.", "how": { "title": "Da necessidade à solução, sem complicações.", "description": "A Vila organiza a procura, a comparação e o contacto num percurso claro.", @@ -101,7 +101,9 @@ "manageBookings": "Gerir reservas", "manageReviews": "Avaliações", "manageEntitlements": "Planos e promoções", - "manageModeration": "Aprovar anúncios" + "manageModeration": "Aprovar anúncios", + "managePayouts": "Pagamentos e recebimentos", + "managePaymentsAdmin": "Gerir pagamentos e disputas" } }, "ProviderProfile": { @@ -312,7 +314,43 @@ "complete": "Concluir", "cancel": "Cancelar", "dispute": "Abrir disputa", - "refund": "Marcar reembolso" + "refund": "Marcar reembolso", + "preparePayment": "Preparar pagamento", + "continuePayment": "Continuar para o pagamento seguro", + "paymentStatus": "Estado do pagamento", + "platformFee": "Taxa da plataforma", + "providerNet": "Valor previsto para o prestador", + "paymentUnavailable": "O pagamento seguro não está disponível neste momento.", + "paymentTerms": "Consultar pagamentos, cancelamentos e reembolsos" + }, + "Payouts": { + "title": "Recebimentos do prestador", + "description": "Conclua a verificação alojada pela Stripe antes de a Vila encaminhar pagamentos de clientes. A Vila nunca recolhe os seus dados bancários.", + "loading": "A carregar o estado dos recebimentos…", + "unavailable": "A configuração de recebimentos não está disponível neste momento.", + "notStarted": "A configuração de recebimentos ainda não começou.", + "ready": "A sua conta está pronta para aceitar pagamentos e receber valores.", + "incomplete": "A Stripe ainda precisa de informação antes de ativar pagamentos.", + "charges": "Aceitar pagamentos", + "payouts": "Receber valores", + "details": "Identidade e dados profissionais", + "start": "Iniciar configuração segura na Stripe", + "continue": "Continuar configuração na Stripe" + }, + "PaymentAdministration": { + "title": "Pagamentos e disputas", + "description": "Acompanhe pagamentos protegidos, disputas e reembolsos integrais.", + "loading": "A carregar pagamentos…", + "error": "Não foi possível carregar ou atualizar os pagamentos.", + "empty": "Não há pagamentos protegidos.", + "booking": "Reserva", + "gross": "Total", + "fee": "Taxa Vila", + "providerNet": "Valor do prestador", + "refund": "Reembolsar", + "refundConfirm": "Confirmar o reembolso integral para o método de pagamento original?", + "refundPending": "O reembolso foi enviado à Stripe.", + "state": "Estado" }, "Reviews": { "title": "Avaliações", diff --git a/frontend/src/app/[locale]/account/bookings/page.tsx b/frontend/src/app/[locale]/account/bookings/page.tsx index c00014f..1977c20 100644 --- a/frontend/src/app/[locale]/account/bookings/page.tsx +++ b/frontend/src/app/[locale]/account/bookings/page.tsx @@ -45,6 +45,13 @@ export default async function BookingsPage({ cancel: t("cancel"), dispute: t("dispute"), refund: t("refund"), + preparePayment: t("preparePayment"), + continuePayment: t("continuePayment"), + paymentStatus: t("paymentStatus"), + platformFee: t("platformFee"), + providerNet: t("providerNet"), + paymentUnavailable: t("paymentUnavailable"), + paymentTerms: t("paymentTerms"), }} /> diff --git a/frontend/src/app/[locale]/account/page.test.tsx b/frontend/src/app/[locale]/account/page.test.tsx index 2fcbf74..5d9748f 100644 --- a/frontend/src/app/[locale]/account/page.test.tsx +++ b/frontend/src/app/[locale]/account/page.test.tsx @@ -53,6 +53,8 @@ describe("AccountPage", () => { "capabilities.loading": "A carregar as capacidades da conta…", "capabilities.manageProvider": "Gerir perfil de prestador", "capabilities.manageModeration": "Aprovar anúncios", + "capabilities.managePaymentsAdmin": "Gerir pagamentos e disputas", + "capabilities.managePayouts": "Pagamentos e recebimentos", "capabilities.providerDescription": "Ative esta opção para preparar o seu perfil de prestador.", "capabilities.providerLabel": "Disponibilizar serviços", @@ -78,9 +80,11 @@ describe("AccountPage", () => { expect(screen.getByTestId("account-capabilities-card")).toHaveTextContent( "Disponibilizar serviços", ); - expect(screen.getByRole("link", { name: "Aprovar anúncios" })).toHaveAttribute( - "href", - "/pt-PT/admin/listings", - ); + expect( + screen.getByRole("link", { name: "Aprovar anúncios" }), + ).toHaveAttribute("href", "/pt-PT/admin/listings"); + expect( + screen.getByRole("link", { name: "Gerir pagamentos e disputas" }), + ).toHaveAttribute("href", "/pt-PT/admin/payments"); }); }); diff --git a/frontend/src/app/[locale]/account/page.tsx b/frontend/src/app/[locale]/account/page.tsx index 17079cd..6eef1a8 100644 --- a/frontend/src/app/[locale]/account/page.tsx +++ b/frontend/src/app/[locale]/account/page.tsx @@ -98,13 +98,27 @@ export default async function AccountPage({ params }: AccountPageProps) { > {t("capabilities.manageEntitlements")} + + {t("capabilities.managePayouts")} + {soleAdministrator ? ( - - {t("capabilities.manageModeration")} - + <> + + {t("capabilities.manageModeration")} + + + {t("capabilities.managePaymentsAdmin")} + + ) : null} diff --git a/frontend/src/app/[locale]/account/payouts/page.tsx b/frontend/src/app/[locale]/account/payouts/page.tsx new file mode 100644 index 0000000..e5622c4 --- /dev/null +++ b/frontend/src/app/[locale]/account/payouts/page.tsx @@ -0,0 +1,41 @@ +import { hasLocale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +import { requireAuthenticatedUser } from "@/features/auth/require-session"; +import { PayoutDashboard } from "@/features/payments/payout-dashboard"; +import { routing } from "@/i18n/routing"; + +export const dynamic = "force-dynamic"; + +export default async function PayoutsPage({ + params, +}: PageProps<"/[locale]/account/payouts">) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + await requireAuthenticatedUser(locale); + const t = await getTranslations({ locale, namespace: "Payouts" }); + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/app/[locale]/admin/payments/page.tsx b/frontend/src/app/[locale]/admin/payments/page.tsx new file mode 100644 index 0000000..fcfcc46 --- /dev/null +++ b/frontend/src/app/[locale]/admin/payments/page.tsx @@ -0,0 +1,45 @@ +import { hasLocale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +import { requireSoleAdministrator } from "@/features/auth/sole-administrator"; +import { PaymentAdministration } from "@/features/payments/payment-administration"; +import { routing } from "@/i18n/routing"; + +export const dynamic = "force-dynamic"; + +export default async function AdministrativePaymentsPage({ + params, +}: PageProps<"/[locale]/admin/payments">) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + await requireSoleAdministrator(locale); + const t = await getTranslations({ + locale, + namespace: "PaymentAdministration", + }); + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/app/[locale]/legal/[document]/page.tsx b/frontend/src/app/[locale]/legal/[document]/page.tsx new file mode 100644 index 0000000..080720d --- /dev/null +++ b/frontend/src/app/[locale]/legal/[document]/page.tsx @@ -0,0 +1,330 @@ +import { hasLocale } from "next-intl"; +import { notFound } from "next/navigation"; + +import { routing, type AppLocale } from "@/i18n/routing"; + +const documents = [ + "terms", + "privacy", + "refund-policy", + "payment-policy", +] as const; +type Document = (typeof documents)[number]; + +type Policy = { + title: string; + updated: string; + intro: string; + sections: Array<{ title: string; body: string }>; +}; + +const policy: Record> = { + "pt-PT": { + terms: { + title: "Termos da Vila", + updated: "Atualizado em 2 de setembro de 2026", + intro: + "A Vila é um marketplace que aproxima clientes e prestadores independentes. Ao utilizar a plataforma, aceita estes termos e as políticas de pagamento e reembolso.", + sections: [ + { + title: "Papel da plataforma", + body: "A Vila facilita descoberta, comunicação, reservas e pagamentos. O prestador continua responsável pela descrição, legalidade, qualidade e execução do serviço. A Vila não é o empregador nem o executante do serviço.", + }, + { + title: "Contas e anúncios", + body: "As informações devem ser verdadeiras, atuais e não enganosas. Anúncios são sujeitos a revisão e podem ser rejeitados, suspensos ou removidos por segurança, fraude, ilegalidade ou incumprimento.", + }, + { + title: "Reservas", + body: "Preço, data, local e âmbito devem ser confirmados antes do pagamento. Alterações materiais exigem acordo entre cliente e prestador. As ações ficam registadas para segurança e resolução de litígios.", + }, + { + title: "Lei e contacto", + body: "Aplicam-se os direitos imperativos do consumidor e a legislação portuguesa e europeia aplicável. Questões podem ser enviadas para source.sensei1205@gmail.com. A identificação legal e fiscal completa do operador deve constar do aviso comercial antes da ativação pública de pagamentos.", + }, + ], + }, + privacy: { + title: "Privacidade", + updated: "Atualizado em 2 de setembro de 2026", + intro: + "A Vila minimiza os dados que recolhe e separa identidade, contactos privados, pagamentos e informação pública.", + sections: [ + { + title: "Dados tratados", + body: "Tratamos identidade de conta, perfil, anúncios, conversas, reservas, eventos de moderação e referências de pagamento. Não armazenamos números de cartão nem dados bancários; a Clerk processa identidade e a Stripe processa pagamentos e verificação de recebimentos.", + }, + { + title: "Finalidades e conservação", + body: "Os dados servem para prestar o serviço, prevenir fraude, cumprir obrigações legais, resolver litígios e manter registos financeiros. A conservação deve limitar-se ao período necessário e aos prazos legais aplicáveis.", + }, + { + title: "Direitos", + body: "Pode pedir acesso, correção, exportação, oposição ou eliminação quando legalmente possível através de source.sensei1205@gmail.com. Dados exigidos por obrigações financeiras, fiscais ou de segurança podem ter de ser conservados.", + }, + ], + }, + "refund-policy": { + title: "Cancelamentos e reembolsos", + updated: "Atualizado em 2 de setembro de 2026", + intro: + "Os pedidos são avaliados com base no estado da reserva, execução do serviço, acordo entre as partes e direitos legais aplicáveis.", + sections: [ + { + title: "Antes do serviço", + body: "Um cancelamento antes do início pode originar reembolso total ou parcial conforme custos já incorridos e termos claramente aceites na reserva. Nenhuma regra reduz direitos imperativos do consumidor.", + }, + { + title: "Serviço iniciado ou concluído", + body: "Reembolsos após o início exigem análise do trabalho executado, evidência e acordo ou decisão administrativa. Serviços personalizados ou iniciados com consentimento podem ter regras legais específicas.", + }, + { + title: "Processamento", + body: "Reembolsos aprovados são enviados à Stripe para o método de pagamento original. O prazo bancário depende do método e da instituição. Fraude, abuso e chargebacks podem suspender pagamentos e recebimentos durante a investigação.", + }, + ], + }, + "payment-policy": { + title: "Pagamentos e recebimentos", + updated: "Atualizado em 2 de setembro de 2026", + intro: + "A Vila utiliza Checkout e Connect alojados pela Stripe para que dados financeiros sensíveis não passem pelos formulários da Vila.", + sections: [ + { + title: "Preço e taxas", + body: "Antes de continuar para a Stripe, o cliente vê o valor total, a taxa da plataforma e o valor previsto para o prestador. Os valores são calculados no servidor em euros e não podem ser escolhidos pelo navegador.", + }, + { + title: "Métodos e impostos", + body: "Os métodos disponíveis, incluindo MB WAY quando elegível, dependem da conta Stripe, país, moeda, montante e configuração. A Stripe pode recolher morada e identificação fiscal e gerar documentação de pagamento; o cumprimento e entrega de declarações fiscais permanecem responsabilidade das partes aplicáveis.", + }, + { + title: "Recebimentos e litígios", + body: "Prestadores concluem verificação diretamente na Stripe. Pagamentos, reembolsos, disputas e chargebacks são sincronizados por webhooks assinados. A Vila pode suspender recebimentos enquanto uma disputa está aberta ou quando a Stripe limita a conta.", + }, + ], + }, + }, + en: {} as Record, + es: {} as Record, +}; +policy.en = translateEnglish(); +policy.es = translateSpanish(); + +export function generateStaticParams() { + return routing.locales.flatMap((locale) => + documents.map((document) => ({ locale, document })), + ); +} + +export default async function LegalPage({ + params, +}: PageProps<"/[locale]/legal/[document]">) { + const { locale, document } = await params; + if ( + !hasLocale(routing.locales, locale) || + !documents.includes(document as Document) + ) + notFound(); + const value = policy[locale][document as Document]; + return ( +
+
+

Vila

+

+ {value.title} +

+

{value.updated}

+

{value.intro}

+
+ {value.sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+
+
+ ); +} + +function translateEnglish(): Record { + const updated = "Updated 2 September 2026"; + return { + terms: { + title: "Vila terms", + updated, + intro: + "Vila is a marketplace connecting customers with independent providers. By using it, you accept these terms and the payment and refund policies.", + sections: [ + { + title: "Platform role", + body: "Vila facilitates discovery, communication, bookings, and payments. Providers remain responsible for the description, legality, quality, and delivery of their services. Vila is neither the provider nor their employer.", + }, + { + title: "Accounts and listings", + body: "Information must be accurate, current, and not misleading. Listings are reviewed and may be rejected, suspended, or removed for safety, fraud, illegality, or breach.", + }, + { + title: "Bookings", + body: "Price, date, private location, and scope must be confirmed before payment. Material changes require agreement. Actions are recorded for safety and dispute resolution.", + }, + { + title: "Law and contact", + body: "Mandatory consumer rights and applicable Portuguese and European law remain in force. Contact source.sensei1205@gmail.com. The operator's complete legal and tax identity must appear in the commercial notice before public payment activation.", + }, + ], + }, + privacy: { + title: "Privacy", + updated, + intro: + "Vila minimizes data and separates identity, private contact details, payments, and public information.", + sections: [ + { + title: "Data processed", + body: "We process account identity, profiles, listings, conversations, bookings, moderation events, and payment references. We do not store card numbers or bank details; Clerk processes identity and Stripe processes payments and payout verification.", + }, + { + title: "Purpose and retention", + body: "Data is used to provide the service, prevent fraud, meet legal obligations, resolve disputes, and maintain financial records. Retention is limited to necessity and applicable legal periods.", + }, + { + title: "Your rights", + body: "Request access, correction, export, objection, or deletion where legally available through source.sensei1205@gmail.com. Financial, tax, fraud-prevention, and security records may need to be retained.", + }, + ], + }, + "refund-policy": { + title: "Cancellations and refunds", + updated, + intro: + "Requests are assessed from booking state, work performed, agreement between the parties, and applicable statutory rights.", + sections: [ + { + title: "Before work starts", + body: "Cancellation before work begins may qualify for a full or partial refund according to costs already incurred and terms clearly accepted in the booking. Mandatory consumer rights are not reduced.", + }, + { + title: "Started or completed work", + body: "Refunds after work starts require review of delivery, evidence, and agreement or an administrative decision. Customized services or services begun with consent may have specific legal rules.", + }, + { + title: "Processing", + body: "Approved refunds are submitted to Stripe for the original payment method. Bank timing depends on the method and institution. Fraud, abuse, and chargebacks can suspend payments and payouts during investigation.", + }, + ], + }, + "payment-policy": { + title: "Payments and payouts", + updated, + intro: + "Vila uses Stripe-hosted Checkout and Connect so sensitive financial details never pass through Vila forms.", + sections: [ + { + title: "Price and fees", + body: "Before continuing to Stripe, customers see the total, platform fee, and expected provider payout. Values are calculated server-side in euros and cannot be selected by the browser.", + }, + { + title: "Methods and tax", + body: "Available methods, including MB WAY when eligible, depend on Stripe account, country, currency, amount, and settings. Stripe can collect addresses and tax IDs and create payment documents; applicable filing and tax obligations remain with the relevant parties.", + }, + { + title: "Payouts and disputes", + body: "Providers complete verification directly with Stripe. Payments, refunds, disputes, and chargebacks synchronize through signed webhooks. Vila may suspend payouts while a dispute is open or Stripe restricts an account.", + }, + ], + }, + }; +} + +function translateSpanish(): Record { + const updated = "Actualizado el 2 de septiembre de 2026"; + return { + terms: { + title: "Términos de Vila", + updated, + intro: + "Vila es un marketplace que conecta clientes con profesionales independientes. Al utilizarlo, aceptas estos términos y las políticas de pago y reembolso.", + sections: [ + { + title: "Función de la plataforma", + body: "Vila facilita el descubrimiento, la comunicación, las reservas y los pagos. El profesional sigue siendo responsable de la descripción, legalidad, calidad y prestación del servicio. Vila no es el profesional ni su empleador.", + }, + { + title: "Cuentas y anuncios", + body: "La información debe ser veraz, actual y no engañosa. Los anuncios se revisan y pueden rechazarse, suspenderse o eliminarse por seguridad, fraude, ilegalidad o incumplimiento.", + }, + { + title: "Reservas", + body: "El precio, la fecha, la ubicación privada y el alcance deben confirmarse antes del pago. Los cambios importantes requieren acuerdo. Las acciones quedan registradas para seguridad y resolución de conflictos.", + }, + { + title: "Ley y contacto", + body: "Siguen vigentes los derechos obligatorios de los consumidores y la legislación portuguesa y europea aplicable. Contacto: source.sensei1205@gmail.com. La identidad legal y fiscal completa del operador debe aparecer en el aviso comercial antes de activar pagos públicos.", + }, + ], + }, + privacy: { + title: "Privacidad", + updated, + intro: + "Vila minimiza los datos y separa identidad, contactos privados, pagos e información pública.", + sections: [ + { + title: "Datos tratados", + body: "Tratamos identidad de cuenta, perfiles, anuncios, conversaciones, reservas, eventos de moderación y referencias de pago. No almacenamos números de tarjeta ni datos bancarios; Clerk trata la identidad y Stripe los pagos y la verificación de cobros.", + }, + { + title: "Finalidad y conservación", + body: "Los datos se usan para prestar el servicio, prevenir fraude, cumplir obligaciones legales, resolver conflictos y conservar registros financieros. La conservación se limita a lo necesario y a los plazos legales aplicables.", + }, + { + title: "Tus derechos", + body: "Puedes solicitar acceso, rectificación, exportación, oposición o supresión cuando sea legalmente posible mediante source.sensei1205@gmail.com. Puede ser necesario conservar datos financieros, fiscales, de prevención de fraude y seguridad.", + }, + ], + }, + "refund-policy": { + title: "Cancelaciones y reembolsos", + updated, + intro: + "Las solicitudes se evalúan según el estado de la reserva, el trabajo realizado, el acuerdo entre las partes y los derechos legales aplicables.", + sections: [ + { + title: "Antes de comenzar", + body: "Una cancelación antes del inicio puede dar derecho a un reembolso total o parcial según los costes ya incurridos y las condiciones aceptadas claramente en la reserva. No se reducen los derechos obligatorios del consumidor.", + }, + { + title: "Servicio iniciado o finalizado", + body: "Los reembolsos tras el inicio requieren revisar la prestación, las pruebas y el acuerdo o una decisión administrativa. Los servicios personalizados o iniciados con consentimiento pueden tener reglas legales específicas.", + }, + { + title: "Procesamiento", + body: "Los reembolsos aprobados se envían a Stripe para el método de pago original. El plazo bancario depende del método y la entidad. El fraude, abuso y los contracargos pueden suspender pagos y cobros durante la investigación.", + }, + ], + }, + "payment-policy": { + title: "Pagos y cobros", + updated, + intro: + "Vila utiliza Checkout y Connect alojados por Stripe para que los datos financieros sensibles no pasen por los formularios de Vila.", + sections: [ + { + title: "Precio y comisiones", + body: "Antes de continuar a Stripe, el cliente ve el total, la comisión de la plataforma y el cobro previsto del profesional. Los importes se calculan en el servidor en euros y el navegador no puede elegirlos.", + }, + { + title: "Métodos e impuestos", + body: "Los métodos disponibles, incluido MB WAY cuando corresponda, dependen de la cuenta Stripe, país, moneda, importe y configuración. Stripe puede recoger dirección e identificación fiscal y crear documentos de pago; las obligaciones fiscales aplicables siguen correspondiendo a las partes pertinentes.", + }, + { + title: "Cobros y conflictos", + body: "Los profesionales completan la verificación directamente con Stripe. Pagos, reembolsos, disputas y contracargos se sincronizan mediante webhooks firmados. Vila puede suspender cobros mientras exista una disputa o Stripe restrinja una cuenta.", + }, + ], + }, + }; +} diff --git a/frontend/src/app/api/v1/admin/payments/[orderId]/refund/route.ts b/frontend/src/app/api/v1/admin/payments/[orderId]/refund/route.ts new file mode 100644 index 0000000..de4fff5 --- /dev/null +++ b/frontend/src/app/api/v1/admin/payments/[orderId]/refund/route.ts @@ -0,0 +1,50 @@ +import { resolveSoleAdministratorSession } from "@/features/auth/sole-administrator"; +import { + exact, + fail, + requestID, + uuid, +} from "@/features/messaging/protected-bff"; +import { validOrder } from "@/features/payments/payment-bff"; +import { forwardPayment } from "@/features/payments/payment-proxy"; + +export const runtime = "nodejs"; +type Context = { params: Promise<{ orderId: string }> }; + +export async function POST( + request: Request, + context: Context, +): Promise { + const id = requestID(request.headers); + const session = await resolveSoleAdministratorSession(); + if (session.status === "unauthenticated") + return fail("UNAUTHORIZED", "Unauthorized", 401, id); + if (session.status === "forbidden") + return fail("FORBIDDEN", "Forbidden", 403, id); + if (session.status !== "authorized") + return fail("SERVICE_UNAVAILABLE", "Service unavailable", 503, id); + const { orderId } = await context.params; + let body: unknown; + try { + body = JSON.parse(await request.text()); + } catch { + body = null; + } + if ( + !uuid(orderId) || + !exact(body, ["idempotencyKey"]) || + typeof body.idempotencyKey !== "string" || + !/^[A-Za-z0-9._:-]{8,128}$/.test(body.idempotencyKey) + ) { + return fail("INVALID_REQUEST", "Invalid request", 400, id); + } + return forwardPayment( + id, + session.token, + `/api/v1/admin/payments/${orderId}/refund`, + "POST", + JSON.stringify(body), + validOrder, + 200, + ); +} diff --git a/frontend/src/app/api/v1/admin/payments/route.test.ts b/frontend/src/app/api/v1/admin/payments/route.test.ts new file mode 100644 index 0000000..1eb4397 --- /dev/null +++ b/frontend/src/app/api/v1/admin/payments/route.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ auth: vi.fn(), currentUser: vi.fn() })); +vi.mock("@clerk/nextjs/server", () => ({ + auth: mocks.auth, + currentUser: mocks.currentUser, +})); + +import { GET } from "./route"; + +const order = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + bookingId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + customerId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + providerId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + state: "disputed", + grossMinor: 12500, + platformFeeMinor: 1250, + providerNetMinor: 11250, + currency: "EUR", + createdAt: "2026-09-02T12:00:00Z", + updatedAt: "2026-09-02T12:00:00Z", +}; + +describe("administrative payments BFF", () => { + beforeEach(() => vi.stubEnv("JUNTLY_API_ORIGIN", "http://go-api:8080")); + afterEach(() => { + mocks.auth.mockReset(); + mocks.currentUser.mockReset(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("allows only the exact verified administrator and forwards its server token", async () => { + mocks.auth.mockResolvedValue({ + isAuthenticated: true, + userId: "user_admin", + getToken: vi.fn().mockResolvedValue("server-token"), + }); + mocks.currentUser.mockResolvedValue({ + id: "user_admin", + emailAddresses: [ + { + emailAddress: "source.sensei1205@gmail.com", + verification: { status: "verified" }, + }, + ], + }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + expect(url).toBe("http://go-api:8080/api/v1/admin/payments"); + expect(new Headers(init?.headers).get("Authorization")).toBe( + "Bearer server-token", + ); + return Response.json( + { orders: [order] }, + { + headers: { "X-Request-ID": "req_admin_payments" }, + }, + ); + }), + ); + + const response = await GET( + new Request("http://localhost/api/v1/admin/payments", { + headers: { "X-Request-ID": "req_admin_payments" }, + }), + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ orders: [order] }); + }); + + it("rejects another authenticated account before token or upstream access", async () => { + const getToken = vi.fn(); + const fetch = vi.fn(); + mocks.auth.mockResolvedValue({ + isAuthenticated: true, + userId: "user_other", + getToken, + }); + mocks.currentUser.mockResolvedValue({ + id: "user_other", + emailAddresses: [ + { + emailAddress: "other@example.com", + verification: { status: "verified" }, + }, + ], + }); + vi.stubGlobal("fetch", fetch); + + const response = await GET( + new Request("http://localhost/api/v1/admin/payments"), + ); + expect(response.status).toBe(403); + expect(getToken).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/api/v1/admin/payments/route.ts b/frontend/src/app/api/v1/admin/payments/route.ts new file mode 100644 index 0000000..cb71eac --- /dev/null +++ b/frontend/src/app/api/v1/admin/payments/route.ts @@ -0,0 +1,26 @@ +import { resolveSoleAdministratorSession } from "@/features/auth/sole-administrator"; +import { fail, requestID } from "@/features/messaging/protected-bff"; +import { validPaymentOrders } from "@/features/payments/payment-bff"; +import { forwardPayment } from "@/features/payments/payment-proxy"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const id = requestID(request.headers); + const session = await resolveSoleAdministratorSession(); + if (session.status === "unauthenticated") + return fail("UNAUTHORIZED", "Unauthorized", 401, id); + if (session.status === "forbidden") + return fail("FORBIDDEN", "Forbidden", 403, id); + if (session.status !== "authorized") + return fail("SERVICE_UNAVAILABLE", "Service unavailable", 503, id); + return forwardPayment( + id, + session.token, + "/api/v1/admin/payments", + "GET", + undefined, + validPaymentOrders, + 200, + ); +} diff --git a/frontend/src/app/api/v1/me/bookings/[bookingId]/checkout/route.ts b/frontend/src/app/api/v1/me/bookings/[bookingId]/checkout/route.ts new file mode 100644 index 0000000..a58c33e --- /dev/null +++ b/frontend/src/app/api/v1/me/bookings/[bookingId]/checkout/route.ts @@ -0,0 +1,50 @@ +import { + exact, + fail, + requestID, + sessionToken, + uuid, +} from "@/features/messaging/protected-bff"; +import { validCheckoutResult } from "@/features/payments/payment-bff"; +import { forwardPayment } from "@/features/payments/payment-proxy"; + +export const runtime = "nodejs"; +type Context = { params: Promise<{ bookingId: string }> }; + +export async function POST( + request: Request, + context: Context, +): Promise { + const id = requestID(request.headers); + const token = await sessionToken(); + if (!token) return fail("UNAUTHORIZED", "Unauthorized", 401, id); + const { bookingId } = await context.params; + if (!uuid(bookingId)) + return fail("INVALID_REQUEST", "Invalid request", 400, id); + const body = await requestJSON(request); + if ( + !exact(body, ["idempotencyKey", "locale"]) || + typeof body.idempotencyKey !== "string" || + !/^[A-Za-z0-9._:-]{8,128}$/.test(body.idempotencyKey) || + !["pt-PT", "en", "es"].includes(String(body.locale)) + ) { + return fail("INVALID_REQUEST", "Invalid request", 400, id); + } + return forwardPayment( + id, + token, + `/api/v1/me/bookings/${bookingId}/checkout`, + "POST", + JSON.stringify(body), + validCheckoutResult, + 201, + ); +} + +async function requestJSON(request: Request): Promise { + try { + return JSON.parse(await request.text()); + } catch { + return null; + } +} diff --git a/frontend/src/app/api/v1/me/payments/route.ts b/frontend/src/app/api/v1/me/payments/route.ts new file mode 100644 index 0000000..27acc2a --- /dev/null +++ b/frontend/src/app/api/v1/me/payments/route.ts @@ -0,0 +1,24 @@ +import { + fail, + requestID, + sessionToken, +} from "@/features/messaging/protected-bff"; +import { validPaymentOrders } from "@/features/payments/payment-bff"; +import { forwardPayment } from "@/features/payments/payment-proxy"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const id = requestID(request.headers); + const token = await sessionToken(); + if (!token) return fail("UNAUTHORIZED", "Unauthorized", 401, id); + return forwardPayment( + id, + token, + "/api/v1/me/payments", + "GET", + undefined, + validPaymentOrders, + 200, + ); +} diff --git a/frontend/src/app/api/v1/me/payout-account/route.ts b/frontend/src/app/api/v1/me/payout-account/route.ts new file mode 100644 index 0000000..64150a4 --- /dev/null +++ b/frontend/src/app/api/v1/me/payout-account/route.ts @@ -0,0 +1,55 @@ +import { + exact, + fail, + requestID, + sessionToken, +} from "@/features/messaging/protected-bff"; +import { + validPayoutAccount, + validPayoutOnboarding, +} from "@/features/payments/payment-bff"; +import { forwardPayment } from "@/features/payments/payment-proxy"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const id = requestID(request.headers); + const token = await sessionToken(); + if (!token) return fail("UNAUTHORIZED", "Unauthorized", 401, id); + return forwardPayment( + id, + token, + "/api/v1/me/payout-account", + "GET", + undefined, + validPayoutAccount, + 200, + ); +} + +export async function POST(request: Request): Promise { + const id = requestID(request.headers); + const token = await sessionToken(); + if (!token) return fail("UNAUTHORIZED", "Unauthorized", 401, id); + let body: unknown; + try { + body = JSON.parse(await request.text()); + } catch { + body = null; + } + if ( + !exact(body, ["locale"]) || + !["pt-PT", "en", "es"].includes(String(body.locale)) + ) { + return fail("INVALID_REQUEST", "Invalid request", 400, id); + } + return forwardPayment( + id, + token, + "/api/v1/me/payout-account", + "POST", + JSON.stringify(body), + validPayoutOnboarding, + 200, + ); +} diff --git a/frontend/src/app/api/v1/moderation/listings/route.ts b/frontend/src/app/api/v1/moderation/listings/route.ts index ea3bbe0..377f02d 100644 --- a/frontend/src/app/api/v1/moderation/listings/route.ts +++ b/frontend/src/app/api/v1/moderation/listings/route.ts @@ -107,8 +107,7 @@ function accessError( status: "unauthenticated" | "forbidden" | "unavailable", id: string, ) { - if (status === "forbidden") - return error("FORBIDDEN", "Forbidden", 403, id); + if (status === "forbidden") return error("FORBIDDEN", "Forbidden", 403, id); if (status === "unavailable") return unavailable(id); return error("UNAUTHORIZED", "Unauthorized", 401, id); } diff --git a/frontend/src/app/api/v1/payments/webhooks/stripe/route.ts b/frontend/src/app/api/v1/payments/webhooks/stripe/route.ts new file mode 100644 index 0000000..8f2a211 --- /dev/null +++ b/frontend/src/app/api/v1/payments/webhooks/stripe/route.ts @@ -0,0 +1,44 @@ +import { + exact, + requestID, + requestIDHeader, + unavailable, +} from "@/features/messaging/protected-bff"; + +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + const id = requestID(request.headers); + const origin = process.env.JUNTLY_API_ORIGIN; + const signature = request.headers.get("Stripe-Signature"); + if (!origin || !signature) return unavailable(id); + const body = await request.arrayBuffer(); + if (body.byteLength === 0 || body.byteLength > 256 * 1024) + return unavailable(id); + try { + const upstream = await fetch(`${origin}/api/v1/payments/webhooks/stripe`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Stripe-Signature": signature, + [requestIDHeader]: id, + }, + body, + }); + const value: unknown = await upstream.json(); + if ( + upstream.ok && + upstream.headers.get(requestIDHeader) === id && + exact(value, ["received"]) && + value.received === true + ) { + return Response.json(value, { + status: 200, + headers: { [requestIDHeader]: id }, + }); + } + return unavailable(id); + } catch { + return unavailable(id); + } +} diff --git a/frontend/src/features/bookings/booking-dashboard.tsx b/frontend/src/features/bookings/booking-dashboard.tsx index 876793c..3038ae2 100644 --- a/frontend/src/features/bookings/booking-dashboard.tsx +++ b/frontend/src/features/bookings/booking-dashboard.tsx @@ -2,6 +2,11 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import type { Booking, BookingState } from "@/shared/api/generated"; import { AvailableListingSelect } from "@/features/listings/available-listing-select"; +import { + type PaymentOrder, + validCheckoutResult, + validPaymentOrders, +} from "@/features/payments/payment-bff"; export type BookingsCopy = { title: string; description: string; @@ -30,6 +35,13 @@ export type BookingsCopy = { cancel: string; dispute: string; refund: string; + preparePayment: string; + continuePayment: string; + paymentStatus: string; + platformFee: string; + providerNet: string; + paymentUnavailable: string; + paymentTerms: string; }; export function BookingDashboard({ copy, @@ -41,6 +53,9 @@ export function BookingDashboard({ const [items, setItems] = useState([]), [loading, setLoading] = useState(true), [failed, setFailed] = useState(false), + [paymentFailed, setPaymentFailed] = useState(false), + [orders, setOrders] = useState([]), + [checkoutURLs, setCheckoutURLs] = useState>({}), [creating, setCreating] = useState(false), [sourceType, setSourceType] = useState<"proposal" | "listing" | "direct">( "proposal", @@ -60,6 +75,15 @@ export function BookingDashboard({ .finally(() => { if (active) setLoading(false); }); + void fetch("/api/v1/me/payments") + .then(async (response) => { + const value: unknown = await response.json(); + if (!response.ok || !validPaymentOrders(value)) throw new Error(); + if (active) setOrders(value.orders); + }) + .catch(() => { + if (active) setPaymentFailed(true); + }); return () => { active = false; }; @@ -116,6 +140,31 @@ export function BookingDashboard({ setFailed(true); } } + async function preparePayment(item: Booking) { + setPaymentFailed(false); + try { + const response = await fetch(`/api/v1/me/bookings/${item.id}/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + idempotencyKey: `checkout-${item.id}`, + locale, + }), + }); + const value: unknown = await response.json(); + if (!response.ok || !validCheckoutResult(value)) throw new Error(); + setOrders((current) => [ + value.order, + ...current.filter((order) => order.id !== value.order.id), + ]); + setCheckoutURLs((current) => ({ + ...current, + [value.order.id]: value.url, + })); + } catch { + setPaymentFailed(true); + } + } return (
@@ -133,6 +182,11 @@ export function BookingDashboard({ {copy.error}

) : null} + {paymentFailed ? ( +

+ {copy.paymentUnavailable} +

+ ) : null} - ))} + {new Date(item.scheduledAt).toLocaleString()} + +

+ {item.privateLocation} +

+

+ {copy.price}: € {(item.agreedPriceMinor / 100).toFixed(2)} +

+
+
+ {nextStates(item.state).map((state) => ( + + ))} +
- - - )) + {item.state === "confirmed" || item.state === "scheduled" ? ( +
+ + {copy.paymentTerms} + + {order ? ( +
+
+
{copy.paymentStatus}
+
{order.state}
+
+
+
{copy.price}
+
+ € {(order.grossMinor / 100).toFixed(2)} +
+
+
+
{copy.platformFee}
+
+ € {(order.platformFeeMinor / 100).toFixed(2)} +
+
+
+
{copy.providerNet}
+
+ € {(order.providerNetMinor / 100).toFixed(2)} +
+
+
+ ) : null} + {checkoutURL ? ( + + {copy.continuePayment} + + ) : order?.state === "paid" || + order?.state === "refunded" ? null : ( + + )} +
+ ) : null} + + ); + }) )}
diff --git a/frontend/src/features/discovery/public-discovery.test.tsx b/frontend/src/features/discovery/public-discovery.test.tsx index e980d7e..470539a 100644 --- a/frontend/src/features/discovery/public-discovery.test.tsx +++ b/frontend/src/features/discovery/public-discovery.test.tsx @@ -149,7 +149,10 @@ describe("PublicDiscovery", () => { fireEvent.change(screen.getByLabelText(copy.modeLabel), { target: { value: "travels_to_customer" }, }); - fireEvent.click(screen.getByRole("button", { name: copy.applyFilters })); + const applyButton = screen.getByRole("button", { name: copy.applyFilters }); + expect(applyButton).toHaveClass("market-button-compact"); + expect(applyButton).not.toHaveClass("w-full"); + fireEvent.click(applyButton); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( diff --git a/frontend/src/features/discovery/public-discovery.tsx b/frontend/src/features/discovery/public-discovery.tsx index 367cfc0..f573ec8 100644 --- a/frontend/src/features/discovery/public-discovery.tsx +++ b/frontend/src/features/discovery/public-discovery.tsx @@ -243,7 +243,10 @@ export function PublicDiscovery({ ]} placeholder={copy.anyMode} /> - diff --git a/frontend/src/features/landing/components/landing-shell.test.tsx b/frontend/src/features/landing/components/landing-shell.test.tsx index 7d1d3a2..b3510b9 100644 --- a/frontend/src/features/landing/components/landing-shell.test.tsx +++ b/frontend/src/features/landing/components/landing-shell.test.tsx @@ -15,7 +15,7 @@ const copy = { visionTitle: "Criada para ligações locais reais", visionDescription: "Descoberta, contacto e confiança sem retirar a escolha às pessoas.", - showcaseTitle: "Fazer local, mais simples.", + showcaseTitle: "Comércio local, mais simples.", howTitle: "Da necessidade à solução, sem complicações.", howDescription: "Um percurso claro.", discoverBlock: { title: "Procure", description: "Encontre serviços." }, diff --git a/frontend/src/features/payments/payment-administration.test.tsx b/frontend/src/features/payments/payment-administration.test.tsx new file mode 100644 index 0000000..8b74b0e --- /dev/null +++ b/frontend/src/features/payments/payment-administration.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PaymentAdministration } from "./payment-administration"; + +const copy = { + title: "Pagamentos e disputas", + description: "Acompanhe pagamentos protegidos.", + loading: "A carregar pagamentos…", + error: "Não foi possível carregar os pagamentos.", + empty: "Não há pagamentos.", + booking: "Reserva", + gross: "Total", + fee: "Taxa Vila", + providerNet: "Prestador", + refund: "Reembolsar", + refundConfirm: "Confirmar reembolso total?", + refundPending: "Reembolso enviado.", + state: "Estado", +}; +const paid = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + bookingId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + customerId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + providerId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + state: "paid", + grossMinor: 12500, + platformFeeMinor: 1250, + providerNetMinor: 11250, + currency: "EUR", + createdAt: "2026-09-02T12:00:00Z", + updatedAt: "2026-09-02T12:00:00Z", +}; + +describe("PaymentAdministration", () => { + afterEach(() => vi.restoreAllMocks()); + + it("lists durable amounts and requires confirmation before a full refund", async () => { + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ orders: [paid] })) + .mockResolvedValueOnce( + Response.json({ ...paid, state: "refund_pending" }), + ); + vi.stubGlobal("fetch", fetch); + render(); + + expect(await screen.findByText(/125,00/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: copy.refund })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(confirm).toHaveBeenCalledWith(copy.refundConfirm); + expect(fetch).toHaveBeenLastCalledWith( + `/api/v1/admin/payments/${paid.id}/refund`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ idempotencyKey: `refund-${paid.id}` }), + }), + ); + expect(await screen.findByText(copy.refundPending)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/payments/payment-administration.tsx b/frontend/src/features/payments/payment-administration.tsx new file mode 100644 index 0000000..a3dc54c --- /dev/null +++ b/frontend/src/features/payments/payment-administration.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + type PaymentOrder, + validOrder, + validPaymentOrders, +} from "./payment-bff"; + +export type PaymentAdministrationCopy = { + title: string; + description: string; + loading: string; + error: string; + empty: string; + booking: string; + gross: string; + fee: string; + providerNet: string; + refund: string; + refundConfirm: string; + refundPending: string; + state: string; +}; + +export function PaymentAdministration({ + copy, + locale, +}: { + copy: PaymentAdministrationCopy; + locale: string; +}) { + const [orders, setOrders] = useState(null); + const [failed, setFailed] = useState(false); + const [pendingID, setPendingID] = useState(null); + const [saved, setSaved] = useState(false); + useEffect(() => { + let active = true; + void fetch("/api/v1/admin/payments") + .then(async (response) => { + const value: unknown = await response.json(); + if (!response.ok || !validPaymentOrders(value)) throw new Error(); + if (active) setOrders(value.orders); + }) + .catch(() => { + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, []); + + async function refund(order: PaymentOrder) { + if (pendingID !== null || !confirm(copy.refundConfirm)) return; + setPendingID(order.id); + setFailed(false); + setSaved(false); + try { + const response = await fetch( + `/api/v1/admin/payments/${order.id}/refund`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ idempotencyKey: `refund-${order.id}` }), + }, + ); + const value: unknown = await response.json(); + if (!response.ok || !validOrder(value) || value.id !== order.id) + throw new Error(); + setOrders( + (current) => + current?.map((item) => (item.id === value.id ? value : item)) ?? null, + ); + setSaved(true); + } catch { + setFailed(true); + } finally { + setPendingID(null); + } + } + + return ( +
+
+

+ {copy.title} +

+

{copy.description}

+
+ {failed ? ( +

+ {copy.error} +

+ ) : null} + {saved ? ( +

+ {copy.refundPending} +

+ ) : null} + {orders === null && !failed ? ( +

{copy.loading}

+ ) : null} + {orders?.length === 0 ? ( +

{copy.empty}

+ ) : null} +
+ {orders?.map((order) => { + const refundable = ["paid", "dispute_won"].includes(order.state); + return ( +
+
+
+

+ {copy.booking} +

+

+ {order.bookingId} +

+
+ + {copy.state}: {order.state} + +
+
+ + + +
+ {refundable ? ( + + ) : null} +
+ ); + })} +
+
+ ); +} + +function Amount({ + label, + value, + locale, +}: { + label: string; + value: number; + locale: string; +}) { + return ( +
+
{label}
+
+ {new Intl.NumberFormat(locale, { + style: "currency", + currency: "EUR", + }).format(value / 100)} +
+
+ ); +} diff --git a/frontend/src/features/payments/payment-bff.test.ts b/frontend/src/features/payments/payment-bff.test.ts new file mode 100644 index 0000000..934d605 --- /dev/null +++ b/frontend/src/features/payments/payment-bff.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + validCheckoutResult, + validPaymentOrders, + validPayoutAccount, +} from "./payment-bff"; + +const order = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + bookingId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + customerId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + providerId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + state: "checkout_created", + grossMinor: 12500, + platformFeeMinor: 1250, + providerNetMinor: 11250, + currency: "EUR", + createdAt: "2026-09-02T12:00:00Z", + updatedAt: "2026-09-02T12:00:00Z", +}; + +describe("payment BFF contracts", () => { + it("accepts bounded checkout, order-list, and payout projections", () => { + expect( + validCheckoutResult({ + order, + url: "https://checkout.stripe.test/session", + }), + ).toBe(true); + expect(validPaymentOrders({ orders: [order] })).toBe(true); + expect( + validPayoutAccount({ + detailsSubmitted: true, + chargesEnabled: true, + payoutsEnabled: true, + updatedAt: "2026-09-02T12:00:00Z", + }), + ).toBe(true); + expect( + validPayoutAccount({ + internalUserId: order.providerId, + stripeAccountId: "acct_test", + detailsSubmitted: true, + chargesEnabled: true, + payoutsEnabled: true, + updatedAt: "2026-09-02T12:00:00Z", + }), + ).toBe(false); + expect( + validPaymentOrders({ + orders: [{ ...order, paymentIntentId: "pi_test" }], + }), + ).toBe(false); + }); + + it("rejects browser-visible provider authority and inconsistent money", () => { + expect( + validCheckoutResult({ + order: { ...order, providerNetMinor: 12000 }, + url: "https://checkout.stripe.test/session", + }), + ).toBe(false); + expect( + validCheckoutResult({ + order, + url: "javascript:alert(1)", + stripeSecret: "no", + }), + ).toBe(false); + }); +}); diff --git a/frontend/src/features/payments/payment-bff.ts b/frontend/src/features/payments/payment-bff.ts new file mode 100644 index 0000000..cc00c7c --- /dev/null +++ b/frontend/src/features/payments/payment-bff.ts @@ -0,0 +1,169 @@ +export type PaymentState = + | "pending_checkout" + | "checkout_created" + | "processing" + | "paid" + | "failed" + | "refund_pending" + | "refunded" + | "disputed" + | "dispute_won" + | "dispute_lost" + | "cancelled"; + +export type PaymentOrder = { + id: string; + bookingId: string; + customerId: string; + providerId: string; + state: PaymentState; + grossMinor: number; + platformFeeMinor: number; + providerNetMinor: number; + currency: "EUR"; + + createdAt: string; + updatedAt: string; +}; + +export type PayoutAccount = { + detailsSubmitted: boolean; + chargesEnabled: boolean; + payoutsEnabled: boolean; + updatedAt: string; +}; + +const states = new Set([ + "pending_checkout", + "checkout_created", + "processing", + "paid", + "failed", + "refund_pending", + "refunded", + "disputed", + "dispute_won", + "dispute_lost", + "cancelled", +]); + +export function validCheckoutResult( + value: unknown, +): value is { order: PaymentOrder; url: string } { + return ( + exact(value, ["order", "url"]) && + validOrder(value.order) && + validHTTPS(value.url) + ); +} + +export function validPaymentOrders( + value: unknown, +): value is { orders: PaymentOrder[] } { + return ( + exact(value, ["orders"]) && + Array.isArray(value.orders) && + value.orders.every(validOrder) + ); +} + +export function validPayoutAccount(value: unknown): value is PayoutAccount { + return ( + exact(value, [ + "detailsSubmitted", + "chargesEnabled", + "payoutsEnabled", + "updatedAt", + ]) && + typeof value.detailsSubmitted === "boolean" && + typeof value.chargesEnabled === "boolean" && + typeof value.payoutsEnabled === "boolean" && + validDate(value.updatedAt) + ); +} + +export function validPayoutOnboarding( + value: unknown, +): value is { account: PayoutAccount; url: string } { + return ( + exact(value, ["account", "url"]) && + validPayoutAccount(value.account) && + validHTTPS(value.url) + ); +} + +export function validOrder(value: unknown): value is PaymentOrder { + if (!record(value)) return false; + const required = [ + "id", + "bookingId", + "customerId", + "providerId", + "state", + "grossMinor", + "platformFeeMinor", + "providerNetMinor", + "currency", + "createdAt", + "updatedAt", + ]; + if (!allowed(value, required, [])) return false; + return ( + required.slice(0, 4).every((key) => uuid(value[key])) && + typeof value.state === "string" && + states.has(value.state as PaymentState) && + Number.isInteger(value.grossMinor) && + Number(value.grossMinor) > 0 && + Number.isInteger(value.platformFeeMinor) && + Number(value.platformFeeMinor) >= 0 && + Number.isInteger(value.providerNetMinor) && + Number(value.providerNetMinor) === + Number(value.grossMinor) - Number(value.platformFeeMinor) && + value.currency === "EUR" && + validDate(value.createdAt) && + validDate(value.updatedAt) + ); +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function exact( + value: unknown, + keys: string[], +): value is Record { + return record(value) && allowed(value, keys, []); +} + +function allowed( + value: Record, + required: string[], + optional: string[], +): boolean { + const actual = Object.keys(value); + return ( + required.every((key) => actual.includes(key)) && + actual.every((key) => required.includes(key) || optional.includes(key)) + ); +} + +function uuid(value: unknown): value is string { + return ( + typeof value === "string" && + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value) + ); +} + +function validDate(value: unknown): value is string { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function validHTTPS(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} diff --git a/frontend/src/features/payments/payment-proxy.ts b/frontend/src/features/payments/payment-proxy.ts new file mode 100644 index 0000000..fff87af --- /dev/null +++ b/frontend/src/features/payments/payment-proxy.ts @@ -0,0 +1,60 @@ +import type { ErrorResponse } from "@/shared/api/generated"; +import { + authorizedHeaders, + correlated, + correlatedError, + requestIDHeader, + unavailable, +} from "@/features/messaging/protected-bff"; + +export async function forwardPayment( + requestID: string, + token: string, + path: string, + method: "GET" | "POST", + body: string | undefined, + valid: (value: unknown) => boolean, + successStatus: number, +): Promise { + const origin = process.env.JUNTLY_API_ORIGIN; + if (!origin) return unavailable(requestID); + try { + const headers = authorizedHeaders(token, requestID); + if (body !== undefined) headers["Content-Type"] = "application/json"; + const upstream = await fetch(`${origin}${path}`, { + method, + headers, + ...(body === undefined ? {} : { body }), + }); + const value: unknown = await upstream.json(); + if (correlated(upstream, requestID) && upstream.ok && valid(value)) { + return Response.json(value, { + status: successStatus, + headers: { [requestIDHeader]: requestID }, + }); + } + const errors: Array<[number, ErrorResponse["error"]["code"]]> = [ + [400, "INVALID_REQUEST"], + [401, "UNAUTHORIZED"], + [403, "FORBIDDEN"], + [404, "NOT_FOUND"], + [409, "CONFLICT"], + [503, "SERVICE_UNAVAILABLE"], + ]; + for (const [status, code] of errors) { + if ( + upstream.status === status && + correlated(upstream, requestID) && + correlatedError(value, code, requestID) + ) { + return Response.json(value, { + status, + headers: { [requestIDHeader]: requestID }, + }); + } + } + return unavailable(requestID); + } catch { + return unavailable(requestID); + } +} diff --git a/frontend/src/features/payments/payout-dashboard.tsx b/frontend/src/features/payments/payout-dashboard.tsx new file mode 100644 index 0000000..31a5082 --- /dev/null +++ b/frontend/src/features/payments/payout-dashboard.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + type PayoutAccount, + validPayoutAccount, + validPayoutOnboarding, +} from "./payment-bff"; + +type Copy = { + title: string; + description: string; + loading: string; + unavailable: string; + notStarted: string; + ready: string; + incomplete: string; + charges: string; + payouts: string; + details: string; + start: string; + continue: string; +}; + +export function PayoutDashboard({ + copy, + locale, +}: { + copy: Copy; + locale: "pt-PT" | "en" | "es"; +}) { + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let active = true; + void fetch("/api/v1/me/payout-account") + .then(async (response) => { + if (response.status === 404) return null; + const value: unknown = await response.json(); + if (!response.ok || !validPayoutAccount(value)) throw new Error(); + return value; + }) + .then((value) => { + if (active) setAccount(value); + }) + .catch(() => { + if (active) setFailed(true); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, []); + + async function onboard() { + if (saving) return; + setSaving(true); + setFailed(false); + try { + const response = await fetch("/api/v1/me/payout-account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale }), + }); + const value: unknown = await response.json(); + if (!response.ok || !validPayoutOnboarding(value)) throw new Error(); + window.location.assign(value.url); + } catch { + setFailed(true); + setSaving(false); + } + } + + return ( +
+
+

+ {copy.title} +

+

{copy.description}

+
+ {loading ?

{copy.loading}

: null} + {failed ? ( +

+ {copy.unavailable} +

+ ) : null} + {!loading ? ( +
+

+ {!account + ? copy.notStarted + : account.detailsSubmitted && + account.chargesEnabled && + account.payoutsEnabled + ? copy.ready + : copy.incomplete} +

+ {account ? ( +
+ + + +
+ ) : null} + {!account || + !account.detailsSubmitted || + !account.chargesEnabled || + !account.payoutsEnabled ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +function Status({ label, enabled }: { label: string; enabled: boolean }) { + return ( +
+
{label}
+
{enabled ? "✓" : "—"}
+
+ ); +} diff --git a/frontend/src/shared/api/generated/index.ts b/frontend/src/shared/api/generated/index.ts index 48b8fc8..7b95902 100644 --- a/frontend/src/shared/api/generated/index.ts +++ b/frontend/src/shared/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { acceptQuotationProposal, approveListing, archiveListing, createBooking, createBookingReview, createListing, createListingMediaUploadIntent, createQuotationRequest, getAccountCapabilities, getAdministrationDashboard, getContactChannelStatuses, getEntitlementCatalog, getHealth, getMyBooking, getMyEntitlements, getMyListing, getNotificationPreferences, getProviderProfile, getProviderRating, getPublicListing, getReadiness, listConversationMessages, listConversations, listLocalities, listMyBookings, listMyListings, listMyProviderReviews, listMyQuotationRequests, listNotifications, listPendingModerationListings, listQuotationOpportunities, listQuotationProposals, listServiceCategories, listSpokenLanguages, markNotificationRead, moderateAdministrativeTarget, type Options, pauseListing, reconcileInternalUser, rejectListing, replaceContactChannel, replaceConversationBlock, replaceMyDraftListing, replaceNotificationPreferences, replaceProviderProfile, reportConversation, requestListingPromotion, requestSubscription, respondToReview, revealListingContact, searchPublicListings, sendConversationMessage, startListingConversation, submitListingForReview, submitQuotationProposal, transitionBooking, updateAccountCapabilities } from './sdk.gen'; -export type { AcceptQuotationProposalData, AcceptQuotationProposalError, AcceptQuotationProposalErrors, AcceptQuotationProposalResponse, AcceptQuotationProposalResponses, AccountCapabilitiesResponse, AdministrationDashboard, AdministrationMetrics, AdministrationQueue, AdministrativeModerationAction, AdministrativeReport, AdministrativeReview, ApproveListingData, ApproveListingError, ApproveListingErrors, ApproveListingResponse, ApproveListingResponses, ArchiveListingData, ArchiveListingError, ArchiveListingErrors, ArchiveListingRequest, ArchiveListingResponse, ArchiveListingResponses, Attribution, BlockConversationRequest, BlockConversationResponse, Booking, BookingIdPath, BookingsResponse, BookingState, CategoriesResponse, Category, ClientOptions, ContactChannel, ContactChannelsResponse, ContactChannelStatus, ContactRevealRequest, ContactRevealResponse, Conversation, ConversationIdPath, ConversationsResponse, CreateBooking, CreateBookingData, CreateBookingError, CreateBookingErrors, CreateBookingResponse, CreateBookingResponses, CreateBookingReviewData, CreateBookingReviewError, CreateBookingReviewErrors, CreateBookingReviewResponse, CreateBookingReviewResponses, CreateListingData, CreateListingError, CreateListingErrors, CreateListingMediaUploadIntentData, CreateListingMediaUploadIntentError, CreateListingMediaUploadIntentErrors, CreateListingMediaUploadIntentResponse, CreateListingMediaUploadIntentResponses, CreateListingRequest, CreateListingResponse, CreateListingResponses, CreateQuotationRequest, CreateQuotationRequestData, CreateQuotationRequestError, CreateQuotationRequestErrors, CreateQuotationRequestResponse, CreateQuotationRequestResponses, CreateReview, CreateUploadIntentRequest, EntitlementCatalog, EntitlementStatus, ErrorCode, ErrorDetail, ErrorResponse, GetAccountCapabilitiesData, GetAccountCapabilitiesError, GetAccountCapabilitiesErrors, GetAccountCapabilitiesResponse, GetAccountCapabilitiesResponses, GetAdministrationDashboardData, GetAdministrationDashboardError, GetAdministrationDashboardErrors, GetAdministrationDashboardResponse, GetAdministrationDashboardResponses, GetContactChannelStatusesData, GetContactChannelStatusesError, GetContactChannelStatusesErrors, GetContactChannelStatusesResponse, GetContactChannelStatusesResponses, GetEntitlementCatalogData, GetEntitlementCatalogError, GetEntitlementCatalogErrors, GetEntitlementCatalogResponse, GetEntitlementCatalogResponses, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetMyBookingData, GetMyBookingError, GetMyBookingErrors, GetMyBookingResponse, GetMyBookingResponses, GetMyEntitlementsData, GetMyEntitlementsError, GetMyEntitlementsErrors, GetMyEntitlementsResponse, GetMyEntitlementsResponses, GetMyListingData, GetMyListingError, GetMyListingErrors, GetMyListingResponse, GetMyListingResponses, GetNotificationPreferencesData, GetNotificationPreferencesError, GetNotificationPreferencesErrors, GetNotificationPreferencesResponse, GetNotificationPreferencesResponses, GetProviderProfileData, GetProviderProfileError, GetProviderProfileErrors, GetProviderProfileResponse, GetProviderProfileResponses, GetProviderRatingData, GetProviderRatingError, GetProviderRatingErrors, GetProviderRatingResponse, GetProviderRatingResponses, GetPublicListingData, GetPublicListingError, GetPublicListingErrors, GetPublicListingResponse, GetPublicListingResponses, GetReadinessData, GetReadinessError, GetReadinessErrors, GetReadinessResponse, GetReadinessResponses, HealthResponse, HealthStatus, InternalUserResponse, LanguagesResponse, ListConversationMessagesData, ListConversationMessagesError, ListConversationMessagesErrors, ListConversationMessagesResponse, ListConversationMessagesResponses, ListConversationsData, ListConversationsError, ListConversationsErrors, ListConversationsResponse, ListConversationsResponses, ListingIdPath, ListingPriceType, ListingResponse, ListingsResponse, ListingState, ListLocalitiesData, ListLocalitiesError, ListLocalitiesErrors, ListLocalitiesResponse, ListLocalitiesResponses, ListMyBookingsData, ListMyBookingsError, ListMyBookingsErrors, ListMyBookingsResponse, ListMyBookingsResponses, ListMyListingsData, ListMyListingsError, ListMyListingsErrors, ListMyListingsResponse, ListMyListingsResponses, ListMyProviderReviewsData, ListMyProviderReviewsError, ListMyProviderReviewsErrors, ListMyProviderReviewsResponse, ListMyProviderReviewsResponses, ListMyQuotationRequestsData, ListMyQuotationRequestsError, ListMyQuotationRequestsErrors, ListMyQuotationRequestsResponse, ListMyQuotationRequestsResponses, ListNotificationsData, ListNotificationsError, ListNotificationsErrors, ListNotificationsResponse, ListNotificationsResponses, ListPendingModerationListingsData, ListPendingModerationListingsError, ListPendingModerationListingsErrors, ListPendingModerationListingsResponse, ListPendingModerationListingsResponses, ListQuotationOpportunitiesData, ListQuotationOpportunitiesError, ListQuotationOpportunitiesErrors, ListQuotationOpportunitiesResponse, ListQuotationOpportunitiesResponses, ListQuotationProposalsData, ListQuotationProposalsError, ListQuotationProposalsErrors, ListQuotationProposalsResponse, ListQuotationProposalsResponses, ListServiceCategoriesData, ListServiceCategoriesError, ListServiceCategoriesErrors, ListServiceCategoriesResponse, ListServiceCategoriesResponses, ListSpokenLanguagesData, ListSpokenLanguagesError, ListSpokenLanguagesErrors, ListSpokenLanguagesResponse, ListSpokenLanguagesResponses, LocaleQuery, LocalitiesResponse, Locality, MarkNotificationReadData, MarkNotificationReadError, MarkNotificationReadErrors, MarkNotificationReadResponse, MarkNotificationReadResponses, Message, MessagesResponse, ModerateAdministrativeTargetData, ModerateAdministrativeTargetError, ModerateAdministrativeTargetErrors, ModerateAdministrativeTargetResponse, ModerateAdministrativeTargetResponses, MyEntitlements, Notification, NotificationIdPath, NotificationPreferences, NotificationsResponse, PauseListingData, PauseListingError, PauseListingErrors, PauseListingResponse, PauseListingResponses, ProfessionalPlan, Promotion, PromotionPeriod, ProviderAccess, ProviderIdPath, ProviderProfileEnvelope, ProviderProfileResponse, ProviderRating, ProviderType, PublicListingResponse, PublicListingsResponse, PublicServiceMode, QuotationProposal, QuotationProposalIdPath, QuotationProposalsResponse, QuotationRequest, QuotationRequestIdPath, QuotationRequestsResponse, ReadinessResponse, ReadResponse, ReconcileInternalUserData, ReconcileInternalUserError, ReconcileInternalUserErrors, ReconcileInternalUserResponse, ReconcileInternalUserResponses, RejectListingData, RejectListingError, RejectListingErrors, RejectListingRequest, RejectListingResponse, RejectListingResponses, ReplaceContactChannelData, ReplaceContactChannelError, ReplaceContactChannelErrors, ReplaceContactChannelRequest, ReplaceContactChannelResponse, ReplaceContactChannelResponses, ReplaceConversationBlockData, ReplaceConversationBlockError, ReplaceConversationBlockErrors, ReplaceConversationBlockResponse, ReplaceConversationBlockResponses, ReplaceDraftListingRequest, ReplaceMyDraftListingData, ReplaceMyDraftListingError, ReplaceMyDraftListingErrors, ReplaceMyDraftListingResponse, ReplaceMyDraftListingResponses, ReplaceNotificationPreferencesData, ReplaceNotificationPreferencesError, ReplaceNotificationPreferencesErrors, ReplaceNotificationPreferencesResponse, ReplaceNotificationPreferencesResponses, ReplaceProviderProfileData, ReplaceProviderProfileError, ReplaceProviderProfileErrors, ReplaceProviderProfileRequest, ReplaceProviderProfileResponse, ReplaceProviderProfileResponses, ReportConversationData, ReportConversationError, ReportConversationErrors, ReportConversationRequest, ReportConversationResponse, ReportConversationResponses, ReportedResponse, RequestId, RequestIdHeader, RequestListingPromotionData, RequestListingPromotionError, RequestListingPromotionErrors, RequestListingPromotionResponse, RequestListingPromotionResponses, RequestPromotion, RequestSubscription, RequestSubscriptionData, RequestSubscriptionError, RequestSubscriptionErrors, RequestSubscriptionResponse, RequestSubscriptionResponses, RespondToReview, RespondToReviewData, RespondToReviewError, RespondToReviewErrors, RespondToReviewResponse, RespondToReviewResponses, RevealListingContactData, RevealListingContactError, RevealListingContactErrors, RevealListingContactResponse, RevealListingContactResponses, Review, ReviewIdPath, ReviewsResponse, RevisionRequest, SearchPublicListingsData, SearchPublicListingsError, SearchPublicListingsErrors, SearchPublicListingsResponse, SearchPublicListingsResponses, SendConversationMessageData, SendConversationMessageError, SendConversationMessageErrors, SendConversationMessageResponse, SendConversationMessageResponses, SendMessageRequest, SpokenLanguage, StartConversationRequest, StartListingConversationData, StartListingConversationError, StartListingConversationErrors, StartListingConversationResponse, StartListingConversationResponses, SubmitListingForReviewData, SubmitListingForReviewError, SubmitListingForReviewErrors, SubmitListingForReviewResponse, SubmitListingForReviewResponses, SubmitQuotationProposal, SubmitQuotationProposalData, SubmitQuotationProposalError, SubmitQuotationProposalErrors, SubmitQuotationProposalResponse, SubmitQuotationProposalResponses, Subscription, TransitionBooking, TransitionBookingData, TransitionBookingError, TransitionBookingErrors, TransitionBookingResponse, TransitionBookingResponses, UpdateAccountCapabilitiesData, UpdateAccountCapabilitiesError, UpdateAccountCapabilitiesErrors, UpdateAccountCapabilitiesRequest, UpdateAccountCapabilitiesResponse, UpdateAccountCapabilitiesResponses, UploadCapability, UploadIntentResponse } from './types.gen'; +export { acceptQuotationProposal, approveListing, archiveListing, beginBookingCheckout, beginMyPayoutOnboarding, createBooking, createBookingReview, createListing, createListingMediaUploadIntent, createQuotationRequest, getAccountCapabilities, getAdministrationDashboard, getContactChannelStatuses, getEntitlementCatalog, getHealth, getMyBooking, getMyEntitlements, getMyListing, getMyPayoutAccount, getNotificationPreferences, getProviderProfile, getProviderRating, getPublicListing, getReadiness, listAdministrativePayments, listConversationMessages, listConversations, listLocalities, listMyBookings, listMyListings, listMyPayments, listMyProviderReviews, listMyQuotationRequests, listNotifications, listPendingModerationListings, listQuotationOpportunities, listQuotationProposals, listServiceCategories, listSpokenLanguages, markNotificationRead, moderateAdministrativeTarget, type Options, pauseListing, receiveStripeWebhook, reconcileInternalUser, refundPaymentOrder, rejectListing, replaceContactChannel, replaceConversationBlock, replaceMyDraftListing, replaceNotificationPreferences, replaceProviderProfile, reportConversation, requestListingPromotion, requestSubscription, respondToReview, revealListingContact, searchPublicListings, sendConversationMessage, startListingConversation, submitListingForReview, submitQuotationProposal, transitionBooking, updateAccountCapabilities } from './sdk.gen'; +export type { AcceptQuotationProposalData, AcceptQuotationProposalError, AcceptQuotationProposalErrors, AcceptQuotationProposalResponse, AcceptQuotationProposalResponses, AccountCapabilitiesResponse, AdministrationDashboard, AdministrationMetrics, AdministrationQueue, AdministrativeModerationAction, AdministrativeReport, AdministrativeReview, ApproveListingData, ApproveListingError, ApproveListingErrors, ApproveListingResponse, ApproveListingResponses, ArchiveListingData, ArchiveListingError, ArchiveListingErrors, ArchiveListingRequest, ArchiveListingResponse, ArchiveListingResponses, Attribution, BeginBookingCheckoutData, BeginBookingCheckoutError, BeginBookingCheckoutErrors, BeginBookingCheckoutResponse, BeginBookingCheckoutResponses, BeginCheckout, BeginMyPayoutOnboardingData, BeginMyPayoutOnboardingError, BeginMyPayoutOnboardingErrors, BeginMyPayoutOnboardingResponse, BeginMyPayoutOnboardingResponses, BlockConversationRequest, BlockConversationResponse, Booking, BookingIdPath, BookingsResponse, BookingState, CategoriesResponse, Category, CheckoutResult, ClientOptions, ContactChannel, ContactChannelsResponse, ContactChannelStatus, ContactRevealRequest, ContactRevealResponse, Conversation, ConversationIdPath, ConversationsResponse, CreateBooking, CreateBookingData, CreateBookingError, CreateBookingErrors, CreateBookingResponse, CreateBookingResponses, CreateBookingReviewData, CreateBookingReviewError, CreateBookingReviewErrors, CreateBookingReviewResponse, CreateBookingReviewResponses, CreateListingData, CreateListingError, CreateListingErrors, CreateListingMediaUploadIntentData, CreateListingMediaUploadIntentError, CreateListingMediaUploadIntentErrors, CreateListingMediaUploadIntentResponse, CreateListingMediaUploadIntentResponses, CreateListingRequest, CreateListingResponse, CreateListingResponses, CreateQuotationRequest, CreateQuotationRequestData, CreateQuotationRequestError, CreateQuotationRequestErrors, CreateQuotationRequestResponse, CreateQuotationRequestResponses, CreateReview, CreateUploadIntentRequest, EntitlementCatalog, EntitlementStatus, ErrorCode, ErrorDetail, ErrorResponse, GetAccountCapabilitiesData, GetAccountCapabilitiesError, GetAccountCapabilitiesErrors, GetAccountCapabilitiesResponse, GetAccountCapabilitiesResponses, GetAdministrationDashboardData, GetAdministrationDashboardError, GetAdministrationDashboardErrors, GetAdministrationDashboardResponse, GetAdministrationDashboardResponses, GetContactChannelStatusesData, GetContactChannelStatusesError, GetContactChannelStatusesErrors, GetContactChannelStatusesResponse, GetContactChannelStatusesResponses, GetEntitlementCatalogData, GetEntitlementCatalogError, GetEntitlementCatalogErrors, GetEntitlementCatalogResponse, GetEntitlementCatalogResponses, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetMyBookingData, GetMyBookingError, GetMyBookingErrors, GetMyBookingResponse, GetMyBookingResponses, GetMyEntitlementsData, GetMyEntitlementsError, GetMyEntitlementsErrors, GetMyEntitlementsResponse, GetMyEntitlementsResponses, GetMyListingData, GetMyListingError, GetMyListingErrors, GetMyListingResponse, GetMyListingResponses, GetMyPayoutAccountData, GetMyPayoutAccountError, GetMyPayoutAccountErrors, GetMyPayoutAccountResponse, GetMyPayoutAccountResponses, GetNotificationPreferencesData, GetNotificationPreferencesError, GetNotificationPreferencesErrors, GetNotificationPreferencesResponse, GetNotificationPreferencesResponses, GetProviderProfileData, GetProviderProfileError, GetProviderProfileErrors, GetProviderProfileResponse, GetProviderProfileResponses, GetProviderRatingData, GetProviderRatingError, GetProviderRatingErrors, GetProviderRatingResponse, GetProviderRatingResponses, GetPublicListingData, GetPublicListingError, GetPublicListingErrors, GetPublicListingResponse, GetPublicListingResponses, GetReadinessData, GetReadinessError, GetReadinessErrors, GetReadinessResponse, GetReadinessResponses, HealthResponse, HealthStatus, InternalUserResponse, LanguagesResponse, ListAdministrativePaymentsData, ListAdministrativePaymentsError, ListAdministrativePaymentsErrors, ListAdministrativePaymentsResponse, ListAdministrativePaymentsResponses, ListConversationMessagesData, ListConversationMessagesError, ListConversationMessagesErrors, ListConversationMessagesResponse, ListConversationMessagesResponses, ListConversationsData, ListConversationsError, ListConversationsErrors, ListConversationsResponse, ListConversationsResponses, ListingIdPath, ListingPriceType, ListingResponse, ListingsResponse, ListingState, ListLocalitiesData, ListLocalitiesError, ListLocalitiesErrors, ListLocalitiesResponse, ListLocalitiesResponses, ListMyBookingsData, ListMyBookingsError, ListMyBookingsErrors, ListMyBookingsResponse, ListMyBookingsResponses, ListMyListingsData, ListMyListingsError, ListMyListingsErrors, ListMyListingsResponse, ListMyListingsResponses, ListMyPaymentsData, ListMyPaymentsError, ListMyPaymentsErrors, ListMyPaymentsResponse, ListMyPaymentsResponses, ListMyProviderReviewsData, ListMyProviderReviewsError, ListMyProviderReviewsErrors, ListMyProviderReviewsResponse, ListMyProviderReviewsResponses, ListMyQuotationRequestsData, ListMyQuotationRequestsError, ListMyQuotationRequestsErrors, ListMyQuotationRequestsResponse, ListMyQuotationRequestsResponses, ListNotificationsData, ListNotificationsError, ListNotificationsErrors, ListNotificationsResponse, ListNotificationsResponses, ListPendingModerationListingsData, ListPendingModerationListingsError, ListPendingModerationListingsErrors, ListPendingModerationListingsResponse, ListPendingModerationListingsResponses, ListQuotationOpportunitiesData, ListQuotationOpportunitiesError, ListQuotationOpportunitiesErrors, ListQuotationOpportunitiesResponse, ListQuotationOpportunitiesResponses, ListQuotationProposalsData, ListQuotationProposalsError, ListQuotationProposalsErrors, ListQuotationProposalsResponse, ListQuotationProposalsResponses, ListServiceCategoriesData, ListServiceCategoriesError, ListServiceCategoriesErrors, ListServiceCategoriesResponse, ListServiceCategoriesResponses, ListSpokenLanguagesData, ListSpokenLanguagesError, ListSpokenLanguagesErrors, ListSpokenLanguagesResponse, ListSpokenLanguagesResponses, LocaleQuery, LocalitiesResponse, Locality, MarkNotificationReadData, MarkNotificationReadError, MarkNotificationReadErrors, MarkNotificationReadResponse, MarkNotificationReadResponses, Message, MessagesResponse, ModerateAdministrativeTargetData, ModerateAdministrativeTargetError, ModerateAdministrativeTargetErrors, ModerateAdministrativeTargetResponse, ModerateAdministrativeTargetResponses, MyEntitlements, Notification, NotificationIdPath, NotificationPreferences, NotificationsResponse, PauseListingData, PauseListingError, PauseListingErrors, PauseListingResponse, PauseListingResponses, PaymentOrder, PaymentOrders, PaymentState, PayoutAccount, PayoutOnboardingRequest, PayoutOnboardingResult, ProfessionalPlan, Promotion, PromotionPeriod, ProviderAccess, ProviderIdPath, ProviderProfileEnvelope, ProviderProfileResponse, ProviderRating, ProviderType, PublicListingResponse, PublicListingsResponse, PublicServiceMode, QuotationProposal, QuotationProposalIdPath, QuotationProposalsResponse, QuotationRequest, QuotationRequestIdPath, QuotationRequestsResponse, ReadinessResponse, ReadResponse, ReceiveStripeWebhookData, ReceiveStripeWebhookError, ReceiveStripeWebhookErrors, ReceiveStripeWebhookResponse, ReceiveStripeWebhookResponses, ReconcileInternalUserData, ReconcileInternalUserError, ReconcileInternalUserErrors, ReconcileInternalUserResponse, ReconcileInternalUserResponses, RefundPayment, RefundPaymentOrderData, RefundPaymentOrderError, RefundPaymentOrderErrors, RefundPaymentOrderResponse, RefundPaymentOrderResponses, RejectListingData, RejectListingError, RejectListingErrors, RejectListingRequest, RejectListingResponse, RejectListingResponses, ReplaceContactChannelData, ReplaceContactChannelError, ReplaceContactChannelErrors, ReplaceContactChannelRequest, ReplaceContactChannelResponse, ReplaceContactChannelResponses, ReplaceConversationBlockData, ReplaceConversationBlockError, ReplaceConversationBlockErrors, ReplaceConversationBlockResponse, ReplaceConversationBlockResponses, ReplaceDraftListingRequest, ReplaceMyDraftListingData, ReplaceMyDraftListingError, ReplaceMyDraftListingErrors, ReplaceMyDraftListingResponse, ReplaceMyDraftListingResponses, ReplaceNotificationPreferencesData, ReplaceNotificationPreferencesError, ReplaceNotificationPreferencesErrors, ReplaceNotificationPreferencesResponse, ReplaceNotificationPreferencesResponses, ReplaceProviderProfileData, ReplaceProviderProfileError, ReplaceProviderProfileErrors, ReplaceProviderProfileRequest, ReplaceProviderProfileResponse, ReplaceProviderProfileResponses, ReportConversationData, ReportConversationError, ReportConversationErrors, ReportConversationRequest, ReportConversationResponse, ReportConversationResponses, ReportedResponse, RequestId, RequestIdHeader, RequestListingPromotionData, RequestListingPromotionError, RequestListingPromotionErrors, RequestListingPromotionResponse, RequestListingPromotionResponses, RequestPromotion, RequestSubscription, RequestSubscriptionData, RequestSubscriptionError, RequestSubscriptionErrors, RequestSubscriptionResponse, RequestSubscriptionResponses, RespondToReview, RespondToReviewData, RespondToReviewError, RespondToReviewErrors, RespondToReviewResponse, RespondToReviewResponses, RevealListingContactData, RevealListingContactError, RevealListingContactErrors, RevealListingContactResponse, RevealListingContactResponses, Review, ReviewIdPath, ReviewsResponse, RevisionRequest, SearchPublicListingsData, SearchPublicListingsError, SearchPublicListingsErrors, SearchPublicListingsResponse, SearchPublicListingsResponses, SendConversationMessageData, SendConversationMessageError, SendConversationMessageErrors, SendConversationMessageResponse, SendConversationMessageResponses, SendMessageRequest, SpokenLanguage, StartConversationRequest, StartListingConversationData, StartListingConversationError, StartListingConversationErrors, StartListingConversationResponse, StartListingConversationResponses, SubmitListingForReviewData, SubmitListingForReviewError, SubmitListingForReviewErrors, SubmitListingForReviewResponse, SubmitListingForReviewResponses, SubmitQuotationProposal, SubmitQuotationProposalData, SubmitQuotationProposalError, SubmitQuotationProposalErrors, SubmitQuotationProposalResponse, SubmitQuotationProposalResponses, Subscription, TransitionBooking, TransitionBookingData, TransitionBookingError, TransitionBookingErrors, TransitionBookingResponse, TransitionBookingResponses, UpdateAccountCapabilitiesData, UpdateAccountCapabilitiesError, UpdateAccountCapabilitiesErrors, UpdateAccountCapabilitiesRequest, UpdateAccountCapabilitiesResponse, UpdateAccountCapabilitiesResponses, UploadCapability, UploadIntentResponse } from './types.gen'; diff --git a/frontend/src/shared/api/generated/sdk.gen.ts b/frontend/src/shared/api/generated/sdk.gen.ts index b9dd997..306981a 100644 --- a/frontend/src/shared/api/generated/sdk.gen.ts +++ b/frontend/src/shared/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { AcceptQuotationProposalData, AcceptQuotationProposalErrors, AcceptQuotationProposalResponses, ApproveListingData, ApproveListingErrors, ApproveListingResponses, ArchiveListingData, ArchiveListingErrors, ArchiveListingResponses, CreateBookingData, CreateBookingErrors, CreateBookingResponses, CreateBookingReviewData, CreateBookingReviewErrors, CreateBookingReviewResponses, CreateListingData, CreateListingErrors, CreateListingMediaUploadIntentData, CreateListingMediaUploadIntentErrors, CreateListingMediaUploadIntentResponses, CreateListingResponses, CreateQuotationRequestData, CreateQuotationRequestErrors, CreateQuotationRequestResponses, GetAccountCapabilitiesData, GetAccountCapabilitiesErrors, GetAccountCapabilitiesResponses, GetAdministrationDashboardData, GetAdministrationDashboardErrors, GetAdministrationDashboardResponses, GetContactChannelStatusesData, GetContactChannelStatusesErrors, GetContactChannelStatusesResponses, GetEntitlementCatalogData, GetEntitlementCatalogErrors, GetEntitlementCatalogResponses, GetHealthData, GetHealthErrors, GetHealthResponses, GetMyBookingData, GetMyBookingErrors, GetMyBookingResponses, GetMyEntitlementsData, GetMyEntitlementsErrors, GetMyEntitlementsResponses, GetMyListingData, GetMyListingErrors, GetMyListingResponses, GetNotificationPreferencesData, GetNotificationPreferencesErrors, GetNotificationPreferencesResponses, GetProviderProfileData, GetProviderProfileErrors, GetProviderProfileResponses, GetProviderRatingData, GetProviderRatingErrors, GetProviderRatingResponses, GetPublicListingData, GetPublicListingErrors, GetPublicListingResponses, GetReadinessData, GetReadinessErrors, GetReadinessResponses, ListConversationMessagesData, ListConversationMessagesErrors, ListConversationMessagesResponses, ListConversationsData, ListConversationsErrors, ListConversationsResponses, ListLocalitiesData, ListLocalitiesErrors, ListLocalitiesResponses, ListMyBookingsData, ListMyBookingsErrors, ListMyBookingsResponses, ListMyListingsData, ListMyListingsErrors, ListMyListingsResponses, ListMyProviderReviewsData, ListMyProviderReviewsErrors, ListMyProviderReviewsResponses, ListMyQuotationRequestsData, ListMyQuotationRequestsErrors, ListMyQuotationRequestsResponses, ListNotificationsData, ListNotificationsErrors, ListNotificationsResponses, ListPendingModerationListingsData, ListPendingModerationListingsErrors, ListPendingModerationListingsResponses, ListQuotationOpportunitiesData, ListQuotationOpportunitiesErrors, ListQuotationOpportunitiesResponses, ListQuotationProposalsData, ListQuotationProposalsErrors, ListQuotationProposalsResponses, ListServiceCategoriesData, ListServiceCategoriesErrors, ListServiceCategoriesResponses, ListSpokenLanguagesData, ListSpokenLanguagesErrors, ListSpokenLanguagesResponses, MarkNotificationReadData, MarkNotificationReadErrors, MarkNotificationReadResponses, ModerateAdministrativeTargetData, ModerateAdministrativeTargetErrors, ModerateAdministrativeTargetResponses, PauseListingData, PauseListingErrors, PauseListingResponses, ReconcileInternalUserData, ReconcileInternalUserErrors, ReconcileInternalUserResponses, RejectListingData, RejectListingErrors, RejectListingResponses, ReplaceContactChannelData, ReplaceContactChannelErrors, ReplaceContactChannelResponses, ReplaceConversationBlockData, ReplaceConversationBlockErrors, ReplaceConversationBlockResponses, ReplaceMyDraftListingData, ReplaceMyDraftListingErrors, ReplaceMyDraftListingResponses, ReplaceNotificationPreferencesData, ReplaceNotificationPreferencesErrors, ReplaceNotificationPreferencesResponses, ReplaceProviderProfileData, ReplaceProviderProfileErrors, ReplaceProviderProfileResponses, ReportConversationData, ReportConversationErrors, ReportConversationResponses, RequestListingPromotionData, RequestListingPromotionErrors, RequestListingPromotionResponses, RequestSubscriptionData, RequestSubscriptionErrors, RequestSubscriptionResponses, RespondToReviewData, RespondToReviewErrors, RespondToReviewResponses, RevealListingContactData, RevealListingContactErrors, RevealListingContactResponses, SearchPublicListingsData, SearchPublicListingsErrors, SearchPublicListingsResponses, SendConversationMessageData, SendConversationMessageErrors, SendConversationMessageResponses, StartListingConversationData, StartListingConversationErrors, StartListingConversationResponses, SubmitListingForReviewData, SubmitListingForReviewErrors, SubmitListingForReviewResponses, SubmitQuotationProposalData, SubmitQuotationProposalErrors, SubmitQuotationProposalResponses, TransitionBookingData, TransitionBookingErrors, TransitionBookingResponses, UpdateAccountCapabilitiesData, UpdateAccountCapabilitiesErrors, UpdateAccountCapabilitiesResponses } from './types.gen'; +import type { AcceptQuotationProposalData, AcceptQuotationProposalErrors, AcceptQuotationProposalResponses, ApproveListingData, ApproveListingErrors, ApproveListingResponses, ArchiveListingData, ArchiveListingErrors, ArchiveListingResponses, BeginBookingCheckoutData, BeginBookingCheckoutErrors, BeginBookingCheckoutResponses, BeginMyPayoutOnboardingData, BeginMyPayoutOnboardingErrors, BeginMyPayoutOnboardingResponses, CreateBookingData, CreateBookingErrors, CreateBookingResponses, CreateBookingReviewData, CreateBookingReviewErrors, CreateBookingReviewResponses, CreateListingData, CreateListingErrors, CreateListingMediaUploadIntentData, CreateListingMediaUploadIntentErrors, CreateListingMediaUploadIntentResponses, CreateListingResponses, CreateQuotationRequestData, CreateQuotationRequestErrors, CreateQuotationRequestResponses, GetAccountCapabilitiesData, GetAccountCapabilitiesErrors, GetAccountCapabilitiesResponses, GetAdministrationDashboardData, GetAdministrationDashboardErrors, GetAdministrationDashboardResponses, GetContactChannelStatusesData, GetContactChannelStatusesErrors, GetContactChannelStatusesResponses, GetEntitlementCatalogData, GetEntitlementCatalogErrors, GetEntitlementCatalogResponses, GetHealthData, GetHealthErrors, GetHealthResponses, GetMyBookingData, GetMyBookingErrors, GetMyBookingResponses, GetMyEntitlementsData, GetMyEntitlementsErrors, GetMyEntitlementsResponses, GetMyListingData, GetMyListingErrors, GetMyListingResponses, GetMyPayoutAccountData, GetMyPayoutAccountErrors, GetMyPayoutAccountResponses, GetNotificationPreferencesData, GetNotificationPreferencesErrors, GetNotificationPreferencesResponses, GetProviderProfileData, GetProviderProfileErrors, GetProviderProfileResponses, GetProviderRatingData, GetProviderRatingErrors, GetProviderRatingResponses, GetPublicListingData, GetPublicListingErrors, GetPublicListingResponses, GetReadinessData, GetReadinessErrors, GetReadinessResponses, ListAdministrativePaymentsData, ListAdministrativePaymentsErrors, ListAdministrativePaymentsResponses, ListConversationMessagesData, ListConversationMessagesErrors, ListConversationMessagesResponses, ListConversationsData, ListConversationsErrors, ListConversationsResponses, ListLocalitiesData, ListLocalitiesErrors, ListLocalitiesResponses, ListMyBookingsData, ListMyBookingsErrors, ListMyBookingsResponses, ListMyListingsData, ListMyListingsErrors, ListMyListingsResponses, ListMyPaymentsData, ListMyPaymentsErrors, ListMyPaymentsResponses, ListMyProviderReviewsData, ListMyProviderReviewsErrors, ListMyProviderReviewsResponses, ListMyQuotationRequestsData, ListMyQuotationRequestsErrors, ListMyQuotationRequestsResponses, ListNotificationsData, ListNotificationsErrors, ListNotificationsResponses, ListPendingModerationListingsData, ListPendingModerationListingsErrors, ListPendingModerationListingsResponses, ListQuotationOpportunitiesData, ListQuotationOpportunitiesErrors, ListQuotationOpportunitiesResponses, ListQuotationProposalsData, ListQuotationProposalsErrors, ListQuotationProposalsResponses, ListServiceCategoriesData, ListServiceCategoriesErrors, ListServiceCategoriesResponses, ListSpokenLanguagesData, ListSpokenLanguagesErrors, ListSpokenLanguagesResponses, MarkNotificationReadData, MarkNotificationReadErrors, MarkNotificationReadResponses, ModerateAdministrativeTargetData, ModerateAdministrativeTargetErrors, ModerateAdministrativeTargetResponses, PauseListingData, PauseListingErrors, PauseListingResponses, ReceiveStripeWebhookData, ReceiveStripeWebhookErrors, ReceiveStripeWebhookResponses, ReconcileInternalUserData, ReconcileInternalUserErrors, ReconcileInternalUserResponses, RefundPaymentOrderData, RefundPaymentOrderErrors, RefundPaymentOrderResponses, RejectListingData, RejectListingErrors, RejectListingResponses, ReplaceContactChannelData, ReplaceContactChannelErrors, ReplaceContactChannelResponses, ReplaceConversationBlockData, ReplaceConversationBlockErrors, ReplaceConversationBlockResponses, ReplaceMyDraftListingData, ReplaceMyDraftListingErrors, ReplaceMyDraftListingResponses, ReplaceNotificationPreferencesData, ReplaceNotificationPreferencesErrors, ReplaceNotificationPreferencesResponses, ReplaceProviderProfileData, ReplaceProviderProfileErrors, ReplaceProviderProfileResponses, ReportConversationData, ReportConversationErrors, ReportConversationResponses, RequestListingPromotionData, RequestListingPromotionErrors, RequestListingPromotionResponses, RequestSubscriptionData, RequestSubscriptionErrors, RequestSubscriptionResponses, RespondToReviewData, RespondToReviewErrors, RespondToReviewResponses, RevealListingContactData, RevealListingContactErrors, RevealListingContactResponses, SearchPublicListingsData, SearchPublicListingsErrors, SearchPublicListingsResponses, SendConversationMessageData, SendConversationMessageErrors, SendConversationMessageResponses, StartListingConversationData, StartListingConversationErrors, StartListingConversationResponses, SubmitListingForReviewData, SubmitListingForReviewErrors, SubmitListingForReviewResponses, SubmitQuotationProposalData, SubmitQuotationProposalErrors, SubmitQuotationProposalResponses, TransitionBookingData, TransitionBookingErrors, TransitionBookingResponses, UpdateAccountCapabilitiesData, UpdateAccountCapabilitiesErrors, UpdateAccountCapabilitiesResponses } from './types.gen'; export type Options = Options2 & { /** @@ -492,6 +492,84 @@ export const transitionBooking = (options: } }); +/** + * Prepare one server-priced Stripe Checkout Session for an eligible booking. + */ +export const beginBookingCheckout = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/me/bookings/{bookingId}/checkout', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List bounded payment orders where the authenticated user is customer or provider. + */ +export const listMyPayments = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/me/payments', + ...options +}); + +/** + * Refresh and read Stripe Connect payout readiness. + */ +export const getMyPayoutAccount = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/me/payout-account', + ...options +}); + +/** + * Create or continue Stripe-hosted Connect onboarding without collecting bank data in Vila. + */ +export const beginMyPayoutOnboarding = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/me/payout-account', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Initiate a moderator-authorized Stripe refund with transfer and application-fee reversal. + */ +export const refundPaymentOrder = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/admin/payments/{orderId}/refund', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List the latest bounded payment orders for moderator refund and dispute operations. + */ +export const listAdministrativePayments = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/admin/payments', + ...options +}); + +/** + * Verify and idempotently apply an allowlisted Stripe event from the exact raw request body. + */ +export const receiveStripeWebhook = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/v1/payments/webhooks/stripe', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Create one verified review for an owned completed booking. */ diff --git a/frontend/src/shared/api/generated/types.gen.ts b/frontend/src/shared/api/generated/types.gen.ts index 6aa3aa7..18ac97c 100644 --- a/frontend/src/shared/api/generated/types.gen.ts +++ b/frontend/src/shared/api/generated/types.gen.ts @@ -194,6 +194,56 @@ export type BookingsResponse = { bookings: Array; }; +export type BeginCheckout = { + idempotencyKey: string; + locale: 'pt-PT' | 'en' | 'es'; +}; + +export type PaymentState = 'pending_checkout' | 'checkout_created' | 'processing' | 'paid' | 'failed' | 'refund_pending' | 'refunded' | 'disputed' | 'dispute_won' | 'dispute_lost' | 'cancelled'; + +export type PaymentOrder = { + id: string; + bookingId: string; + customerId: string; + providerId: string; + state: PaymentState; + grossMinor: number; + platformFeeMinor: number; + providerNetMinor: number; + currency: 'EUR'; + createdAt: string; + updatedAt: string; +}; + +export type PaymentOrders = { + orders: Array; +}; + +export type CheckoutResult = { + order: PaymentOrder; + url: string; +}; + +export type PayoutAccount = { + detailsSubmitted: boolean; + chargesEnabled: boolean; + payoutsEnabled: boolean; + updatedAt: string; +}; + +export type PayoutOnboardingRequest = { + locale: 'pt-PT' | 'en' | 'es'; +}; + +export type PayoutOnboardingResult = { + account: PayoutAccount; + url: string; +}; + +export type RefundPayment = { + idempotencyKey: string; +}; + export type CreateQuotationRequest = { title: string; description: string; @@ -2366,6 +2416,280 @@ export type TransitionBookingResponses = { export type TransitionBookingResponse = TransitionBookingResponses[keyof TransitionBookingResponses]; +export type BeginBookingCheckoutData = { + body: BeginCheckout; + path: { + bookingId: string; + }; + query?: never; + url: '/api/v1/me/bookings/{bookingId}/checkout'; +}; + +export type BeginBookingCheckoutErrors = { + /** + * The request is invalid. + */ + 400: ErrorResponse; + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * Provider capability is required. + */ + 403: ErrorResponse; + /** + * The requested public resource is not available. + */ + 404: ErrorResponse; + /** + * The requested listing state or revision is stale. + */ + 409: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type BeginBookingCheckoutError = BeginBookingCheckoutErrors[keyof BeginBookingCheckoutErrors]; + +export type BeginBookingCheckoutResponses = { + /** + * Durable payment order and hosted Checkout URL + */ + 201: CheckoutResult; +}; + +export type BeginBookingCheckoutResponse = BeginBookingCheckoutResponses[keyof BeginBookingCheckoutResponses]; + +export type ListMyPaymentsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/me/payments'; +}; + +export type ListMyPaymentsErrors = { + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type ListMyPaymentsError = ListMyPaymentsErrors[keyof ListMyPaymentsErrors]; + +export type ListMyPaymentsResponses = { + /** + * Participant payment orders + */ + 200: PaymentOrders; +}; + +export type ListMyPaymentsResponse = ListMyPaymentsResponses[keyof ListMyPaymentsResponses]; + +export type GetMyPayoutAccountData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/me/payout-account'; +}; + +export type GetMyPayoutAccountErrors = { + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * Provider capability is required. + */ + 403: ErrorResponse; + /** + * The requested public resource is not available. + */ + 404: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type GetMyPayoutAccountError = GetMyPayoutAccountErrors[keyof GetMyPayoutAccountErrors]; + +export type GetMyPayoutAccountResponses = { + /** + * Provider payout readiness + */ + 200: PayoutAccount; +}; + +export type GetMyPayoutAccountResponse = GetMyPayoutAccountResponses[keyof GetMyPayoutAccountResponses]; + +export type BeginMyPayoutOnboardingData = { + body: PayoutOnboardingRequest; + path?: never; + query?: never; + url: '/api/v1/me/payout-account'; +}; + +export type BeginMyPayoutOnboardingErrors = { + /** + * The request is invalid. + */ + 400: ErrorResponse; + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * Provider capability is required. + */ + 403: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type BeginMyPayoutOnboardingError = BeginMyPayoutOnboardingErrors[keyof BeginMyPayoutOnboardingErrors]; + +export type BeginMyPayoutOnboardingResponses = { + /** + * Payout readiness and one-time hosted onboarding URL + */ + 200: PayoutOnboardingResult; +}; + +export type BeginMyPayoutOnboardingResponse = BeginMyPayoutOnboardingResponses[keyof BeginMyPayoutOnboardingResponses]; + +export type RefundPaymentOrderData = { + body: RefundPayment; + path: { + orderId: string; + }; + query?: never; + url: '/api/v1/admin/payments/{orderId}/refund'; +}; + +export type RefundPaymentOrderErrors = { + /** + * The request is invalid. + */ + 400: ErrorResponse; + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * Provider capability is required. + */ + 403: ErrorResponse; + /** + * The requested public resource is not available. + */ + 404: ErrorResponse; + /** + * The requested listing state or revision is stale. + */ + 409: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type RefundPaymentOrderError = RefundPaymentOrderErrors[keyof RefundPaymentOrderErrors]; + +export type RefundPaymentOrderResponses = { + /** + * Refund-pending payment order + */ + 200: PaymentOrder; +}; + +export type RefundPaymentOrderResponse = RefundPaymentOrderResponses[keyof RefundPaymentOrderResponses]; + +export type ListAdministrativePaymentsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/admin/payments'; +}; + +export type ListAdministrativePaymentsErrors = { + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * Provider capability is required. + */ + 403: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type ListAdministrativePaymentsError = ListAdministrativePaymentsErrors[keyof ListAdministrativePaymentsErrors]; + +export type ListAdministrativePaymentsResponses = { + /** + * Latest payment orders + */ + 200: PaymentOrders; +}; + +export type ListAdministrativePaymentsResponse = ListAdministrativePaymentsResponses[keyof ListAdministrativePaymentsResponses]; + +export type ReceiveStripeWebhookData = { + body: { + [key: string]: unknown; + }; + headers: { + 'Stripe-Signature': string; + }; + path?: never; + query?: never; + url: '/api/v1/payments/webhooks/stripe'; +}; + +export type ReceiveStripeWebhookErrors = { + /** + * The request is invalid. + */ + 400: ErrorResponse; + /** + * Session authorization is missing or invalid. + */ + 401: ErrorResponse; + /** + * The requested listing state or revision is stale. + */ + 409: ErrorResponse; + /** + * A required dependency is unavailable. + */ + 503: ErrorResponse; +}; + +export type ReceiveStripeWebhookError = ReceiveStripeWebhookErrors[keyof ReceiveStripeWebhookErrors]; + +export type ReceiveStripeWebhookResponses = { + /** + * Event received idempotently + */ + 200: { + received: true; + }; +}; + +export type ReceiveStripeWebhookResponse = ReceiveStripeWebhookResponses[keyof ReceiveStripeWebhookResponses]; + export type CreateBookingReviewData = { body: CreateReview; path?: never; diff --git a/openapi/juntly-api.v1.yaml b/openapi/juntly-api.v1.yaml index 5b017f3..fd9eb4b 100644 --- a/openapi/juntly-api.v1.yaml +++ b/openapi/juntly-api.v1.yaml @@ -752,6 +752,91 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "503": { $ref: "#/components/responses/ServiceUnavailable" } + /api/v1/me/bookings/{bookingId}/checkout: + post: + operationId: beginBookingCheckout + summary: Prepare one server-priced Stripe Checkout Session for an eligible booking. + security: [{ clerkSession: [] }] + parameters: [{ $ref: "#/components/parameters/BookingIdPath" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/BeginCheckout" } } } } + responses: + "201": { description: Durable payment order and hosted Checkout URL, content: { application/json: { schema: { $ref: "#/components/schemas/CheckoutResult" } } } } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + /api/v1/me/payments: + get: + operationId: listMyPayments + summary: List bounded payment orders where the authenticated user is customer or provider. + security: [{ clerkSession: [] }] + responses: + "200": { description: Participant payment orders, content: { application/json: { schema: { $ref: "#/components/schemas/PaymentOrders" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + /api/v1/me/payout-account: + get: + operationId: getMyPayoutAccount + summary: Refresh and read Stripe Connect payout readiness. + security: [{ clerkSession: [] }] + responses: + "200": { description: Provider payout readiness, content: { application/json: { schema: { $ref: "#/components/schemas/PayoutAccount" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + post: + operationId: beginMyPayoutOnboarding + summary: Create or continue Stripe-hosted Connect onboarding without collecting bank data in Vila. + security: [{ clerkSession: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PayoutOnboardingRequest" } } } } + responses: + "200": { description: Payout readiness and one-time hosted onboarding URL, content: { application/json: { schema: { $ref: "#/components/schemas/PayoutOnboardingResult" } } } } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + /api/v1/admin/payments/{orderId}/refund: + post: + operationId: refundPaymentOrder + summary: Initiate a moderator-authorized Stripe refund with transfer and application-fee reversal. + security: [{ clerkSession: [] }] + parameters: + - { name: orderId, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/RefundPayment" } } } } + responses: + "200": { description: Refund-pending payment order, content: { application/json: { schema: { $ref: "#/components/schemas/PaymentOrder" } } } } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + /api/v1/admin/payments: + get: + operationId: listAdministrativePayments + summary: List the latest bounded payment orders for moderator refund and dispute operations. + security: [{ clerkSession: [] }] + responses: + "200": { description: Latest payment orders, content: { application/json: { schema: { $ref: "#/components/schemas/PaymentOrders" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + /api/v1/payments/webhooks/stripe: + post: + operationId: receiveStripeWebhook + summary: Verify and idempotently apply an allowlisted Stripe event from the exact raw request body. + parameters: + - { name: Stripe-Signature, in: header, required: true, schema: { type: string, minLength: 8, maxLength: 4096 } } + requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } } + responses: + "200": { description: Event received idempotently, content: { application/json: { schema: { type: object, additionalProperties: false, required: [received], properties: { received: { type: boolean, const: true } } } } } } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } /api/v1/me/reviews: post: operationId: createBookingReview @@ -1219,6 +1304,71 @@ components: additionalProperties: false required: [bookings] properties: { bookings: { type: array, items: { $ref: "#/components/schemas/Booking" } } } + BeginCheckout: + type: object + additionalProperties: false + required: [idempotencyKey, locale] + properties: + idempotencyKey: { type: string, minLength: 8, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" } + locale: { type: string, enum: [pt-PT, en, es] } + PaymentState: + type: string + enum: [pending_checkout, checkout_created, processing, paid, failed, refund_pending, refunded, disputed, dispute_won, dispute_lost, cancelled] + PaymentOrder: + type: object + additionalProperties: false + required: [id, bookingId, customerId, providerId, state, grossMinor, platformFeeMinor, providerNetMinor, currency, createdAt, updatedAt] + properties: + id: { type: string, format: uuid } + bookingId: { type: string, format: uuid } + customerId: { type: string, format: uuid } + providerId: { type: string, format: uuid } + state: { $ref: "#/components/schemas/PaymentState" } + grossMinor: { type: integer, minimum: 1 } + platformFeeMinor: { type: integer, minimum: 0 } + providerNetMinor: { type: integer, minimum: 1 } + currency: { type: string, const: EUR } + + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + PaymentOrders: + type: object + additionalProperties: false + required: [orders] + properties: { orders: { type: array, items: { $ref: "#/components/schemas/PaymentOrder" } } } + CheckoutResult: + type: object + additionalProperties: false + required: [order, url] + properties: + order: { $ref: "#/components/schemas/PaymentOrder" } + url: { type: string, format: uri, pattern: "^https://" } + PayoutAccount: + type: object + additionalProperties: false + required: [detailsSubmitted, chargesEnabled, payoutsEnabled, updatedAt] + properties: + detailsSubmitted: { type: boolean } + chargesEnabled: { type: boolean } + payoutsEnabled: { type: boolean } + updatedAt: { type: string, format: date-time } + PayoutOnboardingRequest: + type: object + additionalProperties: false + required: [locale] + properties: { locale: { type: string, enum: [pt-PT, en, es] } } + PayoutOnboardingResult: + type: object + additionalProperties: false + required: [account, url] + properties: + account: { $ref: "#/components/schemas/PayoutAccount" } + url: { type: string, format: uri, pattern: "^https://" } + RefundPayment: + type: object + additionalProperties: false + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 8, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" } } CreateQuotationRequest: type: object additionalProperties: false diff --git a/supabase/migrations/20260902123000_create_payments.sql b/supabase/migrations/20260902123000_create_payments.sql new file mode 100644 index 0000000..7f9b904 --- /dev/null +++ b/supabase/migrations/20260902123000_create_payments.sql @@ -0,0 +1,89 @@ +create table public.provider_payment_accounts ( + internal_user_id uuid primary key references public.provider_profiles(internal_user_id) on delete cascade, + stripe_account_id text not null unique, + details_submitted boolean not null default false, + charges_enabled boolean not null default false, + payouts_enabled boolean not null default false, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + constraint provider_payment_accounts_stripe_id check (stripe_account_id ~ '^acct_[A-Za-z0-9]+$') +); + +create table public.payment_orders ( + id uuid primary key default gen_random_uuid(), + booking_id uuid not null references public.bookings(id) on delete restrict, + customer_internal_user_id uuid not null references public.internal_users(id) on delete restrict, + provider_internal_user_id uuid not null references public.provider_profiles(internal_user_id) on delete restrict, + idempotency_key text not null, + state text not null default 'pending_checkout', + gross_minor integer not null, + platform_fee_minor integer not null, + provider_net_minor integer not null, + currency text not null default 'EUR', + stripe_checkout_session_id text unique, + stripe_payment_intent_id text unique, + stripe_invoice_id text, + stripe_refund_id text unique, + paid_at timestamptz, + refunded_at timestamptz, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + unique(booking_id), + unique(customer_internal_user_id, idempotency_key), + constraint payment_orders_state check (state in ('pending_checkout','checkout_created','processing','paid','failed','refund_pending','refunded','disputed','dispute_won','dispute_lost','cancelled')), + constraint payment_orders_amounts check (gross_minor > 0 and platform_fee_minor >= 0 and platform_fee_minor < gross_minor and provider_net_minor = gross_minor - platform_fee_minor), + constraint payment_orders_currency check (currency = 'EUR'), + constraint payment_orders_idempotency check (char_length(idempotency_key) between 8 and 128), + constraint payment_orders_checkout_id check (stripe_checkout_session_id is null or stripe_checkout_session_id ~ '^cs_[A-Za-z0-9_]+$'), + constraint payment_orders_intent_id check (stripe_payment_intent_id is null or stripe_payment_intent_id ~ '^pi_[A-Za-z0-9]+$'), + constraint payment_orders_invoice_id check (stripe_invoice_id is null or stripe_invoice_id ~ '^in_[A-Za-z0-9]+$'), + constraint payment_orders_refund_id check (stripe_refund_id is null or stripe_refund_id ~ '^re_[A-Za-z0-9]+$') +); +create index payment_orders_customer_updated_idx on public.payment_orders(customer_internal_user_id, updated_at desc, id); +create index payment_orders_provider_updated_idx on public.payment_orders(provider_internal_user_id, updated_at desc, id); +create index payment_orders_state_updated_idx on public.payment_orders(state, updated_at desc, id); + +create table public.payment_events ( + id uuid primary key default gen_random_uuid(), + payment_order_id uuid not null references public.payment_orders(id) on delete cascade, + event_type text not null, + from_state text, + to_state text not null, + provider_object_id text, + created_at timestamptz not null default timezone('utc', now()), + constraint payment_events_type check (event_type in ('checkout_created','processing','paid','failed','refund_requested','refunded','dispute_opened','dispute_won','dispute_lost','cancelled')), + constraint payment_events_state check (to_state in ('pending_checkout','checkout_created','processing','paid','failed','refund_pending','refunded','disputed','dispute_won','dispute_lost','cancelled')), + constraint payment_events_from_state check (from_state is null or from_state in ('pending_checkout','checkout_created','processing','paid','failed','refund_pending','refunded','disputed','dispute_won','dispute_lost','cancelled')), + constraint payment_events_provider_id check (provider_object_id is null or char_length(provider_object_id) between 3 and 255) +); +create index payment_events_order_created_idx on public.payment_events(payment_order_id, created_at, id); + +create table public.stripe_webhook_receipts ( + stripe_event_id text primary key, + event_type text not null, + provider_object_id text not null, + outcome text not null, + processed_at timestamptz not null default timezone('utc', now()), + constraint stripe_webhook_receipts_event_id check (stripe_event_id ~ '^evt_[A-Za-z0-9]+$'), + constraint stripe_webhook_receipts_lengths check (char_length(event_type) between 3 and 100 and char_length(provider_object_id) between 3 and 255 and char_length(outcome) between 2 and 100) +); + +create table public.payment_disputes ( + stripe_dispute_id text primary key, + payment_order_id uuid not null references public.payment_orders(id) on delete cascade, + stripe_charge_id text not null, + amount_minor integer not null, + currency text not null default 'EUR', + state text not null, + reason text not null, + opened_at timestamptz not null, + closed_at timestamptz, + updated_at timestamptz not null default timezone('utc', now()), + constraint payment_disputes_id check (stripe_dispute_id ~ '^dp_[A-Za-z0-9]+$'), + constraint payment_disputes_charge check (stripe_charge_id ~ '^ch_[A-Za-z0-9]+$'), + constraint payment_disputes_amount check (amount_minor > 0), + constraint payment_disputes_currency check (currency = 'EUR'), + constraint payment_disputes_state check (state in ('needs_response','under_review','won','lost','warning_closed')), + constraint payment_disputes_reason check (char_length(reason) between 2 and 100) +); +create index payment_disputes_order_updated_idx on public.payment_disputes(payment_order_id, updated_at desc, stripe_dispute_id); From a0300e583a92f5b4ecb4e185c0bc4797663d2a29 Mon Sep 17 00:00:00 2001 From: SourceSensei Date: Thu, 3 Sep 2026 10:33:10 +0100 Subject: [PATCH 3/3] ci: provide synthetic payment topology values --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 020414f..ce540e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,10 @@ jobs: CLERK_AUTHORIZED_PARTIES: https://staging.example.invalid JUNTLY_CONTACT_ENCRYPTION_KEY: MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_only + STRIPE_SECRET_KEY: sk_test_ci_only + STRIPE_WEBHOOK_SECRET: whsec_ci_only + JUNTLY_PUBLIC_ORIGIN: https://staging.example.invalid + JUNTLY_PLATFORM_FEE_BPS: "1000" run: docker compose -f compose.production.yaml config --quiet - name: Validate operational scripts