@obhco/pay 0.0.0-stage → 0.1.0

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.
Files changed (54) hide show
  1. package/README.md +203 -2
  2. package/dist/errors.d.ts +9 -0
  3. package/dist/errors.d.ts.map +1 -0
  4. package/dist/errors.js +15 -0
  5. package/dist/errors.js.map +1 -0
  6. package/dist/gateways/digipay.d.ts +36 -0
  7. package/dist/gateways/digipay.d.ts.map +1 -0
  8. package/dist/gateways/digipay.js +489 -0
  9. package/dist/gateways/digipay.js.map +1 -0
  10. package/dist/gateways/index.d.ts +5 -0
  11. package/dist/gateways/index.d.ts.map +1 -0
  12. package/dist/gateways/index.js +5 -0
  13. package/dist/gateways/index.js.map +1 -0
  14. package/dist/gateways/mellat-constants.d.ts +3 -0
  15. package/dist/gateways/mellat-constants.d.ts.map +1 -0
  16. package/dist/gateways/mellat-constants.js +3 -0
  17. package/dist/gateways/mellat-constants.js.map +1 -0
  18. package/dist/gateways/mellat.d.ts +30 -0
  19. package/dist/gateways/mellat.d.ts.map +1 -0
  20. package/dist/gateways/mellat.js +397 -0
  21. package/dist/gateways/mellat.js.map +1 -0
  22. package/dist/gateways/sadad.d.ts +43 -0
  23. package/dist/gateways/sadad.d.ts.map +1 -0
  24. package/dist/gateways/sadad.js +541 -0
  25. package/dist/gateways/sadad.js.map +1 -0
  26. package/dist/gateways/zarinpal.d.ts +21 -0
  27. package/dist/gateways/zarinpal.d.ts.map +1 -0
  28. package/dist/gateways/zarinpal.js +312 -0
  29. package/dist/gateways/zarinpal.js.map +1 -0
  30. package/dist/http.d.ts +31 -0
  31. package/dist/http.d.ts.map +1 -0
  32. package/dist/http.js +101 -0
  33. package/dist/http.js.map +1 -0
  34. package/dist/index.d.ts +14 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +8 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/pay.d.ts +114 -0
  39. package/dist/pay.d.ts.map +1 -0
  40. package/dist/pay.js +427 -0
  41. package/dist/pay.js.map +1 -0
  42. package/dist/report.d.ts +71 -0
  43. package/dist/report.d.ts.map +1 -0
  44. package/dist/report.js +106 -0
  45. package/dist/report.js.map +1 -0
  46. package/dist/store.d.ts +420 -0
  47. package/dist/store.d.ts.map +1 -0
  48. package/dist/store.js +68 -0
  49. package/dist/store.js.map +1 -0
  50. package/dist/types.d.ts +110 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +16 -0
  53. package/dist/types.js.map +1 -0
  54. package/package.json +34 -4
