@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,84 @@
|
|
|
1
|
+
// The entitlement state machine, pure: a record and an instant give the status, and an event gives
|
|
2
|
+
// the next record. No database and no clock here, so every transition is a plain unit test.
|
|
3
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
4
|
+
import { getDaysLeft } from "./calendar.js";
|
|
5
|
+
import type { BillingErrorCode, Entitlement, EntitlementEvent, EntitlementRecord } from "./contract.js";
|
|
6
|
+
|
|
7
|
+
export interface EntitlementPolicy {
|
|
8
|
+
/** The app's IANA time zone: days left are counted there. */
|
|
9
|
+
readonly timezone: string;
|
|
10
|
+
/** From how many days left a trial counts as ending; 0 never. */
|
|
11
|
+
readonly trialReminderDays: number;
|
|
12
|
+
/** From how many days left paid access counts as ending; 0 never. */
|
|
13
|
+
readonly paidReminderDays: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The status of `record` at `now`. Paid access wins over the trial; access covers instants before
|
|
18
|
+
* its end, so at the end itself the account is read-only.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveEntitlement(record: EntitlementRecord, now: Date, policy: EntitlementPolicy): Entitlement {
|
|
21
|
+
if (record.isLifetime) return { status: "paid", endsAt: null, daysLeft: null, isEnding: false };
|
|
22
|
+
if (record.paidUntil !== null && now < record.paidUntil) {
|
|
23
|
+
const daysLeft = getDaysLeft(record.paidUntil, now, policy.timezone);
|
|
24
|
+
return { status: "paid", endsAt: record.paidUntil, daysLeft, isEnding: daysLeft <= policy.paidReminderDays };
|
|
25
|
+
}
|
|
26
|
+
if (now < record.trialEndsAt) {
|
|
27
|
+
const daysLeft = getDaysLeft(record.trialEndsAt, now, policy.timezone);
|
|
28
|
+
return { status: "trial", endsAt: record.trialEndsAt, daysLeft, isEnding: daysLeft <= policy.trialReminderDays };
|
|
29
|
+
}
|
|
30
|
+
if (record.paidUntil !== null && record.paidUntil > record.trialEndsAt) return { status: "read_only", since: record.paidUntil, reason: "paid_ended" };
|
|
31
|
+
return { status: "read_only", since: record.trialEndsAt, reason: "trial_ended" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether an account in this state may write. */
|
|
35
|
+
export function hasWriteAccess(entitlement: Entitlement): boolean {
|
|
36
|
+
return entitlement.status !== "read_only";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The later of two instants. */
|
|
40
|
+
function getLater(first: Date, second: Date): Date {
|
|
41
|
+
return first > second ? first : second;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The earlier of two instants. */
|
|
45
|
+
function getEarlier(first: Date, second: Date): Date {
|
|
46
|
+
return first < second ? first : second;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The record after `event` at `now`. Grants and trial extensions must end after `now` and never
|
|
51
|
+
* shorten what the account already has; an import never shortens either, but may record past ends;
|
|
52
|
+
* a revoke ends paid access, lifetime included. The dated end lives on under lifetime access: a
|
|
53
|
+
* grant still extends it, and ending lifetime falls back to it.
|
|
54
|
+
*/
|
|
55
|
+
export function applyEntitlementEvent(record: EntitlementRecord, event: EntitlementEvent, now: Date): Ok<EntitlementRecord> | Err<BillingErrorCode> {
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case "grant": {
|
|
58
|
+
if (event.until <= now) return err("billing.end_not_in_future");
|
|
59
|
+
const paidUntil = record.paidUntil === null ? event.until : getLater(record.paidUntil, event.until);
|
|
60
|
+
return ok({ ...record, paidUntil });
|
|
61
|
+
}
|
|
62
|
+
case "grant_lifetime":
|
|
63
|
+
return ok({ ...record, isLifetime: true });
|
|
64
|
+
case "revoke":
|
|
65
|
+
return ok({ ...record, paidUntil: null, isLifetime: false });
|
|
66
|
+
case "shorten": {
|
|
67
|
+
if (record.paidUntil === null) return ok(record);
|
|
68
|
+
const paidUntil = getEarlier(record.paidUntil, event.until);
|
|
69
|
+
// Paid access that would end inside the trial adds nothing: the account is back on its trial.
|
|
70
|
+
return ok({ ...record, paidUntil: paidUntil > record.trialEndsAt ? paidUntil : null });
|
|
71
|
+
}
|
|
72
|
+
case "end_lifetime":
|
|
73
|
+
return ok({ ...record, isLifetime: false });
|
|
74
|
+
case "extend_trial":
|
|
75
|
+
if (event.until <= now) return err("billing.end_not_in_future");
|
|
76
|
+
return ok({ ...record, trialEndsAt: getLater(record.trialEndsAt, event.until) });
|
|
77
|
+
case "import":
|
|
78
|
+
return ok({
|
|
79
|
+
trialEndsAt: event.trialEndsAt === null ? record.trialEndsAt : getLater(record.trialEndsAt, event.trialEndsAt),
|
|
80
|
+
paidUntil: event.paidUntil === null || (record.paidUntil !== null && record.paidUntil >= event.paidUntil) ? record.paidUntil : event.paidUntil,
|
|
81
|
+
isLifetime: record.isLifetime || event.isLifetime,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Form field names shared by the forms (`/ui`) and the actions that read them (`/next`).
|
|
2
|
+
export const PLAN_FIELD = "plan";
|
|
3
|
+
export const EMAIL_FIELD = "email";
|
|
4
|
+
|
|
5
|
+
export const INVOICE_FIELDS = {
|
|
6
|
+
name: "invoiceName",
|
|
7
|
+
taxId: "invoiceTaxId",
|
|
8
|
+
address: "invoiceAddress",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type InvoiceField = (typeof INVOICE_FIELDS)[keyof typeof INVOICE_FIELDS];
|
|
12
|
+
|
|
13
|
+
export const INVOICE_LIMITS = {
|
|
14
|
+
name: 200,
|
|
15
|
+
taxId: 32,
|
|
16
|
+
address: 500,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** The limit of the invoice field with this form name, for the too-long message. */
|
|
20
|
+
export function getInvoiceFieldLimit(field: string): number | undefined {
|
|
21
|
+
const key = (Object.keys(INVOICE_FIELDS) as (keyof typeof INVOICE_FIELDS)[]).find((name) => INVOICE_FIELDS[name] === field);
|
|
22
|
+
return key === undefined ? undefined : INVOICE_LIMITS[key];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The payment page's parameter a hosted checkout returns with: `?checkout=success` or `?checkout=cancelled`. */
|
|
26
|
+
export const CHECKOUT_PARAM = "checkout";
|
|
27
|
+
|
|
28
|
+
export const CHECKOUT_RESULTS = ["success", "cancelled"] as const;
|
|
29
|
+
|
|
30
|
+
export type CheckoutResult = (typeof CHECKOUT_RESULTS)[number];
|
|
31
|
+
|
|
32
|
+
/** The admin page's hidden fields: the request a button grants or dismisses, the grant one revokes. */
|
|
33
|
+
export const REQUEST_FIELD = "request";
|
|
34
|
+
export const GRANT_FIELD = "grant";
|
|
35
|
+
|
|
36
|
+
/** The admin page's parameter that names the account whose history it shows (its id, never its email). */
|
|
37
|
+
export const ACCOUNT_PARAM = "account";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Public API of @softure-ai/billing: the module factory for softure.config.ts, its rate limit bucket,
|
|
2
|
+
// the manual and Stripe payment adapters, Stripe's webhook signature, the pure entitlement state
|
|
3
|
+
// machine and plan periods, prices, types, messages and the tables. Reading and changing
|
|
4
|
+
// entitlements, granting plans, starting payments and recording provider payments is in
|
|
5
|
+
// `@softure-ai/billing/server`, the Next.js adapter (write guard, payment and admin pages, actions,
|
|
6
|
+
// the Stripe webhook route, current badge and notice) in `/next`, the components in `/ui`.
|
|
7
|
+
import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
|
|
8
|
+
import { billingMessages } from "./messages/index.js";
|
|
9
|
+
import { billingOptionsSchema } from "./options.js";
|
|
10
|
+
import { checkBillingTables } from "./server/health.js";
|
|
11
|
+
import { billingPrivacyContributor } from "./server/privacy.js";
|
|
12
|
+
|
|
13
|
+
export const MODULE_ID = "billing";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The rate limit bucket payments consume, with its default to spread into `security({ buckets })`:
|
|
17
|
+
* `billing-payment` per account (a payment page sent again and again).
|
|
18
|
+
*/
|
|
19
|
+
export const BILLING_RATE_LIMIT_BUCKETS = {
|
|
20
|
+
"billing-payment": { limit: 5, windowMinutes: 60 },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enables entitlements, plans and payments in `softure.config.ts` (after `security` and `auth`):
|
|
25
|
+
* `billing({ trial: { days: 14 }, plans: [{ id: "monthly", name: { en: "Monthly" }, price: { amount: 2900, currency: "PLN" }, period: "month" }], payment: stripe() })`
|
|
26
|
+
* (or `manual({ onRequest })` for invoices).
|
|
27
|
+
*/
|
|
28
|
+
export const billing = defineModule({
|
|
29
|
+
manifest: {
|
|
30
|
+
id: MODULE_ID,
|
|
31
|
+
version: "0.1.5",
|
|
32
|
+
dependsOn: { security: "^0.1.0", auth: "^0.1.0" },
|
|
33
|
+
dbSchema: "billing",
|
|
34
|
+
tables: ["entitlements", "payments", "payment_requests", "manual_grants"],
|
|
35
|
+
env: [
|
|
36
|
+
{
|
|
37
|
+
name: "STRIPE_SECRET_KEY",
|
|
38
|
+
required: false,
|
|
39
|
+
description: "Secret API key of the stripe() payment adapter (sk_test_... in the sandbox), read on every payment; not needed with stripe({ secretKey }) or another adapter.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "STRIPE_WEBHOOK_SECRET",
|
|
43
|
+
required: false,
|
|
44
|
+
description: "Signing secret (whsec_...) of the Stripe webhook endpoint that points at stripeWebhookRoute; required when that route is mounted.",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
switches: [],
|
|
48
|
+
routes: { payment: "/payment", admin: "/admin/billing", webhook: "/api/billing/webhook" },
|
|
49
|
+
mount: [{ kind: "route-handler", path: "app/api/billing/webhook/route.ts", export: "stripeWebhookRoute" }],
|
|
50
|
+
privacy: { exports: true, deletes: true },
|
|
51
|
+
},
|
|
52
|
+
messages: billingMessages,
|
|
53
|
+
options: billingOptionsSchema,
|
|
54
|
+
migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
|
|
55
|
+
privacy: billingPrivacyContributor,
|
|
56
|
+
health: checkBillingTables,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export { getDayNumber, getDaysLeft, getStartOfDay, getTrialEnd, parseDay } from "./calendar.js";
|
|
60
|
+
export {
|
|
61
|
+
ENTITLEMENT_STATUSES,
|
|
62
|
+
INITIAL_ADMIN_ACTION_STATE,
|
|
63
|
+
INITIAL_GRANT_FORM_STATE,
|
|
64
|
+
INITIAL_PAYMENT_FORM_STATE,
|
|
65
|
+
PERIOD_UNITS,
|
|
66
|
+
type AdminActionErrorCode,
|
|
67
|
+
type AdminActionState,
|
|
68
|
+
type AdminErrorCode,
|
|
69
|
+
type BillingErrorCode,
|
|
70
|
+
type BillingFormErrorCode,
|
|
71
|
+
type Entitlement,
|
|
72
|
+
type EntitlementEvent,
|
|
73
|
+
type EntitlementRecord,
|
|
74
|
+
type EntitlementStatus,
|
|
75
|
+
type GrantFormErrorCode,
|
|
76
|
+
type GrantFormState,
|
|
77
|
+
type LocalizedText,
|
|
78
|
+
type PaymentErrorCode,
|
|
79
|
+
type PaymentFormErrorCode,
|
|
80
|
+
type PaymentFormState,
|
|
81
|
+
type PaymentGrant,
|
|
82
|
+
type PeriodUnit,
|
|
83
|
+
type Plan,
|
|
84
|
+
type PlanPeriod,
|
|
85
|
+
type PlanPrice,
|
|
86
|
+
} from "./contract.js";
|
|
87
|
+
export { applyEntitlementEvent, hasWriteAccess, resolveEntitlement, type EntitlementPolicy } from "./entitlement.js";
|
|
88
|
+
export { CHECKOUT_PARAM, CHECKOUT_RESULTS, type CheckoutResult } from "./fields.js";
|
|
89
|
+
export { invoiceDetailsSchema, type InvoiceFieldErrorCode } from "./invoice.js";
|
|
90
|
+
export { manual, type ManualPaymentOptions } from "./manual.js";
|
|
91
|
+
export { billingMessages, getBillingErrorMessage, type BillingMessages } from "./messages/index.js";
|
|
92
|
+
export {
|
|
93
|
+
DEFAULT_REQUEST_EXPIRY_DAYS,
|
|
94
|
+
MAX_DAYS,
|
|
95
|
+
MAX_FEATURES,
|
|
96
|
+
MAX_PERIOD_COUNT,
|
|
97
|
+
MAX_PLANS,
|
|
98
|
+
MAX_PRICE_AMOUNT,
|
|
99
|
+
PARTIAL_REFUND_POLICIES,
|
|
100
|
+
type PartialRefundPolicy,
|
|
101
|
+
type BillingOptions,
|
|
102
|
+
type BillingOptionsInput,
|
|
103
|
+
} from "./options.js";
|
|
104
|
+
export {
|
|
105
|
+
isPaymentProvider,
|
|
106
|
+
type InvoiceDetails,
|
|
107
|
+
type PaymentAccount,
|
|
108
|
+
type PaymentContext,
|
|
109
|
+
type PaymentProvider,
|
|
110
|
+
type PaymentRequest,
|
|
111
|
+
type PaymentStart,
|
|
112
|
+
} from "./payment.js";
|
|
113
|
+
export { findPlan, getLocalizedText, getPeriodEnd, getPlanGrant } from "./plans.js";
|
|
114
|
+
export {
|
|
115
|
+
getPaymentGrant,
|
|
116
|
+
getRefundEvent,
|
|
117
|
+
getRestoredDays,
|
|
118
|
+
getTakenBackDays,
|
|
119
|
+
getUnusedDays,
|
|
120
|
+
isFullShare,
|
|
121
|
+
moveBackByDays,
|
|
122
|
+
moveForwardByDays,
|
|
123
|
+
type RefundShare,
|
|
124
|
+
type RefundTiming,
|
|
125
|
+
type RestoredDaysInput,
|
|
126
|
+
} from "./refund.js";
|
|
127
|
+
export {
|
|
128
|
+
ACCESS_REMINDER_KINDS,
|
|
129
|
+
DEFAULT_CATCH_UP_DAYS,
|
|
130
|
+
getAccessReminder,
|
|
131
|
+
getAccessReminderScope,
|
|
132
|
+
MAX_CATCH_UP_DAYS,
|
|
133
|
+
type AccessReminder,
|
|
134
|
+
type AccessReminderInput,
|
|
135
|
+
type AccessReminderKind,
|
|
136
|
+
} from "./reminder.js";
|
|
137
|
+
export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
|
|
138
|
+
export { formatPrice, getMinorUnitDigits, isSupportedCurrency } from "./price.js";
|
|
139
|
+
export { billingSchema, entitlements, manualGrants, paymentRequests, payments } from "./schema.js";
|
|
140
|
+
export { getCheckoutSessionParams, stripe, STRIPE_API_BASE, STRIPE_SECRET_KEY_ENV, STRIPE_TIMEOUT_MS, type StripeOptions } from "./stripe.js";
|
|
141
|
+
export {
|
|
142
|
+
describeStripePriceProblem,
|
|
143
|
+
fromStripeAmount,
|
|
144
|
+
getStripeMinorUnitDigits,
|
|
145
|
+
STRIPE_THREE_DECIMAL_CURRENCIES,
|
|
146
|
+
STRIPE_ZERO_DECIMAL_CURRENCIES,
|
|
147
|
+
toStripeAmount,
|
|
148
|
+
} from "./stripe-currency.js";
|
|
149
|
+
export {
|
|
150
|
+
parseStripeEvent,
|
|
151
|
+
readStripeWebhook,
|
|
152
|
+
signStripePayload,
|
|
153
|
+
STRIPE_METADATA,
|
|
154
|
+
STRIPE_SIGNATURE_HEADER,
|
|
155
|
+
STRIPE_SIGNATURE_TOLERANCE_SECONDS,
|
|
156
|
+
verifyStripeSignature,
|
|
157
|
+
type FailedRefund,
|
|
158
|
+
type PaidCheckout,
|
|
159
|
+
type SignStripePayloadInput,
|
|
160
|
+
type StripeWebhookError,
|
|
161
|
+
type StripeWebhookEvent,
|
|
162
|
+
type VerifyStripeSignatureInput,
|
|
163
|
+
} from "./stripe-webhook.js";
|
package/src/invoice.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Invoice details as the payment form sends them, parsed at the boundary by a zod schema: each
|
|
2
|
+
// field trimmed, the name and the address required, each within its limit, and no control
|
|
3
|
+
// characters (Unicode Cc: line breaks, tabs, escapes) anywhere, so a name cannot add lines to the
|
|
4
|
+
// owner's mail. Every field problem has its own code, so the form says what to fix.
|
|
5
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { INVOICE_FIELDS, INVOICE_LIMITS, type InvoiceField } from "./fields.js";
|
|
8
|
+
import type { InvoiceDetails } from "./payment.js";
|
|
9
|
+
|
|
10
|
+
/** Invoice details as the form sent them, untrimmed. */
|
|
11
|
+
export interface InvoiceInput {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly taxId: string;
|
|
14
|
+
readonly address: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Why one invoice field was refused. */
|
|
18
|
+
export type InvoiceFieldErrorCode =
|
|
19
|
+
/** The name or the address is empty. */
|
|
20
|
+
| "billing.invoice_field_required"
|
|
21
|
+
/** Longer than `INVOICE_LIMITS` allows the field. */
|
|
22
|
+
| "billing.invoice_field_too_long"
|
|
23
|
+
/** A line break, a tab or another control character. */
|
|
24
|
+
| "billing.invoice_field_control_characters";
|
|
25
|
+
|
|
26
|
+
export type InvoiceDetailsError = Err<"billing.invoice_details_invalid"> & {
|
|
27
|
+
readonly fieldErrors: Readonly<Partial<Record<InvoiceField, InvoiceFieldErrorCode>>>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CONTROL_CHARACTER = /\p{Cc}/u;
|
|
31
|
+
|
|
32
|
+
function createFieldSchema(limit: number, isRequired: boolean) {
|
|
33
|
+
return z
|
|
34
|
+
.string()
|
|
35
|
+
.trim()
|
|
36
|
+
.refine((value) => !CONTROL_CHARACTER.test(value), { message: "billing.invoice_field_control_characters", abort: true })
|
|
37
|
+
.refine((value) => !isRequired || value !== "", { message: "billing.invoice_field_required", abort: true })
|
|
38
|
+
.refine((value) => value.length <= limit, { message: "billing.invoice_field_too_long", abort: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const invoiceDetailsSchema = z.strictObject({
|
|
42
|
+
name: createFieldSchema(INVOICE_LIMITS.name, true),
|
|
43
|
+
taxId: createFieldSchema(INVOICE_LIMITS.taxId, false).transform((taxId) => (taxId === "" ? null : taxId)),
|
|
44
|
+
address: createFieldSchema(INVOICE_LIMITS.address, true),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Trimmed invoice details, or the first problem of each field that has one. */
|
|
48
|
+
export function parseInvoiceDetails(input: InvoiceInput): Ok<InvoiceDetails> | InvoiceDetailsError {
|
|
49
|
+
const parsed = invoiceDetailsSchema.safeParse({ name: input.name, taxId: input.taxId, address: input.address });
|
|
50
|
+
if (parsed.success) return ok(parsed.data);
|
|
51
|
+
const fieldErrors: Partial<Record<InvoiceField, InvoiceFieldErrorCode>> = {};
|
|
52
|
+
for (const issue of parsed.error.issues) {
|
|
53
|
+
const key = issue.path[0] as keyof typeof INVOICE_FIELDS;
|
|
54
|
+
// Each refinement's message is one of the codes above; the first problem of a field wins.
|
|
55
|
+
fieldErrors[INVOICE_FIELDS[key]] ??= issue.message as InvoiceFieldErrorCode;
|
|
56
|
+
}
|
|
57
|
+
return { ...err("billing.invoice_details_invalid"), fieldErrors };
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The mailing adapter of @softure-ai/billing: reminder mail before and after access ends, through
|
|
2
|
+
// `@softure-ai/mailing` (an optional peer dependency; only apps that import this entry need it).
|
|
3
|
+
export {
|
|
4
|
+
DEFAULT_REMINDER_PAUSE_MS,
|
|
5
|
+
renderAccessReminderMail,
|
|
6
|
+
sendAccessReminders,
|
|
7
|
+
type AccessReminderMail,
|
|
8
|
+
type AccessReminderMailInput,
|
|
9
|
+
type AccessReminderSummary,
|
|
10
|
+
type SendAccessRemindersOptions,
|
|
11
|
+
} from "./reminder-mail.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The reminder mail: the in-app notice's four states as a mail, sent once per account, kind and
|
|
2
|
+
// end through `@softure-ai/mailing`'s delivery ledger. A transactional mail (an account notice): no
|
|
3
|
+
// unsubscribe link, never suppressed. The copy comes from billing's dictionary in the app's locale;
|
|
4
|
+
// the link to the payment page is placed by the code, so a copy override cannot drop it.
|
|
5
|
+
import { formatMessage, type ModuleContext } from "@softure-ai/core";
|
|
6
|
+
import type { Queryable } from "@softure-ai/db";
|
|
7
|
+
import type { OutgoingMail } from "@softure-ai/mailing";
|
|
8
|
+
import { deliverOnce } from "@softure-ai/mailing/server";
|
|
9
|
+
import type { BillingMessages } from "../messages/index.js";
|
|
10
|
+
import { getAccessReminderScope, type AccessReminderKind } from "../reminder.js";
|
|
11
|
+
import { getBillingMessages, getBillingRoutes } from "../server/options.js";
|
|
12
|
+
import { findAccessReminders, type FindAccessRemindersOptions } from "../server/reminders.js";
|
|
13
|
+
import { formatLastDay } from "../ui/format.js";
|
|
14
|
+
|
|
15
|
+
/** Pause between two mails a provider was called for: Resend allows two requests per second. */
|
|
16
|
+
export const DEFAULT_REMINDER_PAUSE_MS = 500;
|
|
17
|
+
|
|
18
|
+
export interface AccessReminderMailInput {
|
|
19
|
+
readonly kind: AccessReminderKind;
|
|
20
|
+
/** The last day with access, as the app's locale writes it (`formatLastDay`). */
|
|
21
|
+
readonly lastDay: string;
|
|
22
|
+
/** The payment page's absolute URL. */
|
|
23
|
+
readonly link: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The subject and bodies of a reminder mail, without the recipient. */
|
|
27
|
+
export type AccessReminderMail = Pick<OutgoingMail, "subject" | "text"> & { readonly html: string };
|
|
28
|
+
|
|
29
|
+
export interface SendAccessRemindersOptions extends FindAccessRemindersOptions {
|
|
30
|
+
/** Milliseconds to wait after each mail a provider was called for. Default 500; 0 never waits. */
|
|
31
|
+
readonly pauseMs?: number;
|
|
32
|
+
/** How to wait; tests pass their own. */
|
|
33
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AccessReminderSummary {
|
|
37
|
+
/** Accounts due a reminder this run. */
|
|
38
|
+
readonly due: number;
|
|
39
|
+
/** Mails sent now. */
|
|
40
|
+
readonly sent: number;
|
|
41
|
+
/** Mails an earlier run sent (or refused), or another run is sending right now. */
|
|
42
|
+
readonly skipped: number;
|
|
43
|
+
/** Mails refused for good (an invalid address, the provider rejected it). */
|
|
44
|
+
readonly rejected: number;
|
|
45
|
+
/** Mails the provider could not take now; the next run sends them. */
|
|
46
|
+
readonly retryLater: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COPY_KEYS = {
|
|
50
|
+
"trial-ending": "trialEnding",
|
|
51
|
+
"paid-ending": "paidEnding",
|
|
52
|
+
"trial-ended": "trialEnded",
|
|
53
|
+
"paid-ended": "paidEnded",
|
|
54
|
+
} as const satisfies Record<AccessReminderKind, keyof BillingMessages["reminderMail"]>;
|
|
55
|
+
|
|
56
|
+
const HTML_ESCAPES: Readonly<Record<string, string>> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
57
|
+
|
|
58
|
+
function escapeHtml(value: string): string {
|
|
59
|
+
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character] ?? character);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Renders a reminder from `messages` (one locale's dictionary, overrides applied): a plain-text body
|
|
64
|
+
* with the notice's link text and the bare link, and an HTML body with the link as an anchor.
|
|
65
|
+
*/
|
|
66
|
+
export function renderAccessReminderMail(messages: BillingMessages, input: AccessReminderMailInput): AccessReminderMail {
|
|
67
|
+
const copy = messages.reminderMail[COPY_KEYS[input.kind]];
|
|
68
|
+
const action = input.kind.startsWith("trial-") ? messages.notice.choosePlan : messages.notice.renew;
|
|
69
|
+
const values = { date: input.lastDay };
|
|
70
|
+
const subject = formatMessage(copy.subject, values);
|
|
71
|
+
const body = formatMessage(copy.body, values);
|
|
72
|
+
const text = [body, `${action}: ${input.link}`].join("\n\n");
|
|
73
|
+
const html = [`<p>${escapeHtml(body)}</p>`, `<p><a href="${escapeHtml(input.link)}">${escapeHtml(action)}</a></p>`].join("\n");
|
|
74
|
+
return { subject, text, html };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function wait(ms: number): Promise<void> {
|
|
78
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Mails every account due a reminder (`findAccessReminders`) through mailing's delivery ledger, so
|
|
83
|
+
* each account gets one mail per kind and end however often the run repeats or overlaps. Call it
|
|
84
|
+
* on a schedule (a daily cron job running a script). Needs `mailing({ ... })` in the config; a
|
|
85
|
+
* database failure propagates (the next run resumes, and the ledger keeps it from mailing twice).
|
|
86
|
+
*/
|
|
87
|
+
export async function sendAccessReminders(ctx: ModuleContext<Queryable>, options: SendAccessRemindersOptions = {}): Promise<AccessReminderSummary> {
|
|
88
|
+
const { pauseMs = DEFAULT_REMINDER_PAUSE_MS, sleep = wait, ...findOptions } = options;
|
|
89
|
+
const due = await findAccessReminders(ctx, findOptions);
|
|
90
|
+
const messages = getBillingMessages(ctx.config);
|
|
91
|
+
const link = new URL(getBillingRoutes(ctx.config).payment, ctx.config.appOrigin).href;
|
|
92
|
+
const counts = { sent: 0, skipped: 0, rejected: 0, retryLater: 0 };
|
|
93
|
+
|
|
94
|
+
for (const reminder of due) {
|
|
95
|
+
const lastDay = formatLastDay(reminder.endsAt, ctx.config.locale, ctx.config.timezone);
|
|
96
|
+
const mail = renderAccessReminderMail(messages, { kind: reminder.kind, lastDay, link });
|
|
97
|
+
const outcome = await deliverOnce(ctx, { scope: getAccessReminderScope(reminder.kind, reminder.userId, reminder.endsAt), mail: { to: reminder.email, ...mail } });
|
|
98
|
+
if (outcome.status === "done" || outcome.status === "in-flight") {
|
|
99
|
+
counts.skipped += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (outcome.status === "sent") counts.sent += 1;
|
|
103
|
+
else if (outcome.status === "rejected") counts.rejected += 1;
|
|
104
|
+
else counts.retryLater += 1;
|
|
105
|
+
if (pauseMs > 0) await sleep(pauseMs);
|
|
106
|
+
}
|
|
107
|
+
return { due: due.length, ...counts };
|
|
108
|
+
}
|
package/src/manual.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The manual payment adapter: the buyer asks for an invoice on the payment page, the app hands the
|
|
2
|
+
// request to its owner (`onRequest`: a mail, a ticket), and the owner grants the plan in the admin
|
|
3
|
+
// page once the invoice is paid. Nothing is charged here; `startPayment` stores the request before
|
|
4
|
+
// it is handed over, so the admin page lists it, and hands an open request over once.
|
|
5
|
+
import { err, ok, type Err, type ErrorCode, type Ok } from "@softure-ai/core";
|
|
6
|
+
import type { PaymentContext, PaymentProvider, PaymentRequest } from "./payment.js";
|
|
7
|
+
|
|
8
|
+
export interface ManualPaymentOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Hands a request to the owner. An `Err` tells the buyer the request did not go through
|
|
11
|
+
* (`billing.payment_failed`, its code is logged); a throw is reported as an unexpected failure.
|
|
12
|
+
*/
|
|
13
|
+
readonly onRequest: (request: PaymentRequest, ctx: PaymentContext) => Promise<Ok<undefined> | Err<ErrorCode>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `billing({ payment: manual({ onRequest }) })`: invoices requested by the buyer, access granted by an admin. */
|
|
17
|
+
export function manual(options: ManualPaymentOptions): PaymentProvider {
|
|
18
|
+
return {
|
|
19
|
+
name: "manual",
|
|
20
|
+
collectsInvoiceDetails: true,
|
|
21
|
+
handsOverRequests: true,
|
|
22
|
+
async startPayment(ctx, request) {
|
|
23
|
+
const handed = await options.onRequest(request, ctx);
|
|
24
|
+
if (!handed.ok) {
|
|
25
|
+
console.error(`@softure-ai/billing: the manual payment request for plan "${request.plan.id}" was not handed over: ${handed.error}`);
|
|
26
|
+
return err("billing.payment_failed");
|
|
27
|
+
}
|
|
28
|
+
return ok({ type: "requested" });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export const en = {
|
|
2
|
+
badge: {
|
|
3
|
+
trial: "Trial",
|
|
4
|
+
paid: "Paid",
|
|
5
|
+
lifetime: "Lifetime access",
|
|
6
|
+
readOnly: "Read-only",
|
|
7
|
+
daysLeft: { one: "{count} day left", few: "{count} days left", many: "{count} days left", other: "{count} days left" },
|
|
8
|
+
until: "until {date}",
|
|
9
|
+
},
|
|
10
|
+
notice: {
|
|
11
|
+
trialEnding: "Your trial ends on {date}. Choose a plan to keep writing after that.",
|
|
12
|
+
paidEnding: "Your access ends on {date}. Renew it to keep writing after that.",
|
|
13
|
+
trialEnded: "Your trial has ended. Your data is safe and you can still read it; choose a plan to write again.",
|
|
14
|
+
paidEnded: "Your access has ended. Your data is safe and you can still read it; renew it to write again.",
|
|
15
|
+
choosePlan: "Choose a plan",
|
|
16
|
+
renew: "Renew access",
|
|
17
|
+
},
|
|
18
|
+
reminderMail: {
|
|
19
|
+
trialEnding: {
|
|
20
|
+
subject: "Your trial ends on {date}",
|
|
21
|
+
body: "Your trial ends on {date}. Choose a plan to keep writing after that; your data stays safe either way.",
|
|
22
|
+
},
|
|
23
|
+
paidEnding: {
|
|
24
|
+
subject: "Your access ends on {date}",
|
|
25
|
+
body: "Your access ends on {date}. Renew it to keep writing after that; your data stays safe either way.",
|
|
26
|
+
},
|
|
27
|
+
trialEnded: {
|
|
28
|
+
subject: "Your trial has ended",
|
|
29
|
+
body: "Your trial has ended. Your data is safe and you can still read it; choose a plan to write again.",
|
|
30
|
+
},
|
|
31
|
+
paidEnded: {
|
|
32
|
+
subject: "Your access has ended",
|
|
33
|
+
body: "Your access has ended. Your data is safe and you can still read it; renew it to write again.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
pricing: {
|
|
37
|
+
period: {
|
|
38
|
+
day: { one: "per day", few: "per {count} days", many: "per {count} days", other: "per {count} days" },
|
|
39
|
+
week: { one: "per week", few: "per {count} weeks", many: "per {count} weeks", other: "per {count} weeks" },
|
|
40
|
+
month: { one: "per month", few: "per {count} months", many: "per {count} months", other: "per {count} months" },
|
|
41
|
+
year: { one: "per year", few: "per {count} years", many: "per {count} years", other: "per {count} years" },
|
|
42
|
+
lifetime: "one-time payment",
|
|
43
|
+
},
|
|
44
|
+
featured: "Recommended",
|
|
45
|
+
choose: "Choose {plan}",
|
|
46
|
+
empty: "No plans are available yet.",
|
|
47
|
+
},
|
|
48
|
+
payment: {
|
|
49
|
+
title: "Choose a plan",
|
|
50
|
+
lead: "Pick the plan that suits you. Paid access starts when your current access ends.",
|
|
51
|
+
plansLabel: "Plans",
|
|
52
|
+
orderTitle: "Your order",
|
|
53
|
+
orderLead: "{plan}, {price} {period}",
|
|
54
|
+
changePlan: "Choose another plan",
|
|
55
|
+
invoiceLead: "We will send an invoice to {email}. Your access starts once it is paid.",
|
|
56
|
+
fields: {
|
|
57
|
+
name: "Name or company",
|
|
58
|
+
taxId: "Tax ID (optional)",
|
|
59
|
+
address: "Billing address",
|
|
60
|
+
},
|
|
61
|
+
requestInvoice: "Request an invoice",
|
|
62
|
+
checkout: "Go to payment",
|
|
63
|
+
pending: "Sending…",
|
|
64
|
+
requested: "Thank you! Your invoice request for {plan} has been sent. We will email the invoice to {email}; your access starts once it is paid.",
|
|
65
|
+
checkoutSuccess: "Thank you for your payment! Your access is updated as soon as the payment is confirmed, usually within a minute.",
|
|
66
|
+
checkoutCancelled: "The payment was cancelled and nothing was charged. You can try again or choose another plan.",
|
|
67
|
+
lifetime: "You have lifetime access: there is nothing left to pay for.",
|
|
68
|
+
},
|
|
69
|
+
admin: {
|
|
70
|
+
title: "Grant access",
|
|
71
|
+
lead: "Grant a plan to an account once its payment has arrived. A paid period starts when the account's current access ends.",
|
|
72
|
+
email: "Account email",
|
|
73
|
+
plan: "Plan",
|
|
74
|
+
submit: "Grant plan",
|
|
75
|
+
pending: "Granting…",
|
|
76
|
+
granted: "{email} now has {plan}.",
|
|
77
|
+
grantedUntil: "{email} now has {plan}, with access until {date}.",
|
|
78
|
+
noPlans: "There are no plans to grant: add them to billing({ plans }).",
|
|
79
|
+
requests: {
|
|
80
|
+
title: "Invoice requests",
|
|
81
|
+
lead: "Requests sent from the payment page. Grant the plan once the invoice is paid, or dismiss the request.",
|
|
82
|
+
empty: "No open requests.",
|
|
83
|
+
line: "{email} · {plan}",
|
|
84
|
+
requestedOn: "Requested on {date}",
|
|
85
|
+
price: "Price: {price}",
|
|
86
|
+
invoice: "Invoice to: {name}, {address}",
|
|
87
|
+
taxId: "Tax ID: {taxId}",
|
|
88
|
+
noInvoice: "No invoice details",
|
|
89
|
+
grant: "Grant",
|
|
90
|
+
granting: "Granting…",
|
|
91
|
+
dismiss: "Dismiss",
|
|
92
|
+
dismissing: "Dismissing…",
|
|
93
|
+
history: "History",
|
|
94
|
+
grantLabel: "Grant {plan} to {email}",
|
|
95
|
+
dismissLabel: "Dismiss the request of {email} for {plan}",
|
|
96
|
+
},
|
|
97
|
+
history: {
|
|
98
|
+
title: "Account history",
|
|
99
|
+
lead: "Find an account to see its access, the plans granted to it by hand and its payments.",
|
|
100
|
+
email: "Find an account by email",
|
|
101
|
+
submit: "Show history",
|
|
102
|
+
pending: "Searching…",
|
|
103
|
+
accountTitle: "History of {email}",
|
|
104
|
+
empty: "No grants or payments yet.",
|
|
105
|
+
manual: "{plan}, granted by hand",
|
|
106
|
+
fromRequest: "{plan}, granted from a request",
|
|
107
|
+
provider: "{plan}, paid with {provider}",
|
|
108
|
+
grantedOn: "Granted on {date}",
|
|
109
|
+
grantedFor: "Granted for {amount} on {date}",
|
|
110
|
+
paidOn: "Paid {amount} on {date}",
|
|
111
|
+
period: "Access from {from} to {to}",
|
|
112
|
+
lifetime: "Lifetime access",
|
|
113
|
+
noGrant: "Recorded before grants were stored",
|
|
114
|
+
active: "Active",
|
|
115
|
+
revokedOn: "Revoked on {date}",
|
|
116
|
+
paid: "Paid",
|
|
117
|
+
refundedOn: "Refunded on {date}",
|
|
118
|
+
partlyRefunded: "Paid, {amount} refunded",
|
|
119
|
+
revoke: "Revoke",
|
|
120
|
+
revoking: "Revoking…",
|
|
121
|
+
revokeLabel: "Revoke {plan} granted on {date}",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
errors: {
|
|
125
|
+
billing: {
|
|
126
|
+
read_only: "Your account is read-only. Choose a plan to make changes.",
|
|
127
|
+
account_unknown: "No account has this email address.",
|
|
128
|
+
end_not_in_future: "The end date must be in the future.",
|
|
129
|
+
plan_unknown: "This plan is no longer available. Choose another one.",
|
|
130
|
+
invoice_details_invalid: "Check the invoice details below.",
|
|
131
|
+
invoice_field_required: "Fill in this field.",
|
|
132
|
+
invoice_field_too_long: "Use at most {max} characters.",
|
|
133
|
+
invoice_field_control_characters: "Remove line breaks, tabs and other control characters.",
|
|
134
|
+
payment_failed: "The payment could not be started. Try again in a moment.",
|
|
135
|
+
lifetime_active: "This account already has lifetime access: there is nothing to pay for or grant.",
|
|
136
|
+
request_closed: "This request was granted or dismissed already.",
|
|
137
|
+
grant_revoked: "This grant was revoked already.",
|
|
138
|
+
},
|
|
139
|
+
security: {
|
|
140
|
+
rate_limited: "Too many attempts. Try again later.",
|
|
141
|
+
},
|
|
142
|
+
auth: {
|
|
143
|
+
forbidden: "You are not allowed to do this.",
|
|
144
|
+
},
|
|
145
|
+
core: {
|
|
146
|
+
database_failed: "Something went wrong on our side. Try again in a moment.",
|
|
147
|
+
unexpected: "Something went wrong on our side. Try again in a moment.",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AdminActionErrorCode, BillingFormErrorCode, GrantFormErrorCode, PaymentFormErrorCode } from "../contract.js";
|
|
2
|
+
import { en } from "./en.js";
|
|
3
|
+
import { pl } from "./pl.js";
|
|
4
|
+
|
|
5
|
+
export type BillingMessages = typeof en;
|
|
6
|
+
|
|
7
|
+
/** Complete default dictionaries; apps pass partial overrides per locale. */
|
|
8
|
+
export const billingMessages = { en, pl };
|
|
9
|
+
|
|
10
|
+
/** The copy for an error code; an unknown code gets the generic failure. */
|
|
11
|
+
export function getBillingErrorMessage(messages: BillingMessages, code: BillingFormErrorCode | PaymentFormErrorCode | GrantFormErrorCode | AdminActionErrorCode): string {
|
|
12
|
+
const separator = code.lastIndexOf(".");
|
|
13
|
+
const namespace = code.slice(0, separator);
|
|
14
|
+
const name = code.slice(separator + 1);
|
|
15
|
+
const groups = messages.errors as Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
16
|
+
const group = Object.hasOwn(groups, namespace) ? groups[namespace] : undefined;
|
|
17
|
+
return (group !== undefined && Object.hasOwn(group, name) ? group[name] : undefined) ?? messages.errors.core.unexpected;
|
|
18
|
+
}
|