mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 857fc663b3 | |||
| 552ec700c2 | |||
| f7d6066d9a | |||
| b27b5806b1 | |||
| da96a0a838 | |||
| 2374dbcec8 | |||
| 05d612e606 | |||
| dc9bc2db57 | |||
| 7590a5445c | |||
| ed27501afc | |||
|
|
56bd4fc7ce | ||
|
|
eaf54ae893 | ||
|
|
f89ffb2c7e | ||
| d5a47a05b1 | |||
| 64c58552cb | |||
| be57924525 | |||
| 2d10bd82e7 | |||
| 8e6385caa3 | |||
| 83251bb404 | |||
|
|
7b8c89a0a2 | ||
| d651e8199c | |||
| f77719716f | |||
|
|
c6e51892e6 | ||
|
|
38c8c3677d | ||
|
|
e74a2410e6 |
235
.github/CLAUDE_PR_ASSISTANT.md
vendored
235
.github/CLAUDE_PR_ASSISTANT.md
vendored
@@ -1,235 +0,0 @@
|
||||
# Claude PR Assistant
|
||||
|
||||
This workflow allows Claude to assist with Pull Requests by implementing improvements, fixes, and changes directly on the PR branch when mentioned in comments.
|
||||
|
||||
## How to Use
|
||||
|
||||
### 1. Comment on Any PR
|
||||
|
||||
Use one of these trigger commands in a comment on any open pull request:
|
||||
|
||||
- `@claude implement` - Implement new features or functionality
|
||||
- `@claude fix` - Fix bugs or issues in the PR
|
||||
- `@claude improve` - Improve existing code quality
|
||||
- `@claude update` - Update code to match requirements
|
||||
- `@claude refactor` - Refactor code for better structure
|
||||
- `@claude help` - General assistance with the PR
|
||||
|
||||
### 2. Example Usage
|
||||
|
||||
```
|
||||
@claude fix
|
||||
|
||||
Please fix the TypeScript errors in the payments collection and ensure proper typing for the provider data field.
|
||||
```
|
||||
|
||||
```
|
||||
@claude improve
|
||||
|
||||
Can you optimize the invoice generation logic and add better error handling?
|
||||
```
|
||||
|
||||
```
|
||||
@claude implement
|
||||
|
||||
Add validation for email addresses in the customer billing information fields. Make sure it follows the existing validation patterns.
|
||||
```
|
||||
|
||||
## What Happens
|
||||
|
||||
1. **Permission Check**: Verifies you are `bvdaakster` (only authorized user)
|
||||
2. **PR Analysis**: Claude analyzes the current PR context and changes
|
||||
3. **Implementation**: Makes the requested changes directly on the PR branch
|
||||
4. **Quality Checks**: Runs build, typecheck, and lint
|
||||
5. **Commit & Push**: Commits changes with descriptive messages
|
||||
6. **Notification**: Updates the PR with completion status
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Direct PR Modification
|
||||
- Works directly on the existing PR branch
|
||||
- No new branches or PRs created
|
||||
- Changes appear immediately in the PR
|
||||
|
||||
### ✅ Smart Context Awareness
|
||||
- Understands the current PR changes
|
||||
- Analyzes existing code patterns
|
||||
- Maintains consistency with the codebase
|
||||
|
||||
### ✅ Comprehensive Request Handling
|
||||
- Code improvements and refactoring
|
||||
- Bug fixes within the PR
|
||||
- Adding missing features
|
||||
- Documentation updates
|
||||
- Type error fixes
|
||||
- Performance optimizations
|
||||
- Test additions
|
||||
|
||||
### ✅ Quality Assurance
|
||||
- Follows TypeScript conventions
|
||||
- Uses ESM module structure
|
||||
- Runs automated quality checks
|
||||
- Maintains coding standards
|
||||
|
||||
## Command Reference
|
||||
|
||||
| Command | Purpose | Example |
|
||||
|---------|---------|---------|
|
||||
| `@claude implement` | Add new functionality | "implement user authentication" |
|
||||
| `@claude fix` | Fix bugs or errors | "fix the validation logic" |
|
||||
| `@claude improve` | Enhance existing code | "improve performance of query" |
|
||||
| `@claude update` | Update to requirements | "update to use new API format" |
|
||||
| `@claude refactor` | Restructure code | "refactor into smaller functions" |
|
||||
| `@claude help` | General assistance | "help with error handling" |
|
||||
|
||||
## Examples
|
||||
|
||||
### Bug Fix Request
|
||||
```
|
||||
@claude fix
|
||||
|
||||
There's a TypeScript error in `src/providers/stripe.ts` on line 45. The `amount` property is missing from the payment object. Please fix this and ensure proper typing.
|
||||
```
|
||||
|
||||
### Feature Implementation
|
||||
```
|
||||
@claude implement
|
||||
|
||||
Add support for recurring payments in the Stripe provider. Follow the same pattern as the Mollie provider and include proper webhook handling.
|
||||
```
|
||||
|
||||
### Code Improvement
|
||||
```
|
||||
@claude improve
|
||||
|
||||
The `validatePayment` function in utils.ts is getting complex. Please refactor it to be more readable and add proper error messages for each validation case.
|
||||
```
|
||||
|
||||
### Documentation Update
|
||||
```
|
||||
@claude update
|
||||
|
||||
Update the JSDoc comments in the billing plugin configuration to include examples of how to use the new customer info extractor feature.
|
||||
```
|
||||
|
||||
## Response Types
|
||||
|
||||
### Success Response
|
||||
```
|
||||
✅ PR Assistance Complete!
|
||||
|
||||
🔧 Changes Made:
|
||||
- ✅ Analyzed PR context and requirements
|
||||
- ✅ Implemented requested improvements/fixes
|
||||
- ✅ Followed project coding standards
|
||||
- ✅ Updated the current PR branch
|
||||
|
||||
Changes are ready for review! 🚀
|
||||
```
|
||||
|
||||
### No Changes Response
|
||||
```
|
||||
ℹ️ PR Assistance Complete - No Changes
|
||||
|
||||
Analysis Result: The requested feature is already implemented
|
||||
|
||||
💡 Suggestions:
|
||||
- Provide more detailed requirements
|
||||
- Point to specific files or functions
|
||||
```
|
||||
|
||||
### Error Response
|
||||
```
|
||||
❌ PR Assistance Failed
|
||||
|
||||
🔄 Try Again:
|
||||
- Providing more specific instructions
|
||||
- Breaking down complex requests
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Clear Instructions
|
||||
- Be specific about what you want changed
|
||||
- Reference specific files or functions when possible
|
||||
- Provide context about the desired outcome
|
||||
|
||||
### Examples of Good Requests
|
||||
```
|
||||
@claude fix the TypeScript error in src/collections/payments.ts line 42 where the status field is missing from the Payment interface
|
||||
|
||||
@claude implement email validation in the customer billing form using the same pattern as the phone validation
|
||||
|
||||
@claude refactor the webhook handler in providers/mollie.ts to extract the payment processing logic into separate functions
|
||||
```
|
||||
|
||||
### Examples of Unclear Requests
|
||||
```
|
||||
@claude fix everything
|
||||
@claude make it better
|
||||
@claude help with this
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- **User Restriction**: Only `bvdaakster` can use this feature
|
||||
- **PR Only**: Works on pull request comments, not issue comments
|
||||
- **Open PRs**: Only works on open pull requests
|
||||
- **Branch Access**: Requires write access to the PR branch
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Workflow Triggers
|
||||
- Event: `issue_comment` on pull requests
|
||||
- Conditions: Comment contains Claude trigger words
|
||||
- Permissions: User must be `bvdaakster`
|
||||
|
||||
### Quality Checks
|
||||
Claude automatically runs:
|
||||
- `pnpm build` - Verify code compiles
|
||||
- `pnpm typecheck` - Check TypeScript types
|
||||
- `pnpm lint` - Ensure code style compliance
|
||||
- `npm run test` - Run test suite (if available)
|
||||
|
||||
### Commit Messages
|
||||
Automatic commits include:
|
||||
- Description of the request
|
||||
- PR number and requester
|
||||
- Claude attribution
|
||||
- Proper co-authoring
|
||||
|
||||
### Branch Strategy
|
||||
- Works directly on the PR's head branch
|
||||
- No additional branches created
|
||||
- Changes pushed to existing PR
|
||||
- Maintains PR history
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Permission Denied**
|
||||
- Only `bvdaakster` can use Claude PR assistance
|
||||
- Verify you're commenting as the correct user
|
||||
|
||||
2. **No Changes Made**
|
||||
- Request might be unclear or already implemented
|
||||
- Try providing more specific instructions
|
||||
- Reference specific files or lines
|
||||
|
||||
3. **Workflow Failed**
|
||||
- Check the Actions tab for detailed logs
|
||||
- Verify the PR branch is accessible
|
||||
- Ensure request is actionable
|
||||
|
||||
### Getting Help
|
||||
|
||||
If Claude assistance isn't working:
|
||||
1. Check the workflow logs in Actions tab
|
||||
2. Verify your request is specific and actionable
|
||||
3. Try breaking complex requests into smaller parts
|
||||
4. Use different command triggers (@claude fix vs @claude implement)
|
||||
|
||||
---
|
||||
|
||||
**Note**: This feature uses the official Anthropic Claude Code action for reliable, production-ready assistance. All changes should be reviewed before merging.
|
||||
43
.github/claude-config.json
vendored
43
.github/claude-config.json
vendored
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"privilegedUsers": [
|
||||
"bvdaakster"
|
||||
],
|
||||
"permissions": {
|
||||
"issueImplementation": {
|
||||
"strategy": "privilegedUsers",
|
||||
"description": "Only bvdaakster can request Claude implementations"
|
||||
},
|
||||
"codeReview": {
|
||||
"strategy": "privilegedUsers",
|
||||
"description": "Only bvdaakster can trigger Claude reviews"
|
||||
},
|
||||
"prAssistant": {
|
||||
"strategy": "privilegedUsers",
|
||||
"description": "Only bvdaakster can use Claude PR assistance"
|
||||
}
|
||||
},
|
||||
"workflows": {
|
||||
"issueImplementation": {
|
||||
"file": "claude-implement-issue.yml",
|
||||
"triggers": ["@claude implement", "@claude fix", "@claude create"],
|
||||
"description": "Creates new branch and PR for issue implementation"
|
||||
},
|
||||
"codeReview": {
|
||||
"file": "claude-code-review.yml",
|
||||
"triggers": ["automatic on PR"],
|
||||
"description": "Automatic code review for PRs from privileged users"
|
||||
},
|
||||
"prAssistant": {
|
||||
"file": "claude-pr-assistant.yml",
|
||||
"triggers": ["@claude implement", "@claude fix", "@claude improve", "@claude update", "@claude refactor", "@claude help"],
|
||||
"description": "Assists with PR improvements directly on the PR branch"
|
||||
}
|
||||
},
|
||||
"strategies": {
|
||||
"privilegedUsers": "Only users in the privilegedUsers list",
|
||||
"adminsOnly": "Only repository admins",
|
||||
"adminOrPrivileged": "Repository admins OR users in privilegedUsers list",
|
||||
"orgMembersWithWrite": "Organization members with write access",
|
||||
"everyone": "All users with repository access"
|
||||
}
|
||||
}
|
||||
197
.github/workflows/claude-implement-issue.yml
vendored
197
.github/workflows/claude-implement-issue.yml
vendored
@@ -1,197 +0,0 @@
|
||||
name: Claude Issue Implementation
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
claude-implement:
|
||||
if: |
|
||||
github.event.issue_comment.issue.state == 'open' &&
|
||||
(
|
||||
contains(github.event.comment.body, '@claude implement') ||
|
||||
contains(github.event.comment.body, '@claude fix') ||
|
||||
contains(github.event.comment.body, '@claude create')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check user permissions
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.actor !== 'bvdaakster') {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: '❌ **Access Denied**: Only bvdaakster can use Claude implementation.'
|
||||
});
|
||||
throw new Error('Unauthorized user');
|
||||
}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@10.12.4
|
||||
|
||||
- name: Create branch
|
||||
id: create-branch
|
||||
run: |
|
||||
BRANCH_NAME="claude/issue-${{ github.event.issue.number }}-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
- name: Notify start
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `🤖 **Claude Implementation Started**\n\n📋 **Issue**: #${{ github.event.issue.number }}\n🌿 **Branch**: \`${{ steps.create-branch.outputs.branch_name }}\`\n\nImplementing your request...`
|
||||
});
|
||||
|
||||
- name: Implement with Claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
direct_prompt: |
|
||||
Please implement the feature or fix described in this GitHub issue:
|
||||
|
||||
**Issue #${{ github.event.issue.number }}**: ${{ github.event.issue.title }}
|
||||
|
||||
**Issue Description**:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
**User Request**:
|
||||
${{ github.event.comment.body }}
|
||||
|
||||
**Instructions**:
|
||||
1. Analyze the issue requirements carefully
|
||||
2. Follow existing code patterns and conventions
|
||||
3. Use TypeScript with proper typing
|
||||
4. Follow ESM module structure with .js extensions
|
||||
5. Add tests if needed
|
||||
6. Update documentation if necessary
|
||||
|
||||
This is the @xtr-dev/payload-billing plugin for PayloadCMS.
|
||||
allowed_tools: "Bash(pnpm build),Bash(pnpm typecheck),Bash(pnpm lint),Bash(npm run test)"
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "Claude Implementation Bot"
|
||||
git add .
|
||||
git commit -m "feat: implement issue #${{ github.event.issue.number }}
|
||||
|
||||
Implemented via Claude automation.
|
||||
|
||||
Issue: #${{ github.event.issue.number }}
|
||||
Requested by: @${{ github.event.comment.user.login }}
|
||||
|
||||
🤖 Generated with Claude Code
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
git push origin ${{ steps.create-branch.outputs.branch_name }}
|
||||
|
||||
- name: Create PR
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: actions/github-script@v7
|
||||
id: create-pr
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `🤖 Implement: ${{ github.event.issue.title }}`,
|
||||
head: '${{ steps.create-branch.outputs.branch_name }}',
|
||||
base: 'dev',
|
||||
body: `## 🤖 Claude Implementation
|
||||
|
||||
This PR implements issue #${{ github.event.issue.number }}.
|
||||
|
||||
**Issue**: #${{ github.event.issue.number }}
|
||||
**Requested by**: @${{ github.event.comment.user.login }}
|
||||
**Branch**: \`${{ steps.create-branch.outputs.branch_name }}\`
|
||||
|
||||
### Review Checklist
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass
|
||||
- [ ] Implementation matches requirements
|
||||
|
||||
Closes #${{ github.event.issue.number }}
|
||||
|
||||
🤖 Generated with Claude Code`
|
||||
});
|
||||
return pr.number;
|
||||
|
||||
- name: Notify success
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = '${{ steps.create-pr.outputs.result }}';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `✅ **Implementation Complete!**\n\n🎯 **Pull Request**: #${prNumber}\n🌿 **Branch**: \`${{ steps.create-branch.outputs.branch_name }}\`\n\nReady for review! 🚀`
|
||||
});
|
||||
|
||||
- name: Handle no changes
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `ℹ️ **No Changes Needed**\n\nI analyzed the issue but no code changes are required.`
|
||||
});
|
||||
|
||||
- name: Clean up on no changes
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
run: git push origin --delete ${{ steps.create-branch.outputs.branch_name }} || true
|
||||
|
||||
- name: Handle failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `❌ **Implementation Failed**\n\nCheck the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.`
|
||||
});
|
||||
172
.github/workflows/claude-pr-assistant.yml
vendored
172
.github/workflows/claude-pr-assistant.yml
vendored
@@ -1,172 +0,0 @@
|
||||
name: Claude PR Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
claude-pr-assist:
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
github.event.issue_comment.issue.state == 'open' &&
|
||||
(
|
||||
contains(github.event.comment.body, '@claude implement') ||
|
||||
contains(github.event.comment.body, '@claude fix') ||
|
||||
contains(github.event.comment.body, '@claude improve') ||
|
||||
contains(github.event.comment.body, '@claude update') ||
|
||||
contains(github.event.comment.body, '@claude refactor') ||
|
||||
contains(github.event.comment.body, '@claude help')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check user permissions
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.actor !== 'bvdaakster') {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: '❌ **Access Denied**: Only bvdaakster can use Claude PR assistance.'
|
||||
});
|
||||
throw new Error('Unauthorized user');
|
||||
}
|
||||
|
||||
- name: Get PR details
|
||||
id: pr-details
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
return {
|
||||
head_ref: pr.head.ref,
|
||||
title: pr.title,
|
||||
body: pr.body
|
||||
};
|
||||
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ fromJson(steps.pr-details.outputs.result).head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@10.12.4
|
||||
|
||||
- name: Notify start
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `🤖 **Claude PR Assistant Started**\n\n💬 **Request**: ${{ github.event.comment.body }}\n🌿 **Branch**: \`${{ fromJson(steps.pr-details.outputs.result).head_ref }}\`\n\nWorking on your request...`
|
||||
});
|
||||
|
||||
- name: Assist with Claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
direct_prompt: |
|
||||
You are assisting with a GitHub Pull Request. Please help with this request:
|
||||
|
||||
**Pull Request #${{ github.event.issue.number }}**: ${{ fromJson(steps.pr-details.outputs.result).title }}
|
||||
|
||||
**PR Description**:
|
||||
${{ fromJson(steps.pr-details.outputs.result).body }}
|
||||
|
||||
**User Request**:
|
||||
${{ github.event.comment.body }}
|
||||
|
||||
**Instructions**:
|
||||
1. Analyze the current PR changes and the user's request
|
||||
2. Implement the requested improvements, fixes, or changes
|
||||
3. Follow existing code patterns and conventions
|
||||
4. Use TypeScript with proper typing
|
||||
5. Follow ESM module structure with .js extensions
|
||||
6. Run quality checks if needed
|
||||
|
||||
This is the @xtr-dev/payload-billing plugin for PayloadCMS.
|
||||
Please implement the requested changes directly on this PR branch.
|
||||
allowed_tools: "Bash(pnpm build),Bash(pnpm typecheck),Bash(pnpm lint),Bash(npm run test)"
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "Claude PR Assistant"
|
||||
git add .
|
||||
git commit -m "feat: Claude PR assistance - ${{ github.event.comment.user.login }} request
|
||||
|
||||
Request: ${{ github.event.comment.body }}
|
||||
PR: #${{ github.event.issue.number }}
|
||||
|
||||
🤖 Generated with Claude PR Assistant
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
git push origin ${{ fromJson(steps.pr-details.outputs.result).head_ref }}
|
||||
|
||||
- name: Notify success
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `✅ **PR Assistance Complete!**\n\n🔧 **Changes Made**: Implemented your requested improvements\n🌿 **Branch**: \`${{ fromJson(steps.pr-details.outputs.result).head_ref }}\`\n\nChanges are ready for review! 🚀`
|
||||
});
|
||||
|
||||
- name: Handle no changes
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `ℹ️ **No Changes Needed**\n\nI analyzed your request but no code changes are required.`
|
||||
});
|
||||
|
||||
- name: Handle failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `❌ **PR Assistance Failed**\n\nCheck the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.`
|
||||
});
|
||||
381
README.md
381
README.md
@@ -1,17 +1,38 @@
|
||||
# @xtr-dev/payload-billing
|
||||
|
||||
A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking.
|
||||
[](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing)
|
||||
|
||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||
A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking and flexible customer data management.
|
||||
|
||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.1.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Imports](#imports)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [Creating a Payment](#creating-a-payment)
|
||||
- [Creating an Invoice](#creating-an-invoice)
|
||||
- [Creating a Refund](#creating-a-refund)
|
||||
- [Querying Payments](#querying-payments)
|
||||
- [Using REST API](#using-rest-api)
|
||||
- [Provider Types](#provider-types)
|
||||
- [Collections](#collections)
|
||||
- [Webhook Endpoints](#webhook-endpoints)
|
||||
- [Development](#development)
|
||||
|
||||
## Features
|
||||
|
||||
- 💳 Multiple payment providers (Stripe, Mollie, Test)
|
||||
- 🧾 Invoice generation and management
|
||||
- 🧾 Invoice generation and management with embedded customer info
|
||||
- 👥 Flexible customer data management with relationship support
|
||||
- 📊 Complete payment tracking and history
|
||||
- 🪝 Secure webhook processing for all providers
|
||||
- 🧪 Built-in test provider for local development
|
||||
- 📱 Payment management in PayloadCMS admin
|
||||
- 🔄 Callback-based customer data syncing
|
||||
- 🔒 Full TypeScript support
|
||||
|
||||
## Installation
|
||||
@@ -42,6 +63,8 @@ pnpm add @mollie/api-client
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```typescript
|
||||
import { buildConfig } from 'payload'
|
||||
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||
@@ -70,6 +93,68 @@ export default buildConfig({
|
||||
})
|
||||
```
|
||||
|
||||
### With Customer Management
|
||||
|
||||
```typescript
|
||||
import { billingPlugin, CustomerInfoExtractor } from '@xtr-dev/payload-billing'
|
||||
|
||||
// Define how to extract customer info from your customer collection
|
||||
const customerExtractor: CustomerInfoExtractor = (customer) => ({
|
||||
name: customer.name,
|
||||
email: customer.email,
|
||||
phone: customer.phone,
|
||||
company: customer.company,
|
||||
taxId: customer.taxId,
|
||||
billingAddress: {
|
||||
line1: customer.address.line1,
|
||||
line2: customer.address.line2,
|
||||
city: customer.address.city,
|
||||
state: customer.address.state,
|
||||
postalCode: customer.address.postalCode,
|
||||
country: customer.address.country,
|
||||
}
|
||||
})
|
||||
|
||||
billingPlugin({
|
||||
// ... providers
|
||||
collections: {
|
||||
payments: 'payments',
|
||||
invoices: 'invoices',
|
||||
refunds: 'refunds',
|
||||
},
|
||||
customerRelationSlug: 'customers', // Enable customer relationships
|
||||
customerInfoExtractor: customerExtractor, // Auto-sync customer data
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Customer Data Extraction
|
||||
|
||||
```typescript
|
||||
import { CustomerInfoExtractor } from '@xtr-dev/payload-billing'
|
||||
|
||||
const customExtractor: CustomerInfoExtractor = (customer) => ({
|
||||
name: customer.fullName,
|
||||
email: customer.contactEmail,
|
||||
phone: customer.phoneNumber,
|
||||
company: customer.companyName,
|
||||
taxId: customer.vatNumber,
|
||||
billingAddress: {
|
||||
line1: customer.billing.street,
|
||||
line2: customer.billing.apartment,
|
||||
city: customer.billing.city,
|
||||
state: customer.billing.state,
|
||||
postalCode: customer.billing.zip,
|
||||
country: customer.billing.countryCode,
|
||||
}
|
||||
})
|
||||
|
||||
billingPlugin({
|
||||
// ... other config
|
||||
customerRelationSlug: 'clients',
|
||||
customerInfoExtractor: customExtractor,
|
||||
})
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```typescript
|
||||
@@ -80,7 +165,17 @@ import { billingPlugin } from '@xtr-dev/payload-billing'
|
||||
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||
|
||||
// Types
|
||||
import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
|
||||
import type {
|
||||
PaymentProvider,
|
||||
Payment,
|
||||
Invoice,
|
||||
Refund,
|
||||
BillingPluginConfig,
|
||||
CustomerInfoExtractor,
|
||||
MollieProviderConfig,
|
||||
StripeProviderConfig,
|
||||
ProviderData
|
||||
} from '@xtr-dev/payload-billing'
|
||||
```
|
||||
|
||||
## Provider Types
|
||||
@@ -99,9 +194,285 @@ Local development testing with configurable scenarios, automatic completion, deb
|
||||
The plugin adds these collections:
|
||||
|
||||
- **payments** - Payment transactions with status and provider data
|
||||
- **invoices** - Invoice generation with line items and PDF support
|
||||
- **invoices** - Invoice generation with line items and embedded customer info
|
||||
- **refunds** - Refund tracking and management
|
||||
|
||||
### Customer Data Management
|
||||
|
||||
The plugin supports flexible customer data handling:
|
||||
|
||||
1. **With Customer Relationship + Extractor**: Customer relationship required, customer info auto-populated and read-only, syncs automatically when customer changes
|
||||
|
||||
2. **With Customer Relationship (no extractor)**: Customer relationship optional, customer info manually editable, either relationship OR customer info required
|
||||
|
||||
3. **No Customer Collection**: Customer info fields always required and editable, no relationship field available
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Payment
|
||||
|
||||
Payments are created through PayloadCMS's local API or REST API. The plugin automatically initializes the payment with the configured provider.
|
||||
|
||||
```typescript
|
||||
// Using Payload Local API
|
||||
const payment = await payload.create({
|
||||
collection: 'payments',
|
||||
data: {
|
||||
provider: 'stripe', // or 'mollie' or 'test'
|
||||
amount: 2000, // Amount in cents ($20.00)
|
||||
currency: 'USD',
|
||||
description: 'Product purchase',
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
orderId: 'order-123',
|
||||
customerId: 'cust-456'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Creating an Invoice
|
||||
|
||||
Invoices can be created with customer information embedded or linked via relationship:
|
||||
|
||||
```typescript
|
||||
// Create invoice with embedded customer info
|
||||
const invoice = await payload.create({
|
||||
collection: 'invoices',
|
||||
data: {
|
||||
customerInfo: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '+1234567890',
|
||||
company: 'Acme Corp',
|
||||
taxId: 'TAX-123'
|
||||
},
|
||||
billingAddress: {
|
||||
line1: '123 Main St',
|
||||
line2: 'Suite 100',
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
postalCode: '10001',
|
||||
country: 'US'
|
||||
},
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
description: 'Web Development Services',
|
||||
quantity: 10,
|
||||
unitAmount: 5000 // $50.00 per hour
|
||||
},
|
||||
{
|
||||
description: 'Hosting (Monthly)',
|
||||
quantity: 1,
|
||||
unitAmount: 2500 // $25.00
|
||||
}
|
||||
],
|
||||
taxAmount: 7500, // $75.00 tax
|
||||
status: 'open'
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Invoice created: ${invoice.number}`)
|
||||
console.log(`Total amount: $${invoice.amount / 100}`)
|
||||
```
|
||||
|
||||
### Creating an Invoice with Customer Relationship
|
||||
|
||||
If you've configured a customer collection with `customerRelationSlug` and `customerInfoExtractor`:
|
||||
|
||||
```typescript
|
||||
// Create invoice linked to customer (info auto-populated)
|
||||
const invoice = await payload.create({
|
||||
collection: 'invoices',
|
||||
data: {
|
||||
customer: 'customer-id-123', // Customer relationship
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
description: 'Subscription - Pro Plan',
|
||||
quantity: 1,
|
||||
unitAmount: 9900 // $99.00
|
||||
}
|
||||
],
|
||||
status: 'open'
|
||||
// customerInfo and billingAddress are auto-populated from customer
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Creating a Refund
|
||||
|
||||
Refunds are linked to existing payments:
|
||||
|
||||
```typescript
|
||||
const refund = await payload.create({
|
||||
collection: 'refunds',
|
||||
data: {
|
||||
payment: payment.id, // Link to payment
|
||||
providerId: 'refund-provider-id', // Provider's refund ID
|
||||
amount: 1000, // Partial refund: $10.00
|
||||
currency: 'USD',
|
||||
status: 'succeeded',
|
||||
reason: 'requested_by_customer',
|
||||
description: 'Customer requested partial refund'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Querying Payments
|
||||
|
||||
```typescript
|
||||
// Find all successful payments
|
||||
const payments = await payload.find({
|
||||
collection: 'payments',
|
||||
where: {
|
||||
status: {
|
||||
equals: 'succeeded'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Find payments for a specific invoice
|
||||
const invoicePayments = await payload.find({
|
||||
collection: 'payments',
|
||||
where: {
|
||||
invoice: {
|
||||
equals: invoiceId
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Updating Payment Status
|
||||
|
||||
Payment status is typically updated via webhooks, but you can also update manually:
|
||||
|
||||
```typescript
|
||||
const updatedPayment = await payload.update({
|
||||
collection: 'payments',
|
||||
id: payment.id,
|
||||
data: {
|
||||
status: 'succeeded',
|
||||
providerData: {
|
||||
// Provider-specific data
|
||||
raw: providerResponse,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'stripe'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Marking an Invoice as Paid
|
||||
|
||||
```typescript
|
||||
const paidInvoice = await payload.update({
|
||||
collection: 'invoices',
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: 'paid',
|
||||
payment: payment.id // Link to payment
|
||||
// paidAt is automatically set by the plugin
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Using the Test Provider
|
||||
|
||||
The test provider is useful for local development:
|
||||
|
||||
```typescript
|
||||
// In your payload.config.ts
|
||||
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'
|
||||
|
||||
billingPlugin({
|
||||
providers: [
|
||||
testProvider({
|
||||
enabled: true,
|
||||
testModeIndicators: {
|
||||
showWarningBanners: true,
|
||||
showTestBadges: true,
|
||||
consoleWarnings: true
|
||||
}
|
||||
})
|
||||
],
|
||||
collections: {
|
||||
payments: 'payments',
|
||||
invoices: 'invoices',
|
||||
refunds: 'refunds',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Then create test payments:
|
||||
|
||||
```typescript
|
||||
const testPayment = await payload.create({
|
||||
collection: 'payments',
|
||||
data: {
|
||||
provider: 'test',
|
||||
amount: 5000,
|
||||
currency: 'USD',
|
||||
description: 'Test payment',
|
||||
status: 'pending'
|
||||
}
|
||||
})
|
||||
// Test provider automatically processes the payment
|
||||
```
|
||||
|
||||
### Using REST API
|
||||
|
||||
All collections can be accessed via PayloadCMS REST API:
|
||||
|
||||
```bash
|
||||
# Create a payment
|
||||
curl -X POST http://localhost:3000/api/payments \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"provider": "stripe",
|
||||
"amount": 2000,
|
||||
"currency": "USD",
|
||||
"description": "Product purchase",
|
||||
"status": "pending"
|
||||
}'
|
||||
|
||||
# Create an invoice
|
||||
curl -X POST http://localhost:3000/api/invoices \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"customerInfo": {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
},
|
||||
"billingAddress": {
|
||||
"line1": "123 Main St",
|
||||
"city": "New York",
|
||||
"postalCode": "10001",
|
||||
"country": "US"
|
||||
},
|
||||
"currency": "USD",
|
||||
"items": [
|
||||
{
|
||||
"description": "Service",
|
||||
"quantity": 1,
|
||||
"unitAmount": 5000
|
||||
}
|
||||
],
|
||||
"status": "open"
|
||||
}'
|
||||
|
||||
# Get all payments
|
||||
curl http://localhost:3000/api/payments \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# Get a specific invoice
|
||||
curl http://localhost:3000/api/invoices/INVOICE_ID \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
## Webhook Endpoints
|
||||
|
||||
Automatic webhook endpoints are created for configured providers:
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
||||
import { seed } from './seed'
|
||||
import billingPlugin from '../src/plugin'
|
||||
import { mollieProvider } from '../src/providers'
|
||||
import { testProvider } from '../src/providers'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -50,8 +50,13 @@ const buildConfigWithSQLite = () => {
|
||||
plugins: [
|
||||
billingPlugin({
|
||||
providers: [
|
||||
mollieProvider({
|
||||
apiKey: process.env.MOLLIE_KEY!
|
||||
testProvider({
|
||||
enabled: true,
|
||||
testModeIndicators: {
|
||||
showWarningBanners: true,
|
||||
showTestBadges: true,
|
||||
consoleWarnings: true
|
||||
}
|
||||
})
|
||||
],
|
||||
collections: {
|
||||
|
||||
147
docs/test-provider-example.md
Normal file
147
docs/test-provider-example.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Advanced Test Provider Example
|
||||
|
||||
The advanced test provider allows you to test complex payment scenarios with an interactive UI for development purposes.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
```typescript
|
||||
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'
|
||||
|
||||
// Configure the test provider
|
||||
const testProviderConfig = {
|
||||
enabled: true, // Enable the test provider
|
||||
defaultDelay: 2000, // Default delay in milliseconds
|
||||
baseUrl: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000',
|
||||
customUiRoute: '/test-payment', // Custom route for test payment UI
|
||||
testModeIndicators: {
|
||||
showWarningBanners: true, // Show warning banners in test mode
|
||||
showTestBadges: true, // Show test badges
|
||||
consoleWarnings: true, // Show console warnings
|
||||
}
|
||||
}
|
||||
|
||||
// Add to your payload config
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
billingPlugin({
|
||||
providers: [
|
||||
testProvider(testProviderConfig)
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Scenarios
|
||||
|
||||
You can define custom payment scenarios:
|
||||
|
||||
```typescript
|
||||
const customScenarios = [
|
||||
{
|
||||
id: 'quick-success',
|
||||
name: 'Quick Success',
|
||||
description: 'Payment succeeds in 1 second',
|
||||
outcome: 'paid' as const,
|
||||
delay: 1000,
|
||||
method: 'creditcard' as const
|
||||
},
|
||||
{
|
||||
id: 'network-timeout',
|
||||
name: 'Network Timeout',
|
||||
description: 'Simulates network timeout',
|
||||
outcome: 'failed' as const,
|
||||
delay: 10000
|
||||
},
|
||||
{
|
||||
id: 'user-abandonment',
|
||||
name: 'User Abandonment',
|
||||
description: 'User closes payment window',
|
||||
outcome: 'cancelled' as const,
|
||||
delay: 5000
|
||||
}
|
||||
]
|
||||
|
||||
const testProviderConfig = {
|
||||
enabled: true,
|
||||
scenarios: customScenarios,
|
||||
// ... other config
|
||||
}
|
||||
```
|
||||
|
||||
## Available Payment Outcomes
|
||||
|
||||
- `paid` - Payment succeeds
|
||||
- `failed` - Payment fails
|
||||
- `cancelled` - Payment is cancelled by user
|
||||
- `expired` - Payment expires
|
||||
- `pending` - Payment remains pending
|
||||
|
||||
## Available Payment Methods
|
||||
|
||||
- `ideal` - iDEAL (Dutch banking)
|
||||
- `creditcard` - Credit/Debit Cards
|
||||
- `paypal` - PayPal
|
||||
- `applepay` - Apple Pay
|
||||
- `banktransfer` - Bank Transfer
|
||||
|
||||
## Using the Test UI
|
||||
|
||||
1. Create a payment using the test provider
|
||||
2. The payment will return a `paymentUrl` in the provider data
|
||||
3. Navigate to this URL to access the interactive test interface
|
||||
4. Select a payment method and scenario
|
||||
5. Click "Process Test Payment" to simulate the payment
|
||||
6. The payment status will update automatically based on the selected scenario
|
||||
|
||||
## React Components
|
||||
|
||||
Use the provided React components in your admin interface:
|
||||
|
||||
```tsx
|
||||
import { TestModeWarningBanner, TestModeBadge, TestPaymentControls } from '@xtr-dev/payload-billing/client'
|
||||
|
||||
// Show warning banner when in test mode
|
||||
<TestModeWarningBanner visible={isTestMode} />
|
||||
|
||||
// Add test badge to payment status
|
||||
<div>
|
||||
Payment Status: {status}
|
||||
<TestModeBadge visible={isTestMode} />
|
||||
</div>
|
||||
|
||||
// Payment testing controls
|
||||
<TestPaymentControls
|
||||
paymentId={paymentId}
|
||||
onScenarioSelect={(scenario) => console.log('Selected scenario:', scenario)}
|
||||
onMethodSelect={(method) => console.log('Selected method:', method)}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The test provider automatically registers these endpoints:
|
||||
|
||||
- `GET /api/payload-billing/test/payment/:id` - Test payment UI
|
||||
- `POST /api/payload-billing/test/process` - Process test payment
|
||||
- `GET /api/payload-billing/test/status/:id` - Get payment status
|
||||
|
||||
## Development Tips
|
||||
|
||||
1. **Console Warnings**: Keep `consoleWarnings: true` to get notifications about test mode
|
||||
2. **Visual Indicators**: Use warning banners and badges to clearly mark test payments
|
||||
3. **Custom Scenarios**: Create scenarios that match your specific use cases
|
||||
4. **Automated Testing**: Use the test provider in your e2e tests for predictable payment outcomes
|
||||
5. **Method Testing**: Test different payment methods to ensure your UI handles them correctly
|
||||
|
||||
## Production Safety
|
||||
|
||||
The test provider includes several safety mechanisms:
|
||||
|
||||
- Must be explicitly enabled with `enabled: true`
|
||||
- Clearly marked with test indicators
|
||||
- Console warnings when active
|
||||
- Separate endpoint namespace (`/payload-billing/test/`)
|
||||
- No real payment processing
|
||||
|
||||
**Important**: Never use the test provider in production environments!
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-billing",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.12",
|
||||
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
76
playwright-report/index.html
Normal file
76
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -1,11 +1,13 @@
|
||||
import type { Payment } from '../plugin/types/index.js'
|
||||
import type { Payment } from '../plugin/types/index'
|
||||
import type { Payload } from 'payload'
|
||||
import { useBillingPlugin } from '../plugin/index.js'
|
||||
import { useBillingPlugin } from '../plugin/index'
|
||||
|
||||
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
|
||||
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
|
||||
const billing = useBillingPlugin(payload)
|
||||
if (!payment.provider || !billing.providerConfig[payment.provider]) {
|
||||
throw new Error(`Provider ${payment.provider} not found.`)
|
||||
}
|
||||
return billing.providerConfig[payment.provider].initPayment(payload, payment)
|
||||
// Handle both async and non-async initPayment functions
|
||||
const result = billing.providerConfig[payment.provider].initPayment(payload, payment)
|
||||
return await Promise.resolve(result)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { createInvoicesCollection } from './invoices.js'
|
||||
export { createPaymentsCollection } from './payments.js'
|
||||
export { createRefundsCollection } from './refunds.js'
|
||||
export { createInvoicesCollection } from './invoices'
|
||||
export { createPaymentsCollection } from './payments'
|
||||
export { createRefundsCollection } from './refunds'
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
CollectionBeforeValidateHook,
|
||||
CollectionConfig, Field,
|
||||
} from 'payload'
|
||||
import type { BillingPluginConfig} from '../plugin/config.js';
|
||||
import { defaults } from '../plugin/config.js'
|
||||
import { extractSlug } from '../plugin/utils.js'
|
||||
import type { Invoice } from '../plugin/types/invoices.js'
|
||||
import type { BillingPluginConfig} from '@/plugin/config';
|
||||
import { defaults } from '@/plugin/config'
|
||||
import { extractSlug } from '@/plugin/utils'
|
||||
import { createContextLogger } from '@/utils/logger'
|
||||
import type { Invoice } from '@/plugin/types'
|
||||
|
||||
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
||||
@@ -314,7 +315,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
afterChange: [
|
||||
({ doc, operation, req }) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Invoice created: ${doc.number}`)
|
||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||
logger.info(`Invoice created: ${doc.number}`)
|
||||
}
|
||||
},
|
||||
] satisfies CollectionAfterChangeHook<Invoice>[],
|
||||
@@ -350,7 +352,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
data.billingAddress = extractedInfo.billingAddress
|
||||
}
|
||||
} catch (error) {
|
||||
req.payload.logger.error(`Failed to extract customer info: ${error}`)
|
||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||
logger.error(`Failed to extract customer info: ${error}`)
|
||||
throw new Error('Failed to extract customer information')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
|
||||
import type { BillingPluginConfig} from '../plugin/config.js';
|
||||
import { defaults } from '../plugin/config.js'
|
||||
import { extractSlug } from '../plugin/utils.js'
|
||||
import type { Payment } from '../plugin/types/payments.js'
|
||||
import { initProviderPayment } from './hooks.js'
|
||||
import type { BillingPluginConfig} from '../plugin/config';
|
||||
import { defaults } from '../plugin/config'
|
||||
import { extractSlug } from '../plugin/utils'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import { initProviderPayment } from './hooks'
|
||||
|
||||
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
import { BillingPluginConfig, defaults } from '../plugin/config.js'
|
||||
import { extractSlug } from '../plugin/utils.js'
|
||||
import { Payment } from '../plugin/types/index.js'
|
||||
import { BillingPluginConfig, defaults } from '../plugin/config'
|
||||
import { extractSlug } from '../plugin/utils'
|
||||
import { Payment } from '../plugin/types/index'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
// TODO: finish collection overrides
|
||||
@@ -111,7 +112,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
|
||||
const logger = createContextLogger(req.payload, 'Refunds Collection')
|
||||
logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
|
||||
|
||||
// Update the related payment's refund relationship
|
||||
try {
|
||||
@@ -129,7 +131,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
|
||||
const logger = createContextLogger(req.payload, 'Refunds Collection')
|
||||
logger.error(`Failed to update payment refunds: ${error}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,9 +60,130 @@ export const PaymentStatusBadge: React.FC<{ status: string }> = ({ status }) =>
|
||||
)
|
||||
}
|
||||
|
||||
// Test mode indicator components
|
||||
export const TestModeWarningBanner: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(90deg, #ff6b6b, #ffa726)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
🧪 TEST MODE - Payment system is running in test mode for development
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TestModeBadge: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
background: '#6c757d',
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: '8px'
|
||||
}}>
|
||||
Test
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const TestPaymentControls: React.FC<{
|
||||
paymentId?: string
|
||||
onScenarioSelect?: (scenario: string) => void
|
||||
onMethodSelect?: (method: string) => void
|
||||
}> = ({ paymentId, onScenarioSelect, onMethodSelect }) => {
|
||||
const [selectedScenario, setSelectedScenario] = React.useState('')
|
||||
const [selectedMethod, setSelectedMethod] = React.useState('')
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'instant-success', name: 'Instant Success', description: 'Payment succeeds immediately' },
|
||||
{ id: 'delayed-success', name: 'Delayed Success', description: 'Payment succeeds after delay' },
|
||||
{ id: 'cancelled-payment', name: 'Cancelled Payment', description: 'User cancels payment' },
|
||||
{ id: 'declined-payment', name: 'Declined Payment', description: 'Payment declined' },
|
||||
{ id: 'expired-payment', name: 'Expired Payment', description: 'Payment expires' },
|
||||
{ id: 'pending-payment', name: 'Pending Payment', description: 'Payment stays pending' }
|
||||
]
|
||||
|
||||
const methods = [
|
||||
{ id: 'ideal', name: 'iDEAL', icon: '🏦' },
|
||||
{ id: 'creditcard', name: 'Credit Card', icon: '💳' },
|
||||
{ id: 'paypal', name: 'PayPal', icon: '🅿️' },
|
||||
{ id: 'applepay', name: 'Apple Pay', icon: '🍎' },
|
||||
{ id: 'banktransfer', name: 'Bank Transfer', icon: '🏛️' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #e9ecef', borderRadius: '8px', padding: '16px', margin: '16px 0' }}>
|
||||
<h4 style={{ marginBottom: '12px', color: '#2c3e50' }}>🧪 Test Payment Controls</h4>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Payment Method:</label>
|
||||
<select
|
||||
value={selectedMethod}
|
||||
onChange={(e) => {
|
||||
setSelectedMethod(e.target.value)
|
||||
onMethodSelect?.(e.target.value)
|
||||
}}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">Select payment method...</option>
|
||||
{methods.map(method => (
|
||||
<option key={method.id} value={method.id}>
|
||||
{method.icon} {method.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Test Scenario:</label>
|
||||
<select
|
||||
value={selectedScenario}
|
||||
onChange={(e) => {
|
||||
setSelectedScenario(e.target.value)
|
||||
onScenarioSelect?.(e.target.value)
|
||||
}}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">Select test scenario...</option>
|
||||
{scenarios.map(scenario => (
|
||||
<option key={scenario.id} value={scenario.id}>
|
||||
{scenario.name} - {scenario.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{paymentId && (
|
||||
<div style={{ marginTop: '12px', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<small style={{ color: '#6c757d' }}>
|
||||
Payment ID: <code>{paymentId}</code>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
BillingDashboardWidget,
|
||||
formatCurrency,
|
||||
getPaymentStatusColor,
|
||||
PaymentStatusBadge,
|
||||
TestModeWarningBanner,
|
||||
TestModeBadge,
|
||||
TestPaymentControls,
|
||||
}
|
||||
19
src/index.ts
19
src/index.ts
@@ -1,8 +1,21 @@
|
||||
|
||||
export { billingPlugin } from './plugin/index.js'
|
||||
export { mollieProvider, stripeProvider } from './providers/index.js'
|
||||
export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config.js'
|
||||
export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderConfig } from './plugin/config.js'
|
||||
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
|
||||
export type { PaymentProvider, ProviderData } from './providers/types.js'
|
||||
export type { MollieProviderConfig } from './providers/mollie.js'
|
||||
export type { StripeProviderConfig } from './providers/stripe.js'
|
||||
|
||||
// Export logging utilities
|
||||
export { getPluginLogger, createContextLogger } from './utils/logger.js'
|
||||
|
||||
// Export all providers
|
||||
export { testProvider } from './providers/test.js'
|
||||
export type {
|
||||
StripeProviderConfig,
|
||||
MollieProviderConfig,
|
||||
TestProviderConfig,
|
||||
TestProviderConfigResponse,
|
||||
PaymentOutcome,
|
||||
PaymentMethod,
|
||||
PaymentScenario
|
||||
} from './providers/index.js'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CollectionConfig } from 'payload'
|
||||
import { FieldsOverride } from './utils.js'
|
||||
import { PaymentProvider } from './types/index.js'
|
||||
import { FieldsOverride } from './utils'
|
||||
import { PaymentProvider } from './types/index'
|
||||
|
||||
export const defaults = {
|
||||
paymentsCollection: 'payments',
|
||||
@@ -19,6 +19,9 @@ export interface TestProviderConfig {
|
||||
simulateFailures?: boolean
|
||||
}
|
||||
|
||||
// Re-export the actual test provider config instead of duplicating
|
||||
export type { TestProviderConfig as AdvancedTestProviderConfig } from '../providers/test'
|
||||
|
||||
// Customer info extractor callback type
|
||||
export interface CustomerInfoExtractor {
|
||||
(customer: any): {
|
||||
@@ -52,6 +55,6 @@ export interface BillingPluginConfig {
|
||||
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||
customerRelationSlug?: string // Customer collection slug for relationship
|
||||
disabled?: boolean
|
||||
providers?: PaymentProvider[]
|
||||
providers?: (PaymentProvider | undefined | null)[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index.js'
|
||||
import type { BillingPluginConfig } from './config.js'
|
||||
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
|
||||
import type { BillingPluginConfig } from './config'
|
||||
import type { Config, Payload } from 'payload'
|
||||
import { createSingleton } from './singleton.js'
|
||||
import type { PaymentProvider } from '../providers/index.js'
|
||||
import { createSingleton } from './singleton'
|
||||
import type { PaymentProvider } from '../providers/index'
|
||||
|
||||
const singleton = createSingleton(Symbol('billingPlugin'))
|
||||
|
||||
@@ -28,8 +28,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
||||
];
|
||||
|
||||
(pluginConfig.providers || [])
|
||||
.filter(provider => provider.onConfig)
|
||||
.forEach(provider => provider.onConfig!(config, pluginConfig))
|
||||
.filter(provider => provider?.onConfig)
|
||||
.forEach(provider => provider?.onConfig!(config, pluginConfig))
|
||||
|
||||
const incomingOnInit = config.onInit
|
||||
config.onInit = async (payload) => {
|
||||
@@ -38,17 +38,17 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
||||
}
|
||||
singleton.set(payload, {
|
||||
config: pluginConfig,
|
||||
providerConfig: (pluginConfig.providers || []).reduce(
|
||||
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
|
||||
(record, provider) => {
|
||||
record[provider.key] = provider
|
||||
record[provider!.key] = provider as PaymentProvider
|
||||
return record
|
||||
},
|
||||
{} as Record<string, PaymentProvider>
|
||||
)
|
||||
} satisfies BillingPlugin)
|
||||
await Promise.all((pluginConfig.providers || [])
|
||||
.filter(provider => provider.onInit)
|
||||
.map(provider => provider.onInit!(payload)))
|
||||
.filter(provider => provider?.onInit)
|
||||
.map(provider => provider?.onInit!(payload)))
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Payment } from './payments.js'
|
||||
import { Id } from './id.js'
|
||||
import { Payment } from './payments'
|
||||
import { Id } from './id'
|
||||
|
||||
export interface Invoice<TCustomer = unknown> {
|
||||
id: Id;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Refund } from './refunds.js'
|
||||
import { Invoice } from './invoices.js'
|
||||
import { Id } from './id.js'
|
||||
import { Refund } from './refunds'
|
||||
import { Invoice } from './invoices'
|
||||
import { Id } from './id'
|
||||
|
||||
export interface Payment {
|
||||
id: Id;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Payment } from './payments.js'
|
||||
import { Payment } from './payments'
|
||||
|
||||
export interface Refund {
|
||||
id: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
||||
import type { Id } from './types/index.js'
|
||||
import type { Id } from './types/index'
|
||||
|
||||
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export * from './mollie.js'
|
||||
export * from './stripe.js'
|
||||
export * from './types.js'
|
||||
export * from './currency.js'
|
||||
export * from './mollie'
|
||||
export * from './stripe'
|
||||
export * from './test'
|
||||
export * from './types'
|
||||
export * from './currency'
|
||||
|
||||
// Re-export provider configurations and types
|
||||
export type { StripeProviderConfig } from './stripe'
|
||||
export type { MollieProviderConfig } from './mollie'
|
||||
export type { TestProviderConfig, TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Payment } from '../plugin/types/payments.js'
|
||||
import type { PaymentProvider } from '../plugin/types/index.js'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { PaymentProvider } from '../plugin/types/index'
|
||||
import type { Payload } from 'payload'
|
||||
import { createSingleton } from '../plugin/singleton.js'
|
||||
import { createSingleton } from '../plugin/singleton'
|
||||
import type { createMollieClient, MollieClient } from '@mollie/api-client'
|
||||
import {
|
||||
webhookResponses,
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
updateInvoiceOnPaymentSuccess,
|
||||
handleWebhookError,
|
||||
validateProductionUrl
|
||||
} from './utils.js'
|
||||
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency.js'
|
||||
} from './utils'
|
||||
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
const symbol = Symbol('mollie')
|
||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||
@@ -96,12 +97,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
||||
if (status === 'succeeded' && updateSuccess) {
|
||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||
} else if (!updateSuccess) {
|
||||
console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
|
||||
const logger = createContextLogger(payload, 'Mollie Webhook')
|
||||
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
|
||||
}
|
||||
|
||||
return webhookResponses.success()
|
||||
} catch (error) {
|
||||
return handleWebhookError('Mollie', error)
|
||||
return handleWebhookError('Mollie', error, undefined, req.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Payment } from '../plugin/types/payments.js'
|
||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index.js'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||
import type { Payload } from 'payload'
|
||||
import { createSingleton } from '../plugin/singleton.js'
|
||||
import { createSingleton } from '../plugin/singleton'
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
webhookResponses,
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
updateInvoiceOnPaymentSuccess,
|
||||
handleWebhookError,
|
||||
logWebhookEvent
|
||||
} from './utils.js'
|
||||
import { isValidAmount, isValidCurrencyCode } from './currency.js'
|
||||
} from './utils'
|
||||
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
const symbol = Symbol('stripe')
|
||||
|
||||
@@ -60,13 +61,13 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
return webhookResponses.missingBody()
|
||||
}
|
||||
} catch (error) {
|
||||
return handleWebhookError('Stripe', error, 'Failed to read request body')
|
||||
return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload)
|
||||
}
|
||||
|
||||
const signature = req.headers.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
return webhookResponses.error('Missing webhook signature', 400)
|
||||
return webhookResponses.error('Missing webhook signature', 400, req.payload)
|
||||
}
|
||||
|
||||
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
|
||||
@@ -76,7 +77,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
|
||||
} catch (err) {
|
||||
return handleWebhookError('Stripe', err, 'Signature verification failed')
|
||||
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
@@ -90,7 +91,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
||||
|
||||
if (!payment) {
|
||||
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
|
||||
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload)
|
||||
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
||||
}
|
||||
|
||||
@@ -129,7 +130,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
if (status === 'succeeded' && updateSuccess) {
|
||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||
} else if (!updateSuccess) {
|
||||
console.warn(`[Stripe Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
|
||||
const logger = createContextLogger(payload, 'Stripe Webhook')
|
||||
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -172,7 +174,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
)
|
||||
|
||||
if (!updateSuccess) {
|
||||
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
|
||||
const logger = createContextLogger(payload, 'Stripe Webhook')
|
||||
logger.warn(`Failed to update refund status for payment ${payment.id}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -180,19 +183,16 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
|
||||
default:
|
||||
// Unhandled event type
|
||||
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
|
||||
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
|
||||
}
|
||||
|
||||
return webhookResponses.success()
|
||||
} catch (error) {
|
||||
return handleWebhookError('Stripe', error)
|
||||
return handleWebhookError('Stripe', error, undefined, req.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
} else {
|
||||
// Log that webhook endpoint is not registered
|
||||
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
|
||||
}
|
||||
},
|
||||
onInit: async (payload: Payload) => {
|
||||
@@ -201,6 +201,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
||||
})
|
||||
singleton.set(payload, stripe)
|
||||
|
||||
// Log webhook registration status
|
||||
if (!stripeConfig.webhookSecret) {
|
||||
const logger = createContextLogger(payload, 'Stripe Provider')
|
||||
logger.warn('Webhook endpoint not registered - webhookSecret not configured')
|
||||
}
|
||||
},
|
||||
initPayment: async (payload, payment) => {
|
||||
// Validate required fields
|
||||
|
||||
941
src/providers/test.ts
Normal file
941
src/providers/test.ts
Normal file
@@ -0,0 +1,941 @@
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||
import type { BillingPluginConfig } from '../plugin/config'
|
||||
import type { Payload } from 'payload'
|
||||
import { handleWebhookError, logWebhookEvent } from './utils'
|
||||
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
const TestModeWarningSymbol = Symbol('TestModeWarning')
|
||||
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
|
||||
const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true)
|
||||
|
||||
|
||||
// Request validation schemas
|
||||
interface ProcessPaymentRequest {
|
||||
paymentId: string
|
||||
scenarioId: string
|
||||
method: PaymentMethod
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
function validateProcessPaymentRequest(body: any): { isValid: boolean; data?: ProcessPaymentRequest; error?: string } {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { isValid: false, error: 'Request body must be a valid JSON object' }
|
||||
}
|
||||
|
||||
const { paymentId, scenarioId, method } = body
|
||||
|
||||
if (!paymentId || typeof paymentId !== 'string') {
|
||||
return { isValid: false, error: 'paymentId is required and must be a string' }
|
||||
}
|
||||
|
||||
if (!scenarioId || typeof scenarioId !== 'string') {
|
||||
return { isValid: false, error: 'scenarioId is required and must be a string' }
|
||||
}
|
||||
|
||||
if (!method || typeof method !== 'string') {
|
||||
return { isValid: false, error: 'method is required and must be a string' }
|
||||
}
|
||||
|
||||
// Validate method is a valid payment method
|
||||
const validMethods: PaymentMethod[] = ['ideal', 'creditcard', 'paypal', 'applepay', 'banktransfer']
|
||||
if (!validMethods.includes(method as PaymentMethod)) {
|
||||
return { isValid: false, error: `method must be one of: ${validMethods.join(', ')}` }
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
data: { paymentId, scenarioId, method: method as PaymentMethod }
|
||||
}
|
||||
}
|
||||
|
||||
function validatePaymentId(paymentId: string): { isValid: boolean; error?: string } {
|
||||
if (!paymentId || typeof paymentId !== 'string') {
|
||||
return { isValid: false, error: 'Payment ID is required and must be a string' }
|
||||
}
|
||||
|
||||
// Validate payment ID format (should match test payment ID pattern)
|
||||
if (!paymentId.startsWith('test_pay_')) {
|
||||
return { isValid: false, error: 'Invalid payment ID format' }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
// Utility function to safely extract collection name
|
||||
function getPaymentsCollectionName(pluginConfig: BillingPluginConfig): string {
|
||||
if (typeof pluginConfig.collections?.payments === 'string') {
|
||||
return pluginConfig.collections.payments
|
||||
}
|
||||
return 'payments'
|
||||
}
|
||||
|
||||
// Enhanced error handling utility for database operations
|
||||
async function updatePaymentInDatabase(
|
||||
payload: Payload,
|
||||
sessionId: string,
|
||||
status: Payment['status'],
|
||||
providerData: ProviderData,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const paymentsCollection = getPaymentsCollectionName(pluginConfig)
|
||||
const payments = await payload.find({
|
||||
collection: paymentsCollection as any, // PayloadCMS collection type constraint
|
||||
where: { providerId: { equals: sessionId } },
|
||||
limit: 1
|
||||
})
|
||||
|
||||
if (payments.docs.length === 0) {
|
||||
return { success: false, error: 'Payment not found in database' }
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: paymentsCollection as any, // PayloadCMS collection type constraint
|
||||
id: payments.docs[0].id,
|
||||
data: {
|
||||
status,
|
||||
providerData
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown database error'
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Database update failed:', errorMessage)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
|
||||
|
||||
export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer'
|
||||
|
||||
export interface PaymentScenario {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
outcome: PaymentOutcome
|
||||
delay?: number // Delay in milliseconds before processing
|
||||
method?: PaymentMethod
|
||||
}
|
||||
|
||||
export interface TestProviderConfig {
|
||||
enabled: boolean
|
||||
scenarios?: PaymentScenario[]
|
||||
customUiRoute?: string
|
||||
testModeIndicators?: {
|
||||
showWarningBanners?: boolean
|
||||
showTestBadges?: boolean
|
||||
consoleWarnings?: boolean
|
||||
}
|
||||
defaultDelay?: number
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
export interface TestProviderConfigResponse {
|
||||
enabled: boolean
|
||||
scenarios: PaymentScenario[]
|
||||
methods: Array<{
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
}>
|
||||
testModeIndicators: {
|
||||
showWarningBanners: boolean
|
||||
showTestBadges: boolean
|
||||
consoleWarnings: boolean
|
||||
}
|
||||
defaultDelay: number
|
||||
customUiRoute: string
|
||||
}
|
||||
|
||||
// Properly typed session interface
|
||||
export interface TestPaymentSession {
|
||||
id: string
|
||||
payment: Partial<Payment>
|
||||
scenario?: PaymentScenario
|
||||
method?: PaymentMethod
|
||||
createdAt: Date
|
||||
status: PaymentOutcome
|
||||
}
|
||||
|
||||
// Use the proper BillingPluginConfig type
|
||||
|
||||
// Default payment scenarios
|
||||
const DEFAULT_SCENARIOS: PaymentScenario[] = [
|
||||
{
|
||||
id: 'instant-success',
|
||||
name: 'Instant Success',
|
||||
description: 'Payment succeeds immediately',
|
||||
outcome: 'paid',
|
||||
delay: 0
|
||||
},
|
||||
{
|
||||
id: 'delayed-success',
|
||||
name: 'Delayed Success',
|
||||
description: 'Payment succeeds after a delay',
|
||||
outcome: 'paid',
|
||||
delay: 3000
|
||||
},
|
||||
{
|
||||
id: 'cancelled-payment',
|
||||
name: 'Cancelled Payment',
|
||||
description: 'User cancels the payment',
|
||||
outcome: 'cancelled',
|
||||
delay: 1000
|
||||
},
|
||||
{
|
||||
id: 'declined-payment',
|
||||
name: 'Declined Payment',
|
||||
description: 'Payment is declined by the provider',
|
||||
outcome: 'failed',
|
||||
delay: 2000
|
||||
},
|
||||
{
|
||||
id: 'expired-payment',
|
||||
name: 'Expired Payment',
|
||||
description: 'Payment expires before completion',
|
||||
outcome: 'expired',
|
||||
delay: 5000
|
||||
},
|
||||
{
|
||||
id: 'pending-payment',
|
||||
name: 'Pending Payment',
|
||||
description: 'Payment remains in pending state',
|
||||
outcome: 'pending',
|
||||
delay: 1500
|
||||
}
|
||||
]
|
||||
|
||||
// Payment method configurations
|
||||
const PAYMENT_METHODS: Record<PaymentMethod, { name: string; icon: string }> = {
|
||||
ideal: { name: 'iDEAL', icon: '🏦' },
|
||||
creditcard: { name: 'Credit Card', icon: '💳' },
|
||||
paypal: { name: 'PayPal', icon: '🅿️' },
|
||||
applepay: { name: 'Apple Pay', icon: '🍎' },
|
||||
banktransfer: { name: 'Bank Transfer', icon: '🏛️' }
|
||||
}
|
||||
|
||||
// In-memory storage for test payment sessions
|
||||
const testPaymentSessions = new Map<string, TestPaymentSession>()
|
||||
|
||||
export const testProvider = (testConfig: TestProviderConfig) => {
|
||||
if (!testConfig.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
|
||||
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
|
||||
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
||||
|
||||
// Test mode warnings will be logged in onInit when payload is available
|
||||
|
||||
return {
|
||||
key: 'test',
|
||||
onConfig: (config, pluginConfig) => {
|
||||
// Register test payment UI endpoint
|
||||
config.endpoints = [
|
||||
...(config.endpoints || []),
|
||||
{
|
||||
path: '/payload-billing/test/payment/:id',
|
||||
method: 'get',
|
||||
handler: (req) => {
|
||||
// Extract payment ID from URL path
|
||||
const urlParts = req.url?.split('/') || []
|
||||
const paymentId = urlParts[urlParts.length - 1]
|
||||
|
||||
if (!paymentId) {
|
||||
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Validate payment ID format
|
||||
const validation = validatePaymentId(paymentId)
|
||||
if (!validation.isValid) {
|
||||
return new Response(JSON.stringify({ error: validation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const session = testPaymentSessions.get(paymentId)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Generate test payment UI
|
||||
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig)
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payload-billing/test/config',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const response: TestProviderConfigResponse = {
|
||||
enabled: testConfig.enabled,
|
||||
scenarios: scenarios,
|
||||
methods: Object.entries(PAYMENT_METHODS).map(([id, method]) => ({
|
||||
id,
|
||||
name: method.name,
|
||||
icon: method.icon
|
||||
})),
|
||||
testModeIndicators: {
|
||||
showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true,
|
||||
showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true,
|
||||
consoleWarnings: testConfig.testModeIndicators?.consoleWarnings ?? true
|
||||
},
|
||||
defaultDelay: testConfig.defaultDelay || 1000,
|
||||
customUiRoute: uiRoute
|
||||
}
|
||||
return new Response(JSON.stringify(response), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payload-billing/test/process',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const payload = req.payload
|
||||
const body = await req.json?.() || {}
|
||||
|
||||
// Validate request body
|
||||
const validation = validateProcessPaymentRequest(body)
|
||||
if (!validation.isValid) {
|
||||
return new Response(JSON.stringify({ error: validation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const { paymentId, scenarioId, method } = validation.data!
|
||||
|
||||
const session = testPaymentSessions.get(paymentId)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const scenario = scenarios.find(s => s.id === scenarioId)
|
||||
if (!scenario) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update session with selected scenario and method
|
||||
session.scenario = scenario
|
||||
session.method = method
|
||||
session.status = 'pending'
|
||||
|
||||
// Process payment after delay
|
||||
setTimeout(() => {
|
||||
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Failed to process payment:', error)
|
||||
|
||||
// Ensure session status is updated consistently
|
||||
session.status = 'failed'
|
||||
|
||||
// Create error provider data
|
||||
const errorProviderData: ProviderData = {
|
||||
raw: {
|
||||
error: error instanceof Error ? error.message : 'Unknown processing error',
|
||||
processedAt: new Date().toISOString(),
|
||||
testMode: true
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'test'
|
||||
}
|
||||
|
||||
// Update payment record in database with enhanced error handling
|
||||
const dbResult = await updatePaymentInDatabase(
|
||||
payload,
|
||||
session.id,
|
||||
'failed',
|
||||
errorProviderData,
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
if (!dbResult.success) {
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Database error during failure handling:', dbResult.error)
|
||||
// Even if database update fails, we maintain session consistency
|
||||
} else {
|
||||
logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`, undefined, req.payload)
|
||||
}
|
||||
})
|
||||
}, scenario.delay || testConfig.defaultDelay || 1000)
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
status: 'processing',
|
||||
scenario: scenario.name,
|
||||
delay: scenario.delay || testConfig.defaultDelay || 1000
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payload-billing/test/status/:id',
|
||||
method: 'get',
|
||||
handler: (req) => {
|
||||
// Extract payment ID from URL path
|
||||
const urlParts = req.url?.split('/') || []
|
||||
const paymentId = urlParts[urlParts.length - 1]
|
||||
|
||||
if (!paymentId) {
|
||||
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Validate payment ID format
|
||||
const validation = validatePaymentId(paymentId)
|
||||
if (!validation.isValid) {
|
||||
return new Response(JSON.stringify({ error: validation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const session = testPaymentSessions.get(paymentId)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
status: session.status,
|
||||
scenario: session.scenario?.name,
|
||||
method: session.method ? PAYMENT_METHODS[session.method]?.name : undefined
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
onInit: (payload: Payload) => {
|
||||
logWebhookEvent('Test Provider', 'Test payment provider initialized', undefined, payload)
|
||||
|
||||
// Log test mode warnings if enabled
|
||||
if (testConfig.testModeIndicators?.consoleWarnings !== false && !hasGivenTestModeWarning()) {
|
||||
setTestModeWarning()
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.warn('🧪 Payment system is running in test mode')
|
||||
}
|
||||
|
||||
// Clean up old sessions periodically (older than 1 hour)
|
||||
setInterval(() => {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
||||
testPaymentSessions.forEach((session, id) => {
|
||||
if (session.createdAt < oneHourAgo) {
|
||||
testPaymentSessions.delete(id)
|
||||
}
|
||||
})
|
||||
}, 10 * 60 * 1000) // Clean every 10 minutes
|
||||
},
|
||||
initPayment: (payload, payment) => {
|
||||
// Validate required fields
|
||||
if (!payment.amount) {
|
||||
throw new Error('Amount is required')
|
||||
}
|
||||
if (!payment.currency) {
|
||||
throw new Error('Currency is required')
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
if (!isValidAmount(payment.amount)) {
|
||||
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||
}
|
||||
|
||||
// Validate currency code
|
||||
if (!isValidCurrencyCode(payment.currency)) {
|
||||
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||
}
|
||||
|
||||
// Generate unique test payment ID
|
||||
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Create test payment session
|
||||
const session = {
|
||||
id: testPaymentId,
|
||||
payment: { ...payment },
|
||||
createdAt: new Date(),
|
||||
status: 'pending' as PaymentOutcome
|
||||
}
|
||||
|
||||
testPaymentSessions.set(testPaymentId, session)
|
||||
|
||||
// Set provider ID and data
|
||||
payment.providerId = testPaymentId
|
||||
const providerData: ProviderData = {
|
||||
raw: {
|
||||
id: testPaymentId,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
description: payment.description,
|
||||
status: 'pending',
|
||||
testMode: true,
|
||||
paymentUrl: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`,
|
||||
scenarios: scenarios.map(s => ({ id: s.id, name: s.name, description: s.description })),
|
||||
methods: Object.entries(PAYMENT_METHODS).map(([key, value]) => ({
|
||||
id: key,
|
||||
name: value.name,
|
||||
icon: value.icon
|
||||
}))
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'test'
|
||||
}
|
||||
payment.providerData = providerData
|
||||
|
||||
return payment
|
||||
},
|
||||
} satisfies PaymentProvider
|
||||
}
|
||||
|
||||
// Helper function to process test payment based on scenario
|
||||
async function processTestPayment(
|
||||
payload: Payload,
|
||||
session: TestPaymentSession,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!session.scenario) return
|
||||
|
||||
// Map scenario outcome to payment status
|
||||
let finalStatus: Payment['status'] = 'pending'
|
||||
switch (session.scenario.outcome) {
|
||||
case 'paid':
|
||||
finalStatus = 'succeeded'
|
||||
break
|
||||
case 'failed':
|
||||
finalStatus = 'failed'
|
||||
break
|
||||
case 'cancelled':
|
||||
finalStatus = 'canceled'
|
||||
break
|
||||
case 'expired':
|
||||
finalStatus = 'canceled' // Treat expired as canceled
|
||||
break
|
||||
case 'pending':
|
||||
finalStatus = 'pending'
|
||||
break
|
||||
}
|
||||
|
||||
// Update session status
|
||||
session.status = session.scenario.outcome
|
||||
|
||||
// Update payment with final status and provider data
|
||||
const updatedProviderData: ProviderData = {
|
||||
raw: {
|
||||
...session.payment,
|
||||
id: session.id,
|
||||
status: session.scenario.outcome,
|
||||
scenario: session.scenario.name,
|
||||
method: session.method,
|
||||
processedAt: new Date().toISOString(),
|
||||
testMode: true
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'test'
|
||||
}
|
||||
|
||||
// Use the utility function for database operations
|
||||
const dbResult = await updatePaymentInDatabase(
|
||||
payload,
|
||||
session.id,
|
||||
finalStatus,
|
||||
updatedProviderData,
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
if (dbResult.success) {
|
||||
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`, undefined, payload)
|
||||
} else {
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Failed to update payment in database:', dbResult.error)
|
||||
// Update session status to indicate database error, but don't throw
|
||||
// This allows the UI to still show the intended test result
|
||||
session.status = 'failed'
|
||||
throw new Error(`Database update failed: ${dbResult.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown processing error'
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Failed to process payment:', errorMessage)
|
||||
session.status = 'failed'
|
||||
throw error // Re-throw to be handled by the caller
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to generate test payment UI
|
||||
function generateTestPaymentUI(
|
||||
session: TestPaymentSession,
|
||||
scenarios: PaymentScenario[],
|
||||
uiRoute: string,
|
||||
baseUrl: string,
|
||||
testConfig: TestProviderConfig
|
||||
): string {
|
||||
const payment = session.payment
|
||||
const testModeIndicators = testConfig.testModeIndicators || {}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Payment - ${payment.description || 'Payment'}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
${testModeIndicators.showWarningBanners !== false ? `
|
||||
.test-banner {
|
||||
background: linear-gradient(90deg, #ff6b6b, #ffa726);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
` : ''}
|
||||
.header {
|
||||
background: #f8f9fa;
|
||||
padding: 30px 40px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.amount {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #27ae60;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.description {
|
||||
color: #6c757d;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.content { padding: 40px; }
|
||||
.section { margin-bottom: 30px; }
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.payment-methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.method {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
.method:hover {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
.method.selected {
|
||||
border-color: #007bff;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.method-icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.method-name { font-size: 12px; font-weight: 500; }
|
||||
.scenarios {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.scenario {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
.scenario:hover {
|
||||
border-color: #28a745;
|
||||
background: #f8fff9;
|
||||
}
|
||||
.scenario.selected {
|
||||
border-color: #28a745;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.scenario-name { font-weight: 600; margin-bottom: 4px; }
|
||||
.scenario-desc { font-size: 14px; opacity: 0.8; }
|
||||
.process-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #007bff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.process-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,123,255,0.3);
|
||||
}
|
||||
.process-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.processing { background: #fff3cd; color: #856404; }
|
||||
.status.success { background: #d4edda; color: #155724; }
|
||||
.status.error { background: #f8d7da; color: #721c24; }
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 10px;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
${testModeIndicators.showTestBadges !== false ? `
|
||||
.test-badge {
|
||||
display: inline-block;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-left: 8px;
|
||||
}
|
||||
` : ''}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${testModeIndicators.showWarningBanners !== false ? `
|
||||
<div class="test-banner">
|
||||
🧪 TEST MODE - This is a simulated payment for development purposes
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
Test Payment Checkout
|
||||
${testModeIndicators.showTestBadges !== false ? '<span class="test-badge">Test</span>' : ''}
|
||||
</div>
|
||||
<div class="amount">${payment.currency?.toUpperCase()} ${payment.amount ? (payment.amount / 100).toFixed(2) : '0.00'}</div>
|
||||
${payment.description ? `<div class="description">${payment.description}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
💳 Select Payment Method
|
||||
</div>
|
||||
<div class="payment-methods">
|
||||
${Object.entries(PAYMENT_METHODS).map(([key, method]) => `
|
||||
<div class="method" data-method="${key}">
|
||||
<div class="method-icon">${method.icon}</div>
|
||||
<div class="method-name">${method.name}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
🎭 Select Test Scenario
|
||||
</div>
|
||||
<div class="scenarios">
|
||||
${scenarios.map(scenario => `
|
||||
<div class="scenario" data-scenario="${scenario.id}">
|
||||
<div class="scenario-name">${scenario.name}</div>
|
||||
<div class="scenario-desc">${scenario.description}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="process-btn" id="processBtn" disabled>
|
||||
Process Test Payment
|
||||
</button>
|
||||
|
||||
<div id="status" class="status" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedMethod = null;
|
||||
let selectedScenario = null;
|
||||
|
||||
// Payment method selection
|
||||
document.querySelectorAll('.method').forEach(method => {
|
||||
method.addEventListener('click', () => {
|
||||
document.querySelectorAll('.method').forEach(m => m.classList.remove('selected'));
|
||||
method.classList.add('selected');
|
||||
selectedMethod = method.dataset.method;
|
||||
updateProcessButton();
|
||||
});
|
||||
});
|
||||
|
||||
// Scenario selection
|
||||
document.querySelectorAll('.scenario').forEach(scenario => {
|
||||
scenario.addEventListener('click', () => {
|
||||
document.querySelectorAll('.scenario').forEach(s => s.classList.remove('selected'));
|
||||
scenario.classList.add('selected');
|
||||
selectedScenario = scenario.dataset.scenario;
|
||||
updateProcessButton();
|
||||
});
|
||||
});
|
||||
|
||||
function updateProcessButton() {
|
||||
const btn = document.getElementById('processBtn');
|
||||
btn.disabled = !selectedMethod || !selectedScenario;
|
||||
}
|
||||
|
||||
// Process payment
|
||||
document.getElementById('processBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('processBtn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading"></span>Processing...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/payload-billing/test/process', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paymentId: '${session.id}',
|
||||
scenarioId: selectedScenario,
|
||||
method: selectedMethod
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
status.className = 'status processing';
|
||||
status.style.display = 'block';
|
||||
status.innerHTML = \`<span class="loading"></span>Processing payment with \${result.scenario}...\`;
|
||||
|
||||
// Poll for status updates
|
||||
setTimeout(() => pollStatus(), result.delay || 1000);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to process payment');
|
||||
}
|
||||
} catch (error) {
|
||||
status.className = 'status error';
|
||||
status.style.display = 'block';
|
||||
status.textContent = 'Error: ' + error.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Process Test Payment';
|
||||
}
|
||||
});
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/payload-billing/test/status/${session.id}');
|
||||
const result = await response.json();
|
||||
|
||||
const status = document.getElementById('status');
|
||||
const btn = document.getElementById('processBtn');
|
||||
|
||||
if (result.status === 'paid') {
|
||||
status.className = 'status success';
|
||||
status.textContent = '✅ Payment successful!';
|
||||
setTimeout(() => {
|
||||
window.location.href = '${baseUrl}/success';
|
||||
}, 2000);
|
||||
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
|
||||
status.className = 'status error';
|
||||
status.textContent = \`❌ Payment \${result.status}\`;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Try Again';
|
||||
} else if (result.status === 'pending') {
|
||||
status.className = 'status processing';
|
||||
status.innerHTML = '<span class="loading"></span>Payment is still pending...';
|
||||
setTimeout(() => pollStatus(), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Test Provider] Failed to poll status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
${testModeIndicators.consoleWarnings !== false ? `
|
||||
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface for development purposes');
|
||||
` : ''}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Payment } from '../plugin/types/payments.js'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { Config, Payload } from 'payload'
|
||||
import type { BillingPluginConfig } from '../plugin/config.js'
|
||||
import type { BillingPluginConfig } from '../plugin/config'
|
||||
|
||||
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
|
||||
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
|
||||
|
||||
export type PaymentProvider = {
|
||||
key: string
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Payload } from 'payload'
|
||||
import type { Payment } from '../plugin/types/payments.js'
|
||||
import type { BillingPluginConfig } from '../plugin/config.js'
|
||||
import type { ProviderData } from './types.js'
|
||||
import { defaults } from '../plugin/config.js'
|
||||
import { extractSlug, toPayloadId } from '../plugin/utils.js'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { BillingPluginConfig } from '../plugin/config'
|
||||
import type { ProviderData } from './types'
|
||||
import { defaults } from '../plugin/config'
|
||||
import { extractSlug, toPayloadId } from '../plugin/utils'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
/**
|
||||
* Common webhook response utilities
|
||||
@@ -11,9 +12,14 @@ import { extractSlug, toPayloadId } from '../plugin/utils.js'
|
||||
*/
|
||||
export const webhookResponses = {
|
||||
success: () => Response.json({ received: true }, { status: 200 }),
|
||||
error: (message: string, status = 400) => {
|
||||
error: (message: string, status = 400, payload?: Payload) => {
|
||||
// Log error internally but don't expose details
|
||||
if (payload) {
|
||||
const logger = createContextLogger(payload, 'Webhook')
|
||||
logger.error('Error:', message)
|
||||
} else {
|
||||
console.error('[Webhook] Error:', message)
|
||||
}
|
||||
return Response.json({ error: 'Invalid request' }, { status })
|
||||
},
|
||||
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
||||
@@ -63,7 +69,8 @@ export async function updatePaymentStatus(
|
||||
}) as Payment
|
||||
|
||||
if (!currentPayment) {
|
||||
console.error(`[Payment Update] Payment ${paymentId} not found`)
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.error(`Payment ${paymentId} not found`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -74,7 +81,8 @@ export async function updatePaymentStatus(
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
|
||||
if (!transactionID) {
|
||||
console.error(`[Payment Update] Failed to begin transaction`)
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.error('Failed to begin transaction')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -89,7 +97,8 @@ export async function updatePaymentStatus(
|
||||
// Check if version still matches
|
||||
if ((paymentInTransaction.version || 1) !== currentVersion) {
|
||||
// Version conflict detected - payment was modified by another process
|
||||
console.warn(`[Payment Update] Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
return false
|
||||
}
|
||||
@@ -116,7 +125,8 @@ export async function updatePaymentStatus(
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.error(`Failed to update payment ${paymentId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -152,13 +162,19 @@ export async function updateInvoiceOnPaymentSuccess(
|
||||
export function handleWebhookError(
|
||||
provider: string,
|
||||
error: unknown,
|
||||
context?: string
|
||||
context?: string,
|
||||
payload?: Payload
|
||||
): Response {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
|
||||
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
|
||||
|
||||
// Log detailed error internally for debugging
|
||||
console.error(`${fullContext} Error:`, error)
|
||||
if (payload) {
|
||||
const logger = createContextLogger(payload, fullContext)
|
||||
logger.error('Error:', error)
|
||||
} else {
|
||||
console.error(`[${fullContext}] Error:`, error)
|
||||
}
|
||||
|
||||
// Return generic response to avoid information disclosure
|
||||
return Response.json({
|
||||
@@ -173,10 +189,16 @@ export function handleWebhookError(
|
||||
export function logWebhookEvent(
|
||||
provider: string,
|
||||
event: string,
|
||||
details?: any
|
||||
details?: any,
|
||||
payload?: Payload
|
||||
): void {
|
||||
if (payload) {
|
||||
const logger = createContextLogger(payload, `${provider} Webhook`)
|
||||
logger.info(event, details ? JSON.stringify(details) : '')
|
||||
} else {
|
||||
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL for production use
|
||||
|
||||
48
src/utils/logger.ts
Normal file
48
src/utils/logger.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
let pluginLogger: any = null
|
||||
|
||||
/**
|
||||
* Get or create the plugin logger instance
|
||||
* Uses PAYLOAD_BILLING_LOG_LEVEL environment variable to configure log level
|
||||
* Defaults to 'info' if not set
|
||||
*/
|
||||
export function getPluginLogger(payload: Payload) {
|
||||
if (!pluginLogger && payload.logger) {
|
||||
const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info'
|
||||
|
||||
pluginLogger = payload.logger.child({
|
||||
level: logLevel,
|
||||
plugin: '@xtr-dev/payload-billing'
|
||||
})
|
||||
|
||||
// Log the configured log level on first initialization
|
||||
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
|
||||
}
|
||||
|
||||
// Fallback to console if logger not available (shouldn't happen in normal operation)
|
||||
if (!pluginLogger) {
|
||||
return {
|
||||
debug: (...args: any[]) => console.log('[BILLING DEBUG]', ...args),
|
||||
info: (...args: any[]) => console.log('[BILLING INFO]', ...args),
|
||||
warn: (...args: any[]) => console.warn('[BILLING WARN]', ...args),
|
||||
error: (...args: any[]) => console.error('[BILLING ERROR]', ...args),
|
||||
}
|
||||
}
|
||||
|
||||
return pluginLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context-specific logger for a particular operation
|
||||
*/
|
||||
export function createContextLogger(payload: Payload, context: string) {
|
||||
const logger = getPluginLogger(payload)
|
||||
|
||||
return {
|
||||
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
|
||||
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
|
||||
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
|
||||
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user