package/README.md CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ # @obhco/pay
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Take payments inside your own service with Iranian gateways — **Sadad**,
4
+ **Zarinpal**, **Mellat (Behpardakht)** and **Digipay** — and report every
5
+ payment to OBH for accounting, settlement tracking and reports.
6
+
7
+ Each service uses **its own gateway terminal and credentials**. Money settles
8
+ straight to the service's account; OBH never touches a bank, it only receives
9
+ the finance reports.
10
+
11
+ ```
12
+ customer ──► your service ──► bank gateway
13
+ │ ▲ │
14
+ │ └── callback ──┘ (verified server-to-server)
15
+ ▼
16
+ OBH ◄── signed finance report (payment.snapshot)
17
+ ```
18
+
19
+ Requires Node.js 20+, server-side only. Never ship gateway credentials or the
20
+ OBH service key to a browser.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pnpm add @obhco/pay
26
+ ```
27
+
28
+ ## 1. Storage
29
+
30
+ The package keeps no state of its own. You provide a `PaymentStore`; it needs
31
+ a table and a handful of lookups. `MemoryPaymentStore` is included for tests
32
+ and local development only.
33
+
34
+ Minimum table (adapt to your database):
35
+
36
+ | Column | Type | Notes |
37
+ | --- | --- | --- |
38
+ | `reference` | text, unique | `PAY-…` id of the attempt |
39
+ | `order_id` | text, indexed | your own order id |
40
+ | `attempt` | int | 1, 2, 3… per order |
41
+ | `gateway` | text | `sadad`, `zarinpal`, … |
42
+ | `gateway_order_id` | text | digits only, unique per gateway |
43
+ | `amount` | text/bigint | whole Rials |
44
+ | `status` | text | `pending` `completed` `failed` `cancelled` `expired` |
45
+ | `environment` | text | `live` or `test` |
46
+ | `provider_session_id` | text, indexed | token / authority / RefId |
47
+ | … | | the remaining fields of `PaymentRecord` |
48
+
49
+ Implement `PaymentStore` (see `src/store.ts`). The only subtle method is
50
+ `transition(reference, from, patch)`: it must update **only if the stored
51
+ status still equals `from`** and return `null` otherwise (for SQL:
52
+ `UPDATE … WHERE reference = ? AND status = ?` and check the row count). That
53
+ is what stops a double callback from completing a payment twice.
54
+
55
+ `nextGatewayOrderId(gateway)` must return a digits-only number that is unique
56
+ per gateway; a database sequence is ideal.
57
+
58
+ ## 2. Configure
59
+
60
+ ```ts
61
+ // lib/pay.ts
62
+ import { createPay } from "@obhco/pay"
63
+ import { store } from "./payment-store"
64
+
65
+ export const pay = createPay({
66
+ service: "afarin", // short id, same as your OBH service slug
67
+ baseUrl: "https://afarinai.ir", // your public origin (for bank callbacks)
68
+ store,
69
+ // gateways: ["zarinpal", "sadad"], // default: every gateway with credentials
70
+ // defaultGateway: "zarinpal",
71
+ })
72
+ ```
73
+
74
+ Gateways are enabled by environment variables in **your** service:
75
+
76
+ | Gateway | Variables |
77
+ | --- | --- |
78
+ | Sadad | `SADAD_MERCHANT_ID`, `SADAD_TERMINAL_ID`, `SADAD_TERMINAL_KEY` (+ `SADAD_BASE_URL`, `SADAD_RELAY_URL`, `SADAD_RELAY_SECRET` if you call it through an Iranian relay) |
79
+ | Zarinpal | `ZARINPAL_MERCHANT_ID` (`ZARINPAL_SANDBOX=true` for the sandbox) |
80
+ | Mellat | `BEHPARDAKHT_TERMINAL_ID`, `BEHPARDAKHT_USERNAME`, `BEHPARDAKHT_PASSWORD` (+ `BEHPARDAKHT_RELAY_URL`, `BEHPARDAKHT_RELAY_SECRET`, `BEHPARDAKHT_CALLBACK_URL`) |
81
+ | Digipay | `DIGIPAY_USERNAME`, `DIGIPAY_PASSWORD`, and `DIGIPAY_AUTHORIZATION` or `DIGIPAY_CLIENT_ID` + `DIGIPAY_CLIENT_SECRET` (+ `DIGIPAY_BASE_URL`) |
82
+
83
+ Finance reporting to OBH:
84
+
85
+ | Variable | Value |
86
+ | --- | --- |
87
+ | `OBH_BASE_URL` | OBH origin, e.g. `https://obhco.ir` |
88
+ | `OBH_SERVICE_KEY` | Service credential (`keyId.secret`) created in OBH → Services |
89
+ | `PAY_ENVIRONMENT` | `test` while using sandbox terminals; default `live` |
90
+
91
+ If `OBH_BASE_URL` / `OBH_SERVICE_KEY` are missing, payments still work and
92
+ nothing is reported. Check the connection with `await pay.pingObh()`.
93
+
94
+ ## 3. Start a payment
95
+
96
+ ```ts
97
+ // app/api/checkout/route.ts (Next.js)
98
+ import { redirectResponse } from "@obhco/pay"
99
+ import { pay } from "@/lib/pay"
100
+
101
+ export async function POST(request: Request) {
102
+ const { orderId, amount, gateway, mobile } = await request.json()
103
+
104
+ const { redirect } = await pay.start({
105
+ orderId, // your order id; one order can have several attempts
106
+ amount, // whole Rials
107
+ description: `Order ${orderId}`,
108
+ gateway, // optional; see pay.listGateways()
109
+ customerMobile: mobile, // required by Digipay
110
+ returnUrl: `https://afarinai.ir/orders/${orderId}`,
111
+ })
112
+
113
+ return redirectResponse(redirect) // 303, or an auto-POST form for Mellat
114
+ }
115
+ ```
116
+
117
+ `pay.listGateways()` returns what you can offer at checkout (Persian
118
+ `displayName`, `kind: "card" | "installment"`, `requiresCustomerMobile`).
119
+
120
+ Starting the same order twice returns the open session instead of creating a
121
+ second one, and an order that is already paid throws `ORDER_ALREADY_PAID`.
122
+
123
+ ## 4. Receive the bank callback
124
+
125
+ ```ts
126
+ // app/api/pay/[gateway]/callback/route.ts
127
+ import { callbackRoute } from "@obhco/pay"
128
+ import { pay } from "@/lib/pay"
129
+
130
+ async function handle(request: Request, ctx: { params: Promise<{ gateway: string }> }) {
131
+ const { gateway } = await ctx.params
132
+ return callbackRoute(pay, gateway, {
133
+ fallbackUrl: "https://afarinai.ir/checkout",
134
+ })(request)
135
+ }
136
+
137
+ export { handle as GET, handle as POST }
138
+ ```
139
+
140
+ The path must match `callbackPath` (default `/api/pay/{gateway}/callback`).
141
+ The handler finds the payment, **verifies it with the bank server-to-server**,
142
+ records the result and sends the customer back to `returnUrl` with
143
+ `?reference=…&status=completed|failed|pending`.
144
+
145
+ > Never trust that query string. On the page the customer lands on, load the
146
+ > payment with `pay.get(reference)` and fulfil the order only if
147
+ > `status === "completed"`.
148
+
149
+ ## 5. Keep it healthy
150
+
151
+ Run `pay.reconcile()` every few minutes (cron, queue worker or a protected
152
+ route). It expires abandoned sessions, retries verifications that hit a
153
+ network error, and re-sends any report OBH has not acknowledged:
154
+
155
+ ```ts
156
+ const { expired, reverified, reported } = await pay.reconcile()
157
+ ```
158
+
159
+ Without it, a payment whose callback was lost stays pending, and OBH shows the
160
+ service as **stale** on its reports page.
161
+
162
+ When the customer comes back and the payment is still pending (the bank's verify
163
+ gave no answer the first time), call `await pay.recheck(reference)` to verify it
164
+ again right away.
165
+
166
+ ## What OBH receives
167
+
168
+ For every state change the package sends one `payment.snapshot` (the full
169
+ state of one attempt) to `POST {OBH_BASE_URL}/api/v1/finance/events`,
170
+ authenticated with your service key. OBH upserts it by
171
+ `(service, reference)`, so retries and reordering are harmless; a failed
172
+ report never blocks the payment and is re-sent by `reconcile()`.
173
+
174
+ Sent: reference, order id, attempt, gateway, amount, status, timestamps, trace
175
+ and reference numbers, masked card, installment type (Digipay). **Not sent:**
176
+ customer mobile, return URL, or your `metadata` (opt in with
177
+ `report: { …, includeMetadata: true }`).
178
+
179
+ OBH then does the rest: accounting and revenue, per-service settlement
180
+ accounts per gateway, installment schedules for Digipay, reconciliation,
181
+ success rates by gateway and service, and stale-service alerts.
182
+
183
+ ## Testing
184
+
185
+ * Use the gateways' own sandboxes (`ZARINPAL_SANDBOX=true`, bank test
186
+ terminals) with `PAY_ENVIRONMENT=test`; test payments are kept out of
187
+ OBH's financial totals.
188
+ * Unit tests can pass a fake adapter in `gateways: [...]` and a
189
+ `MemoryPaymentStore`; see `tests/pay.test.ts` for a complete example.
190
+
191
+ ## Errors
192
+
193
+ `PayError.code` is one of `INVALID_REQUEST`, `GATEWAY_NOT_AVAILABLE`,
194
+ `GATEWAY_ERROR`, `ORDER_ALREADY_PAID`, `PAYMENT_NOT_FOUND`, `ORDER_MISMATCH`,
195
+ `INVALID_CALLBACK`, `REPORT_FAILED`. Use `isPayError(error)` to branch on it.
196
+
197
+ ## Security notes
198
+
199
+ * A callback is only a hint; the result always comes from the bank's verify
200
+ call, and the verified amount must equal the stored amount (otherwise the
201
+ payment is failed and flagged, never completed).
202
+ * Payments move `pending → final` exactly once (compare-and-set).
203
+ * Keep gateway credentials and `OBH_SERVICE_KEY` in server environment
204
+ variables only.
@@ -0,0 +1,9 @@
1
+ export type PayErrorCode = "INVALID_REQUEST" | "GATEWAY_NOT_AVAILABLE" | "GATEWAY_ERROR" | "ORDER_ALREADY_PAID" | "PAYMENT_NOT_FOUND" | "ORDER_MISMATCH" | "INVALID_CALLBACK" | "REPORT_FAILED";
2
+ /** Error raised by the package. `code` is stable; `message` is for logs. */
3
+ export declare class PayError extends Error {
4
+ readonly code: PayErrorCode;
5
+ readonly details?: Record<string, unknown>;
6
+ constructor(code: PayErrorCode, message: string, details?: Record<string, unknown>);
7
+ }
8
+ export declare function isPayError(error: unknown): error is PayError;
9
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GACpB,iBAAiB,GACjB,uBAAuB,GACvB,eAAe,GACf,oBAAoB,GACpB,mBAAmB,GACnB,gBAAgB,GAChB,kBAAkB,GAClB,eAAe,CAAA;AAEnB,4EAA4E;AAC5E,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;gBAGxC,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAOpC;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,QAAQ,CAE5D"}
package/dist/errors.js ADDED
@@ -0,0 +1,15 @@
1
+ /** Error raised by the package. `code` is stable; `message` is for logs. */
2
+ export class PayError extends Error {
3
+ code;
4
+ details;
5
+ constructor(code, message, details) {
6
+ super(message);
7
+ this.name = "PayError";
8
+ this.code = code;
9
+ this.details = details;
10
+ }
11
+ }
12
+ export function isPayError(error) {
13
+ return error instanceof PayError;
14
+ }
15
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAUA,4EAA4E;AAC5E,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,IAAI,CAAc;IAClB,OAAO,CAA0B;IAE1C,YACE,IAAkB,EAClB,OAAe,EACf,OAAiC;QAEjC,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,UAAU,CAAA;QACtB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;QAChB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;IACxB,CAAC;CACF;AAED,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,OAAO,KAAK,YAAY,QAAQ,CAAA;AAClC,CAAC"}
@@ -0,0 +1,36 @@
1
+ import { type PaymentGatewayAdapter } from "../types.js";
2
+ export declare const DIGIPAY_PROVIDER_CODE = "digipay";
3
+ export declare const DIGIPAY_PROVIDER_NAME = "Digipay (Installments / BNPL)";
4
+ /**
5
+ * Purchase `type` values Digipay posts back in the payment result and expects
6
+ * again on `purchases/verify?type=`.
7
+ */
8
+ export declare const DIGIPAY_PURCHASE_TYPES: Record<number, string>;
9
+ /** Business error codes from the Digipay UPG guide (HTTP 422 bodies). */
10
+ export declare const DIGIPAY_RESULT_CODES: Record<number, {
11
+ message: string;
12
+ retryable: boolean;
13
+ }>;
14
+ export declare function describeDigipayCode(code: number | null | undefined, fallback?: string | null): string;
15
+ export type DigipayConfigStatus = {
16
+ providerCode: typeof DIGIPAY_PROVIDER_CODE;
17
+ providerName: typeof DIGIPAY_PROVIDER_NAME;
18
+ baseUrl: string;
19
+ liveConfigured: boolean;
20
+ usernameMasked: string | null;
21
+ clientIdMasked: string | null;
22
+ };
23
+ export declare function getDigipayConfigStatus(): DigipayConfigStatus;
24
+ /** Test hook: forget the cached OAuth access token. */
25
+ export declare function resetDigipayTokenCache(): void;
26
+ export declare function normalizeIranianMobile(value: string | null | undefined): string | null;
27
+ /**
28
+ * Digipay keys tickets by `providerId` across the whole merchant account,
29
+ * which other services share, so OBH's order number alone can clash with an
30
+ * existing ticket. OBH sends it with an `obh-` prefix and accepts both forms
31
+ * back.
32
+ */
33
+ export declare function toDigipayProviderId(gatewayOrderId: string): string;
34
+ export declare function fromDigipayProviderId(value: string | null): string | null;
35
+ export declare const digipayPaymentAdapter: PaymentGatewayAdapter;
36
+ //# sourceMappingURL=digipay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"digipay.d.ts","sourceRoot":"","sources":["../../src/gateways/digipay.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,qBAAqB,EAG3B,MAAM,aAAa,CAAA;AAEpB,eAAO,MAAM,qBAAqB,YAAY,CAAA;AAC9C,eAAO,MAAM,qBAAqB,kCAAkC,CAAA;AAQpE;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAMzD,CAAA;AAED,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,EAAE,MAAM,CACvC,MAAM,EACN;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAuCxC,CAAA;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,UAazB;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,YAAY,EAAE,OAAO,qBAAqB,CAAA;IAC1C,YAAY,EAAE,OAAO,qBAAqB,CAAA;IAC1C,OAAO,EAAE,MAAM,CAAA;IACf,cAAc,EAAE,OAAO,CAAA;IACvB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B,CAAA;AAmBD,wBAAgB,sBAAsB,IAAI,mBAAmB,CAa5D;AAoDD,uDAAuD;AACvD,wBAAgB,sBAAsB,SAErC;AAwHD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,iBAmBtE;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,UAEzD;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,iBAEzD;AAQD,eAAO,MAAM,qBAAqB,EAAE,qBAkVnC,CAAA"}