@softure-ai/billing 0.0.0-stage → 0.1.5
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/LICENSE +21 -0
- package/README.md +699 -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 +81 -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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Pages ready to mount with one line each:
|
|
2
|
+
// `export { PaymentPage as default } from "@softure-ai/billing/next"` at `routes.payment`, and
|
|
3
|
+
// `export { BillingAdminPage as default } from "@softure-ai/billing/next"` at `routes.admin`.
|
|
4
|
+
// Server components: they read the config and the session, and render the forms from `../ui`.
|
|
5
|
+
import { requireRole, requireUser } from "@softure-ai/auth/next";
|
|
6
|
+
import { formatMessage, type Locale, type SoftureConfig } from "@softure-ai/core";
|
|
7
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
8
|
+
import { ButtonLink, Card, EmptyState } from "@softure-ai/ui";
|
|
9
|
+
import type { PaymentGrant, Plan } from "../contract.js";
|
|
10
|
+
import { ACCOUNT_PARAM, CHECKOUT_PARAM, CHECKOUT_RESULTS, PLAN_FIELD, type CheckoutResult } from "../fields.js";
|
|
11
|
+
import type { BillingMessages } from "../messages/index.js";
|
|
12
|
+
import { findPlan, getLocalizedText } from "../plans.js";
|
|
13
|
+
import { formatPrice } from "../price.js";
|
|
14
|
+
import { getEntitlement } from "../server/entitlements.js";
|
|
15
|
+
import { getAccountHistory, type AccountHistoryEntry } from "../server/grants.js";
|
|
16
|
+
import { getBillingMessages, getBillingOptions, getBillingRoutes } from "../server/options.js";
|
|
17
|
+
import { findAccountById, getBillingPlans, getPaymentProvider } from "../server/plans.js";
|
|
18
|
+
import { listOpenRequests, type OpenPaymentRequest } from "../server/requests.js";
|
|
19
|
+
import { AccessBadge } from "../ui/access-badge.js";
|
|
20
|
+
import { formatDay, formatLastDay, formatPeriod } from "../ui/format.js";
|
|
21
|
+
import { GrantForm } from "../ui/grant-form.js";
|
|
22
|
+
import { AccountLookup, GrantHistory, type GrantHistoryRow } from "../ui/grant-history.js";
|
|
23
|
+
import { PaymentForm } from "../ui/payment-form.js";
|
|
24
|
+
import { PaymentRequestList, type PaymentRequestRow } from "../ui/payment-requests.js";
|
|
25
|
+
import { PricingTiles } from "../ui/pricing-tiles.js";
|
|
26
|
+
import { CurrentAccessBadge } from "./access.js";
|
|
27
|
+
import { dismissRequestAction, findAccountAction, grantPlanAction, grantRequestAction, revokeGrantAction, startPaymentAction } from "./actions.js";
|
|
28
|
+
import { getBillingContext } from "./context.js";
|
|
29
|
+
import { getCurrentEntitlement } from "./current-entitlement.js";
|
|
30
|
+
import { getPlanPaymentHref } from "./pricing.js";
|
|
31
|
+
|
|
32
|
+
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
|
33
|
+
|
|
34
|
+
export interface PaymentPageProps {
|
|
35
|
+
readonly searchParams?: SearchParams;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:flex sft:w-full sft:flex-col sft:gap-4 sft:sm:max-w-md sft:px-4 sft:py-4";
|
|
39
|
+
const STACK_CLASS = "sft:flex sft:flex-col sft:gap-4";
|
|
40
|
+
const LEAD_CLASS = "sft:m-0 sft:font-sans sft:text-sm sft:text-muted";
|
|
41
|
+
const NOTICE_CLASS =
|
|
42
|
+
"sft:m-0 sft:rounded-control sft:border sft:border-border-strong sft:bg-surface-raised sft:px-4 sft:py-2.5 sft:font-sans sft:text-sm sft:text-foreground";
|
|
43
|
+
|
|
44
|
+
async function readParam(searchParams: SearchParams | undefined, name: string): Promise<string | undefined> {
|
|
45
|
+
const value = (await searchParams)?.[name];
|
|
46
|
+
return Array.isArray(value) ? value[0] : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isCheckoutResult(value: string | undefined): value is CheckoutResult {
|
|
50
|
+
return CHECKOUT_RESULTS.some((result) => result === value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The plans and, with `?plan=<id>`, the order: the plan's price and the provider's form (the
|
|
55
|
+
* invoice request of the manual adapter, a checkout button of a hosted provider). Without a session
|
|
56
|
+
* it sends the visitor to log in and back. A read-only account reaches it too: it is where to pay.
|
|
57
|
+
* A hosted checkout comes back with `?checkout=success` or `?checkout=cancelled`, shown as a notice
|
|
58
|
+
* (the access itself changes when the provider's webhook confirms the payment).
|
|
59
|
+
*/
|
|
60
|
+
export async function PaymentPage({ searchParams }: PaymentPageProps) {
|
|
61
|
+
const config = getSoftureConfig();
|
|
62
|
+
const route = getBillingRoutes(config).payment;
|
|
63
|
+
const planId = await readParam(searchParams, PLAN_FIELD);
|
|
64
|
+
const checkout = await readParam(searchParams, CHECKOUT_PARAM);
|
|
65
|
+
const user = await requireUser({ next: planId === undefined ? route : getPlanPaymentHref(route, planId), searchParams: await searchParams });
|
|
66
|
+
const provider = getPaymentProvider(config);
|
|
67
|
+
const messages = getBillingMessages(config);
|
|
68
|
+
const copy = messages.payment;
|
|
69
|
+
const plans = getBillingPlans(config);
|
|
70
|
+
const plan = planId === undefined ? undefined : findPlan(plans, planId);
|
|
71
|
+
const planName = plan === undefined ? "" : getLocalizedText(plan.name, config.locale);
|
|
72
|
+
const checkoutNotice = isCheckoutResult(checkout) ? (checkout === "success" ? copy.checkoutSuccess : copy.checkoutCancelled) : null;
|
|
73
|
+
const entitlement = await getCurrentEntitlement();
|
|
74
|
+
// Lifetime access leaves nothing to pay for: no order form (startPayment refuses it too).
|
|
75
|
+
const hasLifetime = entitlement?.status === "paid" && entitlement.endsAt === null;
|
|
76
|
+
return (
|
|
77
|
+
<main className={LAYOUT_CLASS}>
|
|
78
|
+
<Card title={copy.title} subtitle={copy.lead}>
|
|
79
|
+
<div className={STACK_CLASS}>
|
|
80
|
+
{checkoutNotice === null ? null : (
|
|
81
|
+
<p role="status" className={NOTICE_CLASS} data-checkout={checkout}>
|
|
82
|
+
{checkoutNotice}
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
<div>
|
|
86
|
+
<CurrentAccessBadge />
|
|
87
|
+
</div>
|
|
88
|
+
{hasLifetime ? (
|
|
89
|
+
<p role="status" className={NOTICE_CLASS} data-lifetime="true">
|
|
90
|
+
{copy.lifetime}
|
|
91
|
+
</p>
|
|
92
|
+
) : null}
|
|
93
|
+
<PricingTiles
|
|
94
|
+
plans={plans}
|
|
95
|
+
messages={messages}
|
|
96
|
+
locale={config.locale}
|
|
97
|
+
getPlanHref={(candidate) => getPlanPaymentHref(route, candidate.id)}
|
|
98
|
+
selectedPlanId={plan?.id}
|
|
99
|
+
label={copy.plansLabel}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</Card>
|
|
103
|
+
{plan === undefined || hasLifetime ? null : (
|
|
104
|
+
<Card
|
|
105
|
+
title={copy.orderTitle}
|
|
106
|
+
subtitle={formatMessage(copy.orderLead, { plan: planName, price: formatPrice(plan.price, config.locale), period: formatPeriod(plan.period, config.locale, messages) })}
|
|
107
|
+
>
|
|
108
|
+
<div className={STACK_CLASS}>
|
|
109
|
+
{provider.collectsInvoiceDetails ? <p className={LEAD_CLASS}>{formatMessage(copy.invoiceLead, { email: user.email })}</p> : null}
|
|
110
|
+
<PaymentForm
|
|
111
|
+
action={startPaymentAction}
|
|
112
|
+
planId={plan.id}
|
|
113
|
+
planName={planName}
|
|
114
|
+
email={user.email}
|
|
115
|
+
collectsInvoiceDetails={provider.collectsInvoiceDetails}
|
|
116
|
+
messages={messages}
|
|
117
|
+
locale={config.locale}
|
|
118
|
+
/>
|
|
119
|
+
<div>
|
|
120
|
+
<ButtonLink href={route} variant="ghost" size="sm">
|
|
121
|
+
{copy.changePlan}
|
|
122
|
+
</ButtonLink>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</Card>
|
|
126
|
+
)}
|
|
127
|
+
</main>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The plan's name in the app's locale, or its id when the config no longer has it. */
|
|
132
|
+
function getPlanName(plans: readonly Plan[], planId: string, locale: Locale): string {
|
|
133
|
+
const plan = findPlan(plans, planId);
|
|
134
|
+
return plan === undefined ? planId : getLocalizedText(plan.name, locale);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface RowContext {
|
|
138
|
+
readonly config: SoftureConfig;
|
|
139
|
+
readonly messages: BillingMessages;
|
|
140
|
+
readonly plans: readonly Plan[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getHistoryHref(config: SoftureConfig, userId: string): string {
|
|
144
|
+
return `${getBillingRoutes(config).admin}?${new URLSearchParams({ [ACCOUNT_PARAM]: userId }).toString()}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toRequestRow(request: OpenPaymentRequest, { config, messages, plans }: RowContext): PaymentRequestRow {
|
|
148
|
+
const copy = messages.admin.requests;
|
|
149
|
+
const values = { email: request.email, plan: getPlanName(plans, request.planId, config.locale) };
|
|
150
|
+
const { invoice } = request;
|
|
151
|
+
const details = [formatMessage(copy.requestedOn, { date: formatDay(request.requestedAt, config.locale, config.timezone) })];
|
|
152
|
+
if (request.price !== null) details.push(formatMessage(copy.price, { price: formatPrice(request.price, config.locale) }));
|
|
153
|
+
if (invoice === null) details.push(copy.noInvoice);
|
|
154
|
+
else {
|
|
155
|
+
details.push(formatMessage(copy.invoice, { name: invoice.name, address: invoice.address }));
|
|
156
|
+
if (invoice.taxId !== null) details.push(formatMessage(copy.taxId, { taxId: invoice.taxId }));
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
id: request.id,
|
|
160
|
+
title: formatMessage(copy.line, values),
|
|
161
|
+
details,
|
|
162
|
+
historyHref: getHistoryHref(config, request.userId),
|
|
163
|
+
grantLabel: formatMessage(copy.grantLabel, values),
|
|
164
|
+
dismissLabel: formatMessage(copy.dismissLabel, values),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function describeGrant(grant: PaymentGrant | null, { config, messages }: RowContext): string {
|
|
169
|
+
const copy = messages.admin.history;
|
|
170
|
+
if (grant === null) return copy.noGrant;
|
|
171
|
+
if (grant.kind === "lifetime") return copy.lifetime;
|
|
172
|
+
return formatMessage(copy.period, { from: formatDay(grant.from, config.locale, config.timezone), to: formatLastDay(grant.until, config.locale, config.timezone) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toHistoryRow(entry: AccountHistoryEntry, context: RowContext): GrantHistoryRow {
|
|
176
|
+
const { config, messages, plans } = context;
|
|
177
|
+
const copy = messages.admin.history;
|
|
178
|
+
const plan = getPlanName(plans, entry.planId, config.locale);
|
|
179
|
+
const formatDate = (date: Date) => formatDay(date, config.locale, config.timezone);
|
|
180
|
+
if (entry.source === "manual") {
|
|
181
|
+
const isActive = entry.status === "active";
|
|
182
|
+
return {
|
|
183
|
+
id: entry.id,
|
|
184
|
+
title: formatMessage(entry.isFromRequest ? copy.fromRequest : copy.manual, { plan }),
|
|
185
|
+
statusText: isActive || entry.revokedAt === null ? copy.active : formatMessage(copy.revokedOn, { date: formatDate(entry.revokedAt) }),
|
|
186
|
+
isCurrent: isActive,
|
|
187
|
+
details: [
|
|
188
|
+
entry.price === null
|
|
189
|
+
? formatMessage(copy.grantedOn, { date: formatDate(entry.at) })
|
|
190
|
+
: formatMessage(copy.grantedFor, { amount: formatPrice(entry.price, config.locale), date: formatDate(entry.at) }),
|
|
191
|
+
describeGrant(entry.grant, context),
|
|
192
|
+
],
|
|
193
|
+
revokeLabel: isActive ? formatMessage(copy.revokeLabel, { plan, date: formatDate(entry.at) }) : null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const isPaid = entry.status === "paid";
|
|
197
|
+
const formatAmount = (amount: number) => formatPrice({ amount, currency: entry.currency }, config.locale);
|
|
198
|
+
let statusText = copy.paid;
|
|
199
|
+
if (!isPaid && entry.refundedAt !== null) statusText = formatMessage(copy.refundedOn, { date: formatDate(entry.refundedAt) });
|
|
200
|
+
else if (entry.refundedAmount > 0) statusText = formatMessage(copy.partlyRefunded, { amount: formatAmount(entry.refundedAmount) });
|
|
201
|
+
return {
|
|
202
|
+
id: entry.id,
|
|
203
|
+
title: formatMessage(copy.provider, { plan, provider: entry.provider }),
|
|
204
|
+
statusText,
|
|
205
|
+
isCurrent: isPaid,
|
|
206
|
+
details: [
|
|
207
|
+
formatMessage(copy.paidOn, { amount: formatAmount(entry.amount), date: formatDate(entry.at) }),
|
|
208
|
+
describeGrant(entry.grant, context),
|
|
209
|
+
],
|
|
210
|
+
revokeLabel: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface BillingAdminPageProps {
|
|
215
|
+
readonly searchParams?: SearchParams;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The admin page of manual payments: the open invoice requests (grant or dismiss each), the grant
|
|
220
|
+
* form, and an account's history (`?account=<id>`, reached by the email lookup or a request's
|
|
221
|
+
* link) with its access and a revoke button on each active manual grant. Anyone without the role
|
|
222
|
+
* of `billing({ adminRole })`, signed in or not, gets Next's "not found".
|
|
223
|
+
*/
|
|
224
|
+
export async function BillingAdminPage({ searchParams }: BillingAdminPageProps) {
|
|
225
|
+
const config = getSoftureConfig();
|
|
226
|
+
await requireRole(getBillingOptions(config).adminRole);
|
|
227
|
+
const messages = getBillingMessages(config);
|
|
228
|
+
const plans = getBillingPlans(config);
|
|
229
|
+
const context: RowContext = { config, messages, plans };
|
|
230
|
+
const ctx = await getBillingContext(config);
|
|
231
|
+
const requests = (await listOpenRequests(ctx)).map((request) => toRequestRow(request, context));
|
|
232
|
+
const accountId = await readParam(searchParams, ACCOUNT_PARAM);
|
|
233
|
+
const account = accountId === undefined ? null : await findAccountById(ctx, accountId);
|
|
234
|
+
const entitlement = account === null ? null : await getEntitlement(ctx, account.id);
|
|
235
|
+
const history = account === null ? [] : (await getAccountHistory(ctx, account.id)).map((entry) => toHistoryRow(entry, context));
|
|
236
|
+
const planOptions = plans.map((plan) => ({ value: plan.id, label: getLocalizedText(plan.name, config.locale) }));
|
|
237
|
+
return (
|
|
238
|
+
<main className={LAYOUT_CLASS}>
|
|
239
|
+
<Card title={messages.admin.requests.title} subtitle={messages.admin.requests.lead}>
|
|
240
|
+
<PaymentRequestList requests={requests} grantAction={grantRequestAction} dismissAction={dismissRequestAction} messages={messages} />
|
|
241
|
+
</Card>
|
|
242
|
+
<Card title={messages.admin.title} subtitle={messages.admin.lead}>
|
|
243
|
+
{planOptions.length === 0 ? (
|
|
244
|
+
<EmptyState title={messages.admin.noPlans} />
|
|
245
|
+
) : (
|
|
246
|
+
<GrantForm action={grantPlanAction} plans={planOptions} messages={messages} locale={config.locale} />
|
|
247
|
+
)}
|
|
248
|
+
</Card>
|
|
249
|
+
<Card title={messages.admin.history.title} subtitle={messages.admin.history.lead}>
|
|
250
|
+
<div className={STACK_CLASS}>
|
|
251
|
+
<AccountLookup action={findAccountAction} messages={messages} locale={config.locale} />
|
|
252
|
+
{account === null ? null : (
|
|
253
|
+
<section className={STACK_CLASS} aria-label={formatMessage(messages.admin.history.accountTitle, { email: account.email })}>
|
|
254
|
+
<p className={LEAD_CLASS}>{formatMessage(messages.admin.history.accountTitle, { email: account.email })}</p>
|
|
255
|
+
{entitlement === null ? null : (
|
|
256
|
+
<div>
|
|
257
|
+
<AccessBadge entitlement={entitlement} messages={messages} locale={config.locale} timezone={config.timezone} />
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
<GrantHistory rows={history} revokeAction={revokeGrantAction} messages={messages} />
|
|
261
|
+
</section>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
</Card>
|
|
265
|
+
</main>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The pricing tiles wired to the registered config, for any server component (a landing or a
|
|
2
|
+
// pricing page): `<Pricing LinkComponent={Link} />`. Each tile's button opens the payment page with
|
|
3
|
+
// its plan; the page asks a visitor without a session to log in first.
|
|
4
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
5
|
+
import type { ClassNames, LinkComponentType } from "@softure-ai/ui";
|
|
6
|
+
import { PLAN_FIELD } from "../fields.js";
|
|
7
|
+
import { getBillingMessages, getBillingRoutes } from "../server/options.js";
|
|
8
|
+
import { getBillingPlans } from "../server/plans.js";
|
|
9
|
+
import { PricingTiles, type PricingTilesSlot } from "../ui/pricing-tiles.js";
|
|
10
|
+
|
|
11
|
+
export interface PricingProps {
|
|
12
|
+
/** The app's link component, e.g. Next's `Link`; a plain `<a>` when omitted. */
|
|
13
|
+
readonly LinkComponent?: LinkComponentType;
|
|
14
|
+
readonly classNames?: ClassNames<PricingTilesSlot>;
|
|
15
|
+
readonly unstyled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The payment page with a plan chosen. */
|
|
19
|
+
export function getPlanPaymentHref(paymentRoute: string, planId: string): string {
|
|
20
|
+
return `${paymentRoute}?${PLAN_FIELD}=${encodeURIComponent(planId)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Pricing({ LinkComponent, classNames, unstyled }: PricingProps) {
|
|
24
|
+
const config = getSoftureConfig();
|
|
25
|
+
const messages = getBillingMessages(config);
|
|
26
|
+
const payment = getBillingRoutes(config).payment;
|
|
27
|
+
return (
|
|
28
|
+
<PricingTiles
|
|
29
|
+
plans={getBillingPlans(config)}
|
|
30
|
+
messages={messages}
|
|
31
|
+
locale={config.locale}
|
|
32
|
+
getPlanHref={(plan) => getPlanPaymentHref(payment, plan.id)}
|
|
33
|
+
LinkComponent={LinkComponent}
|
|
34
|
+
label={messages.payment.plansLabel}
|
|
35
|
+
classNames={classNames}
|
|
36
|
+
unstyled={unstyled}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The Stripe webhook route. Mount it with a rename in app/api/billing/webhook/route.ts:
|
|
2
|
+
// `export { stripeWebhookRoute as POST } from "@softure-ai/billing/next"`, and point a Stripe
|
|
3
|
+
// webhook endpoint at it for `checkout.session.completed`, `checkout.session.async_payment_succeeded`
|
|
4
|
+
// and `charge.refunded`.
|
|
5
|
+
//
|
|
6
|
+
// Stripe calls it from its own servers: no session, no cookies, so it stays outside any auth
|
|
7
|
+
// guard. There is no rate limit on purpose: Stripe sends from a few addresses, and a bucket would
|
|
8
|
+
// drop real payments. The body is capped and the signature checked before the database is touched.
|
|
9
|
+
import { errorLogLabel } from "@softure-ai/core";
|
|
10
|
+
import { readSmallBody } from "@softure-ai/security";
|
|
11
|
+
import { receiveStripeWebhook } from "../server/payments.js";
|
|
12
|
+
import { STRIPE_SIGNATURE_HEADER } from "../stripe-webhook.js";
|
|
13
|
+
import { getBillingContext } from "./context.js";
|
|
14
|
+
|
|
15
|
+
export const STRIPE_WEBHOOK_SECRET_ENV = "STRIPE_WEBHOOK_SECRET";
|
|
16
|
+
/** Stripe's checkout and charge events are a few kilobytes; anything far larger is not Stripe. */
|
|
17
|
+
export const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024;
|
|
18
|
+
|
|
19
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
20
|
+
|
|
21
|
+
function answer(status: number): Response {
|
|
22
|
+
return new Response(null, { status, headers: NO_STORE });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Handles one delivery: 200 once it is recorded (also again, and for events billing ignores), 400
|
|
27
|
+
* for a delivery that is not signed by Stripe within the tolerance, 413 for an oversized body, 500
|
|
28
|
+
* when the secret is missing or the database fails, so Stripe retries. A payment whose account or
|
|
29
|
+
* plan is gone answers 200 and is logged: a retry cannot fix it, the owner refunds it in Stripe.
|
|
30
|
+
*/
|
|
31
|
+
export async function stripeWebhookRoute(request: Request): Promise<Response> {
|
|
32
|
+
const secret = (process.env[STRIPE_WEBHOOK_SECRET_ENV] ?? "").trim();
|
|
33
|
+
if (secret === "") {
|
|
34
|
+
console.error(`@softure-ai/billing: the Stripe webhook has no secret; set ${STRIPE_WEBHOOK_SECRET_ENV}`);
|
|
35
|
+
return answer(500);
|
|
36
|
+
}
|
|
37
|
+
const body = await readSmallBody(request, { maxBytes: STRIPE_WEBHOOK_MAX_BYTES });
|
|
38
|
+
if (!body.ok) return answer(body.error === "security.body_too_large" ? 413 : 400);
|
|
39
|
+
|
|
40
|
+
let received;
|
|
41
|
+
try {
|
|
42
|
+
received = await receiveStripeWebhook(await getBillingContext(), {
|
|
43
|
+
payload: body.value,
|
|
44
|
+
signature: request.headers.get(STRIPE_SIGNATURE_HEADER),
|
|
45
|
+
secret,
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`@softure-ai/billing: a Stripe webhook failed: ${errorLogLabel(error)}`);
|
|
49
|
+
return answer(500);
|
|
50
|
+
}
|
|
51
|
+
if (!received.ok) return answer(400);
|
|
52
|
+
const { outcome } = received.value;
|
|
53
|
+
if (outcome.status === "refused") {
|
|
54
|
+
console.error(`@softure-ai/billing: the paid Stripe checkout ${outcome.checkoutId} granted nothing (${outcome.error}); refund it in the Stripe dashboard`);
|
|
55
|
+
}
|
|
56
|
+
return answer(200);
|
|
57
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The options an app passes to `billing({ ... })` in softure.config.ts, parsed at startup.
|
|
2
|
+
import { LOCALES } from "@softure-ai/core";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { parseDay } from "./calendar.js";
|
|
5
|
+
import { PERIOD_UNITS, type PlanPeriod } from "./contract.js";
|
|
6
|
+
import { isPaymentProvider, type PaymentProvider } from "./payment.js";
|
|
7
|
+
import { isSupportedCurrency } from "./price.js";
|
|
8
|
+
|
|
9
|
+
/** The longest trial or reminder window, in days. */
|
|
10
|
+
export const MAX_DAYS = 365;
|
|
11
|
+
/** The most plans the config declares. */
|
|
12
|
+
export const MAX_PLANS = 12;
|
|
13
|
+
/** The most feature lines one plan lists. */
|
|
14
|
+
export const MAX_FEATURES = 20;
|
|
15
|
+
/** The highest price, in the currency's minor unit. */
|
|
16
|
+
export const MAX_PRICE_AMOUNT = 100_000_000;
|
|
17
|
+
/** The most units one period counts. */
|
|
18
|
+
export const MAX_PERIOD_COUNT = 1000;
|
|
19
|
+
/** How long an open invoice request waits by default before it expires, in days. */
|
|
20
|
+
export const DEFAULT_REQUEST_EXPIRY_DAYS = 30;
|
|
21
|
+
/**
|
|
22
|
+
* What a partial refund of a provider payment does to access: `pro_rata` takes back the refunded
|
|
23
|
+
* share of the payment's unused days, `keep_access` takes back nothing until the whole payment is
|
|
24
|
+
* refunded.
|
|
25
|
+
*/
|
|
26
|
+
export const PARTIAL_REFUND_POLICIES = ["pro_rata", "keep_access"] as const;
|
|
27
|
+
export type PartialRefundPolicy = (typeof PARTIAL_REFUND_POLICIES)[number];
|
|
28
|
+
|
|
29
|
+
const daysSchema = z.number().int().min(0).max(MAX_DAYS);
|
|
30
|
+
|
|
31
|
+
/** Kebab-case, at most 64 characters. */
|
|
32
|
+
const PLAN_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
33
|
+
|
|
34
|
+
/** Copy per locale; a locale without its own text falls back to `en`. */
|
|
35
|
+
const localizedTextSchema = z
|
|
36
|
+
.partialRecord(z.enum(LOCALES), z.string().trim().min(1).max(500))
|
|
37
|
+
.refine((text) => text.en !== undefined, "needs at least an en text");
|
|
38
|
+
|
|
39
|
+
const periodSchema = z.union([
|
|
40
|
+
z.enum([...PERIOD_UNITS, "lifetime"]).transform((unit): PlanPeriod => (unit === "lifetime" ? { unit } : { unit, count: 1 })),
|
|
41
|
+
z.strictObject({ unit: z.enum(PERIOD_UNITS), count: z.number().int().min(1).max(MAX_PERIOD_COUNT).default(1) }),
|
|
42
|
+
z.strictObject({ unit: z.literal("lifetime") }),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const planSchema = z.strictObject({
|
|
46
|
+
/** Kebab-case and unique, e.g. `monthly`; the payment page and the providers name the plan by it. */
|
|
47
|
+
id: z.string().max(64, "must be at most 64 characters").regex(PLAN_ID_PATTERN, "must be kebab-case, e.g. pro-yearly"),
|
|
48
|
+
name: localizedTextSchema,
|
|
49
|
+
description: localizedTextSchema.optional(),
|
|
50
|
+
price: z.strictObject({
|
|
51
|
+
/** In the currency's minor unit: 2900 is 29.00 PLN. */
|
|
52
|
+
amount: z.number().int().min(0).max(MAX_PRICE_AMOUNT),
|
|
53
|
+
currency: z.string().refine((code) => /^[A-Z]{3}$/.test(code) && isSupportedCurrency(code), "must be an upper-case ISO 4217 currency code billing knows, e.g. PLN"),
|
|
54
|
+
}),
|
|
55
|
+
/** `"month"`, `"year"`, `"lifetime"`, or `{ unit, count }` such as `{ unit: "month", count: 3 }`. */
|
|
56
|
+
period: periodSchema,
|
|
57
|
+
features: z.array(localizedTextSchema).max(MAX_FEATURES).default([]),
|
|
58
|
+
isFeatured: z.boolean().default(false),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const billingOptionsSchema = z.strictObject({
|
|
62
|
+
trial: z
|
|
63
|
+
.strictObject({
|
|
64
|
+
/** Length of the trial every account starts with, the registration day included; 0 for none. */
|
|
65
|
+
days: daysSchema.default(14),
|
|
66
|
+
/** From how many days left the trial notice shows; 0 never. */
|
|
67
|
+
reminderDays: daysSchema.default(3),
|
|
68
|
+
/**
|
|
69
|
+
* The first day a trial can start, `YYYY-MM-DD` in the app's time zone: an account created
|
|
70
|
+
* before it (without an entitlement row) gets its trial from this day. For turning billing on
|
|
71
|
+
* for accounts that already exist.
|
|
72
|
+
*/
|
|
73
|
+
startsAt: z
|
|
74
|
+
.string()
|
|
75
|
+
.refine((day) => parseDay(day) !== null, "must be a calendar day as YYYY-MM-DD, e.g. 2026-11-01")
|
|
76
|
+
.optional(),
|
|
77
|
+
})
|
|
78
|
+
.prefault({}),
|
|
79
|
+
paid: z
|
|
80
|
+
.strictObject({
|
|
81
|
+
/** From how many days left the renewal notice shows; 0 never. Lifetime access never ends. */
|
|
82
|
+
reminderDays: daysSchema.default(7),
|
|
83
|
+
})
|
|
84
|
+
.prefault({}),
|
|
85
|
+
requests: z
|
|
86
|
+
.strictObject({
|
|
87
|
+
/**
|
|
88
|
+
* Days an open invoice request waits for the admin, counted from the buyer's last ask:
|
|
89
|
+
* `expireStaleRequests` (run daily) then closes it as `expired` and clears its invoice
|
|
90
|
+
* details. From 1 to 365.
|
|
91
|
+
*/
|
|
92
|
+
expireAfterDays: z.number().int().min(1).max(MAX_DAYS).default(DEFAULT_REQUEST_EXPIRY_DAYS),
|
|
93
|
+
})
|
|
94
|
+
.prefault({}),
|
|
95
|
+
/** The plans the pricing tiles and the payment page offer, in the order they show them. */
|
|
96
|
+
plans: z
|
|
97
|
+
.array(planSchema)
|
|
98
|
+
.max(MAX_PLANS)
|
|
99
|
+
.default([])
|
|
100
|
+
.superRefine((plans, context) => {
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
plans.forEach((plan, index) => {
|
|
103
|
+
if (seen.has(plan.id)) context.addIssue({ code: "custom", message: `repeats the plan id "${plan.id}"`, path: [index, "id"] });
|
|
104
|
+
seen.add(plan.id);
|
|
105
|
+
});
|
|
106
|
+
}),
|
|
107
|
+
/** The payment adapter, e.g. `manual({ onRequest })`; the payment page needs one. */
|
|
108
|
+
payment: z.custom<PaymentProvider>(isPaymentProvider, "must be a payment provider such as manual()").optional(),
|
|
109
|
+
/**
|
|
110
|
+
* A partial refund of a provider payment: `pro_rata` takes back the share of the payment's unused
|
|
111
|
+
* days that the refunded money is of the money not refunded before (whole days, rounded down);
|
|
112
|
+
* `keep_access` takes back nothing. Either way the refund that completes the amount acts as a
|
|
113
|
+
* full refund, and a lifetime ends only then.
|
|
114
|
+
*/
|
|
115
|
+
partialRefunds: z.enum(PARTIAL_REFUND_POLICIES).default("pro_rata"),
|
|
116
|
+
/** The auth role that may grant plans in the admin page; declared in `auth({ roles })` unless `admin`. */
|
|
117
|
+
adminRole: z.string().min(1).default("admin"),
|
|
118
|
+
}).superRefine((options, context) => {
|
|
119
|
+
const { payment } = options;
|
|
120
|
+
if (payment?.checkPrice === undefined) return;
|
|
121
|
+
options.plans.forEach((plan, index) => {
|
|
122
|
+
const problem = payment.checkPrice?.(plan.price) ?? null;
|
|
123
|
+
if (problem !== null) context.addIssue({ code: "custom", message: problem, path: ["plans", index, "price"] });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export type BillingOptionsInput = z.input<typeof billingOptionsSchema>;
|
|
128
|
+
export type BillingOptions = z.output<typeof billingOptionsSchema>;
|
package/src/payment.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The contract between the billing module and a payment provider: the manual adapter here, a card
|
|
2
|
+
// or transfer provider later (MO-3). The module validates the request and counts the attempt; the
|
|
3
|
+
// provider starts the payment and says where the buyer goes next. Access is granted afterwards,
|
|
4
|
+
// through `grantPlan` (an admin for manual payments, a verified webhook for a provider).
|
|
5
|
+
import type { Err, ModuleContext, Ok } from "@softure-ai/core";
|
|
6
|
+
import type { Queryable } from "@softure-ai/db";
|
|
7
|
+
import type { Plan, PlanPrice } from "./contract.js";
|
|
8
|
+
|
|
9
|
+
/** The signed-in account that pays. */
|
|
10
|
+
export interface PaymentAccount {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly email: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What an invoice needs, as the buyer typed it (trimmed). */
|
|
16
|
+
export interface InvoiceDetails {
|
|
17
|
+
/** A person's or a company's name. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** A tax number such as a Polish NIP or an EU VAT id; null when not given. */
|
|
20
|
+
readonly taxId: string | null;
|
|
21
|
+
readonly address: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PaymentRequest {
|
|
25
|
+
readonly plan: Plan;
|
|
26
|
+
readonly account: PaymentAccount;
|
|
27
|
+
/** The details the payment page asked for; null when the provider collects its own. */
|
|
28
|
+
readonly invoice: InvoiceDetails | null;
|
|
29
|
+
/** The payment page as an absolute URL, where a hosted checkout sends the buyer back. */
|
|
30
|
+
readonly returnUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where the buyer goes next. */
|
|
34
|
+
export type PaymentStart =
|
|
35
|
+
/** A hosted checkout (e.g. a provider's payment page); the browser is sent there. */
|
|
36
|
+
| { readonly type: "redirect"; readonly url: string }
|
|
37
|
+
/** The request was handed over (e.g. an invoice requested); access follows once it is paid. */
|
|
38
|
+
| { readonly type: "requested" };
|
|
39
|
+
|
|
40
|
+
export type PaymentContext = ModuleContext<Queryable>;
|
|
41
|
+
|
|
42
|
+
/** A payment adapter for `billing({ payment })`, e.g. `manual({ onRequest })`. */
|
|
43
|
+
export interface PaymentProvider {
|
|
44
|
+
/** A short lowercase name for logs, e.g. `manual`. */
|
|
45
|
+
readonly name: string;
|
|
46
|
+
/** Whether the payment page asks for invoice details before `startPayment`. */
|
|
47
|
+
readonly collectsInvoiceDetails: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Whether `startPayment` hands a request to the owner and answers `requested` (the manual
|
|
50
|
+
* adapter) rather than sending the buyer to a checkout. Such a request is stored before the
|
|
51
|
+
* call, and the call is made once per open request: asking again only refreshes the stored one.
|
|
52
|
+
*/
|
|
53
|
+
readonly handsOverRequests: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Starts paying for `request.plan`. An expected failure (the provider refused, a notification
|
|
56
|
+
* could not be sent) is `billing.payment_failed`; anything thrown is a bug or an outage.
|
|
57
|
+
*/
|
|
58
|
+
startPayment(ctx: PaymentContext, request: PaymentRequest): Promise<Ok<PaymentStart> | Err<"billing.payment_failed">>;
|
|
59
|
+
/**
|
|
60
|
+
* Why the provider cannot charge `price` (e.g. a fraction of its currency's unit), or null when it
|
|
61
|
+
* can. Run for every plan when the config loads, so the deployer meets it, not the buyer.
|
|
62
|
+
*/
|
|
63
|
+
checkPrice?(price: PlanPrice): string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isPaymentProvider(value: unknown): value is PaymentProvider {
|
|
67
|
+
if (typeof value !== "object" || value === null) return false;
|
|
68
|
+
const candidate = value as Partial<Record<keyof PaymentProvider, unknown>>;
|
|
69
|
+
return (
|
|
70
|
+
typeof candidate.name === "string" &&
|
|
71
|
+
candidate.name !== "" &&
|
|
72
|
+
typeof candidate.collectsInvoiceDetails === "boolean" &&
|
|
73
|
+
typeof candidate.handsOverRequests === "boolean" &&
|
|
74
|
+
typeof candidate.startPayment === "function" &&
|
|
75
|
+
(candidate.checkPrice === undefined || typeof candidate.checkPrice === "function")
|
|
76
|
+
);
|
|
77
|
+
}
|
package/src/plans.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Plans from `billing({ plans })`, pure: how long a payment of a plan gives access and the change it
|
|
2
|
+
// makes to an entitlement. Periods run in local calendar days, like trials, so a month bought on
|
|
3
|
+
// 3 October covers every day up to 2 November and ends when 3 November begins in the app's time zone.
|
|
4
|
+
import type { Locale } from "@softure-ai/core";
|
|
5
|
+
import { getDayNumber, getStartOfDay } from "./calendar.js";
|
|
6
|
+
import type { EntitlementEvent, EntitlementRecord, LocalizedText, Plan, PlanPeriod } from "./contract.js";
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DAYS_PER_WEEK = 7;
|
|
10
|
+
const MONTHS_PER_YEAR = 12;
|
|
11
|
+
|
|
12
|
+
/** The text in `locale`, else the `en` text (the options require one). */
|
|
13
|
+
export function getLocalizedText(text: LocalizedText, locale: Locale): string {
|
|
14
|
+
return text[locale] ?? text.en ?? "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The plan with this id, or undefined. */
|
|
18
|
+
export function findPlan(plans: readonly Plan[], planId: string): Plan | undefined {
|
|
19
|
+
return plans.find((plan) => plan.id === planId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A day number `months` calendar months later; a day the target month lacks becomes its last day. */
|
|
23
|
+
function addMonths(dayNumber: number, months: number): number {
|
|
24
|
+
const date = new Date(dayNumber * DAY_MS);
|
|
25
|
+
const year = date.getUTCFullYear();
|
|
26
|
+
const month = date.getUTCMonth() + months;
|
|
27
|
+
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
28
|
+
return Math.floor(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)) / DAY_MS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The end of one period begun at `start`: the start of the local day one period after the start
|
|
33
|
+
* day. The start day counts as the first day, as for trials. Lifetime periods have no end.
|
|
34
|
+
*/
|
|
35
|
+
export function getPeriodEnd(start: Date, period: Exclude<PlanPeriod, { unit: "lifetime" }>, timezone: string): Date {
|
|
36
|
+
const day = getDayNumber(start, timezone);
|
|
37
|
+
switch (period.unit) {
|
|
38
|
+
case "day":
|
|
39
|
+
return getStartOfDay(day + period.count, timezone);
|
|
40
|
+
case "week":
|
|
41
|
+
return getStartOfDay(day + period.count * DAYS_PER_WEEK, timezone);
|
|
42
|
+
case "month":
|
|
43
|
+
return getStartOfDay(addMonths(day, period.count), timezone);
|
|
44
|
+
case "year":
|
|
45
|
+
return getStartOfDay(addMonths(day, period.count * MONTHS_PER_YEAR), timezone);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The latest of some instants. */
|
|
50
|
+
function getLatest(first: Date, ...rest: readonly Date[]): Date {
|
|
51
|
+
return rest.reduce((latest, instant) => (instant > latest ? instant : latest), first);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The change one payment of `plan` makes to `record` at `now`. A paid period starts when the access
|
|
56
|
+
* the account already has ends (a running trial or paid access), so paying early loses no day; a
|
|
57
|
+
* lifetime plan grants lifetime access.
|
|
58
|
+
*/
|
|
59
|
+
export function getPlanGrant(record: EntitlementRecord, plan: Plan, now: Date, timezone: string): EntitlementEvent {
|
|
60
|
+
if (plan.period.unit === "lifetime") return { type: "grant_lifetime" };
|
|
61
|
+
const start = getLatest(now, record.trialEndsAt, ...(record.paidUntil === null ? [] : [record.paidUntil]));
|
|
62
|
+
return { type: "grant", until: getPeriodEnd(start, plan.period, timezone) };
|
|
63
|
+
}
|