@softure-ai/billing 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +695 -2
- package/dist/calendar.d.ts +26 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +81 -0
- package/dist/calendar.js.map +1 -0
- package/dist/contract.d.ts +187 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +6 -0
- package/dist/contract.js.map +1 -0
- package/dist/currency-digits.d.ts +3 -0
- package/dist/currency-digits.d.ts.map +1 -0
- package/dist/currency-digits.js +32 -0
- package/dist/currency-digits.js.map +1 -0
- package/dist/entitlement.d.ts +25 -0
- package/dist/entitlement.d.ts.map +1 -0
- package/dist/entitlement.js +75 -0
- package/dist/entitlement.js.map +1 -0
- package/dist/fields.d.ts +25 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +27 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +274 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/invoice.d.ts +29 -0
- package/dist/invoice.d.ts.map +1 -0
- package/dist/invoice.js +35 -0
- package/dist/invoice.js.map +1 -0
- package/dist/mailing/index.d.ts +2 -0
- package/dist/mailing/index.d.ts.map +1 -0
- package/dist/mailing/index.js +4 -0
- package/dist/mailing/index.js.map +1 -0
- package/dist/mailing/reminder-mail.d.ts +50 -0
- package/dist/mailing/reminder-mail.d.ts.map +1 -0
- package/dist/mailing/reminder-mail.js +71 -0
- package/dist/mailing/reminder-mail.js.map +1 -0
- package/dist/manual.d.ts +12 -0
- package/dist/manual.d.ts.map +1 -0
- package/dist/manual.js +22 -0
- package/dist/manual.js.map +1 -0
- package/dist/messages/en.d.ts +176 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +151 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +359 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +14 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +151 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/access.d.ts +16 -0
- package/dist/next/access.d.ts.map +1 -0
- package/dist/next/access.js +24 -0
- package/dist/next/access.js.map +1 -0
- package/dist/next/actions.d.ts +24 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +158 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +17 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/current-entitlement.d.ts +18 -0
- package/dist/next/current-entitlement.d.ts.map +1 -0
- package/dist/next/current-entitlement.js +26 -0
- package/dist/next/current-entitlement.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +12 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/pages.d.ts +24 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +166 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/pricing.d.ts +12 -0
- package/dist/next/pricing.d.ts.map +1 -0
- package/dist/next/pricing.js +20 -0
- package/dist/next/pricing.js.map +1 -0
- package/dist/next/route.d.ts +11 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +56 -0
- package/dist/next/route.js.map +1 -0
- package/dist/options.d.ts +85 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +121 -0
- package/dist/options.js.map +1 -0
- package/dist/payment.d.ts +61 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +12 -0
- package/dist/payment.js.map +1 -0
- package/dist/plans.d.ts +20 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +53 -0
- package/dist/plans.js.map +1 -0
- package/dist/price.d.ts +12 -0
- package/dist/price.d.ts.map +1 -0
- package/dist/price.js +37 -0
- package/dist/price.js.map +1 -0
- package/dist/refund.d.ts +70 -0
- package/dist/refund.d.ts.map +1 -0
- package/dist/refund.js +109 -0
- package/dist/refund.js.map +1 -0
- package/dist/reminder.d.ts +30 -0
- package/dist/reminder.d.ts.map +1 -0
- package/dist/reminder.js +34 -0
- package/dist/reminder.js.map +1 -0
- package/dist/schema.d.ts +1023 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/scripts/entitlement-scripts.d.ts +17 -0
- package/dist/scripts/entitlement-scripts.d.ts.map +1 -0
- package/dist/scripts/entitlement-scripts.js +167 -0
- package/dist/scripts/entitlement-scripts.js.map +1 -0
- package/dist/scripts/index.d.ts +3 -0
- package/dist/scripts/index.d.ts.map +1 -0
- package/dist/scripts/index.js +5 -0
- package/dist/scripts/index.js.map +1 -0
- package/dist/scripts/plan-scripts.d.ts +19 -0
- package/dist/scripts/plan-scripts.d.ts.map +1 -0
- package/dist/scripts/plan-scripts.js +106 -0
- package/dist/scripts/plan-scripts.js.map +1 -0
- package/dist/server/entitlements.d.ts +69 -0
- package/dist/server/entitlements.d.ts.map +1 -0
- package/dist/server/entitlements.js +202 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/grants.d.ts +74 -0
- package/dist/server/grants.d.ts.map +1 -0
- package/dist/server/grants.js +174 -0
- package/dist/server/grants.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +17 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/options.d.ts +20 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +38 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/payments.d.ts +140 -0
- package/dist/server/payments.d.ts.map +1 -0
- package/dist/server/payments.js +339 -0
- package/dist/server/payments.js.map +1 -0
- package/dist/server/plans.d.ts +50 -0
- package/dist/server/plans.d.ts.map +1 -0
- package/dist/server/plans.js +129 -0
- package/dist/server/plans.js.map +1 -0
- package/dist/server/privacy.d.ts +77 -0
- package/dist/server/privacy.d.ts.map +1 -0
- package/dist/server/privacy.js +110 -0
- package/dist/server/privacy.js.map +1 -0
- package/dist/server/reminders.d.ts +20 -0
- package/dist/server/reminders.d.ts.map +1 -0
- package/dist/server/reminders.js +85 -0
- package/dist/server/reminders.js.map +1 -0
- package/dist/server/requests.d.ts +80 -0
- package/dist/server/requests.d.ts.map +1 -0
- package/dist/server/requests.js +155 -0
- package/dist/server/requests.js.map +1 -0
- package/dist/server/setup.d.ts +9 -0
- package/dist/server/setup.d.ts.map +1 -0
- package/dist/server/setup.js +34 -0
- package/dist/server/setup.js.map +1 -0
- package/dist/server/take-back.d.ts +80 -0
- package/dist/server/take-back.d.ts.map +1 -0
- package/dist/server/take-back.js +138 -0
- package/dist/server/take-back.js.map +1 -0
- package/dist/server/user-id.d.ts +4 -0
- package/dist/server/user-id.d.ts.map +1 -0
- package/dist/server/user-id.js +11 -0
- package/dist/server/user-id.js.map +1 -0
- package/dist/stripe-currency.d.ts +27 -0
- package/dist/stripe-currency.d.ts.map +1 -0
- package/dist/stripe-currency.js +57 -0
- package/dist/stripe-currency.js.map +1 -0
- package/dist/stripe-webhook.d.ts +101 -0
- package/dist/stripe-webhook.d.ts.map +1 -0
- package/dist/stripe-webhook.js +209 -0
- package/dist/stripe-webhook.js.map +1 -0
- package/dist/stripe.d.ts +25 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +116 -0
- package/dist/stripe.js.map +1 -0
- package/dist/ui/access-badge.d.ts +16 -0
- package/dist/ui/access-badge.d.ts.map +1 -0
- package/dist/ui/access-badge.js +43 -0
- package/dist/ui/access-badge.js.map +1 -0
- package/dist/ui/access-notice.d.ts +20 -0
- package/dist/ui/access-notice.d.ts.map +1 -0
- package/dist/ui/access-notice.js +41 -0
- package/dist/ui/access-notice.js.map +1 -0
- package/dist/ui/format.d.ts +11 -0
- package/dist/ui/format.d.ts.map +1 -0
- package/dist/ui/format.js +21 -0
- package/dist/ui/format.js.map +1 -0
- package/dist/ui/grant-form.d.ts +18 -0
- package/dist/ui/grant-form.d.ts.map +1 -0
- package/dist/ui/grant-form.js +23 -0
- package/dist/ui/grant-form.js.map +1 -0
- package/dist/ui/grant-history.d.ts +39 -0
- package/dist/ui/grant-history.d.ts.map +1 -0
- package/dist/ui/grant-history.js +36 -0
- package/dist/ui/grant-history.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/payment-form.d.ts +23 -0
- package/dist/ui/payment-form.d.ts.map +1 -0
- package/dist/ui/payment-form.js +34 -0
- package/dist/ui/payment-form.js.map +1 -0
- package/dist/ui/payment-requests.d.ts +30 -0
- package/dist/ui/payment-requests.d.ts.map +1 -0
- package/dist/ui/payment-requests.js +31 -0
- package/dist/ui/payment-requests.js.map +1 -0
- package/dist/ui/pricing-tiles.d.ts +22 -0
- package/dist/ui/pricing-tiles.d.ts.map +1 -0
- package/dist/ui/pricing-tiles.js +40 -0
- package/dist/ui/pricing-tiles.js.map +1 -0
- package/migrations/0001_create_entitlements.sql +16 -0
- package/migrations/0002_create_payments.sql +30 -0
- package/migrations/0003_record_payment_grants.sql +25 -0
- package/migrations/0004_create_requests_and_grants.sql +58 -0
- package/migrations/0005_record_refunded_amounts.sql +18 -0
- package/migrations/0006_record_request_handover_and_prices.sql +38 -0
- package/migrations/0007_record_failed_refunds.sql +27 -0
- package/migrations/0008_record_request_handover_claims.sql +9 -0
- package/migrations/0009_record_pending_charge_states.sql +16 -0
- package/module.json +23 -0
- package/package.json +85 -4
- package/src/calendar.ts +90 -0
- package/src/contract.ts +181 -0
- package/src/currency-digits.ts +37 -0
- package/src/entitlement.ts +84 -0
- package/src/fields.ts +37 -0
- package/src/index.ts +163 -0
- package/src/invoice.ts +58 -0
- package/src/mailing/index.ts +11 -0
- package/src/mailing/reminder-mail.ts +108 -0
- package/src/manual.ts +31 -0
- package/src/messages/en.ts +150 -0
- package/src/messages/index.ts +18 -0
- package/src/messages/pl.ts +152 -0
- package/src/next/access.tsx +55 -0
- package/src/next/actions.ts +176 -0
- package/src/next/context.ts +18 -0
- package/src/next/current-entitlement.ts +36 -0
- package/src/next/index.ts +11 -0
- package/src/next/next-modules.d.ts +21 -0
- package/src/next/pages.tsx +267 -0
- package/src/next/pricing.tsx +39 -0
- package/src/next/route.ts +57 -0
- package/src/options.ts +128 -0
- package/src/payment.ts +77 -0
- package/src/plans.ts +63 -0
- package/src/price.ts +50 -0
- package/src/refund.ts +135 -0
- package/src/reminder.ts +52 -0
- package/src/schema.ts +86 -0
- package/src/scripts/entitlement-scripts.ts +188 -0
- package/src/scripts/index.ts +11 -0
- package/src/scripts/plan-scripts.ts +143 -0
- package/src/server/entitlements.ts +227 -0
- package/src/server/grants.ts +227 -0
- package/src/server/health.ts +18 -0
- package/src/server/index.ts +83 -0
- package/src/server/options.ts +52 -0
- package/src/server/payments.ts +434 -0
- package/src/server/plans.ts +143 -0
- package/src/server/privacy.ts +189 -0
- package/src/server/reminders.ts +113 -0
- package/src/server/requests.ts +201 -0
- package/src/server/setup.ts +37 -0
- package/src/server/take-back.ts +190 -0
- package/src/server/user-id.ts +12 -0
- package/src/stripe-currency.ts +65 -0
- package/src/stripe-webhook.ts +278 -0
- package/src/stripe.ts +130 -0
- package/src/ui/access-badge.tsx +64 -0
- package/src/ui/access-notice.tsx +73 -0
- package/src/ui/format.ts +25 -0
- package/src/ui/grant-form.tsx +69 -0
- package/src/ui/grant-history.tsx +127 -0
- package/src/ui/index.ts +25 -0
- package/src/ui/payment-form.tsx +111 -0
- package/src/ui/payment-requests.tsx +126 -0
- package/src/ui/pricing-tiles.tsx +105 -0
package/README.md
CHANGED
|
@@ -1,3 +1,696 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @softure-ai/billing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Depends on:** core, db, ui, security, auth (mailing optional, for `@softure-ai/billing/mailing`).
|
|
4
|
+
|
|
5
|
+
Decides whether an account may still write: a trial every account starts with, paid access (dated
|
|
6
|
+
or lifetime) and a read-only state once both end. It replaces FIRE_TRACKER's access logic
|
|
7
|
+
(`src/lib/access.ts`, `src/db/access.ts`, `src/components/{access-badge,access-notice*}.tsx`), with
|
|
8
|
+
the `paid_until` and `trial_ends_at` columns moved off the users table into `billing.entitlements`
|
|
9
|
+
and the hand-written guard replaced by a pure state machine. FIRE's hard-coded prices and its access
|
|
10
|
+
script become plans in the config, a payment page and an admin page that grants a plan. Accounts
|
|
11
|
+
that exist when billing is turned on keep their access through a trial floor (`trial.startsAt`), an
|
|
12
|
+
import of what the old system knew (`import-entitlements`) and a pin step for derived trials
|
|
13
|
+
(`pin-trials`); see "Existing accounts" in §4.
|
|
14
|
+
|
|
15
|
+
## 1. What it provides
|
|
16
|
+
|
|
17
|
+
- **A pure state machine** (`resolveEntitlement`, `applyEntitlementEvent` from the root entry): a
|
|
18
|
+
record (trial end, paid until, lifetime) and an instant give `trial | paid | read_only`, with the
|
|
19
|
+
days left and whether the reminder window is open; an event (`grant`, `grant_lifetime`, `revoke`,
|
|
20
|
+
`shorten`, `end_lifetime`, `extend_trial`, `import`) gives the next record. No database and no clock.
|
|
21
|
+
- **`billing.entitlements`**, at most one row per account, apart from `auth.users`. An account
|
|
22
|
+
without a row is on the trial that starts at its `auth.users.created_at` (see §5).
|
|
23
|
+
- **The write guard**: `requireWriteAccess()` (`/next`) for server actions, `checkWriteAccess()`
|
|
24
|
+
(`/server`) for other hosts.
|
|
25
|
+
- **`getEntitlement()`** and `getCurrentEntitlement()` to read where an account stands, and
|
|
26
|
+
**`changeEntitlement()`**, the one write path (grants, revokes, trial extensions) the payment
|
|
27
|
+
adapters build on.
|
|
28
|
+
- **`AccessBadge` and `AccessNotice`** (`/ui`), standalone with slots, `unstyled` and messages, and
|
|
29
|
+
`CurrentAccessBadge` / `CurrentAccessNotice` (`/next`) wired to the signed-in account.
|
|
30
|
+
- **Plans in the config** (`billing({ plans })`): name, description, price in the currency's minor
|
|
31
|
+
unit, period (days, weeks, months, years or lifetime), feature lines; `formatPrice` and the
|
|
32
|
+
period copy follow the app's locale.
|
|
33
|
+
- **`PricingTiles`** (`/ui`) and **`Pricing`** (`/next`, wired to the config), a **`PaymentPage`**
|
|
34
|
+
to mount at `routes.payment`, and a **`BillingAdminPage`** at `routes.admin` where an admin
|
|
35
|
+
works the open invoice requests (grant or dismiss each), grants a plan by email, and looks up an
|
|
36
|
+
account's history of grants and payments with a revoke button on each manual grant.
|
|
37
|
+
- **A `PaymentProvider` interface** and its first adapter, **`manual({ onRequest })`**: the buyer
|
|
38
|
+
requests an invoice, the app hands the request to its owner (a mail, a ticket), and the owner
|
|
39
|
+
grants the plan once it is paid. The request is stored in `billing.payment_requests` before it is
|
|
40
|
+
handed over, reaches the owner once however often the buyer asks again, and stays until the
|
|
41
|
+
admin grants or dismisses it or it expires (`expireStaleRequests`). **`grantPlanManually()`** grants and records a plan in
|
|
42
|
+
`billing.manual_grants`; **`revokeManualGrant()`** takes back only what one grant added.
|
|
43
|
+
- **`stripe()`**, a card, BLIK and transfer adapter on Stripe Checkout (one-time payments), and
|
|
44
|
+
**`stripeWebhookRoute`** (`/next`): a verified Stripe webhook that grants a paid checkout's plan
|
|
45
|
+
and, on a refund, takes back what that one payment granted (a partial refund its share, by the
|
|
46
|
+
`partialRefunds` policy), exactly once per payment, recorded in `billing.payments`, and gives it
|
|
47
|
+
back when the refund fails (see "Refunds" and "Failed refunds" below).
|
|
48
|
+
- **Reminder mail** (`@softure-ai/billing/mailing`): `sendAccessReminders(ctx)` mails every
|
|
49
|
+
account whose trial or dated paid access is in its reminder window, or ended in the last few
|
|
50
|
+
days, once per account and window through `@softure-ai/mailing`'s delivery ledger; the app runs
|
|
51
|
+
it on a schedule (see "Reminder mail" in §4). `findAccessReminders` (`/server`) is the same list
|
|
52
|
+
without mail, for an app that sends its own.
|
|
53
|
+
- **Plan scripts** (`@softure-ai/billing/scripts`): `grant-plan` and `revoke-grant`, ops scripts
|
|
54
|
+
(dry run by default, `--commit` writes) for a host without the admin page; their grants are in
|
|
55
|
+
the account's history like the admin page's (see "Scripts" in §4).
|
|
56
|
+
- **Existing accounts**: `trial.startsAt` gives accounts created before a chosen day a trial from
|
|
57
|
+
that day; `import-entitlements` (`importEntitlement()` on the server) records the trial ends, paid
|
|
58
|
+
periods and lifetime access another system knew, never shortening access; `pin-trials`
|
|
59
|
+
(`pinDerivedTrials()`) writes every derived trial into a row before a config change would move it
|
|
60
|
+
(see "Existing accounts" in §4).
|
|
61
|
+
- Export and deletion of the entitlement row, the payments, the invoice requests and the manual grants (`@softure-ai/privacy`), and a health check for
|
|
62
|
+
`GET /api/health`.
|
|
63
|
+
|
|
64
|
+
## 2. Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install @softure-ai/billing @softure-ai/auth @softure-ai/security @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-orm zod
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Peer dependencies: `next` 16, `react` 19, `drizzle-orm`; `@softure-ai/mailing` (optional) for
|
|
71
|
+
`@softure-ai/billing/mailing`. The module depends on `security` and `auth`; a configuration without
|
|
72
|
+
them fails at startup.
|
|
73
|
+
|
|
74
|
+
## 3. Configuration
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { billing, BILLING_RATE_LIMIT_BUCKETS, manual } from "@softure-ai/billing";
|
|
78
|
+
|
|
79
|
+
// in defineSoftureConfig({ timezone: "Europe/Warsaw", modules: [...] }):
|
|
80
|
+
security({ buckets: { ...AUTH_RATE_LIMIT_BUCKETS, ...BILLING_RATE_LIMIT_BUCKETS } }),
|
|
81
|
+
// ... auth(...), then:
|
|
82
|
+
billing({
|
|
83
|
+
trial: { days: 14, reminderDays: 3 },
|
|
84
|
+
paid: { reminderDays: 7 },
|
|
85
|
+
plans: [
|
|
86
|
+
{ id: "monthly", name: { en: "Monthly", pl: "..." }, price: { amount: 2900, currency: "PLN" }, period: "month", features: [{ en: "Unlimited notes" }] },
|
|
87
|
+
{ id: "yearly", name: { en: "Yearly" }, price: { amount: 29000, currency: "PLN" }, period: "year", isFeatured: true },
|
|
88
|
+
{ id: "lifetime", name: { en: "Lifetime" }, price: { amount: 79000, currency: "PLN" }, period: "lifetime" },
|
|
89
|
+
],
|
|
90
|
+
payment: manual({ onRequest: async (request, ctx) => sendInvoiceRequestMail(request, ctx) }),
|
|
91
|
+
}),
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Option | Type | Default | Meaning |
|
|
95
|
+
| --- | --- | --- | --- |
|
|
96
|
+
| `trial.days` | integer 0 to 365 | `14` | Length of the trial every account starts with, the registration day included. `0`: no trial, an account is read-only until it pays. |
|
|
97
|
+
| `trial.startsAt` | `YYYY-MM-DD` | — | The first day a trial can start, a local day in `config.timezone`: an account without a row created before it gets its `trial.days` from this day (see "Existing accounts" in §4). A day in the future keeps those accounts writing until it, plus `trial.days`. |
|
|
98
|
+
| `trial.reminderDays` | integer 0 to 365 | `3` | From how many days left the trial counts as ending (badge tone, notice). `0`: never. |
|
|
99
|
+
| `paid.reminderDays` | integer 0 to 365 | `7` | The same for dated paid access. Lifetime access never ends. |
|
|
100
|
+
| `plans` | array, at most 12 | `[]` | The plans, in the order the tiles show them (see below). |
|
|
101
|
+
| `payment` | `PaymentProvider` | — | The adapter the payment page uses: `stripe()` or `manual({ onRequest })`. The payment page throws without one. |
|
|
102
|
+
| `partialRefunds` | `"pro_rata"` or `"keep_access"` | `"pro_rata"` | What a partial provider refund does to access (see "Refunds" in §4): `pro_rata` takes back the refunded share of the payment's unused days, `keep_access` nothing until the whole payment is refunded. |
|
|
103
|
+
| `requests.expireAfterDays` | 1-365 | `30` | Days an open invoice request waits, counted from the buyer's last ask; `expireStaleRequests` (run daily, see "Invoice requests" in §4) then closes it as `expired` and clears its invoice details. |
|
|
104
|
+
| `adminRole` | role | `admin` | The auth role that may grant plans in `BillingAdminPage`; declare any other in `auth({ roles })`. A role auth does not declare fails the first billing request and the readiness probe. |
|
|
105
|
+
| `routes.payment` | path | `/payment` | Where `PaymentPage` is mounted; the notice and the tiles link there, and Stripe Checkout returns there. |
|
|
106
|
+
| `routes.admin` | path | `/admin/billing` | Where `BillingAdminPage` is mounted; its actions revalidate it, and the account lookup sends the admin there with `?account=<id>`. |
|
|
107
|
+
| `routes.webhook` | path | `/api/billing/webhook` | Where `stripeWebhookRoute` is mounted (the Stripe endpoint's URL). |
|
|
108
|
+
| `messages` | partial `en` / `pl` | — | Copy overrides. |
|
|
109
|
+
|
|
110
|
+
**A plan** is `{ id, name, description?, price: { amount, currency }, period, features?, isFeatured? }`:
|
|
111
|
+
`id` kebab-case and unique; `name`, `description` and each feature line a text per locale with at
|
|
112
|
+
least `en`; `amount` an integer in the currency's minor unit as billing pins it (2900 is 29.00 PLN,
|
|
113
|
+
1500 is ¥1,500, 1500 is ISK 1,500, 1250 is KWD 1.250, 2950 is HUF 29.50);
|
|
114
|
+
`currency` an upper-case ISO 4217 code billing knows; `period` `"day"`, `"week"`, `"month"`, `"year"`,
|
|
115
|
+
`"lifetime"` or `{ unit, count }` such as `{ unit: "month", count: 3 }`. A plan has one currency; a
|
|
116
|
+
second currency is a second plan. Plans live in the config, not in a table: a price change is a
|
|
117
|
+
deploy, and a payment record (with the price paid) belongs to the provider.
|
|
118
|
+
|
|
119
|
+
**Minor units.** The digits of each currency's minor unit come from a table billing pins
|
|
120
|
+
(`CURRENCY_MINOR_UNIT_DIGITS`): ISO 4217 List One of 2024-06-25 without funds and units that are not
|
|
121
|
+
prices, MGA counted without a minor unit (its subunit is a fifth, as Stripe counts it) and XCG added.
|
|
122
|
+
The runtime's `Intl` only supplies the notation (symbol, separators, where the sign goes), so a price
|
|
123
|
+
means the same amount on every Node build; `Intl`'s own digits follow its CLDR data and have changed
|
|
124
|
+
between builds (HUF had 0 on some and 2 on others). A code outside the table (HRK, SLL, a typo) is
|
|
125
|
+
refused when the config loads. Where `Intl` on current runtimes counts other digits, an amount
|
|
126
|
+
written against `Intl`'s unit must be converted: AFN, ALL, IRR, KPW, LAK, LBP, MMK, RSD, SOS, SYP and
|
|
127
|
+
YER have two decimals here (ALL 1,500 is `150000`), IQD three (IQD 25,000 is `25000000`), and HUF and
|
|
128
|
+
TWD two on every runtime.
|
|
129
|
+
|
|
130
|
+
**A paid period** runs in local calendar days like a trial, the start day included: a month granted
|
|
131
|
+
on 3 October covers every day to 2 November and ends when 3 November begins. It starts when the
|
|
132
|
+
access the account already has ends (a running trial or paid access), so paying early loses no day,
|
|
133
|
+
and each grant adds one period. A month keeps the day of the month where it can: from 31 January it
|
|
134
|
+
ends with 27 February (the 28th starts the next period), and renewals then continue from the 28th.
|
|
135
|
+
A lifetime plan grants lifetime access; a dated grant to a lifetime account changes nothing.
|
|
136
|
+
|
|
137
|
+
**Stripe.** `stripe({ secretKey?, apiBase?, fetch? })` reads `STRIPE_SECRET_KEY` on every payment
|
|
138
|
+
(so the config loads at build time without it) and creates one Checkout session per payment
|
|
139
|
+
(`mode: "payment"`): the plan's price as a one-off line item in its currency, the plan's name in the
|
|
140
|
+
app's locale, the buyer's email, and the account id and plan id in the session's and the
|
|
141
|
+
PaymentIntent's metadata (`softure_user_id`, `softure_plan_id`). The buyer returns to
|
|
142
|
+
`routes.payment` with `?checkout=success` (the page thanks them; access follows the webhook, usually
|
|
143
|
+
within seconds) or `?plan=<id>&checkout=cancelled`. Payment methods are the ones enabled in the
|
|
144
|
+
Stripe dashboard (cards, BLIK, Przelewy24, transfers). A refusal, a timeout or a missing key is
|
|
145
|
+
`billing.payment_failed` for the buyer and one log line (HTTP status and Stripe's error type and
|
|
146
|
+
code, never its message or the key). Subscriptions are not used: each payment buys one period, and
|
|
147
|
+
renewing is paying again, as with `manual()`.
|
|
148
|
+
|
|
149
|
+
**Stripe's currency units.** Stripe takes amounts in its own unit per currency
|
|
150
|
+
([currency guide](https://docs.stripe.com/currencies)), which is not always billing's: ISK and UGX
|
|
151
|
+
have no decimals in ISO 4217 but two (always `00`) at Stripe. Plans stay in billing's unit;
|
|
152
|
+
`stripe()` converts what it sends (ISK 1,500 goes as `150000`) and the webhook converts
|
|
153
|
+
what Stripe reports back, so payments and refunds are stored and shown in the plan's unit. HUF and
|
|
154
|
+
TWD need nothing: Stripe's divisible-by-100 rule for them is for payouts, not charges. A price
|
|
155
|
+
`stripe()` cannot charge exactly is refused when the config loads, naming the plan: a three-decimal
|
|
156
|
+
amount (BHD, JOD, KWD, OMR, TND) whose last digit is not 0, or an amount finer than Stripe's unit
|
|
157
|
+
(IQD and LYD have three decimals in ISO 4217 and two at Stripe, so their amounts must end in 0). Stripe's minimum and maximum amounts depend on the account and the payment method, so
|
|
158
|
+
Stripe checks them at Checkout (`billing.payment_failed` and a log line).
|
|
159
|
+
|
|
160
|
+
**Days and time zones.** Trials end at the start of a local day in `config.timezone`: a 14-day
|
|
161
|
+
trial begun at any hour of 3 October ends when 17 October begins there, so 16 October is its last
|
|
162
|
+
day. Days left count local calendar days, today included (1 on the last day). Access covers every
|
|
163
|
+
instant before its end; at the end itself the account is read-only. Paid access wins over a trial;
|
|
164
|
+
a trial that outlasts paid access takes over again when the payment ends.
|
|
165
|
+
|
|
166
|
+
## 4. Mounting
|
|
167
|
+
|
|
168
|
+
Mount the payment page at `routes.payment` and the admin page at `routes.admin`, one line each:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// app/payment/page.tsx
|
|
172
|
+
export { PaymentPage as default } from "@softure-ai/billing/next";
|
|
173
|
+
// app/admin/billing/page.tsx
|
|
174
|
+
export { BillingAdminPage as default } from "@softure-ai/billing/next";
|
|
175
|
+
// app/api/billing/webhook/route.ts (with stripe(); public, outside any auth guard)
|
|
176
|
+
export { stripeWebhookRoute as POST } from "@softure-ai/billing/next";
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**The Stripe webhook.** In the Stripe dashboard, add an endpoint at `<appOrigin>/api/billing/webhook`
|
|
180
|
+
for `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `charge.refunded` and
|
|
181
|
+
`refund.failed`, and put its signing secret in `STRIPE_WEBHOOK_SECRET` (locally: `stripe listen --forward-to
|
|
182
|
+
localhost:3000/api/billing/webhook` prints one). The route checks `Stripe-Signature` (HMAC-SHA256,
|
|
183
|
+
at most five minutes old, any `v1` entry during a secret rotation) before it parses the body (at
|
|
184
|
+
most 256 KiB) or touches the database, then:
|
|
185
|
+
|
|
186
|
+
| Delivery | Effect | Answer |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| a paid checkout (`completed` with `payment_status` `paid` or `no_payment_required`, or `async_payment_succeeded`) | the payment is stored and its plan granted, in one transaction | 200 |
|
|
189
|
+
| the same checkout again (a retry, or both events of a delayed payment) | nothing | 200 |
|
|
190
|
+
| a checkout still waiting for a transfer, a charge with nothing refunded, any other event, a session billing did not create | nothing | 200 |
|
|
191
|
+
| `charge.refunded` with `refunded: true` for a stored payment | the payment is marked refunded and what it granted taken back, once | 200 |
|
|
192
|
+
| `charge.refunded` with `refunded: false` for a stored payment | `amount_refunded` (the total so far) is recorded and access follows `partialRefunds`; a total not above the stored one (a retry, a late delivery) changes nothing; when that state is newer than every one recorded, it is kept for a late failure (see "Failed refunds") | 200 |
|
|
193
|
+
| `refund.failed` (or `refund.updated` / `charge.refund.updated` with `status` `failed` or `canceled`) for a stored payment | what the refund took is given back, once per refund (see "Failed refunds") | 200 |
|
|
194
|
+
| `charge.refunded` or a Refund event without the event's `created` | nothing | 400 |
|
|
195
|
+
| a paid checkout whose account was deleted or whose plan left the config | nothing stored; one log line with the checkout id to refund in Stripe | 200 |
|
|
196
|
+
| no or a wrong signature, a replay, a body that is not a Stripe event | nothing | 400 |
|
|
197
|
+
| no `STRIPE_WEBHOOK_SECRET`, a database failure | nothing; Stripe retries | 500 |
|
|
198
|
+
|
|
199
|
+
**Refunds.** Each payment row records what its grant added: a period (from where access ended, the
|
|
200
|
+
trial's end or the payment's instant, to the period's end) or lifetime access. A full refund takes
|
|
201
|
+
back only that, with the pure `getRefundEvent` (root entry):
|
|
202
|
+
|
|
203
|
+
- **A period** loses its unused days, `[max(from, now), until)`, counted in local days of the app's
|
|
204
|
+
time zone: the dated end moves back by that many days. Access ahead of now is one unbroken run
|
|
205
|
+
(every grant starts where running access ends), so the other stacked periods, manual grants and
|
|
206
|
+
the trial keep their length. A period already used up takes nothing back, and an old payment
|
|
207
|
+
refunded after a lapse never touches a newer period. The stored periods of the payments stacked
|
|
208
|
+
after it move back by the same days, so a later refund of one of them takes back the right
|
|
209
|
+
days. A dated end moved to the trial's end or before it drops paid access: the account is back
|
|
210
|
+
on its trial.
|
|
211
|
+
- **A lifetime** ends lifetime access unless another lifetime payment of the account is still
|
|
212
|
+
`paid` or an active manual lifetime grant (`billing.manual_grants`) still gives it. Lifetime
|
|
213
|
+
keeps the dated end beside it (a grant on lifetime still extends it), so the months bought next
|
|
214
|
+
to a refunded lifetime stay.
|
|
215
|
+
- **A payment stored before grants were recorded** (no grant columns) revokes paid access, as
|
|
216
|
+
before.
|
|
217
|
+
|
|
218
|
+
**Partial refunds.** A payment keeps the total refunded so far (`refunded_amount`, from Stripe's
|
|
219
|
+
cumulative `amount_refunded`) and stays `paid` until that total reaches its amount. Under
|
|
220
|
+
`partialRefunds: "pro_rata"` (the default) each partial refund takes back the share of the period's
|
|
221
|
+
unused days that the newly refunded money is of the money not refunded before, rounded down to
|
|
222
|
+
whole days (the account keeps a part of a day), and the payment's stored period ends that many days
|
|
223
|
+
earlier; the periods stacked after it move back too. Refunding 14.50 of a 29.00 month bought for 31
|
|
224
|
+
unused days takes back 15. The refund that completes the amount is a full refund, so partial refunds
|
|
225
|
+
that add up to the payment end exactly where one full refund at the time of the last one would.
|
|
226
|
+
Under `"keep_access"` (refunds as goodwill or compensation) a partial refund takes nothing back,
|
|
227
|
+
and the completing one takes back every unused day left. Either way a partial refund never ends a
|
|
228
|
+
lifetime nor revokes a payment stored before grants were recorded: only the completing refund does.
|
|
229
|
+
|
|
230
|
+
**Failed refunds.** A refund the bank or card refuses after Stripe reported it (`refund.failed`)
|
|
231
|
+
gives back what it took, once per refund id (`billing.refund_failures`):
|
|
232
|
+
|
|
233
|
+
- the payment's `refunded_amount` drops by the refund's amount, and a payment refunded in full is
|
|
234
|
+
`paid` again;
|
|
235
|
+
- a lifetime payment refunded in full gives lifetime access back;
|
|
236
|
+
- a period gets back days: each payment keeps the local days refunds took from it
|
|
237
|
+
(`taken_back_days`), and a failure gives back the failed money's share of them under
|
|
238
|
+
`pro_rata` (rounded down; the failure that leaves nothing refunded gives back the rest), all of
|
|
239
|
+
them under `keep_access` (only the completing refund took any). While the payment is still paid
|
|
240
|
+
and its period still ahead, the days go back right after that period and the periods stacked
|
|
241
|
+
after it move forward again, the refund in reverse. Otherwise (refunded in full, or the period
|
|
242
|
+
used up) they are a grant at the end of the account's access, from the latest of the trial's end,
|
|
243
|
+
dated access and now, and the payment's stored period becomes that grant;
|
|
244
|
+
- a payment stored before grants were recorded gets its total and status back, not its access.
|
|
245
|
+
|
|
246
|
+
Stripe does not order events, so billing keeps the time of the newest charge state it recorded
|
|
247
|
+
(`refunds_seen_at`, the event's `created`). A failure of a refund created after that state, or failed
|
|
248
|
+
before it, was never counted: it is recorded and gives back nothing. A charge state taken before a
|
|
249
|
+
failure (a late retry of `charge.refunded`) still counts the failed refund; billing subtracts it, so
|
|
250
|
+
the delivery changes nothing. A lower `amount_refunded` on its own is never read as a failure.
|
|
251
|
+
|
|
252
|
+
A new refund can be reported before the failure of an earlier one: the bank refuses refund A, then
|
|
253
|
+
refund B's `charge.refunded` arrives with an `amount_refunded` that no longer counts A, so it is not
|
|
254
|
+
above what billing recorded. When such a state is newer than every one billing recorded, the
|
|
255
|
+
payment keeps it (`pending_refunded_amount`, `pending_refunds_seen_at`; only the newest) and the
|
|
256
|
+
delivery answers `duplicate`. Each failure billing records then gives back what the failed refund
|
|
257
|
+
took and applies the kept state if, less the failures it still counts, it reports more than billing
|
|
258
|
+
now counts: B is taken back once, by the `partialRefunds` policy (in full when it completes the
|
|
259
|
+
amount). A kept state waits through as many failures as it needs, and a state applied at or after
|
|
260
|
+
its time clears it.
|
|
261
|
+
|
|
262
|
+
The decision is made under the entitlement row's lock, so a lifetime bought at the same moment is
|
|
263
|
+
either seen or granted after the refund.
|
|
264
|
+
|
|
265
|
+
There is no rate limit on the route: Stripe sends from a few addresses, and an unsigned request costs
|
|
266
|
+
one HMAC.
|
|
267
|
+
|
|
268
|
+
`PaymentPage` needs a session (a visitor goes to the login page and back) and shows the account's
|
|
269
|
+
badge and the plans; with `?plan=<id>` it shows the order and the provider's form: the invoice
|
|
270
|
+
details for `manual()`, one checkout button for a hosted provider. An account with lifetime access
|
|
271
|
+
sees that it has nothing left to pay for instead of the order (`startPayment` refuses it with
|
|
272
|
+
`billing.lifetime_active`). Show the plans anywhere else, e.g. a public pricing page, with
|
|
273
|
+
`<Pricing LinkComponent={Link} />`.
|
|
274
|
+
|
|
275
|
+
**The admin page.** `BillingAdminPage` answers "not found" to anyone without `adminRole`; every
|
|
276
|
+
action checks the role from the session again before reading its form. It has three cards:
|
|
277
|
+
|
|
278
|
+
- **Invoice requests**: the open requests, oldest first, with the account, the plan, when it was
|
|
279
|
+
asked for, the price it quoted and the invoice details (the latest ones: a buyer who asks again
|
|
280
|
+
refreshes them without a new hand-over, so the owner's mail may hold older ones). **Grant** applies the plan and closes the request in one
|
|
281
|
+
transaction (`grantPaymentRequest`); **Dismiss** closes it without a grant; **History** opens the
|
|
282
|
+
account's history. A request is granted or dismissed once: a second click finds it closed.
|
|
283
|
+
- **Grant access**: a plan for the account with a given email, recorded like a request's grant.
|
|
284
|
+
An account with lifetime access is refused (`billing.lifetime_active`): a dated period under
|
|
285
|
+
lifetime would be invisible.
|
|
286
|
+
- **Account history**: the email lookup sends the admin to `?account=<id>` (no address in a URL),
|
|
287
|
+
which shows the account's badge and its manual grants and provider payments, newest first, each
|
|
288
|
+
with its price, the access it added and its state. **Revoke** on an active manual grant takes back only
|
|
289
|
+
what it added, like a refund: a period loses its unused days and the periods stored after it
|
|
290
|
+
(manual or paid) move back; a lifetime ends unless another active manual lifetime or a paid
|
|
291
|
+
lifetime payment still gives it. Provider payments are refunded at the provider, not here.
|
|
292
|
+
|
|
293
|
+
**Invoice requests.** `startPayment` stores a manual request first, then claims its hand-over on
|
|
294
|
+
the row and calls `onRequest`; an open request is handed over once, and asking again only
|
|
295
|
+
refreshes its details, price and time. When `onRequest` answers an `Err` or throws, the claim is
|
|
296
|
+
released: the buyer sees `billing.payment_failed`, the admin page still lists the request, and the
|
|
297
|
+
next ask hands it over. A claim left without an answer (the process stopped while `onRequest` ran)
|
|
298
|
+
blocks other asks for a minute; the first ask after that hands the request over again. Invoice details are refused with a code per field:
|
|
299
|
+
`billing.invoice_field_required`, `billing.invoice_field_too_long` (the copy names the limit:
|
|
300
|
+
200, 32, 500) or `billing.invoice_field_control_characters` (line breaks, tabs and other control
|
|
301
|
+
characters, in every field, so a name cannot add lines to the owner's mail; the database refuses
|
|
302
|
+
them too). Run `expireStaleRequests(ctx)` (`/server`) daily, like the reminder mail, to close
|
|
303
|
+
requests nobody asked again for in `requests.expireAfterDays` days; it returns `{ expired }`, and
|
|
304
|
+
a repeated run closes nothing new:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// scripts/expire-invoice-requests.ts (cron: 0 3 * * *)
|
|
308
|
+
import { expireStaleRequests } from "@softure-ai/billing/server";
|
|
309
|
+
import { systemClock } from "@softure-ai/core";
|
|
310
|
+
import { createDatabase } from "@softure-ai/db";
|
|
311
|
+
import config from "../softure.config.ts";
|
|
312
|
+
|
|
313
|
+
if (config.database === null) throw new Error("expire-invoice-requests: the config has no database");
|
|
314
|
+
const database = await createDatabase(config.database.url, { max: 1 });
|
|
315
|
+
try {
|
|
316
|
+
console.log(JSON.stringify(await expireStaleRequests({ db: database.db, clock: systemClock, config })));
|
|
317
|
+
} finally {
|
|
318
|
+
await database.close();
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Reminder mail.** With `mailing({ ... })` in the config, run `sendAccessReminders` on a schedule,
|
|
323
|
+
e.g. a daily cron job (or a platform scheduler) running a script:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
// scripts/send-access-reminders.ts (cron: 0 9 * * *)
|
|
327
|
+
import { sendAccessReminders } from "@softure-ai/billing/mailing";
|
|
328
|
+
import { systemClock } from "@softure-ai/core";
|
|
329
|
+
import { createDatabase } from "@softure-ai/db";
|
|
330
|
+
import config from "../softure.config.ts";
|
|
331
|
+
|
|
332
|
+
if (config.database === null) throw new Error("send-access-reminders: the config has no database");
|
|
333
|
+
const database = await createDatabase(config.database.url, { max: 1 });
|
|
334
|
+
try {
|
|
335
|
+
console.log(JSON.stringify(await sendAccessReminders({ db: database.db, clock: systemClock, config })));
|
|
336
|
+
} finally {
|
|
337
|
+
await database.close();
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Each run mails the four states the notice shows: the trial or dated paid access ending (from
|
|
342
|
+
`trial.reminderDays` / `paid.reminderDays` days left, "ends on {date}") and ended ("has ended",
|
|
343
|
+
from the day it ended through `catchUpDays` days after it, default 3, so turning reminders on never
|
|
344
|
+
mails accounts that lapsed long ago; an account created without a trial gets no "trial ended" mail).
|
|
345
|
+
Lifetime access gets nothing. The mail is transactional (no unsubscribe footer: it is an account
|
|
346
|
+
notice), in the app's locale, with the notice's link text and the absolute payment page URL. Each
|
|
347
|
+
account gets one mail per kind and end (scope `billing.<kind>:<account id>:<end>` in
|
|
348
|
+
`mailing.deliveries`): a run repeated the same day, or two runs at once, send nothing new, while an
|
|
349
|
+
extended trial or a renewal is a new window. Options: `catchUpDays` (0 to 365), `pauseMs` between two
|
|
350
|
+
mails the provider was called for (default 500, Resend's two requests per second). The summary counts
|
|
351
|
+
`due`, `sent`, `skipped` (sent by an earlier run, or another run is sending it), `rejected` (refused
|
|
352
|
+
for good) and `retryLater` (the provider was unavailable; the next run sends it). A database failure
|
|
353
|
+
throws; the next run resumes. Candidates come from two range queries, the stored ends and
|
|
354
|
+
`auth.users.created_at` (indexed by auth's `0004`) for accounts without a row, never a scan of every
|
|
355
|
+
account.
|
|
356
|
+
|
|
357
|
+
Guard every write action of the app, before reading any input:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
"use server";
|
|
361
|
+
import { requireWriteAccess } from "@softure-ai/billing/next";
|
|
362
|
+
|
|
363
|
+
export async function saveNote(formData: FormData) {
|
|
364
|
+
const access = await requireWriteAccess(); // no session: redirects to the login page
|
|
365
|
+
if (!access.ok) return access; // Err("billing.read_only"), for the form to show
|
|
366
|
+
// ... the app's own authorization and the write, as access.value.user
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Show where the account stands in any server component (both render nothing without a session):
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
import { CurrentAccessBadge, CurrentAccessNotice } from "@softure-ai/billing/next";
|
|
374
|
+
import Link from "next/link";
|
|
375
|
+
|
|
376
|
+
<CurrentAccessBadge />
|
|
377
|
+
<CurrentAccessNotice LinkComponent={Link} />
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Server functions, for scripts and other hosts (`@softure-ai/billing/server`):
|
|
381
|
+
`grantPlanManually(ctx, { userId, planId, adminId, requestId? })` grants one payment of a plan,
|
|
382
|
+
records it in the account's history (closing the request when given) and returns
|
|
383
|
+
`{ grantId, entitlement }` (`Err<billing.plan_unknown | billing.account_unknown |
|
|
384
|
+
billing.lifetime_active | billing.request_closed>` otherwise); `grantPaymentRequest(ctx, { requestId,
|
|
385
|
+
adminId })` does it for an open request; `revokeManualGrant(ctx, { grantId, adminId })` takes one
|
|
386
|
+
back (`Err<billing.grant_revoked>` when it was revoked before); `getAccountHistory(ctx, userId)`,
|
|
387
|
+
`listOpenRequests(ctx, limit?)`, `dismissPaymentRequest(ctx, requestId)`. `grantPlan(ctx, userId,
|
|
388
|
+
planId)` grants without a record: nothing to revoke, not in the history; scripts that grant for
|
|
389
|
+
an admin use `grantPlanManually`. `startPayment(ctx, input)` counts the `billing-payment` bucket
|
|
390
|
+
per account, checks the plan, lifetime access and the invoice details (`parseInvoiceDetails`, a zod
|
|
391
|
+
schema, `invoiceDetailsSchema` from the root entry), stores a request for a provider that hands
|
|
392
|
+
requests over and calls the provider once per open request (see "Invoice requests");
|
|
393
|
+
`expireStaleRequests(ctx)` closes requests older than `requests.expireAfterDays`; `findAccountByEmail(ctx, email)`,
|
|
394
|
+
`findAccountById(ctx, id)`, `getBillingPlans(config)`.
|
|
395
|
+
`receiveStripeWebhook(ctx, { payload, signature, secret })` is the route without Next;
|
|
396
|
+
`recordPayment(ctx, { provider, checkoutId, paymentId, userId, planId, amount, currency })` and
|
|
397
|
+
`refundPayment(ctx, { provider, paymentId })` are its two writes, for another provider's webhook.
|
|
398
|
+
`getEntitlement(ctx, userId)` returns the `Entitlement` or null for an unknown account;
|
|
399
|
+
`checkWriteAccess(ctx, userId)` returns `Ok<Entitlement>` or `Err<billing.read_only | billing.account_unknown>`;
|
|
400
|
+
`changeEntitlement(ctx, userId, event)` returns the `Entitlement` after the change or
|
|
401
|
+
`Err<billing.end_not_in_future | billing.account_unknown>`. A refused event writes nothing. `event`
|
|
402
|
+
may also be a function of the current record, run under the row's lock (how `grantPlan` extends a
|
|
403
|
+
period without losing a concurrent grant).
|
|
404
|
+
|
|
405
|
+
**Scripts.** `@softure-ai/billing/scripts` builds ops scripts on `@softure-ai/ops/scripts` (dry run by
|
|
406
|
+
default, `--commit` writes, one transaction), for an operator without the admin page or at a
|
|
407
|
+
terminal: `grant-plan` and `revoke-grant` here, `import-entitlements` and `pin-trials` under
|
|
408
|
+
"Existing accounts" below. The app bundles them like its other scripts and runs them with its
|
|
409
|
+
database URL:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
// scripts/grant-plan.ts: npm run grant-plan -- --email=member@example.com --plan=monthly [--commit]
|
|
413
|
+
import { createGrantPlanScript } from "@softure-ai/billing/scripts";
|
|
414
|
+
import { runOpsScript } from "@softure-ai/ops/scripts";
|
|
415
|
+
import config from "../softure.config";
|
|
416
|
+
|
|
417
|
+
process.exitCode = await runOpsScript({ script: createGrantPlanScript(config), argv: process.argv.slice(2), config });
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- `grant-plan --email=… --plan=<plan id>` grants one payment of a declared plan through
|
|
421
|
+
`grantPlanManually` (no admin: `granted_by` is null), so it is in the account's history and the
|
|
422
|
+
admin page can revoke it. Refuses an undeclared plan (naming the declared ones), an unknown email
|
|
423
|
+
and an account with lifetime access.
|
|
424
|
+
- `revoke-grant --email=… --grant=<id>` revokes one active manual grant of that account through
|
|
425
|
+
`revokeManualGrant` (a script's or the admin page's) and takes back what it added. Refuses an
|
|
426
|
+
unknown email and an id that is not an active manual grant of the account (another account's,
|
|
427
|
+
revoked, mistyped).
|
|
428
|
+
|
|
429
|
+
Both print the account's state `before` and `after`: the user id (never the email), the
|
|
430
|
+
entitlement and the active manual grants with their ids, newest first; a dry run of either script
|
|
431
|
+
shows the id `revoke-grant` takes. `createGrantPlanScript(config, { clock? })` and
|
|
432
|
+
`createRevokeGrantScript(config, { clock? })` take a clock for tests (`executeOpsScript`).
|
|
433
|
+
|
|
434
|
+
**Existing accounts.** An account without a `billing.entitlements` row is on the trial derived from
|
|
435
|
+
its creation day (§5), so turning billing on for accounts that already exist would make every one
|
|
436
|
+
older than `trial.days` read-only at once. Three tools, in this order, keep their access:
|
|
437
|
+
|
|
438
|
+
1. **A trial floor**, `billing({ trial: { startsAt: "2026-11-01" } })`: every account created before
|
|
439
|
+
that local day gets its `trial.days` from it, on every read, without a write; accounts created on
|
|
440
|
+
or after it keep their own trial. The reminder mail sees the floored trials too, so all those
|
|
441
|
+
accounts get their trial-ending mail in the same window.
|
|
442
|
+
2. **An import** of what the old system knew (FIRE_TRACKER's `trial_ends_at`, `paid_until`):
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
// scripts/import-entitlements.ts: npm run import-entitlements -- --file=entitlements.json [--commit]
|
|
446
|
+
import { createImportEntitlementsScript } from "@softure-ai/billing/scripts";
|
|
447
|
+
import { runOpsScript } from "@softure-ai/ops/scripts";
|
|
448
|
+
import config from "../softure.config";
|
|
449
|
+
|
|
450
|
+
process.exitCode = await runOpsScript({ script: createImportEntitlementsScript(config), argv: process.argv.slice(2), config });
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
The file (its path relative to the working directory) is a JSON array, at most 50,000 rows:
|
|
454
|
+
|
|
455
|
+
```json
|
|
456
|
+
[
|
|
457
|
+
{ "email": "ada@example.com", "trialEndsAt": "2026-08-15T00:00:00+02:00", "paidUntil": "2027-01-01T00:00:00+01:00" },
|
|
458
|
+
{ "email": "grace@example.com", "isLifetime": true }
|
|
459
|
+
]
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Each row needs `email` and at least one of `trialEndsAt`, `paidUntil` (ISO 8601 with an offset,
|
|
463
|
+
the first instant without access, as `billing.entitlements` stores it; `null` for none) and
|
|
464
|
+
`isLifetime`. Each is merged onto the account's current record (its row, or its derived and
|
|
465
|
+
floored trial) by the `import` event: an end only moves later, lifetime only turns on, so an
|
|
466
|
+
import never takes access away and running the same file again changes nothing. An imported end
|
|
467
|
+
earlier than the account's own is therefore not recorded; past ends later than it are (an ended
|
|
468
|
+
paid period shows as `paid_ended`). The import is not a grant: it is not in the account's history
|
|
469
|
+
and is not revocable from the admin page (correct a mistake with `changeEntitlement`'s `revoke` or
|
|
470
|
+
`shorten`). The whole file is one transaction and refused as a whole for a file that cannot be
|
|
471
|
+
read, a row that fails the format, an email repeated in the file (compared as auth stores emails)
|
|
472
|
+
or an email no account has; refusals name row numbers, never emails. The report counts the named
|
|
473
|
+
accounts by state (`trial`, `paid`, `lifetime`, `readOnly`) `before` and `after`. Split a very
|
|
474
|
+
large file: every row takes its locks until the end of the run. `importEntitlement(ctx, { userId,
|
|
475
|
+
trialEndsAt?, paidUntil?, isLifetime? })` (`/server`) is the same merge for an app that migrates in
|
|
476
|
+
its own code; it returns the `Entitlement` or `Err<billing.account_unknown>`.
|
|
477
|
+
3. **A pin** before any change of `trial.days`, `trial.startsAt` or `config.timezone`:
|
|
478
|
+
`npm run pin-trials [-- --commit]` (`createPinTrialsScript(config)`, no arguments) writes the trial
|
|
479
|
+
every account without a row is on, exactly as reads derive it, into a row, so the change moves no
|
|
480
|
+
existing trial and applies to new accounts only. Rows written meanwhile by a change are kept; a
|
|
481
|
+
second run pins nothing. The report gives `accountsWithoutRow` `before` and `after`, and `pinned`.
|
|
482
|
+
`pinDerivedTrials(ctx)` (`/server`) is the same step, returning how many rows it wrote.
|
|
483
|
+
|
|
484
|
+
Both scripts take `{ clock? }` for tests, like the plan scripts.
|
|
485
|
+
|
|
486
|
+
**A payment provider** (`PaymentProvider` from the root entry) has a `name`, says whether the page
|
|
487
|
+
collects invoice details (`collectsInvoiceDetails`) and whether it hands requests to the owner
|
|
488
|
+
instead of sending the buyer to a checkout (`handsOverRequests`: billing then stores the request
|
|
489
|
+
before the call and makes the call once per open request), and implements
|
|
490
|
+
`startPayment(ctx, { plan, account, invoice, returnUrl })`, which resolves with
|
|
491
|
+
`{ type: "redirect", url }` (a hosted checkout; the action redirects there), `{ type: "requested" }`
|
|
492
|
+
(handed over, only for `handsOverRequests: true`; the page confirms) or
|
|
493
|
+
`Err<billing.payment_failed>`. Granting access afterwards goes through `grantPlanManually` (an
|
|
494
|
+
admin) or `recordPayment` (a webhook).
|
|
495
|
+
|
|
496
|
+
## 5. Migrations and tables
|
|
497
|
+
|
|
498
|
+
`migrations/0001_create_entitlements.sql` creates `billing.entitlements`:
|
|
499
|
+
|
|
500
|
+
| Column | Meaning |
|
|
501
|
+
| --- | --- |
|
|
502
|
+
| `user_id` | `uuid`, primary key, references `auth.users(id)` `ON DELETE CASCADE`. |
|
|
503
|
+
| `trial_ends_at` | The first instant the trial no longer covers. |
|
|
504
|
+
| `paid_until` | The first instant dated paid access no longer covers; NULL when never paid or revoked. Kept under lifetime (since `0003`). |
|
|
505
|
+
| `is_lifetime` | Paid access without an end; it wins over `paid_until`. |
|
|
506
|
+
| `created_at`, `updated_at` | The first change and the last one. |
|
|
507
|
+
|
|
508
|
+
**No row until something changes.** Reads never write: an account without a row gets its trial
|
|
509
|
+
derived from `auth.users.created_at`, `trial.days`, `trial.startsAt` and `config.timezone`, and
|
|
510
|
+
auth's single `onRegistered` hook stays free for the app. Accounts created before billing was
|
|
511
|
+
enabled are on that derived trial too, so without `trial.startsAt` or an import those older than
|
|
512
|
+
`trial.days` are read-only from the first read (see "Existing accounts" in §4). The first change
|
|
513
|
+
(`changeEntitlement`, an import, `pin-trials`) stores the derived trial end with the event applied,
|
|
514
|
+
so the trial end never moves when a row appears. Until then every read derives it again, so for
|
|
515
|
+
accounts without a row:
|
|
516
|
+
|
|
517
|
+
| Config change | Effect |
|
|
518
|
+
| --- | --- |
|
|
519
|
+
| shorter `trial.days` | every derived trial ends earlier: accounts past the new end are read-only at once |
|
|
520
|
+
| longer `trial.days` | every derived trial ends later: accounts whose trial had ended can write again |
|
|
521
|
+
| `trial.startsAt` set, moved or removed | the trial of every account created before the (old or new) floor day moves with it |
|
|
522
|
+
| `config.timezone` | every derived trial ends at the start of the same local day in the new zone, hours earlier or later |
|
|
523
|
+
|
|
524
|
+
Run `pin-trials` before such a change to keep existing trials where they are.
|
|
525
|
+
|
|
526
|
+
`migrations/0002_create_payments.sql` creates `billing.payments`, one row per paid provider checkout:
|
|
527
|
+
|
|
528
|
+
| Column | Meaning |
|
|
529
|
+
| --- | --- |
|
|
530
|
+
| `id` | `uuid`, primary key. |
|
|
531
|
+
| `user_id` | The account, references `auth.users(id)` `ON DELETE CASCADE`. |
|
|
532
|
+
| `provider` | The adapter's name, `stripe`. |
|
|
533
|
+
| `checkout_id` | The provider's checkout (`cs_...`); unique per provider: a checkout grants once. |
|
|
534
|
+
| `payment_id` | The provider's payment (`pi_...`) refunds name; unique per provider; NULL for a free checkout. |
|
|
535
|
+
| `plan_id`, `amount`, `currency` | The plan and what the provider charged, in the currency's minor unit. |
|
|
536
|
+
| `status`, `paid_at`, `refunded_at` | `paid` or `refunded`; a CHECK ties `refunded_at` to the status. |
|
|
537
|
+
| `grant_kind`, `granted_from`, `granted_until` | What the payment granted (`0003`): `period` with its start and end, or `lifetime` with no dates; all NULL for rows recorded before. A CHECK (`payments_grant_shape`) ties the dates to the kind. A partial refund moves `granted_until` back by the days it took. |
|
|
538
|
+
| `refunded_amount` | The total refunded so far, in the currency's minor unit (`0005`): `amount` once `refunded`, below it while `paid` (CHECK `payments_refunded_amount_by_status`). |
|
|
539
|
+
| `taken_back_days` | The local days refunds took from the payment's period so far (`0007`); a failed refund gives back its share. |
|
|
540
|
+
| `refunds_seen_at` | When Stripe took the newest charge state billing recorded (`0007`, the event's `created`); NULL before any refund. |
|
|
541
|
+
| `pending_refunded_amount`, `pending_refunds_seen_at` | The newest charge state billing did not apply because it reported no more than billing counted (`0009`): Stripe's raw `amount_refunded` and the event's `created`, applied by a later failure; both NULL when none is kept (CHECK `payments_pending_charge_state_shape`). |
|
|
542
|
+
|
|
543
|
+
`migrations/0003_record_payment_grants.sql` adds the grant columns and drops the CHECK that kept
|
|
544
|
+
`paid_until` NULL under lifetime. `migrations/0005_record_refunded_amounts.sql` adds
|
|
545
|
+
`refunded_amount` (set to `amount` on payments refunded before).
|
|
546
|
+
`migrations/0006_record_request_handover_and_prices.sql` adds `handed_over_at` (set to
|
|
547
|
+
`requested_at` on open requests, which were all handed over), the price columns of both manual
|
|
548
|
+
tables, the `expired` status and the control-character CHECKs.
|
|
549
|
+
`migrations/0007_record_failed_refunds.sql` adds `taken_back_days` (0 on payments refunded before:
|
|
550
|
+
their failure gives back no days) and `refunds_seen_at` (set to `refunded_at`, or the migration's
|
|
551
|
+
time, on payments with a refund), and creates `billing.refund_failures`, one row per failed refund:
|
|
552
|
+
`payment_id` (references `billing.payments(id)` `ON DELETE CASCADE`), `refund_id`, `amount`,
|
|
553
|
+
`refund_created_at`, `failed_at` (the event's `created`) and `recorded_at`, primary key
|
|
554
|
+
`(payment_id, refund_id)`.
|
|
555
|
+
`migrations/0008_record_request_handover_claims.sql` adds `handover_claimed_at`; `handed_over_at`
|
|
556
|
+
keeps its values, so requests from before count as handed over.
|
|
557
|
+
`migrations/0009_record_pending_charge_states.sql` adds `pending_refunded_amount` and
|
|
558
|
+
`pending_refunds_seen_at` (NULL on every payment from before: nothing was kept).
|
|
559
|
+
|
|
560
|
+
The insert, the grant and its grant columns share a transaction, as do the refund's conditional update and the change it makes,
|
|
561
|
+
so a delivery seen twice changes nothing. Every write takes the account first (like the privacy
|
|
562
|
+
erase); a refund and a manual revoke then take the entitlement before their own row, since moving
|
|
563
|
+
the later periods back updates other rows under that lock.
|
|
564
|
+
|
|
565
|
+
`migrations/0004_create_requests_and_grants.sql` creates the manual payments' two tables:
|
|
566
|
+
|
|
567
|
+
`billing.payment_requests`, one row per invoice request:
|
|
568
|
+
|
|
569
|
+
| Column | Meaning |
|
|
570
|
+
| --- | --- |
|
|
571
|
+
| `id`, `user_id`, `plan_id` | The request, its account (`ON DELETE CASCADE`) and the plan asked for. |
|
|
572
|
+
| `invoice_name`, `invoice_tax_id`, `invoice_address` | The details as typed, kept **only while the request is open**: closing it clears them (CHECK `payment_requests_details_while_open`). No control characters in new values (`0006`, CHECKs `payment_requests_invoice_*_printable`, `NOT VALID`: older rows are not rewritten). |
|
|
573
|
+
| `status`, `requested_at`, `closed_at` | `open`, `granted`, `dismissed` or `expired` (`0006`); a CHECK ties `closed_at` to the status. `requested_at` is the last ask. |
|
|
574
|
+
| `handed_over_at` | When the hand-over to the owner answered `Ok` (`0006`; since `0008`, before it the claim time): never handed over again once set. |
|
|
575
|
+
| `handover_claimed_at` | When an ask claimed the hand-over (`0008`); NULL when none runs, the claim failed and was released, or the hand-over answered. A claim a minute old is taken over by the next ask. |
|
|
576
|
+
| `amount`, `currency` | The plan's price at the last ask, in the currency's minor unit (`0006`); NULL on rows stored before. |
|
|
577
|
+
|
|
578
|
+
One open request per account and plan (partial unique index `payment_requests_one_open`): asking
|
|
579
|
+
again refreshes its details and time.
|
|
580
|
+
|
|
581
|
+
`billing.manual_grants`, one row per plan an admin granted:
|
|
582
|
+
|
|
583
|
+
| Column | Meaning |
|
|
584
|
+
| --- | --- |
|
|
585
|
+
| `id`, `user_id`, `plan_id` | The grant, its account (`ON DELETE CASCADE`) and the plan. |
|
|
586
|
+
| `request_id` | The request it answered (unique), or NULL for a grant by email. |
|
|
587
|
+
| `granted_by`, `revoked_by` | The admins (`ON DELETE SET NULL`). |
|
|
588
|
+
| `granted_at`, `grant_kind`, `granted_from`, `granted_until` | What it added, as `billing.payments` records it (CHECK `manual_grants_grant_shape`). |
|
|
589
|
+
| `status`, `revoked_at` | `active` or `revoked`; CHECKs tie `revoked_at` and `revoked_by` to the status. |
|
|
590
|
+
| `amount`, `currency` | What it was granted for (`0006`): the price its request quoted, else the plan's price when granted; NULL on rows stored before. |
|
|
591
|
+
|
|
592
|
+
A grant and the request it closes share a transaction; a grant and a revoke take the account, then
|
|
593
|
+
the entitlement, then their row (a conditional update), the order of a refund.
|
|
594
|
+
|
|
595
|
+
## 6. Environment variables
|
|
596
|
+
|
|
597
|
+
| Name | Required | Meaning |
|
|
598
|
+
| --- | --- | --- |
|
|
599
|
+
| `STRIPE_SECRET_KEY` | with `stripe()` unless `stripe({ secretKey })` | The secret API key (`sk_test_...` in the sandbox), read on every payment. |
|
|
600
|
+
| `STRIPE_WEBHOOK_SECRET` | when `stripeWebhookRoute` is mounted | The webhook endpoint's signing secret (`whsec_...`). |
|
|
601
|
+
|
|
602
|
+
## 7. Switches
|
|
603
|
+
|
|
604
|
+
None.
|
|
605
|
+
|
|
606
|
+
## 8. Appearance
|
|
607
|
+
|
|
608
|
+
`AccessBadge` takes `classNames` for its slots `root`, `status` and `detail`; the status colour
|
|
609
|
+
follows the state (`--sft-color-foreground` for a trial, `success` when paid, `warning` in a
|
|
610
|
+
reminder window, `danger` when read-only). It sets `data-status` and, in a reminder window,
|
|
611
|
+
`data-ending="true"` for app styles. `AccessNotice` takes `root`, `message` and `actions` and
|
|
612
|
+
renders the `@softure-ai/ui` `ButtonLink`; ended access uses the danger surface of `FormError`.
|
|
613
|
+
Both accept `unstyled`. `PricingTiles` takes `root`, `tile`, `badge`, `name`, `description`,
|
|
614
|
+
`priceRow`, `price`, `period`, `features`, `feature`, `featureIcon` and `action`; a featured or chosen
|
|
615
|
+
tile gets `--sft-border-strong` and a shadow, and sets `data-plan`, `data-featured` and
|
|
616
|
+
`aria-current`. `PaymentForm` and `GrantForm` take `root`, `form` and `notice`.
|
|
617
|
+
|
|
618
|
+
## 9. Copy
|
|
619
|
+
|
|
620
|
+
`billingMessages` (`en`, `pl`): `badge` (status names, `daysLeft` plural forms, `until`), `notice`
|
|
621
|
+
(the four notices and their two link texts), `pricing` (period plural forms per unit, `lifetime`,
|
|
622
|
+
`featured`, `choose`, `empty`), `reminderMail` (`subject` and `body` of `trialEnding`,
|
|
623
|
+
`paidEnding`, `trialEnded` and `paidEnded`; the link text is the notice's), `payment` (the payment page, the invoice form and the notices after a
|
|
624
|
+
hosted checkout, `checkoutSuccess` and `checkoutCancelled`), `admin` (the
|
|
625
|
+
grant form) and `errors`. Plan names, descriptions and features come from the config, per locale. `{date}` is the last day of access in
|
|
626
|
+
the app's locale and time zone, `{count}` the days left. Override them with
|
|
627
|
+
`billing({ messages: { en: { notice: { choosePlan: "See plans" } } } })`.
|
|
628
|
+
|
|
629
|
+
## 10. Hooks
|
|
630
|
+
|
|
631
|
+
`manual({ onRequest(request, ctx) })` receives every invoice request (plan, account, invoice
|
|
632
|
+
details, return URL) and resolves with `Ok` once handed over, or an `Err` the buyer sees as
|
|
633
|
+
`billing.payment_failed`. Apps react to a change in their own code around `changeEntitlement` and
|
|
634
|
+
`grantPlan`. The Stripe webhook has no hook yet; its effect shows in `getEntitlement`.
|
|
635
|
+
|
|
636
|
+
## 11. GDPR
|
|
637
|
+
|
|
638
|
+
- Export: the account's entitlement row (`trialEndsAt`, `paidUntil`, `isLifetime`, `createdAt`,
|
|
639
|
+
`updatedAt`), or `entitlement: null` for an account without one, its payments oldest first
|
|
640
|
+
(`provider`, `checkoutId`, `paymentId`, `planId`, `amount`, `currency`, `status`, `paidAt`,
|
|
641
|
+
`refundedAt`, `refundedAmount`, `grantKind`, `grantedFrom`, `grantedUntil`), the refunds of them that
|
|
642
|
+
failed (`paymentId`, `refundId`, `amount`, `refundCreatedAt`, `failedAt`), its invoice requests (`planId`, the
|
|
643
|
+
invoice details while open, `amount`, `currency`, `status`, `requestedAt`, `closedAt`) and the
|
|
644
|
+
plans granted to it by hand (`planId`, `grantedAt`, the grant, `status`, `revokedAt`, `amount`,
|
|
645
|
+
`currency`). Which admin granted or revoked
|
|
646
|
+
is the admin's data and stays out of the account's export.
|
|
647
|
+
- Deletion: the row, the payments (their failed refunds with them), the requests and the manual grants, and the foreign keys remove
|
|
648
|
+
them with the account too; an erased admin's id is cleared from the grants they made.
|
|
649
|
+
Stripe keeps its own record of each payment (the controller's accounting record there).
|
|
650
|
+
- Reminder mail: what was sent is in `mailing.deliveries` under a recipient key (never the
|
|
651
|
+
address) and a scope naming the account id and the end; mailing keeps that ledger after an account
|
|
652
|
+
is deleted (see its README §11), when the id no longer points at anyone.
|
|
653
|
+
- Retention: invoice details are personal data the app needs only until the request is handled,
|
|
654
|
+
so granting or dismissing it erases them, and `expireStaleRequests` erases them from a request
|
|
655
|
+
nobody asked again for in `requests.expireAfterDays` days (30 by default). What `onRequest` delivered (the mail to the owner) and
|
|
656
|
+
the issued invoice are the app's and the owner's own records.
|
|
657
|
+
|
|
658
|
+
## 12. Limitations
|
|
659
|
+
|
|
660
|
+
- A refund that reaches the app before its checkout (Stripe does not order events) finds no
|
|
661
|
+
payment and is not retried.
|
|
662
|
+
- A failed refund gives back a share of the days refunds took, not the exact days that refund took
|
|
663
|
+
at its time: under `pro_rata` a failed completing refund may give back a day more or less than it
|
|
664
|
+
took, while failures that undo every refund give back exactly what was taken. Payments refunded
|
|
665
|
+
before migration `0007` recorded no taken days, so their failure gives back the status and the
|
|
666
|
+
amount only. Times are Stripe's whole seconds: a refund created in the second of a charge state
|
|
667
|
+
counts as included in it.
|
|
668
|
+
- A new refund kept until a late failure explains it (see "Failed refunds") takes back its share
|
|
669
|
+
of the days unused when that failure arrives, not when Stripe reported the refund, as a late
|
|
670
|
+
`charge.refunded` delivery would. A state reported before migration `0009` was not kept.
|
|
671
|
+
- The charge's currency is not compared with the payment's: a Checkout payment has one charge, in
|
|
672
|
+
the session's currency.
|
|
673
|
+
- A dated manual grant keeps its length when a refund or a revoke takes back another period.
|
|
674
|
+
- A refund of a period moves the dated end back by local days; a `grant { until }` an app applies
|
|
675
|
+
by hand with an end inside the stack is not a period of its own and shifts with it.
|
|
676
|
+
- A payment recorded before migration `0003` has no grant: its refund revokes all paid access.
|
|
677
|
+
- The Stripe adapter is tested against the sandbox's Checkout API and with a browser payment in the
|
|
678
|
+
sandbox whose webhook Stripe delivers through `stripe listen` (both only when `STRIPE_SECRET_KEY`
|
|
679
|
+
holds a test key; the example's `e2e/billing-checkout.stripe-sandbox.spec.ts`), and with signed
|
|
680
|
+
webhook fixtures. Only the card is paid end to end; BLIK and Przelewy24 are not.
|
|
681
|
+
- A grant through `grantPlan` (or a raw `changeEntitlement`, an import) is not recorded: it is not
|
|
682
|
+
in the history and cannot be revoked; the `grant-plan` script records its grants.
|
|
683
|
+
- The owner hears of an open request once: a buyer who corrects the details later changes the
|
|
684
|
+
admin page, not the mail already sent. Two asks at once for the same plan hand over once; if that
|
|
685
|
+
hand-over fails, the other ask has already answered "sent", and the next ask retries.
|
|
686
|
+
- A hand-over cut off after its claim (the process stopped while `onRequest` ran) is handed over
|
|
687
|
+
by the first ask a minute later; an ask within that minute answers "sent" without one. If the
|
|
688
|
+
owner's mail did go out before the stop (or `onRequest` answered but recording it failed), the
|
|
689
|
+
retry mails again: after a crash the hand-over is at least once.
|
|
690
|
+
- The admin page lists up to 50 open requests and 100 entries of each source in a history; there
|
|
691
|
+
is no paging.
|
|
692
|
+
- The write guard is per action: a read-only account can still call a write the app did not guard.
|
|
693
|
+
- Reminder mail is plain text with a minimal HTML body; there is no app template for it, and it
|
|
694
|
+
uses the app's locale (accounts have none of their own).
|
|
695
|
+
- No history of entitlement changes beyond grants: a row holds the current state; provider
|
|
696
|
+
payments and manual grants are stored, trial extensions and raw events are not.
|