@softure-ai/billing 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +695 -2
- package/dist/calendar.d.ts +26 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +81 -0
- package/dist/calendar.js.map +1 -0
- package/dist/contract.d.ts +187 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +6 -0
- package/dist/contract.js.map +1 -0
- package/dist/currency-digits.d.ts +3 -0
- package/dist/currency-digits.d.ts.map +1 -0
- package/dist/currency-digits.js +32 -0
- package/dist/currency-digits.js.map +1 -0
- package/dist/entitlement.d.ts +25 -0
- package/dist/entitlement.d.ts.map +1 -0
- package/dist/entitlement.js +75 -0
- package/dist/entitlement.js.map +1 -0
- package/dist/fields.d.ts +25 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +27 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +274 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/invoice.d.ts +29 -0
- package/dist/invoice.d.ts.map +1 -0
- package/dist/invoice.js +35 -0
- package/dist/invoice.js.map +1 -0
- package/dist/mailing/index.d.ts +2 -0
- package/dist/mailing/index.d.ts.map +1 -0
- package/dist/mailing/index.js +4 -0
- package/dist/mailing/index.js.map +1 -0
- package/dist/mailing/reminder-mail.d.ts +50 -0
- package/dist/mailing/reminder-mail.d.ts.map +1 -0
- package/dist/mailing/reminder-mail.js +71 -0
- package/dist/mailing/reminder-mail.js.map +1 -0
- package/dist/manual.d.ts +12 -0
- package/dist/manual.d.ts.map +1 -0
- package/dist/manual.js +22 -0
- package/dist/manual.js.map +1 -0
- package/dist/messages/en.d.ts +176 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +151 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +359 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +14 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +151 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/access.d.ts +16 -0
- package/dist/next/access.d.ts.map +1 -0
- package/dist/next/access.js +24 -0
- package/dist/next/access.js.map +1 -0
- package/dist/next/actions.d.ts +24 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +158 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +17 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/current-entitlement.d.ts +18 -0
- package/dist/next/current-entitlement.d.ts.map +1 -0
- package/dist/next/current-entitlement.js +26 -0
- package/dist/next/current-entitlement.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +12 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/pages.d.ts +24 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +166 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/pricing.d.ts +12 -0
- package/dist/next/pricing.d.ts.map +1 -0
- package/dist/next/pricing.js +20 -0
- package/dist/next/pricing.js.map +1 -0
- package/dist/next/route.d.ts +11 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +56 -0
- package/dist/next/route.js.map +1 -0
- package/dist/options.d.ts +85 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +121 -0
- package/dist/options.js.map +1 -0
- package/dist/payment.d.ts +61 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +12 -0
- package/dist/payment.js.map +1 -0
- package/dist/plans.d.ts +20 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +53 -0
- package/dist/plans.js.map +1 -0
- package/dist/price.d.ts +12 -0
- package/dist/price.d.ts.map +1 -0
- package/dist/price.js +37 -0
- package/dist/price.js.map +1 -0
- package/dist/refund.d.ts +70 -0
- package/dist/refund.d.ts.map +1 -0
- package/dist/refund.js +109 -0
- package/dist/refund.js.map +1 -0
- package/dist/reminder.d.ts +30 -0
- package/dist/reminder.d.ts.map +1 -0
- package/dist/reminder.js +34 -0
- package/dist/reminder.js.map +1 -0
- package/dist/schema.d.ts +1023 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/scripts/entitlement-scripts.d.ts +17 -0
- package/dist/scripts/entitlement-scripts.d.ts.map +1 -0
- package/dist/scripts/entitlement-scripts.js +167 -0
- package/dist/scripts/entitlement-scripts.js.map +1 -0
- package/dist/scripts/index.d.ts +3 -0
- package/dist/scripts/index.d.ts.map +1 -0
- package/dist/scripts/index.js +5 -0
- package/dist/scripts/index.js.map +1 -0
- package/dist/scripts/plan-scripts.d.ts +19 -0
- package/dist/scripts/plan-scripts.d.ts.map +1 -0
- package/dist/scripts/plan-scripts.js +106 -0
- package/dist/scripts/plan-scripts.js.map +1 -0
- package/dist/server/entitlements.d.ts +69 -0
- package/dist/server/entitlements.d.ts.map +1 -0
- package/dist/server/entitlements.js +202 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/grants.d.ts +74 -0
- package/dist/server/grants.d.ts.map +1 -0
- package/dist/server/grants.js +174 -0
- package/dist/server/grants.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +17 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/options.d.ts +20 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +38 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/payments.d.ts +140 -0
- package/dist/server/payments.d.ts.map +1 -0
- package/dist/server/payments.js +339 -0
- package/dist/server/payments.js.map +1 -0
- package/dist/server/plans.d.ts +50 -0
- package/dist/server/plans.d.ts.map +1 -0
- package/dist/server/plans.js +129 -0
- package/dist/server/plans.js.map +1 -0
- package/dist/server/privacy.d.ts +77 -0
- package/dist/server/privacy.d.ts.map +1 -0
- package/dist/server/privacy.js +110 -0
- package/dist/server/privacy.js.map +1 -0
- package/dist/server/reminders.d.ts +20 -0
- package/dist/server/reminders.d.ts.map +1 -0
- package/dist/server/reminders.js +85 -0
- package/dist/server/reminders.js.map +1 -0
- package/dist/server/requests.d.ts +80 -0
- package/dist/server/requests.d.ts.map +1 -0
- package/dist/server/requests.js +155 -0
- package/dist/server/requests.js.map +1 -0
- package/dist/server/setup.d.ts +9 -0
- package/dist/server/setup.d.ts.map +1 -0
- package/dist/server/setup.js +34 -0
- package/dist/server/setup.js.map +1 -0
- package/dist/server/take-back.d.ts +80 -0
- package/dist/server/take-back.d.ts.map +1 -0
- package/dist/server/take-back.js +138 -0
- package/dist/server/take-back.js.map +1 -0
- package/dist/server/user-id.d.ts +4 -0
- package/dist/server/user-id.d.ts.map +1 -0
- package/dist/server/user-id.js +11 -0
- package/dist/server/user-id.js.map +1 -0
- package/dist/stripe-currency.d.ts +27 -0
- package/dist/stripe-currency.d.ts.map +1 -0
- package/dist/stripe-currency.js +57 -0
- package/dist/stripe-currency.js.map +1 -0
- package/dist/stripe-webhook.d.ts +101 -0
- package/dist/stripe-webhook.d.ts.map +1 -0
- package/dist/stripe-webhook.js +209 -0
- package/dist/stripe-webhook.js.map +1 -0
- package/dist/stripe.d.ts +25 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +116 -0
- package/dist/stripe.js.map +1 -0
- package/dist/ui/access-badge.d.ts +16 -0
- package/dist/ui/access-badge.d.ts.map +1 -0
- package/dist/ui/access-badge.js +43 -0
- package/dist/ui/access-badge.js.map +1 -0
- package/dist/ui/access-notice.d.ts +20 -0
- package/dist/ui/access-notice.d.ts.map +1 -0
- package/dist/ui/access-notice.js +41 -0
- package/dist/ui/access-notice.js.map +1 -0
- package/dist/ui/format.d.ts +11 -0
- package/dist/ui/format.d.ts.map +1 -0
- package/dist/ui/format.js +21 -0
- package/dist/ui/format.js.map +1 -0
- package/dist/ui/grant-form.d.ts +18 -0
- package/dist/ui/grant-form.d.ts.map +1 -0
- package/dist/ui/grant-form.js +23 -0
- package/dist/ui/grant-form.js.map +1 -0
- package/dist/ui/grant-history.d.ts +39 -0
- package/dist/ui/grant-history.d.ts.map +1 -0
- package/dist/ui/grant-history.js +36 -0
- package/dist/ui/grant-history.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/payment-form.d.ts +23 -0
- package/dist/ui/payment-form.d.ts.map +1 -0
- package/dist/ui/payment-form.js +34 -0
- package/dist/ui/payment-form.js.map +1 -0
- package/dist/ui/payment-requests.d.ts +30 -0
- package/dist/ui/payment-requests.d.ts.map +1 -0
- package/dist/ui/payment-requests.js +31 -0
- package/dist/ui/payment-requests.js.map +1 -0
- package/dist/ui/pricing-tiles.d.ts +22 -0
- package/dist/ui/pricing-tiles.d.ts.map +1 -0
- package/dist/ui/pricing-tiles.js +40 -0
- package/dist/ui/pricing-tiles.js.map +1 -0
- package/migrations/0001_create_entitlements.sql +16 -0
- package/migrations/0002_create_payments.sql +30 -0
- package/migrations/0003_record_payment_grants.sql +25 -0
- package/migrations/0004_create_requests_and_grants.sql +58 -0
- package/migrations/0005_record_refunded_amounts.sql +18 -0
- package/migrations/0006_record_request_handover_and_prices.sql +38 -0
- package/migrations/0007_record_failed_refunds.sql +27 -0
- package/migrations/0008_record_request_handover_claims.sql +9 -0
- package/migrations/0009_record_pending_charge_states.sql +16 -0
- package/module.json +23 -0
- package/package.json +85 -4
- package/src/calendar.ts +90 -0
- package/src/contract.ts +181 -0
- package/src/currency-digits.ts +37 -0
- package/src/entitlement.ts +84 -0
- package/src/fields.ts +37 -0
- package/src/index.ts +163 -0
- package/src/invoice.ts +58 -0
- package/src/mailing/index.ts +11 -0
- package/src/mailing/reminder-mail.ts +108 -0
- package/src/manual.ts +31 -0
- package/src/messages/en.ts +150 -0
- package/src/messages/index.ts +18 -0
- package/src/messages/pl.ts +152 -0
- package/src/next/access.tsx +55 -0
- package/src/next/actions.ts +176 -0
- package/src/next/context.ts +18 -0
- package/src/next/current-entitlement.ts +36 -0
- package/src/next/index.ts +11 -0
- package/src/next/next-modules.d.ts +21 -0
- package/src/next/pages.tsx +267 -0
- package/src/next/pricing.tsx +39 -0
- package/src/next/route.ts +57 -0
- package/src/options.ts +128 -0
- package/src/payment.ts +77 -0
- package/src/plans.ts +63 -0
- package/src/price.ts +50 -0
- package/src/refund.ts +135 -0
- package/src/reminder.ts +52 -0
- package/src/schema.ts +86 -0
- package/src/scripts/entitlement-scripts.ts +188 -0
- package/src/scripts/index.ts +11 -0
- package/src/scripts/plan-scripts.ts +143 -0
- package/src/server/entitlements.ts +227 -0
- package/src/server/grants.ts +227 -0
- package/src/server/health.ts +18 -0
- package/src/server/index.ts +83 -0
- package/src/server/options.ts +52 -0
- package/src/server/payments.ts +434 -0
- package/src/server/plans.ts +143 -0
- package/src/server/privacy.ts +189 -0
- package/src/server/reminders.ts +113 -0
- package/src/server/requests.ts +201 -0
- package/src/server/setup.ts +37 -0
- package/src/server/take-back.ts +190 -0
- package/src/server/user-id.ts +12 -0
- package/src/stripe-currency.ts +65 -0
- package/src/stripe-webhook.ts +278 -0
- package/src/stripe.ts +130 -0
- package/src/ui/access-badge.tsx +64 -0
- package/src/ui/access-notice.tsx +73 -0
- package/src/ui/format.ts +25 -0
- package/src/ui/grant-form.tsx +69 -0
- package/src/ui/grant-history.tsx +127 -0
- package/src/ui/index.ts +25 -0
- package/src/ui/payment-form.tsx +111 -0
- package/src/ui/payment-requests.tsx +126 -0
- package/src/ui/pricing-tiles.tsx +105 -0
package/src/stripe.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The Stripe adapter (https://docs.stripe.com/api/checkout/sessions/create): a one-time Checkout
|
|
2
|
+
// session per payment, paid on Stripe's page; the verified webhook (`stripeWebhookRoute` in
|
|
3
|
+
// `/next`) grants the plan once Stripe reports the money. Stripe's HTTP API through `fetch`, like
|
|
4
|
+
// mailing's `resend()`: no SDK, and tests inject `fetch`.
|
|
5
|
+
import { err, ok } from "@softure-ai/core";
|
|
6
|
+
import { CHECKOUT_PARAM, PLAN_FIELD, type CheckoutResult } from "./fields.js";
|
|
7
|
+
import type { PaymentContext, PaymentProvider, PaymentRequest } from "./payment.js";
|
|
8
|
+
import { getLocalizedText } from "./plans.js";
|
|
9
|
+
import { STRIPE_PROVIDER } from "./server/payments.js";
|
|
10
|
+
import { describeStripePriceProblem, toStripeAmount } from "./stripe-currency.js";
|
|
11
|
+
import { STRIPE_METADATA } from "./stripe-webhook.js";
|
|
12
|
+
|
|
13
|
+
export const STRIPE_API_BASE = "https://api.stripe.com";
|
|
14
|
+
export const STRIPE_SECRET_KEY_ENV = "STRIPE_SECRET_KEY";
|
|
15
|
+
/** How long the Checkout request may take before the buyer sees `billing.payment_failed`. */
|
|
16
|
+
export const STRIPE_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export interface StripeOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The secret API key (`sk_test_...` in the sandbox). Default: `process.env.STRIPE_SECRET_KEY`,
|
|
21
|
+
* read on every payment, so the configuration loads at build time without the secret.
|
|
22
|
+
*/
|
|
23
|
+
readonly secretKey?: string;
|
|
24
|
+
/** Default: `https://api.stripe.com`. For a proxy or a test server. */
|
|
25
|
+
readonly apiBase?: string;
|
|
26
|
+
/** Injected in tests; default: the global `fetch`. */
|
|
27
|
+
readonly fetch?: typeof fetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getReturnUrl(returnUrl: string, result: CheckoutResult, planId: string | null): string {
|
|
31
|
+
const url = new URL(returnUrl);
|
|
32
|
+
if (planId !== null) url.searchParams.set(PLAN_FIELD, planId);
|
|
33
|
+
url.searchParams.set(CHECKOUT_PARAM, result);
|
|
34
|
+
return url.toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The form body of `POST /v1/checkout/sessions` for one payment of the plan: its price as a one-off
|
|
39
|
+
* line item in Stripe's unit (`toStripeAmount`), the account in `client_reference_id` and the metadata the webhook reads (copied onto
|
|
40
|
+
* the PaymentIntent, so the charge in Stripe's dashboard names the account too).
|
|
41
|
+
*/
|
|
42
|
+
export function getCheckoutSessionParams(ctx: Pick<PaymentContext, "config">, request: PaymentRequest): URLSearchParams {
|
|
43
|
+
const { plan, account } = request;
|
|
44
|
+
const { locale } = ctx.config;
|
|
45
|
+
const unitAmount = toStripeAmount(plan.price);
|
|
46
|
+
// The config refuses such a price (`checkPrice`) and `startPayment` checks it first.
|
|
47
|
+
if (unitAmount === null) throw new Error(`@softure-ai/billing: stripe() cannot charge the price of plan "${plan.id}"`);
|
|
48
|
+
const params = new URLSearchParams({
|
|
49
|
+
mode: "payment",
|
|
50
|
+
locale,
|
|
51
|
+
client_reference_id: account.id,
|
|
52
|
+
customer_email: account.email,
|
|
53
|
+
success_url: getReturnUrl(request.returnUrl, "success", null),
|
|
54
|
+
cancel_url: getReturnUrl(request.returnUrl, "cancelled", plan.id),
|
|
55
|
+
"line_items[0][quantity]": "1",
|
|
56
|
+
"line_items[0][price_data][currency]": plan.price.currency.toLowerCase(),
|
|
57
|
+
"line_items[0][price_data][unit_amount]": String(unitAmount),
|
|
58
|
+
"line_items[0][price_data][product_data][name]": getLocalizedText(plan.name, locale),
|
|
59
|
+
[`metadata[${STRIPE_METADATA.userId}]`]: account.id,
|
|
60
|
+
[`metadata[${STRIPE_METADATA.planId}]`]: plan.id,
|
|
61
|
+
[`payment_intent_data[metadata][${STRIPE_METADATA.userId}]`]: account.id,
|
|
62
|
+
[`payment_intent_data[metadata][${STRIPE_METADATA.planId}]`]: plan.id,
|
|
63
|
+
});
|
|
64
|
+
if (plan.description !== undefined) params.set("line_items[0][price_data][product_data][description]", getLocalizedText(plan.description, locale));
|
|
65
|
+
return params;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The hosted page's URL from Stripe's answer, or null when the answer has none. */
|
|
69
|
+
function readCheckoutUrl(payload: unknown): string | null {
|
|
70
|
+
if (typeof payload !== "object" || payload === null || !Object.hasOwn(payload, "url")) return null;
|
|
71
|
+
const url: unknown = (payload as Record<string, unknown>).url;
|
|
72
|
+
return typeof url === "string" && url.startsWith("https://") ? url : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stripe's error type and code for the log; its message can echo the request back. */
|
|
76
|
+
function describeStripeError(payload: unknown): string {
|
|
77
|
+
const error: unknown = typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>).error : null;
|
|
78
|
+
if (typeof error !== "object" || error === null) return "no error body";
|
|
79
|
+
const { type, code } = error as Record<string, unknown>;
|
|
80
|
+
return [type, code].filter((part) => typeof part === "string").join("/") || "no error type";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `billing({ payment: stripe() })`: card, BLIK and transfer payments on Stripe Checkout. */
|
|
84
|
+
export function stripe(options: StripeOptions = {}): PaymentProvider {
|
|
85
|
+
const apiBase = options.apiBase ?? STRIPE_API_BASE;
|
|
86
|
+
return {
|
|
87
|
+
name: STRIPE_PROVIDER,
|
|
88
|
+
collectsInvoiceDetails: false,
|
|
89
|
+
handsOverRequests: false,
|
|
90
|
+
checkPrice: describeStripePriceProblem,
|
|
91
|
+
async startPayment(ctx, request) {
|
|
92
|
+
const priceProblem = describeStripePriceProblem(request.plan.price);
|
|
93
|
+
if (priceProblem !== null) {
|
|
94
|
+
// Only a config built around billing({ ... }) gets here; the options schema refuses it.
|
|
95
|
+
console.error(`@softure-ai/billing: plan "${request.plan.id}": ${priceProblem}`);
|
|
96
|
+
return err("billing.payment_failed");
|
|
97
|
+
}
|
|
98
|
+
const secretKey = (options.secretKey ?? process.env[STRIPE_SECRET_KEY_ENV] ?? "").trim();
|
|
99
|
+
if (secretKey === "") {
|
|
100
|
+
// A deployment without the key cannot take payments until it is set; never the buyer's fault.
|
|
101
|
+
console.error(`@softure-ai/billing: stripe has no secret key; set ${STRIPE_SECRET_KEY_ENV} or pass stripe({ secretKey })`);
|
|
102
|
+
return err("billing.payment_failed");
|
|
103
|
+
}
|
|
104
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
105
|
+
let response: Response;
|
|
106
|
+
try {
|
|
107
|
+
response = await fetchImpl(new URL("/v1/checkout/sessions", apiBase), {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { Authorization: `Bearer ${secretKey}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
110
|
+
body: getCheckoutSessionParams(ctx, request),
|
|
111
|
+
signal: AbortSignal.timeout(STRIPE_TIMEOUT_MS),
|
|
112
|
+
cache: "no-store",
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// A timeout or a network failure: nothing was created that the buyer can pay.
|
|
116
|
+
console.error(`@softure-ai/billing: the Stripe checkout for plan "${request.plan.id}" could not be created: ${error instanceof Error ? error.name : typeof error}`);
|
|
117
|
+
return err("billing.payment_failed");
|
|
118
|
+
}
|
|
119
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
120
|
+
const url = response.ok ? readCheckoutUrl(payload) : null;
|
|
121
|
+
if (url === null) {
|
|
122
|
+
console.error(
|
|
123
|
+
`@softure-ai/billing: Stripe refused the checkout for plan "${request.plan.id}" (HTTP ${String(response.status)}, ${response.ok ? "no checkout url" : describeStripeError(payload)})`,
|
|
124
|
+
);
|
|
125
|
+
return err("billing.payment_failed");
|
|
126
|
+
}
|
|
127
|
+
return ok({ type: "redirect", url });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A compact label of where an account stands: "Trial · 5 days left", "Paid · until November 30,
|
|
2
|
+
// 2026", "Lifetime access" or "Read-only". Server-safe: it renders from props, so a server
|
|
3
|
+
// component passes the entitlement it read (`CurrentAccessBadge` in `/next` does).
|
|
4
|
+
import { formatMessage, type Locale } from "@softure-ai/core";
|
|
5
|
+
import { type ClassNames, createSlotClassGetter } from "@softure-ai/ui";
|
|
6
|
+
import type { Entitlement } from "../contract.js";
|
|
7
|
+
import type { BillingMessages } from "../messages/index.js";
|
|
8
|
+
import { formatDaysLeft, formatLastDay } from "./format.js";
|
|
9
|
+
|
|
10
|
+
export type AccessBadgeSlot = "root" | "status" | "detail";
|
|
11
|
+
|
|
12
|
+
export interface AccessBadgeProps {
|
|
13
|
+
readonly entitlement: Entitlement;
|
|
14
|
+
readonly messages: BillingMessages;
|
|
15
|
+
readonly locale: Locale;
|
|
16
|
+
/** The app's IANA time zone, for the dates shown. */
|
|
17
|
+
readonly timezone: string;
|
|
18
|
+
readonly classNames?: ClassNames<AccessBadgeSlot>;
|
|
19
|
+
readonly unstyled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Tone = "neutral" | "success" | "warning" | "danger";
|
|
23
|
+
|
|
24
|
+
const TONE: Readonly<Record<Tone, string>> = {
|
|
25
|
+
neutral: "sft:text-foreground",
|
|
26
|
+
success: "sft:text-success",
|
|
27
|
+
warning: "sft:text-warning",
|
|
28
|
+
danger: "sft:text-danger",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function describe(entitlement: Entitlement, messages: BillingMessages, locale: Locale, timezone: string): { status: string; detail?: string; tone: Tone } {
|
|
32
|
+
switch (entitlement.status) {
|
|
33
|
+
case "trial":
|
|
34
|
+
return { status: messages.badge.trial, detail: formatDaysLeft(entitlement.daysLeft, locale, messages), tone: entitlement.isEnding ? "warning" : "neutral" };
|
|
35
|
+
case "paid":
|
|
36
|
+
if (entitlement.endsAt === null) return { status: messages.badge.lifetime, tone: "success" };
|
|
37
|
+
return {
|
|
38
|
+
status: messages.badge.paid,
|
|
39
|
+
detail: formatMessage(messages.badge.until, { date: formatLastDay(entitlement.endsAt, locale, timezone) }),
|
|
40
|
+
tone: entitlement.isEnding ? "warning" : "success",
|
|
41
|
+
};
|
|
42
|
+
case "read_only":
|
|
43
|
+
return { status: messages.badge.readOnly, tone: "danger" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function AccessBadge({ entitlement, messages, locale, timezone, classNames, unstyled }: AccessBadgeProps) {
|
|
48
|
+
const { status, detail, tone } = describe(entitlement, messages, locale, timezone);
|
|
49
|
+
const slot = createSlotClassGetter<AccessBadgeSlot>({
|
|
50
|
+
defaults: {
|
|
51
|
+
root: "sft:inline-flex sft:items-center sft:gap-1.5 sft:rounded-full sft:border sft:border-border sft:bg-surface sft:px-2.5 sft:py-1 sft:font-sans sft:text-xs",
|
|
52
|
+
status: `sft:font-medium ${TONE[tone]}`,
|
|
53
|
+
detail: "sft:text-muted",
|
|
54
|
+
},
|
|
55
|
+
classNames,
|
|
56
|
+
unstyled,
|
|
57
|
+
});
|
|
58
|
+
return (
|
|
59
|
+
<span className={slot("root")} data-status={entitlement.status} data-ending={"isEnding" in entitlement && entitlement.isEnding ? "true" : undefined}>
|
|
60
|
+
<span className={slot("status")}>{status}</span>
|
|
61
|
+
{detail === undefined ? null : <span className={slot("detail")}>{detail}</span>}
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The notice an account sees when its access is about to end or has ended, with a link to pay.
|
|
2
|
+
// It renders nothing while access is not in its reminder window. Server-safe and framework-free:
|
|
3
|
+
// the link goes through an injected `LinkComponent` (Next's `Link` in `/next`).
|
|
4
|
+
import { formatMessage, type Locale } from "@softure-ai/core";
|
|
5
|
+
import { ButtonLink, type ClassNames, createSlotClassGetter, type LinkComponentType } from "@softure-ai/ui";
|
|
6
|
+
import type { Entitlement } from "../contract.js";
|
|
7
|
+
import type { BillingMessages } from "../messages/index.js";
|
|
8
|
+
import { formatLastDay } from "./format.js";
|
|
9
|
+
|
|
10
|
+
export type AccessNoticeSlot = "root" | "message" | "actions";
|
|
11
|
+
|
|
12
|
+
export interface AccessNoticeProps {
|
|
13
|
+
readonly entitlement: Entitlement;
|
|
14
|
+
readonly messages: BillingMessages;
|
|
15
|
+
readonly locale: Locale;
|
|
16
|
+
/** The app's IANA time zone, for the dates shown. */
|
|
17
|
+
readonly timezone: string;
|
|
18
|
+
/** Where to pay or renew (`routes.payment`). */
|
|
19
|
+
readonly paymentHref: string;
|
|
20
|
+
/** Renders the link; a plain `<a>` when omitted. */
|
|
21
|
+
readonly LinkComponent?: LinkComponentType;
|
|
22
|
+
readonly classNames?: ClassNames<AccessNoticeSlot>;
|
|
23
|
+
readonly unstyled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface NoticeCopy {
|
|
27
|
+
readonly text: string;
|
|
28
|
+
readonly action: string;
|
|
29
|
+
readonly isEnded: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The notice for this entitlement, or null when there is nothing to say. */
|
|
33
|
+
function getNoticeCopy(entitlement: Entitlement, messages: BillingMessages, locale: Locale, timezone: string): NoticeCopy | null {
|
|
34
|
+
const copy = messages.notice;
|
|
35
|
+
switch (entitlement.status) {
|
|
36
|
+
case "trial":
|
|
37
|
+
if (!entitlement.isEnding) return null;
|
|
38
|
+
return { text: formatMessage(copy.trialEnding, { date: formatLastDay(entitlement.endsAt, locale, timezone) }), action: copy.choosePlan, isEnded: false };
|
|
39
|
+
case "paid":
|
|
40
|
+
if (!entitlement.isEnding || entitlement.endsAt === null) return null;
|
|
41
|
+
return { text: formatMessage(copy.paidEnding, { date: formatLastDay(entitlement.endsAt, locale, timezone) }), action: copy.renew, isEnded: false };
|
|
42
|
+
case "read_only":
|
|
43
|
+
return entitlement.reason === "trial_ended"
|
|
44
|
+
? { text: copy.trialEnded, action: copy.choosePlan, isEnded: true }
|
|
45
|
+
: { text: copy.paidEnded, action: copy.renew, isEnded: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function AccessNotice({ entitlement, messages, locale, timezone, paymentHref, LinkComponent, classNames, unstyled }: AccessNoticeProps) {
|
|
50
|
+
const notice = getNoticeCopy(entitlement, messages, locale, timezone);
|
|
51
|
+
if (notice === null) return null;
|
|
52
|
+
const slot = createSlotClassGetter<AccessNoticeSlot>({
|
|
53
|
+
defaults: {
|
|
54
|
+
root: `sft:flex sft:flex-col sft:gap-3 sft:rounded-control sft:border sft:px-4 sft:py-2.5 sft:font-sans sft:text-sm sft:text-foreground ${
|
|
55
|
+
notice.isEnded ? "sft:border-danger/50 sft:bg-danger/10" : "sft:border-border-strong sft:bg-surface-raised"
|
|
56
|
+
}`,
|
|
57
|
+
message: "sft:m-0",
|
|
58
|
+
actions: "sft:flex sft:shrink-0",
|
|
59
|
+
},
|
|
60
|
+
classNames,
|
|
61
|
+
unstyled,
|
|
62
|
+
});
|
|
63
|
+
return (
|
|
64
|
+
<div role="status" className={slot("root")} data-status={entitlement.status}>
|
|
65
|
+
<p className={slot("message")}>{notice.text}</p>
|
|
66
|
+
<div className={slot("actions")}>
|
|
67
|
+
<ButtonLink href={paymentHref} LinkComponent={LinkComponent} variant={notice.isEnded ? "primary" : "secondary"} size="sm" unstyled={unstyled}>
|
|
68
|
+
{notice.action}
|
|
69
|
+
</ButtonLink>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Dates and day counts of an entitlement as the components show them. An end is the first instant
|
|
2
|
+
// without access, so the date shown is the last day with it, in the app's time zone.
|
|
3
|
+
import { formatMessage, selectPlural, type Locale } from "@softure-ai/core";
|
|
4
|
+
import type { PlanPeriod } from "../contract.js";
|
|
5
|
+
import type { BillingMessages } from "../messages/index.js";
|
|
6
|
+
|
|
7
|
+
/** The last local day covered by an access that ends at `end`, e.g. "October 16, 2026" in en. */
|
|
8
|
+
export function formatLastDay(end: Date, locale: Locale, timezone: string): string {
|
|
9
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: timezone }).format(new Date(end.getTime() - 1));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The local day of an instant, e.g. "October 3, 2026" in en: when something happened, or a first day of access. */
|
|
13
|
+
export function formatDay(instant: Date, locale: Locale, timezone: string): string {
|
|
14
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: timezone }).format(instant);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatDaysLeft(days: number, locale: Locale, messages: BillingMessages): string {
|
|
18
|
+
return formatMessage(selectPlural(locale, days, messages.badge.daysLeft), { count: days });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How long one payment lasts, as the tiles say it: "per month", "per 3 months", "one-time payment". */
|
|
22
|
+
export function formatPeriod(period: PlanPeriod, locale: Locale, messages: BillingMessages): string {
|
|
23
|
+
if (period.unit === "lifetime") return messages.pricing.period.lifetime;
|
|
24
|
+
return formatMessage(selectPlural(locale, period.count, messages.pricing.period[period.unit]), { count: period.count });
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Locale } from "@softure-ai/core";
|
|
4
|
+
import { Button, type ClassNames, createSlotClassGetter, FormError, SelectField, type SelectOption, TextField } from "@softure-ai/ui";
|
|
5
|
+
import { useActionState } from "react";
|
|
6
|
+
import { INITIAL_GRANT_FORM_STATE, type GrantFormState } from "../contract.js";
|
|
7
|
+
import { EMAIL_FIELD, PLAN_FIELD } from "../fields.js";
|
|
8
|
+
import { getBillingErrorMessage, type BillingMessages } from "../messages/index.js";
|
|
9
|
+
|
|
10
|
+
// The admin form that grants a plan to an account once its payment arrived (the manual adapter):
|
|
11
|
+
// the account's email and the plan. It submits straight to its server action, which checks the
|
|
12
|
+
// admin role first; after a grant it says what the account has now and stays ready for the next.
|
|
13
|
+
|
|
14
|
+
export type GrantFormAction = (previous: GrantFormState, formData: FormData) => Promise<GrantFormState>;
|
|
15
|
+
|
|
16
|
+
export type GrantFormSlot = "root" | "form" | "notice";
|
|
17
|
+
|
|
18
|
+
export interface GrantFormProps {
|
|
19
|
+
readonly action: GrantFormAction;
|
|
20
|
+
/** The plans to choose from: id and name in the app's locale. */
|
|
21
|
+
readonly plans: readonly SelectOption[];
|
|
22
|
+
readonly messages: BillingMessages;
|
|
23
|
+
/** Locale of the built-in copy of the ui primitives. */
|
|
24
|
+
readonly locale?: Locale;
|
|
25
|
+
readonly classNames?: ClassNames<GrantFormSlot>;
|
|
26
|
+
readonly unstyled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CLASSES: Readonly<Record<GrantFormSlot, string>> = {
|
|
30
|
+
root: "sft:flex sft:flex-col sft:gap-3 sft:font-sans",
|
|
31
|
+
form: "sft:flex sft:flex-col sft:gap-3",
|
|
32
|
+
notice: "sft:m-0 sft:text-sm sft:text-success",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function GrantForm({ action, plans, messages, locale, classNames, unstyled }: GrantFormProps) {
|
|
36
|
+
const [state, formAction, isPending] = useActionState(action, INITIAL_GRANT_FORM_STATE);
|
|
37
|
+
const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
|
|
38
|
+
const copy = messages.admin;
|
|
39
|
+
const error = state.error === undefined ? undefined : getBillingErrorMessage(messages, state.error);
|
|
40
|
+
const isEmailError = state.error === "billing.account_unknown";
|
|
41
|
+
return (
|
|
42
|
+
<div className={slot("root")}>
|
|
43
|
+
<form action={formAction} className={slot("form")}>
|
|
44
|
+
<TextField
|
|
45
|
+
// A fresh, empty field after each grant; the typed address after an error.
|
|
46
|
+
key={`${state.status}:${state.email ?? ""}`}
|
|
47
|
+
name={EMAIL_FIELD}
|
|
48
|
+
type="email"
|
|
49
|
+
label={copy.email}
|
|
50
|
+
required
|
|
51
|
+
defaultValue={state.status === "error" ? (state.email ?? "") : ""}
|
|
52
|
+
error={isEmailError ? error : undefined}
|
|
53
|
+
locale={locale}
|
|
54
|
+
unstyled={unstyled}
|
|
55
|
+
/>
|
|
56
|
+
<SelectField name={PLAN_FIELD} label={copy.plan} options={plans} defaultValue={state.planId} locale={locale} unstyled={unstyled} />
|
|
57
|
+
<FormError message={isEmailError ? undefined : error} unstyled={unstyled} />
|
|
58
|
+
<Button type="submit" variant="primary" pending={isPending} unstyled={unstyled}>
|
|
59
|
+
{isPending ? copy.pending : copy.submit}
|
|
60
|
+
</Button>
|
|
61
|
+
</form>
|
|
62
|
+
{state.status === "granted" ? (
|
|
63
|
+
<p role="status" className={slot("notice")}>
|
|
64
|
+
{state.notice}
|
|
65
|
+
</p>
|
|
66
|
+
) : null}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Locale } from "@softure-ai/core";
|
|
4
|
+
import { Button, type ClassNames, createSlotClassGetter, FormError, TextField } from "@softure-ai/ui";
|
|
5
|
+
import { useActionState } from "react";
|
|
6
|
+
import { INITIAL_ADMIN_ACTION_STATE } from "../contract.js";
|
|
7
|
+
import { EMAIL_FIELD, GRANT_FIELD } from "../fields.js";
|
|
8
|
+
import { getBillingErrorMessage, type BillingMessages } from "../messages/index.js";
|
|
9
|
+
import type { AdminAction } from "./payment-requests.js";
|
|
10
|
+
|
|
11
|
+
// The admin page's account history: a lookup by email (its action sends the admin to the page with
|
|
12
|
+
// the account's id, so no address lands in a URL) and the account's manual grants and provider
|
|
13
|
+
// payments, newest first, with a Revoke button on each active manual grant. Rows come prepared
|
|
14
|
+
// from the server; the revoke action checks the admin role first and refreshes the page.
|
|
15
|
+
|
|
16
|
+
export type GrantHistorySlot = "root" | "empty" | "item" | "itemHeader" | "title" | "status" | "details";
|
|
17
|
+
|
|
18
|
+
/** One grant or payment, prepared on the server. */
|
|
19
|
+
export interface GrantHistoryRow {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
/** The plan and where it came from, e.g. "Monthly, granted by hand". */
|
|
22
|
+
readonly title: string;
|
|
23
|
+
/** "Active", "Revoked on …", "Paid", "Refunded on …". */
|
|
24
|
+
readonly statusText: string;
|
|
25
|
+
/** Whether it still gives access (active or paid); the rest is drawn muted. */
|
|
26
|
+
readonly isCurrent: boolean;
|
|
27
|
+
/** The date, the access it added and the amount, one line each. */
|
|
28
|
+
readonly details: readonly string[];
|
|
29
|
+
/** Set for an active manual grant: the accessible name of its Revoke button. */
|
|
30
|
+
readonly revokeLabel: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GrantHistoryProps {
|
|
34
|
+
readonly rows: readonly GrantHistoryRow[];
|
|
35
|
+
readonly revokeAction: AdminAction;
|
|
36
|
+
readonly messages: BillingMessages;
|
|
37
|
+
readonly classNames?: ClassNames<GrantHistorySlot>;
|
|
38
|
+
readonly unstyled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_CLASSES: Readonly<Record<GrantHistorySlot, string>> = {
|
|
42
|
+
root: "sft:m-0 sft:flex sft:list-none sft:flex-col sft:gap-3 sft:p-0 sft:font-sans",
|
|
43
|
+
empty: "sft:m-0 sft:font-sans sft:text-sm sft:text-muted",
|
|
44
|
+
item: "sft:flex sft:flex-col sft:gap-1 sft:border-t sft:border-border sft:pt-3 sft:first-of-type:border-t-0 sft:first-of-type:pt-0",
|
|
45
|
+
itemHeader: "sft:flex sft:items-center sft:justify-between sft:gap-2",
|
|
46
|
+
title: "sft:m-0 sft:text-sm sft:font-semibold sft:text-foreground",
|
|
47
|
+
status: "sft:text-xs sft:font-medium sft:text-success",
|
|
48
|
+
details: "sft:m-0 sft:text-xs sft:text-muted",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Slot = (part: GrantHistorySlot) => string | undefined;
|
|
52
|
+
|
|
53
|
+
function HistoryItem({ row, revokeAction, messages, slot, unstyled }: { row: GrantHistoryRow; revokeAction: AdminAction; messages: BillingMessages; slot: Slot; unstyled: boolean | undefined }) {
|
|
54
|
+
const [state, formAction, isPending] = useActionState(revokeAction, INITIAL_ADMIN_ACTION_STATE);
|
|
55
|
+
const copy = messages.admin.history;
|
|
56
|
+
return (
|
|
57
|
+
<li className={slot("item")} data-history-id={row.id}>
|
|
58
|
+
<div className={slot("itemHeader")}>
|
|
59
|
+
<p className={slot("title")}>{row.title}</p>
|
|
60
|
+
{row.revokeLabel === null ? null : (
|
|
61
|
+
<form action={formAction}>
|
|
62
|
+
<input type="hidden" name={GRANT_FIELD} value={row.id} />
|
|
63
|
+
<Button type="submit" variant="danger" size="sm" pending={isPending} aria-label={row.revokeLabel} unstyled={unstyled}>
|
|
64
|
+
{isPending ? copy.revoking : copy.revoke}
|
|
65
|
+
</Button>
|
|
66
|
+
</form>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
<p className={row.isCurrent ? slot("status") : slot("details")}>{row.statusText}</p>
|
|
70
|
+
{row.details.map((line) => (
|
|
71
|
+
<p key={line} className={slot("details")}>
|
|
72
|
+
{line}
|
|
73
|
+
</p>
|
|
74
|
+
))}
|
|
75
|
+
<FormError message={state.status === "error" ? getBillingErrorMessage(messages, state.error) : undefined} unstyled={unstyled} />
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function GrantHistory({ rows, revokeAction, messages, classNames, unstyled }: GrantHistoryProps) {
|
|
81
|
+
const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
|
|
82
|
+
if (rows.length === 0) return <p className={slot("empty")}>{messages.admin.history.empty}</p>;
|
|
83
|
+
return (
|
|
84
|
+
<ul className={slot("root")} aria-label={messages.admin.history.title}>
|
|
85
|
+
{rows.map((row) => (
|
|
86
|
+
<HistoryItem key={row.id} row={row} revokeAction={revokeAction} messages={messages} slot={slot} unstyled={unstyled} />
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type AccountLookupSlot = "root";
|
|
93
|
+
|
|
94
|
+
export interface AccountLookupProps {
|
|
95
|
+
readonly action: AdminAction;
|
|
96
|
+
readonly messages: BillingMessages;
|
|
97
|
+
/** Locale of the built-in copy of the ui primitives. */
|
|
98
|
+
readonly locale?: Locale;
|
|
99
|
+
readonly classNames?: ClassNames<AccountLookupSlot>;
|
|
100
|
+
readonly unstyled?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The email lookup of the history card; a found account reloads the page on its history. */
|
|
104
|
+
export function AccountLookup({ action, messages, locale, classNames, unstyled }: AccountLookupProps) {
|
|
105
|
+
const [state, formAction, isPending] = useActionState(action, INITIAL_ADMIN_ACTION_STATE);
|
|
106
|
+
const slot = createSlotClassGetter<AccountLookupSlot>({ defaults: { root: "sft:flex sft:flex-col sft:gap-3 sft:font-sans" }, classNames, unstyled });
|
|
107
|
+
const copy = messages.admin.history;
|
|
108
|
+
const error = state.status === "error" ? getBillingErrorMessage(messages, state.error) : undefined;
|
|
109
|
+
return (
|
|
110
|
+
<form action={formAction} className={slot("root")}>
|
|
111
|
+
<TextField
|
|
112
|
+
key={state.status === "error" ? `error:${state.email ?? ""}` : "lookup"}
|
|
113
|
+
name={EMAIL_FIELD}
|
|
114
|
+
type="email"
|
|
115
|
+
label={copy.email}
|
|
116
|
+
required
|
|
117
|
+
defaultValue={state.status === "error" ? (state.email ?? "") : ""}
|
|
118
|
+
error={error}
|
|
119
|
+
locale={locale}
|
|
120
|
+
unstyled={unstyled}
|
|
121
|
+
/>
|
|
122
|
+
<Button type="submit" variant="secondary" pending={isPending} unstyled={unstyled}>
|
|
123
|
+
{isPending ? copy.pending : copy.submit}
|
|
124
|
+
</Button>
|
|
125
|
+
</form>
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The billing components, to compose into the app's own pages. They render from props (an
|
|
2
|
+
// entitlement or the plans read on the server) and import nothing from `next/*`
|
|
3
|
+
// (docs/02-module-standard.md §5); `/next` renders them wired to the registered configuration.
|
|
4
|
+
export { AccessBadge, type AccessBadgeProps, type AccessBadgeSlot } from "./access-badge.js";
|
|
5
|
+
export { AccessNotice, type AccessNoticeProps, type AccessNoticeSlot } from "./access-notice.js";
|
|
6
|
+
export { formatDay, formatDaysLeft, formatLastDay, formatPeriod } from "./format.js";
|
|
7
|
+
export { GrantForm, type GrantFormAction, type GrantFormProps, type GrantFormSlot } from "./grant-form.js";
|
|
8
|
+
export {
|
|
9
|
+
AccountLookup,
|
|
10
|
+
type AccountLookupProps,
|
|
11
|
+
type AccountLookupSlot,
|
|
12
|
+
GrantHistory,
|
|
13
|
+
type GrantHistoryProps,
|
|
14
|
+
type GrantHistoryRow,
|
|
15
|
+
type GrantHistorySlot,
|
|
16
|
+
} from "./grant-history.js";
|
|
17
|
+
export { PaymentForm, type PaymentFormAction, type PaymentFormProps, type PaymentFormSlot } from "./payment-form.js";
|
|
18
|
+
export {
|
|
19
|
+
type AdminAction,
|
|
20
|
+
PaymentRequestList,
|
|
21
|
+
type PaymentRequestListProps,
|
|
22
|
+
type PaymentRequestListSlot,
|
|
23
|
+
type PaymentRequestRow,
|
|
24
|
+
} from "./payment-requests.js";
|
|
25
|
+
export { PricingTiles, type PricingTilesProps, type PricingTilesSlot } from "./pricing-tiles.js";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatMessage, type Locale } from "@softure-ai/core";
|
|
4
|
+
import { Button, type ClassNames, createSlotClassGetter, FormError, TextField } from "@softure-ai/ui";
|
|
5
|
+
import { useActionState } from "react";
|
|
6
|
+
import { INITIAL_PAYMENT_FORM_STATE, type PaymentFormState } from "../contract.js";
|
|
7
|
+
import { getInvoiceFieldLimit, INVOICE_FIELDS, INVOICE_LIMITS, PLAN_FIELD } from "../fields.js";
|
|
8
|
+
import { getBillingErrorMessage, type BillingMessages } from "../messages/index.js";
|
|
9
|
+
|
|
10
|
+
// The form that starts paying for the chosen plan. With a provider that needs them (the manual
|
|
11
|
+
// adapter) it asks for the invoice details; otherwise it is one button that sends the buyer to the
|
|
12
|
+
// provider's checkout. It submits straight to its server action through `useActionState`, so it
|
|
13
|
+
// works before (and without) JavaScript; a requested invoice shows the confirmation instead.
|
|
14
|
+
|
|
15
|
+
export type PaymentFormAction = (previous: PaymentFormState, formData: FormData) => Promise<PaymentFormState>;
|
|
16
|
+
|
|
17
|
+
export type PaymentFormSlot = "root" | "form" | "notice";
|
|
18
|
+
|
|
19
|
+
export interface PaymentFormProps {
|
|
20
|
+
readonly action: PaymentFormAction;
|
|
21
|
+
readonly planId: string;
|
|
22
|
+
/** The plan's name in the app's locale, for the confirmation. */
|
|
23
|
+
readonly planName: string;
|
|
24
|
+
/** The buyer's address, for the confirmation. */
|
|
25
|
+
readonly email: string;
|
|
26
|
+
/** Asks for the invoice details (the manual adapter); else one checkout button. */
|
|
27
|
+
readonly collectsInvoiceDetails: boolean;
|
|
28
|
+
readonly messages: BillingMessages;
|
|
29
|
+
/** Locale of the built-in copy of the ui primitives. */
|
|
30
|
+
readonly locale?: Locale;
|
|
31
|
+
readonly classNames?: ClassNames<PaymentFormSlot>;
|
|
32
|
+
readonly unstyled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CLASSES: Readonly<Record<PaymentFormSlot, string>> = {
|
|
36
|
+
root: "sft:flex sft:flex-col sft:gap-3 sft:font-sans",
|
|
37
|
+
form: "sft:flex sft:flex-col sft:gap-3",
|
|
38
|
+
notice: "sft:m-0 sft:text-sm sft:text-success",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function PaymentForm({ action, planId, planName, email, collectsInvoiceDetails, messages, locale, classNames, unstyled }: PaymentFormProps) {
|
|
42
|
+
const [state, formAction, isPending] = useActionState(action, INITIAL_PAYMENT_FORM_STATE);
|
|
43
|
+
const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
|
|
44
|
+
if (state.status === "requested") {
|
|
45
|
+
return (
|
|
46
|
+
<div className={slot("root")}>
|
|
47
|
+
<p role="status" className={slot("notice")}>
|
|
48
|
+
{formatMessage(messages.payment.requested, { plan: planName, email })}
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const copy = messages.payment;
|
|
55
|
+
const values = state.values ?? {};
|
|
56
|
+
const fieldError = (name: string): string | undefined => {
|
|
57
|
+
const code = state.fieldErrors?.[name];
|
|
58
|
+
if (code === undefined) return undefined;
|
|
59
|
+
const limit = getInvoiceFieldLimit(name);
|
|
60
|
+
return formatMessage(getBillingErrorMessage(messages, code), limit === undefined ? {} : { max: limit });
|
|
61
|
+
};
|
|
62
|
+
const hasFieldErrors = Object.keys(state.fieldErrors ?? {}).length > 0;
|
|
63
|
+
const formError = state.error === undefined || hasFieldErrors ? undefined : getBillingErrorMessage(messages, state.error);
|
|
64
|
+
const submitLabel = collectsInvoiceDetails ? copy.requestInvoice : copy.checkout;
|
|
65
|
+
return (
|
|
66
|
+
<div className={slot("root")}>
|
|
67
|
+
<form action={formAction} className={slot("form")}>
|
|
68
|
+
<input type="hidden" name={PLAN_FIELD} value={planId} />
|
|
69
|
+
{collectsInvoiceDetails ? (
|
|
70
|
+
<>
|
|
71
|
+
<TextField
|
|
72
|
+
name={INVOICE_FIELDS.name}
|
|
73
|
+
label={copy.fields.name}
|
|
74
|
+
autoComplete="name"
|
|
75
|
+
required
|
|
76
|
+
maxLength={INVOICE_LIMITS.name}
|
|
77
|
+
defaultValue={values[INVOICE_FIELDS.name] ?? ""}
|
|
78
|
+
error={fieldError(INVOICE_FIELDS.name)}
|
|
79
|
+
locale={locale}
|
|
80
|
+
unstyled={unstyled}
|
|
81
|
+
/>
|
|
82
|
+
<TextField
|
|
83
|
+
name={INVOICE_FIELDS.taxId}
|
|
84
|
+
label={copy.fields.taxId}
|
|
85
|
+
maxLength={INVOICE_LIMITS.taxId}
|
|
86
|
+
defaultValue={values[INVOICE_FIELDS.taxId] ?? ""}
|
|
87
|
+
error={fieldError(INVOICE_FIELDS.taxId)}
|
|
88
|
+
locale={locale}
|
|
89
|
+
unstyled={unstyled}
|
|
90
|
+
/>
|
|
91
|
+
<TextField
|
|
92
|
+
name={INVOICE_FIELDS.address}
|
|
93
|
+
label={copy.fields.address}
|
|
94
|
+
autoComplete="street-address"
|
|
95
|
+
required
|
|
96
|
+
maxLength={INVOICE_LIMITS.address}
|
|
97
|
+
defaultValue={values[INVOICE_FIELDS.address] ?? ""}
|
|
98
|
+
error={fieldError(INVOICE_FIELDS.address)}
|
|
99
|
+
locale={locale}
|
|
100
|
+
unstyled={unstyled}
|
|
101
|
+
/>
|
|
102
|
+
</>
|
|
103
|
+
) : null}
|
|
104
|
+
<FormError message={formError} unstyled={unstyled} />
|
|
105
|
+
<Button type="submit" variant="primary" fullWidth pending={isPending} unstyled={unstyled}>
|
|
106
|
+
{isPending ? copy.pending : submitLabel}
|
|
107
|
+
</Button>
|
|
108
|
+
</form>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|