@lenso/payments 0.0.0-stage → 0.1.2
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/README.md +153 -2
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +9 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +17 -0
- package/dist/contracts.d.ts +155 -0
- package/dist/drizzle/d1.d.ts +10 -0
- package/dist/drizzle/d1.js +17 -0
- package/dist/drizzle/pg.d.ts +3 -0
- package/dist/drizzle/pg.js +81 -0
- package/dist/drizzle/sqlite.d.ts +6 -0
- package/dist/drizzle/sqlite.js +6 -0
- package/dist/fetch.d.ts +8 -0
- package/dist/fetch.js +61 -0
- package/dist/index-bzm562dq.js +461 -0
- package/dist/index-qtpqn1xv.js +18 -0
- package/dist/index-w0b3sn1n.js +76 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +12 -0
- package/dist/manage.d.ts +54 -0
- package/dist/manage.js +38 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +25 -0
- package/dist/scheduler.d.ts +17 -0
- package/dist/scheduler.js +24 -0
- package/dist/stripe.d.ts +17 -0
- package/dist/stripe.js +308 -0
- package/dist/tasks.d.ts +18 -0
- package/dist/tasks.js +40 -0
- package/migrations/postgres.sql +23 -0
- package/migrations/sqlite.sql +23 -0
- package/package.json +127 -4
package/README.md
CHANGED
|
@@ -1,3 +1,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Payments
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@lenso/payments` persists provider payment/refund requests and verified results. It does not price products, grant memberships, maintain a wallet or implement an accounting ledger.
|
|
4
|
+
|
|
5
|
+
## Entries and dependencies
|
|
6
|
+
|
|
7
|
+
The ordinary async core (`createPayments`, contracts and safe errors) has no external runtime dependencies. Install optional peers only for the selected entries:
|
|
8
|
+
|
|
9
|
+
| Entry | Purpose | Optional peers |
|
|
10
|
+
| -------------------------------- | ------------------------------------------------------------- | ---------------------------- |
|
|
11
|
+
| root | Services, durable store/provider contracts | None |
|
|
12
|
+
| `/stripe` | Official Stripe SDK PaymentIntent/refund adapter | `stripe@23.0.0` |
|
|
13
|
+
| `/plugin` | Exact installed store/provider/authorization dependencies | `@lenso/core` |
|
|
14
|
+
| `/auth` | Auth provenance, audience and resource policy enforcement | `@lenso/auth` |
|
|
15
|
+
| `/config` | Lenso Config contract for leases, scan delay and refund count | `@lenso/core`, `zod` |
|
|
16
|
+
| `/tasks` | Existing Tasks queue, retries and scheduled continuation | `@lenso/tasks`, `zod` |
|
|
17
|
+
| `/scheduler` | Explicit recurring recovery plan using the existing Scheduler | Scheduler, Auth, Tasks types |
|
|
18
|
+
| `/fetch` | Bounded raw-body webhook ingress | None |
|
|
19
|
+
| `/manage` | Explicit read-only status companion | Core, Engine, Manage, `zod` |
|
|
20
|
+
| `/drizzle/pg`, `/drizzle/sqlite` | PostgreSQL / Bun SQLite aggregate CAS | `drizzle-orm` |
|
|
21
|
+
| `/drizzle/d1` | Borrowed raw D1 binding, primary reads | Drizzle, Workers types |
|
|
22
|
+
|
|
23
|
+
The package and pinned optional peers are recorded in the single workspace lockfile. Future dependency updates belong to the integration owner; do not create a package-local lockfile.
|
|
24
|
+
|
|
25
|
+
## Minimal integration
|
|
26
|
+
|
|
27
|
+
Use the application's existing Drizzle database, Auth access and Stripe client. Apply `migrations/postgres.sql` or `migrations/sqlite.sql` explicitly through the application's migration workflow, never during service setup.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createPayments } from "@lenso/payments";
|
|
31
|
+
import { createStripeProvider } from "@lenso/payments/stripe";
|
|
32
|
+
import { postgresPaymentsStore } from "@lenso/payments/drizzle/pg";
|
|
33
|
+
import { paymentsAuthorization } from "@lenso/payments/auth";
|
|
34
|
+
|
|
35
|
+
// db, stripeClient, paymentsAccess, paymentPolicies and secrets are existing,
|
|
36
|
+
// trusted application dependencies. No credentials are accepted in business JSON.
|
|
37
|
+
const runtime = createPayments({
|
|
38
|
+
store: postgresPaymentsStore(db),
|
|
39
|
+
provider: createStripeProvider({
|
|
40
|
+
client: stripeClient,
|
|
41
|
+
accountId: expectedAccountId,
|
|
42
|
+
live: false,
|
|
43
|
+
webhookSecret: secrets.stripeEndpointSecret,
|
|
44
|
+
// Product limits in Stripe API units, not global Stripe eligibility guarantees.
|
|
45
|
+
currencies: { usd: { min: 50, max: 99_999_999 }, jpy: { min: 1, max: 99_999_999 } },
|
|
46
|
+
}),
|
|
47
|
+
authorize: paymentsAuthorization(paymentsAccess, paymentPolicies),
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`paymentsAccess` is the **same Auth instance and audience** that authenticated the entry. Its membership reader loads trusted tenant permissions for the supplied `PaymentResource`. Policies are required for `create`, `read`, `client-secret`, `refund` and `results`. Creation must verify the business order/quote and amount; refund policy must require the application's refund permission, not just tenant membership. A copied actor, JSON identity, another Auth instance/audience or revoked credential cannot pass Auth enforcement.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const quote = await orders.paymentQuote(orderId, actor); // trusted server-side pricing
|
|
55
|
+
const payment = await runtime.payments.create(
|
|
56
|
+
{
|
|
57
|
+
tenantId: quote.tenantId,
|
|
58
|
+
orderId: quote.orderId,
|
|
59
|
+
key: quote.paymentRequestKey,
|
|
60
|
+
amount: quote.amount,
|
|
61
|
+
currency: quote.currency,
|
|
62
|
+
},
|
|
63
|
+
actor,
|
|
64
|
+
);
|
|
65
|
+
await queue.enqueue(reconcileTask, { limit: 50 });
|
|
66
|
+
// Only the authorized payer entry may return this capability to Stripe.js.
|
|
67
|
+
const clientSecret = await runtime.payments.clientSecret({ paymentId: payment.paymentId }, actor);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Creation is **unconfirmed**, with automatic capture and automatic payment methods. The application uses Stripe.js to confirm this same PaymentIntent and supply its trusted return URL. Creating an intent is not payment success. There is no parallel Checkout flow, server-side card handling, manual capture, Connect/organization routing, subscription, payout or transfer implementation.
|
|
71
|
+
|
|
72
|
+
Amounts are positive safe integers in Stripe's API units. No floating-point major-unit conversion is performed: USD `1000` and JPY `10` mean different unit scales. ISK/UGX require multiples of 100; HUF/TWD charge amounts do not inherit payout restrictions. Configure a lowercase currency allowlist and product bounds. Refunds require explicit positive amounts in the original currency and do not inherit the minimum charge amount.
|
|
73
|
+
|
|
74
|
+
For Lenso, `createPaymentsPlugin({ id, store, provider, authorization, config? })` takes exact installed plugin objects. Store/authorization companion plugins should require the application's exact DB/Auth instances and call `context.get` on those references. Resources belong to those owners; Payments does not close borrowed clients. Configuration may use `{ contract: paymentsConfig, sources: [...] }` with existing Lenso value/env/file sources. Provider secrets remain in the existing secret/config owner, with sensitive fields explicitly marked; the Payments operational contract contains no secrets.
|
|
75
|
+
|
|
76
|
+
## Durable receive, Tasks and scheduling
|
|
77
|
+
|
|
78
|
+
Mount `createPaymentsWebhookHandler` only at the configured Stripe endpoint. It retains raw bytes, bounds the body, verifies the official SDK signature with a nonzero time tolerance (default 300 seconds), checks account/mode/object/amount/currency, and commits a minimal inbox receipt before 2xx. It does not impersonate a browser actor. Database/provider failures return 503; bad signatures or foreign/mismatched objects return 400. Unrelated, correctly signed events are ignored. Subscribe this endpoint to PaymentIntent and `refund.created`, `refund.updated`, `refund.failed` snapshot events.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { createPaymentsReconciliationTask } from "@lenso/payments/tasks";
|
|
82
|
+
import { createPaymentsWebhookHandler } from "@lenso/payments/fetch";
|
|
83
|
+
|
|
84
|
+
const reconcileTask = createPaymentsReconciliationTask({
|
|
85
|
+
name: "payments.reconcile",
|
|
86
|
+
runtime: () => runtime.reconciliation,
|
|
87
|
+
queue: () => queue,
|
|
88
|
+
continuation: false, // Scheduler below owns the recurring recovery cadence.
|
|
89
|
+
});
|
|
90
|
+
// Register this exact task in the existing queue before starting its worker.
|
|
91
|
+
const webhookHandler = createPaymentsWebhookHandler({
|
|
92
|
+
webhook: runtime.webhook,
|
|
93
|
+
wake: async () => {
|
|
94
|
+
await queue.enqueue(reconcileTask, { limit: 50 });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The trusted queue getter is resolved when the handler runs, after application assembly. Queue payloads contain only the batch limit, never actors, secrets or authorization grants. The handler's existing self-continuation remains enabled by default: it persists the next run through `TaskQueue.enqueue({runAt})`, with stable per-job/due-time keys and Tasks retries on enqueue failure. When using the recurring Scheduler plan, set `continuation: false` as above so cron does not keep adding independent polling chains; deferred work and remaining batches wait for the next scheduled sweep or webhook wake. Task failures still use the same retry policy. Tasks supplies its existing Log/OTel instrumentation; Payments never logs raw provider errors, signatures, client secrets or payment details.
|
|
100
|
+
|
|
101
|
+
**A recurring recovery plan is required**, to cover a crash between reservation/receipt and queue enqueue, or exhausted queue retries. Use the existing [`@lenso/scheduler`](../scheduler/README.md), with the same durable queue and exact reconciliation Task registered in both Scheduler and Tasks:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { createPaymentsRecoverySchedule } from "@lenso/payments/scheduler";
|
|
105
|
+
|
|
106
|
+
// Explicit provisioning from a trusted maintenance entry, not application startup.
|
|
107
|
+
// scheduler is the existing service (or app.get(exactSchedulerPlugin)).
|
|
108
|
+
const recovery = await createPaymentsRecoverySchedule(
|
|
109
|
+
{
|
|
110
|
+
scheduler,
|
|
111
|
+
task: reconcileTask,
|
|
112
|
+
rule: { kind: "cron", expression: "*/5 * * * *", timezone: "UTC" },
|
|
113
|
+
limit: 50,
|
|
114
|
+
},
|
|
115
|
+
maintenanceActor,
|
|
116
|
+
);
|
|
117
|
+
// Persist recovery.id in application-owned provisioning records. Reuse that ID
|
|
118
|
+
// with Scheduler's authorized get/update/pause/cancel, never recreate at every boot.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The adapter delegates cron validation, durable occurrences, queue identity binding, dispatch authorization and deduplication to Scheduler. It fixes misfire handling to `coalesce` (one catch-up sweep), defaults `limit` to 50 and `graceMs` to 5000, and never starts a timer or worker. Creation has Scheduler's ordinary create semantics, not an idempotent "ensure": each explicit call creates a new plan. An unknown creation response requires operator lookup through Scheduler before provisioning another.
|
|
122
|
+
|
|
123
|
+
`maintenanceActor` must come from the configured Auth instance/audience, not JSON. The Scheduler owner's `authorize` must use `Access.enforce` and check maintenance authority for this provider account/mode and task. Its `authorizeExecution` must recheck the persisted subject's current permission at every dispatch. This Task is a trusted account-wide recovery capability, not an ordinary tenant user's query/refund grant; its payload remains only `{limit}`. Do not expose plan creation or `tick` through Payments' read-only Manage surface.
|
|
124
|
+
|
|
125
|
+
Apply Scheduler and Tasks migrations through their existing explicit workflows. Use matching PostgreSQL stores/queue, or matching D1 stores/queue. The host still must drive Scheduler: on Bun, explicitly own `startSchedulerDriver` from `@lenso/scheduler/driver`, immediately register `driver.stop()` cleanup, and observe `driver.done`; on Workers, await finite `scheduler.tick()` and `queue.runBatch()` in the platform handler, with no perpetual loop. Webhook wake remains the fast path; the cron plan is the durable safety net. Choose cron frequency and batch limit for the expected recovery backlog.
|
|
126
|
+
|
|
127
|
+
This adapter consumes the Scheduler/Tasks public exports merged in `origin/main` at `7c35055`. An older checkout must use/build those merged dependencies before checking the adapter; it must not recreate their interfaces locally. No shared package implementation is modified here.
|
|
128
|
+
|
|
129
|
+
Webhook snapshots are not applied as state or ordered by timestamps. Their verified object IDs are retained so even an expired unknown operation can be recovered by provider GET. Worker queries the current provider state; terminal states cannot regress. Result IDs are persisted in the same aggregate CAS as state changes, so different events for one success do not produce another business result.
|
|
130
|
+
|
|
131
|
+
## Idempotency and database boundaries
|
|
132
|
+
|
|
133
|
+
- Payment keys are scoped by account, test/live mode and tenant. A business order has one payment record in that scope. Same key with a different order/amount/currency, or another key for that same order, rejects with `conflict`.
|
|
134
|
+
- Refund keys are scoped to the payment. Same key/different amount rejects. Unknown/pending/successful refunds reserve the cap in a single aggregate CAS, preventing concurrent local over-refunds. Verified failed/canceled refunds release their amount, but retain their key/result. A small documented set of definite Stripe refund rejections also becomes `refund.failed`; arbitrary 400s, timeouts, balance errors and 500s remain unknown.
|
|
135
|
+
- After an unknown response, public repeats return persisted state, not another POST. Workers query by known ID or fully paginated metadata match; only within the conservative 23-hour window may an unresolved request replay the same parameters/key. After that window, it remains unknown for verified webhook/GET or operator investigation. A scan truncation or multiple matches is not absence. Do not clear keys or issue a new payment just because a request timed out.
|
|
136
|
+
- PostgreSQL uses unique constraints and atomic `UPDATE ... WHERE revision ... RETURNING`. State, refund reservations and results share one bounded JSON aggregate. A surrounding DB transaction is possible with the supplied Drizzle transaction handle; no lock spans provider I/O.
|
|
137
|
+
- D1 uses single-statement conditional writes, not interactive transactions. Pass the **raw binding**, `d1PaymentsStore(env.DB)`, never a long-lived `withSession("first-primary")`: only its first query is forced to primary. According to the current D1 routing contract, non-Session queries use primary. Local D1 testing does not verify hosted replica routing.
|
|
138
|
+
- Leases reduce overlapping work; token checks fence stale **DB** observations. Processing renews an unexpired owned lease at each observation/refund step, and the adapter calls the DB write guard again after its preflight. Choose `leaseMs` above the longest individual provider query/recovery scan (not just one HTTP request); hosts need synchronized clocks, bounded SDK requests and worker drain. Leases cannot fence an arbitrarily suspended request at Stripe after its idempotency retention expires. No strict distributed consistency, cross-provider/DB atomic transaction or exactly-once claim is made.
|
|
139
|
+
|
|
140
|
+
Keep provider metadata immutable and use a dedicated SDK client for this account/sandbox. Requests disable SDK retries and use a 10-second per-request timeout (`requestTimeoutMs`, at most 60 seconds). The runtime verifies `/v1/account` and object mode. Stripe v23's `getApiField` guard rejects inherited account/context routing; that SDK configuration accessor is version-pinned and not a promise of compatibility with future SDK majors. Different sandboxes require their own credentials and endpoint secret even though all have `livemode: false`.
|
|
141
|
+
|
|
142
|
+
## Business results and Manage
|
|
143
|
+
|
|
144
|
+
Consume `payments.results({paymentId}, actor)` with the `results` policy. Each result contains only correlation, amount/currency, kind and stable `resultId`. In the application's own transaction, insert `resultId` into a unique consumed-results table and apply that business effect together. For external effects, pass that same stable key to an idempotent destination or maintain a durable application outbox. Reading a result is not acknowledgement or proof of delivery; Payments does not mutate entitlements.
|
|
145
|
+
|
|
146
|
+
`createPaymentsManage({id, payments: exactPaymentsPlugin})` is explicit opt-in and declares **only `status`**. Install its companion plugin and select its operations separately in CLI/MCP/HTTP allowlists. Supply `{actor}` from a trusted Auth entry binding; the shared `read` policy still enforces the loaded payment's tenant. No refund/write method, raw receipt, client secret or provider credential is exposed. No management ingress is enabled by default.
|
|
147
|
+
|
|
148
|
+
## Verification and remaining integration work
|
|
149
|
+
|
|
150
|
+
Focused tests use actual Bun SQLite (including reopen and two handles), local workerd D1 via Miniflare, and the official Stripe SDK with local HTTP/WebCrypto fixtures. The Scheduler test runs the merged D1 ScheduleStore and durable Tasks queue together with Payments D1 storage; only its external payment provider is a fixture. It covers a persisted unknown payment with no remaining wake/continuation, reassembled Scheduler recovery, unique results, forged Actor denial and revoked dispatch permission. These checks do not prove real Stripe or hosted D1 operation. No account, credential, real charge or refund is created.
|
|
151
|
+
|
|
152
|
+
PostgreSQL persistence and real Stripe account/API eligibility have **not** been exercised. Hosted Workers execution and D1 replicas are also unverified. Local Scheduler/Tasks recovery checks are separate from remote platform validation. Before deployment, the application owner must validate these in an authorized disposable environment, apply migrations, configure the endpoint/API version and recovery plan/host driver, and review its quote/refund/result-consumption policies. This task does not authorize deployment.
|
|
153
|
+
|
|
154
|
+
Official references used: [webhooks](https://docs.stripe.com/webhooks), [signature verification](https://docs.stripe.com/webhooks/signature), [PaymentIntent create](https://docs.stripe.com/api/payment_intents/create), [refund create](https://docs.stripe.com/api/refunds/create), [idempotency](https://docs.stripe.com/api/idempotent_requests), [error codes](https://docs.stripe.com/error-codes), [currencies](https://docs.stripe.com/currencies), [D1 read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). SDK `23.0.0` targets API `2026-09-30.endive`; align the configured snapshot endpoint version.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Access, Actor, Policy, PolicyContext } from "@lenso/auth";
|
|
2
|
+
import type { PaymentAction, PaymentResource, PaymentsAuthorization } from "./contracts";
|
|
3
|
+
/** Preserve Auth instance/audience provenance and reverify evidence on every shared service call. */
|
|
4
|
+
export declare function paymentsAuthorization<R extends string, E, S extends string, A extends string, M>(access: Access<R, E, S, A, PaymentResource, M>, policies: Record<PaymentAction, Policy<PolicyContext<Actor<R, S, A>, PaymentResource, M>>>): PaymentsAuthorization<Actor<R, S, A>>;
|
package/dist/auth.js
ADDED
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ConfigBinding } from "@lenso/core/config";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const paymentsConfigSchema: z.ZodObject<{
|
|
4
|
+
leaseMs: z.ZodDefault<z.ZodNumber>;
|
|
5
|
+
reconcileDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
6
|
+
maxRefunds: z.ZodDefault<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strict>;
|
|
8
|
+
export declare const paymentsConfig: import("@lenso/core").ConfigContract<z.ZodObject<{
|
|
9
|
+
leaseMs: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
reconcileDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
11
|
+
maxRefunds: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
}, z.core.$strict>>;
|
|
13
|
+
export type PaymentsConfigBinding = ConfigBinding<typeof paymentsConfigSchema>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { definePluginConfig } from "@lenso/core/config";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
var paymentsConfigSchema = z.object({
|
|
5
|
+
leaseMs: z.number().int().min(1000).max(3600000).default(60000),
|
|
6
|
+
reconcileDelayMs: z.number().int().min(1000).max(86400000).default(60000),
|
|
7
|
+
maxRefunds: z.number().int().min(1).max(1000).default(100)
|
|
8
|
+
}).strict();
|
|
9
|
+
var paymentsConfig = definePluginConfig({
|
|
10
|
+
schema: paymentsConfigSchema,
|
|
11
|
+
description: "Payments concurrency and bounded reconciliation, not provider credentials.",
|
|
12
|
+
jsonSchema: () => z.toJSONSchema(paymentsConfigSchema)
|
|
13
|
+
});
|
|
14
|
+
export {
|
|
15
|
+
paymentsConfig,
|
|
16
|
+
paymentsConfigSchema
|
|
17
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export type PaymentStatus = "unknown" | "requires_payment_method" | "requires_confirmation" | "requires_action" | "processing" | "requires_capture" | "succeeded" | "canceled";
|
|
2
|
+
export type RefundStatus = "unknown" | "pending" | "requires_action" | "succeeded" | "failed" | "canceled";
|
|
3
|
+
export interface PaymentResource {
|
|
4
|
+
readonly paymentId: string;
|
|
5
|
+
readonly tenantId: string;
|
|
6
|
+
readonly orderId: string;
|
|
7
|
+
readonly accountId: string;
|
|
8
|
+
readonly live: boolean;
|
|
9
|
+
readonly amount: number;
|
|
10
|
+
readonly currency: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CreatePayment {
|
|
13
|
+
readonly tenantId: string;
|
|
14
|
+
readonly orderId: string;
|
|
15
|
+
readonly key: string;
|
|
16
|
+
/** Trusted server-side amount, in the currency's Stripe API unit, never a major-unit float. */
|
|
17
|
+
readonly amount: number;
|
|
18
|
+
readonly currency: string;
|
|
19
|
+
}
|
|
20
|
+
export interface CreateRefund {
|
|
21
|
+
readonly paymentId: string;
|
|
22
|
+
readonly key: string;
|
|
23
|
+
readonly amount: number;
|
|
24
|
+
}
|
|
25
|
+
export type PaymentAction = "create" | "read" | "client-secret" | "refund" | "results";
|
|
26
|
+
export type PaymentsAuthorization<A> = (actor: A, action: PaymentAction, resource: Readonly<PaymentResource>) => Promise<void>;
|
|
27
|
+
export interface Lease {
|
|
28
|
+
token: string;
|
|
29
|
+
until: number;
|
|
30
|
+
}
|
|
31
|
+
export interface RefundRecord {
|
|
32
|
+
refundId: string;
|
|
33
|
+
key: string;
|
|
34
|
+
amount: number;
|
|
35
|
+
status: RefundStatus;
|
|
36
|
+
providerId: string | null;
|
|
37
|
+
attemptedAt: number | null;
|
|
38
|
+
}
|
|
39
|
+
export interface PaymentResult extends PaymentResource {
|
|
40
|
+
/** Persisted together with the state transition. The application deduplicates on this key. */
|
|
41
|
+
resultId: string;
|
|
42
|
+
kind: "payment.succeeded" | "payment.canceled" | "refund.succeeded" | "refund.failed" | "refund.canceled";
|
|
43
|
+
refundId?: string;
|
|
44
|
+
refundAmount?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface PaymentRecord extends PaymentResource {
|
|
47
|
+
key: string;
|
|
48
|
+
orderKey: string;
|
|
49
|
+
status: PaymentStatus;
|
|
50
|
+
providerId: string | null;
|
|
51
|
+
revision: number;
|
|
52
|
+
attemptedAt: number | null;
|
|
53
|
+
lease: Lease | null;
|
|
54
|
+
refunds: RefundRecord[];
|
|
55
|
+
results: PaymentResult[];
|
|
56
|
+
createdAt: number;
|
|
57
|
+
updatedAt: number;
|
|
58
|
+
/** Persisted scan cursor. Advanced after every attempt, including unresolved ones. */
|
|
59
|
+
reconcileAt: number;
|
|
60
|
+
}
|
|
61
|
+
export interface PaymentView extends PaymentResource {
|
|
62
|
+
status: PaymentStatus;
|
|
63
|
+
providerId: string | null;
|
|
64
|
+
refunds: readonly {
|
|
65
|
+
refundId: string;
|
|
66
|
+
amount: number;
|
|
67
|
+
status: RefundStatus;
|
|
68
|
+
providerId: string | null;
|
|
69
|
+
}[];
|
|
70
|
+
}
|
|
71
|
+
export interface ProviderPayment {
|
|
72
|
+
id: string;
|
|
73
|
+
paymentId: string;
|
|
74
|
+
tenantId: string;
|
|
75
|
+
orderId: string;
|
|
76
|
+
accountId: string;
|
|
77
|
+
live: boolean;
|
|
78
|
+
amount: number;
|
|
79
|
+
received: number;
|
|
80
|
+
currency: string;
|
|
81
|
+
status: Exclude<PaymentStatus, "unknown">;
|
|
82
|
+
}
|
|
83
|
+
export interface ProviderRefund {
|
|
84
|
+
id: string;
|
|
85
|
+
refundId: string;
|
|
86
|
+
paymentId: string;
|
|
87
|
+
paymentProviderId: string;
|
|
88
|
+
accountId: string;
|
|
89
|
+
live: boolean;
|
|
90
|
+
amount: number;
|
|
91
|
+
currency: string;
|
|
92
|
+
status: Exclude<RefundStatus, "unknown">;
|
|
93
|
+
}
|
|
94
|
+
export interface VerifiedPaymentEvent {
|
|
95
|
+
eventId: string;
|
|
96
|
+
accountId: string;
|
|
97
|
+
live: boolean;
|
|
98
|
+
object: ProviderPayment | ProviderRefund;
|
|
99
|
+
}
|
|
100
|
+
export interface PaymentInbox {
|
|
101
|
+
eventKey: string;
|
|
102
|
+
eventId: string;
|
|
103
|
+
paymentId: string;
|
|
104
|
+
/** Validated object identity retained even when the original provider response was lost. */
|
|
105
|
+
objectId: string;
|
|
106
|
+
refundId: string | null;
|
|
107
|
+
accountId: string;
|
|
108
|
+
live: boolean;
|
|
109
|
+
createdAt: number;
|
|
110
|
+
reconcileAt: number;
|
|
111
|
+
done: boolean;
|
|
112
|
+
}
|
|
113
|
+
/** All operations must use authoritative reads. CAS covers the entire aggregate, including results. */
|
|
114
|
+
export interface PaymentsStore {
|
|
115
|
+
/** False on a paymentId OR orderKey conflict. No replacement of the existing record. */
|
|
116
|
+
insert(record: PaymentRecord): Promise<boolean>;
|
|
117
|
+
get(paymentId: string): Promise<PaymentRecord | null>;
|
|
118
|
+
getByOrder(orderKey: string): Promise<PaymentRecord | null>;
|
|
119
|
+
compareAndSet(record: PaymentRecord, revision: number): Promise<boolean>;
|
|
120
|
+
/** Ordered by reconcileAt/paymentId, scoped to the provider account and mode. */
|
|
121
|
+
due(accountId: string, live: boolean, now: number, limit: number): Promise<PaymentRecord[]>;
|
|
122
|
+
/** Unique eventKey; false means it is already durably received. */
|
|
123
|
+
receive(event: PaymentInbox): Promise<boolean>;
|
|
124
|
+
inbox(accountId: string, live: boolean, now: number, limit: number): Promise<PaymentInbox[]>;
|
|
125
|
+
finishEvent(eventKey: string): Promise<void>;
|
|
126
|
+
deferEvent(eventKey: string, reconcileAt: number): Promise<void>;
|
|
127
|
+
}
|
|
128
|
+
export interface PaymentsProvider {
|
|
129
|
+
readonly accountId: string;
|
|
130
|
+
readonly live: boolean;
|
|
131
|
+
/** Provider's safe replay horizon, less than its minimum idempotency retention. */
|
|
132
|
+
readonly replayWindowMs: number;
|
|
133
|
+
validateAmount(amount: number, currency: string): void;
|
|
134
|
+
/** Refunds need positive API units, but do not inherit the minimum charge amount. */
|
|
135
|
+
validateRefundAmount(amount: number, currency: string): void;
|
|
136
|
+
/** Invoke beforeWrite after adapter preflight, immediately before calling the SDK write. */
|
|
137
|
+
createPayment(record: PaymentRecord, idempotencyKey: string, beforeWrite?: () => Promise<void>): Promise<ProviderPayment>;
|
|
138
|
+
getPayment(id: string): Promise<ProviderPayment>;
|
|
139
|
+
/** Query by immutable metadata; null is not proof that a timed-out request failed. */
|
|
140
|
+
findPayment(record: PaymentRecord): Promise<ProviderPayment | null>;
|
|
141
|
+
clientSecret(id: string): Promise<string | null>;
|
|
142
|
+
createRefund(record: PaymentRecord, refund: RefundRecord, idempotencyKey: string, beforeWrite?: () => Promise<void>): Promise<ProviderRefund>;
|
|
143
|
+
getRefund(id: string): Promise<ProviderRefund>;
|
|
144
|
+
findRefund(record: PaymentRecord, refund: RefundRecord): Promise<ProviderRefund | null>;
|
|
145
|
+
/** Official provider signature verification over the untouched raw bytes. */
|
|
146
|
+
verifyWebhook(raw: Uint8Array, signature: string): Promise<VerifiedPaymentEvent | null>;
|
|
147
|
+
}
|
|
148
|
+
export declare class PaymentsError extends Error {
|
|
149
|
+
readonly code: "invalid-input" | "forbidden" | "not-found" | "conflict" | "provider-mismatch" | "bad-signature" | "unavailable";
|
|
150
|
+
constructor(code: "invalid-input" | "forbidden" | "not-found" | "conflict" | "provider-mismatch" | "bad-signature" | "unavailable");
|
|
151
|
+
}
|
|
152
|
+
/** Only a provider's documented, definitive rejection before creating a refund may use this. */
|
|
153
|
+
export declare class RefundNotCreatedError extends Error {
|
|
154
|
+
constructor();
|
|
155
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
2
|
+
import type { PaymentsStore } from "../contracts";
|
|
3
|
+
/**
|
|
4
|
+
* Borrow the raw D1 binding, NOT a D1 session. Queries without Sessions API route
|
|
5
|
+
* to the primary. A long-lived first-primary session is only sequentially consistent,
|
|
6
|
+
* and can miss reservations committed by another worker.
|
|
7
|
+
* CAS and inserts are single conditional INSERT/UPDATE ... RETURNING statements;
|
|
8
|
+
* D1 interactive transactions are deliberately not used.
|
|
9
|
+
*/
|
|
10
|
+
export declare function d1PaymentsStore(binding: D1Database): PaymentsStore;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sqlitePaymentsStore2
|
|
3
|
+
} from "../index-w0b3sn1n.js";
|
|
4
|
+
import {
|
|
5
|
+
PaymentsError2
|
|
6
|
+
} from "../index-qtpqn1xv.js";
|
|
7
|
+
|
|
8
|
+
// src/drizzle/d1.ts
|
|
9
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
10
|
+
function d1PaymentsStore(binding) {
|
|
11
|
+
if (typeof binding.withSession !== "function" || "getBookmark" in binding)
|
|
12
|
+
throw new PaymentsError2("invalid-input");
|
|
13
|
+
return sqlitePaymentsStore2(drizzle(binding));
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
d1PaymentsStore
|
|
17
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
2
|
+
import type { PaymentsStore } from "../contracts";
|
|
3
|
+
export declare function postgresPaymentsStore<TSchema extends Record<string, unknown> = Record<string, unknown>, TResult extends PgQueryResultHKT = PgQueryResultHKT>(db: PgDatabase<TResult, TSchema>): PaymentsStore;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/drizzle/pg.ts
|
|
2
|
+
import { and, eq, lte, sql } from "drizzle-orm";
|
|
3
|
+
import { bigint, index, integer, jsonb, pgTable, text } from "drizzle-orm/pg-core";
|
|
4
|
+
var payments = pgTable("lenso_payments", {
|
|
5
|
+
paymentId: text("payment_id").primaryKey(),
|
|
6
|
+
orderKey: text("order_key").notNull().unique(),
|
|
7
|
+
accountId: text("account_id").notNull(),
|
|
8
|
+
live: integer("live").notNull(),
|
|
9
|
+
revision: integer("revision").notNull(),
|
|
10
|
+
reconcileAt: bigint("reconcile_at", { mode: "number" }).notNull(),
|
|
11
|
+
data: jsonb("data").$type().notNull()
|
|
12
|
+
}, (t) => [index("lenso_payments_due_idx").on(t.accountId, t.live, t.reconcileAt, t.paymentId)]);
|
|
13
|
+
var inboxTable = pgTable("lenso_payment_events", {
|
|
14
|
+
eventKey: text("event_key").primaryKey(),
|
|
15
|
+
eventId: text("event_id").notNull(),
|
|
16
|
+
paymentId: text("payment_id").notNull(),
|
|
17
|
+
objectId: text("object_id").notNull(),
|
|
18
|
+
refundId: text("refund_id"),
|
|
19
|
+
accountId: text("account_id").notNull(),
|
|
20
|
+
live: integer("live").notNull(),
|
|
21
|
+
createdAt: bigint("created_at", { mode: "number" }).notNull(),
|
|
22
|
+
reconcileAt: bigint("reconcile_at", { mode: "number" }).notNull(),
|
|
23
|
+
done: integer("done").notNull()
|
|
24
|
+
}, (t) => [
|
|
25
|
+
index("lenso_payment_events_due_idx").on(t.accountId, t.live, t.done, t.reconcileAt, t.eventKey)
|
|
26
|
+
]);
|
|
27
|
+
var copy = (value) => structuredClone(value);
|
|
28
|
+
function postgresPaymentsStore(db) {
|
|
29
|
+
return {
|
|
30
|
+
async insert(record) {
|
|
31
|
+
const rows = await db.insert(payments).values({
|
|
32
|
+
paymentId: record.paymentId,
|
|
33
|
+
orderKey: record.orderKey,
|
|
34
|
+
accountId: record.accountId,
|
|
35
|
+
live: Number(record.live),
|
|
36
|
+
revision: record.revision,
|
|
37
|
+
reconcileAt: record.reconcileAt,
|
|
38
|
+
data: sql`${JSON.stringify(record)}::text::jsonb`
|
|
39
|
+
}).onConflictDoNothing().returning();
|
|
40
|
+
return rows.length > 0;
|
|
41
|
+
},
|
|
42
|
+
async get(paymentId) {
|
|
43
|
+
const [row] = await db.select({ data: payments.data }).from(payments).where(eq(payments.paymentId, paymentId)).limit(1);
|
|
44
|
+
return row ? copy(row.data) : null;
|
|
45
|
+
},
|
|
46
|
+
async getByOrder(orderKey) {
|
|
47
|
+
const [row] = await db.select({ data: payments.data }).from(payments).where(eq(payments.orderKey, orderKey)).limit(1);
|
|
48
|
+
return row ? copy(row.data) : null;
|
|
49
|
+
},
|
|
50
|
+
async compareAndSet(record, revision) {
|
|
51
|
+
if (record.revision !== revision + 1)
|
|
52
|
+
return false;
|
|
53
|
+
const rows = await db.update(payments).set({
|
|
54
|
+
revision: record.revision,
|
|
55
|
+
reconcileAt: record.reconcileAt,
|
|
56
|
+
data: sql`${JSON.stringify(record)}::text::jsonb`
|
|
57
|
+
}).where(and(eq(payments.paymentId, record.paymentId), eq(payments.revision, revision), eq(payments.orderKey, record.orderKey), eq(payments.accountId, record.accountId), eq(payments.live, Number(record.live)))).returning();
|
|
58
|
+
return rows.length > 0;
|
|
59
|
+
},
|
|
60
|
+
async due(accountId, live, now, limit) {
|
|
61
|
+
const rows = await db.select({ data: payments.data }).from(payments).where(and(eq(payments.accountId, accountId), eq(payments.live, Number(live)), lte(payments.reconcileAt, now))).orderBy(payments.reconcileAt, payments.paymentId).limit(Math.max(0, limit));
|
|
62
|
+
return rows.map(({ data }) => copy(data));
|
|
63
|
+
},
|
|
64
|
+
async receive(event) {
|
|
65
|
+
const rows = await db.insert(inboxTable).values({ ...copy(event), live: Number(event.live), done: Number(event.done) }).onConflictDoNothing().returning();
|
|
66
|
+
return rows.length > 0;
|
|
67
|
+
},
|
|
68
|
+
async inbox(accountId, live, now, limit) {
|
|
69
|
+
return (await db.select().from(inboxTable).where(and(eq(inboxTable.accountId, accountId), eq(inboxTable.live, Number(live)), eq(inboxTable.done, 0), lte(inboxTable.reconcileAt, now))).orderBy(inboxTable.reconcileAt, inboxTable.eventKey).limit(Math.max(0, limit))).map((r) => ({ ...r, live: Boolean(r.live), done: Boolean(r.done) }));
|
|
70
|
+
},
|
|
71
|
+
async finishEvent(eventKey) {
|
|
72
|
+
await db.update(inboxTable).set({ done: 1 }).where(eq(inboxTable.eventKey, eventKey));
|
|
73
|
+
},
|
|
74
|
+
async deferEvent(eventKey, reconcileAt) {
|
|
75
|
+
await db.update(inboxTable).set({ reconcileAt }).where(eq(inboxTable.eventKey, eventKey));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
postgresPaymentsStore
|
|
81
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
|
2
|
+
import type { DrizzleD1Database } from "drizzle-orm/d1";
|
|
3
|
+
import type { PaymentsStore } from "../contracts";
|
|
4
|
+
type SqliteDb<T extends Record<string, unknown>> = BunSQLiteDatabase<T> | DrizzleD1Database<T>;
|
|
5
|
+
export declare function sqlitePaymentsStore<T extends Record<string, unknown> = Record<string, unknown>>(db: SqliteDb<T>): PaymentsStore;
|
|
6
|
+
export {};
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PaymentsRuntime } from "./index";
|
|
2
|
+
/** Mount only at the configured Stripe endpoint. No browser actor or Auth session is fabricated. */
|
|
3
|
+
export declare function createPaymentsWebhookHandler(options: {
|
|
4
|
+
webhook: PaymentsRuntime<unknown>["webhook"];
|
|
5
|
+
maxBytes?: number;
|
|
6
|
+
/** Enqueue the registered reconciliation Task after durable receipt, including duplicate delivery. */
|
|
7
|
+
wake?: () => Promise<void>;
|
|
8
|
+
}): (request: Request) => Promise<Response>;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PaymentsError2
|
|
3
|
+
} from "./index-qtpqn1xv.js";
|
|
4
|
+
|
|
5
|
+
// src/fetch.ts
|
|
6
|
+
function createPaymentsWebhookHandler(options) {
|
|
7
|
+
const maxBytes = options.maxBytes ?? 1048576;
|
|
8
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
9
|
+
throw new PaymentsError2("invalid-input");
|
|
10
|
+
return async (request) => {
|
|
11
|
+
if (request.method !== "POST")
|
|
12
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
13
|
+
const signature = request.headers.get("stripe-signature");
|
|
14
|
+
if (!signature)
|
|
15
|
+
return new Response(null, { status: 400 });
|
|
16
|
+
const declared = request.headers.get("content-length");
|
|
17
|
+
if (declared && Number(declared) > maxBytes)
|
|
18
|
+
return new Response(null, { status: 413 });
|
|
19
|
+
const reader = request.body?.getReader();
|
|
20
|
+
if (!reader)
|
|
21
|
+
return new Response(null, { status: 400 });
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let size = 0;
|
|
24
|
+
try {
|
|
25
|
+
while (true) {
|
|
26
|
+
const chunk = await reader.read();
|
|
27
|
+
if (chunk.done)
|
|
28
|
+
break;
|
|
29
|
+
size += chunk.value.byteLength;
|
|
30
|
+
if (size > maxBytes) {
|
|
31
|
+
await reader.cancel();
|
|
32
|
+
return new Response(null, { status: 413 });
|
|
33
|
+
}
|
|
34
|
+
chunks.push(chunk.value);
|
|
35
|
+
}
|
|
36
|
+
const raw = new Uint8Array(size);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
raw.set(chunk, offset);
|
|
40
|
+
offset += chunk.byteLength;
|
|
41
|
+
}
|
|
42
|
+
const receipt = await options.webhook.receive(raw, signature);
|
|
43
|
+
if (receipt.accepted && options.wake) {
|
|
44
|
+
try {
|
|
45
|
+
await options.wake();
|
|
46
|
+
} catch {
|
|
47
|
+
return new Response(null, { status: 503 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return new Response(null, { status: 204 });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const invalid = error instanceof PaymentsError2 && ["bad-signature", "provider-mismatch", "invalid-input", "not-found", "forbidden"].includes(error.code);
|
|
53
|
+
return new Response(null, { status: invalid ? 400 : 503 });
|
|
54
|
+
} finally {
|
|
55
|
+
reader.releaseLock();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
createPaymentsWebhookHandler
|
|
61
|
+
};
|